How to Secretly Swap a jQuery Codebase for Vue 3 Without Breaking Production
Warning: This article contains a significant amount of "slacking-off refactoring" behavior. Please imitate with caution. If it feels familiar, it means you are also writing in a "shit mountain".
1. Here's What Happened
Last Friday at 5:47 PM, 13 minutes before clocking out.
I stared at that lump of 2016 jQuery code on my screen. The $(document).ready on line 847 stared back at me like a pair of eyes.
// This code actually exists, I swear I only changed the variable names
function doSomething() {
var that = this;
var self = that;
var _this = self;
// ... 200 lines later
_this.init();
}
At that moment, I made a decision that betrayed my ancestors (and my KPIs): I was going to refactor it.
Not the kind of refactoring where you "apply to the boss for a two-month timeline." The kind that is sneaky, done over weekends, and stuns everyone on Monday.
2. Why the Boss Won't Notice
Because the core logic of this system is: "If it runs, don't touch it."
It looks like this:
📁 legacy-system/
├── 📄 index.html # 3.2MB, contains 47 <script> tags
├── 📄 app.js # 14,000 characters on a single line, webpack would cry seeing this
├── 📄 utils.js # Utility functions, 89 in total, naming ran out from a to z and had to use aa
├── 📄 fix-ie8.js # It's 2024, IE8's coffin lid is shaking
└── 📄 jquery-1.7.2.min.js # An archaeological artifact, older than some colleagues' tenure
Refactoring principle: The exterior remains unchanged, the internal organs are completely replaced.
It's like performing a heart bypass on a Terracotta Warrior—the exterior must maintain its "historical weight," but the inside needs to be connected to 5G.
3. The "Crime" Timeline of 3 Weekends
🌙 Weekend 1: Stealing the Beams and Changing the Pillars
Goal: Painlessly integrate Vue3, but the page still looks like it's jQuery.
// Before: jQuery DOM manipulation "spaghetti"
$('#btn-submit').click(function() {
var name = $('#input-name').val();
if (name === '') {
$('#error-msg').text('Cannot be empty').show();
return;
}
$.ajax({
url: '/api/submit',
data: {name: name},
success: function(res) {
$('#result').html('<div class="success">' + res.msg + '</div>');
}
});
});
<!-- Now: Vue3 component, but the DOM structure is 100% replicated -->
<template>
<!-- Identical ids, jQuery plugins think they are still working -->
<div id="legacy-container">
<input id="input-name" v-model="form.name" />
<button id="btn-submit" @click="handleSubmit">Submit</button>
<div id="error-msg" v-show="error">{{ error }}</div>
<div id="result" v-html="resultHtml"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { submitForm } from '@/api/legacy' // Wraps the original ajax
const form = ref({ name: '' })
const error = ref('')
const resultHtml = ref('')
// Key: Preserve the native DOM ids so other jQuery code thinks everything is normal
onMounted(() => {
// Secretly register global events to be compatible with modules not yet refactored
window.LegacyBridge = {
refresh: () => { /* ... */ }
}
})
const handleSubmit = async () => {
if (!form.value.name) {
error.value = 'Cannot be empty'
return
}
const res = await submitForm(form.value)
resultHtml.value = `<div class="success">${res.msg}</div>`
}
</script>
Core trick: Preserve all original ids and classes, making the leftover jQuery code think it is still manipulating the real DOM. In reality, Vue has already taken over the rendering rights.
On Monday, a colleague saw the page: "Huh, does it seem to load a bit faster?" Me: "Probably the CDN cache." (Guilty conscience)
🌙 Weekend 2: Advancing Secretly by a Hidden Path
Goal: Turn the 89 utils.js functions into TypeScript + Composables.
Selected works from the original utils.js:
// "Cultural heritage" from aa.js to zz.js
function formatDate(d) {
if (typeof d == 'string') d = new Date(d);
var y = d.getFullYear();
var m = d.getMonth() + 1;
var day = d.getDate();
return y + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day);
}
// In another file, there's also a formatDate2, same function but different return format
// And a formatDate3, handling a leap year bug
Changed to this:
// composables/useLegacyFormat.ts
import { computed } from 'vue'
export function useLegacyFormat() {
// Compatibility layer: support the old interface first, then gradually replace
const formatDate = (input: string | Date, pattern: 'YYYY-MM-DD' | 'legacy' = 'YYYY-MM-DD') => {
const d = typeof input === 'string' ? new Date(input) : input
if (isNaN(d.getTime())) return 'Invalid Date' // Originally would return 'NaN-NaN-NaN'
const pad = (n: number) => n.toString().padStart(2, '0')
if (pattern === 'legacy') {
// Some old interfaces depend on this format, keep it for now
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
// Auto-caching: for list renderings that repeatedly format dates
const createMemoFormat = () => {
const cache = new Map<string, string>()
return (date: string) => {
if (cache.has(date)) return cache.get(date)!
const formatted = formatDate(date)
cache.set(date, formatted)
return formatted
}
}
return { formatDate, createMemoFormat }
}
Why did colleagues start asking me for my code?
Because on Friday afternoon, the product manager suddenly said: "This date list, 5,000 records is a bit laggy, can you optimize it?"
I silently changed the original:
// Real-time calculation during rendering, O(n) complexity, reformats on every scroll
list.map(item => formatDate(item.createTime))
To this:
<script setup>
const { createMemoFormat } = useLegacyFormat()
const memoFormat = createMemoFormat()
// Virtual scrolling + memoized formatting
const visibleItems = computed(() =>
virtualList.value.map(item => ({
...item,
displayTime: memoFormat(item.createTime) // Cache hit, O(1)
}))
)
</script>
From 8 seconds of lag to 120ms smooth scrolling.
Colleague Xiao Wang: "What black magic did you use?" Me: "Just... normal Vue3 writing." Xiao Wang: "Send me a copy." Me: "Okay, but don't say it was from me." (Hands over GitHub link)
🌙 Weekend 3: Substituting a Peach for a Plum
Goal: Split that 3.2MB index.html into Vite + on-demand loading.
The original loading waterfall:
index.html (3.2MB) ──► jquery.js ──► bootstrap.js ──► 47 plugins ──► app.js
│ │ │
▼ ▼ ▼
Blocking Render Blocking Render Blocking Render
Total 8.4s White screen time is touching
The current architecture:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Lock the jQuery plugins into a "compatibility jail"
'legacy-jail': ['jquery', 'bootstrap', 'select2', 'datetimepicker'],
// Core business logic
'core': ['./src/main.ts'],
// Split by route
'dashboard': ['./src/views/Dashboard.vue'],
'report': ['./src/views/Report.vue']
}
}
}
},
// Key: Preserve jQuery global variables during development so old code doesn't error out
define: {
'window.$': 'window.jQuery'
}
})
Loading comparison:
| Metric | Before Refactor | After Refactor |
|---|---|---|
| First Screen Resources | 8.4MB | 340KB |
| White Screen Time | 4.2s | 0.8s |
| Time to Interactive | 6.8s | 1.4s |
| Lighthouse Score | 32 | 91 |
CTO at Monday morning meeting: "Did Ops add bandwidth recently? The website is much faster." Ops: "No, the budget hasn't been approved yet." Me: (Looks down and drinks water)
4. Those "Secrets That Cannot Be Told": A Record of Pitfalls
💣 Pitfall 1: The "Possession" Behavior of jQuery Plugins
Some plugins violently modify the DOM, leaving Vue completely baffled.
Solution: Shadow DOM Isolation + Manual Sync
<template>
<div ref="legacyHost"></div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const legacyHost = ref<HTMLDivElement>()
onMounted(() => {
// Create an "extralegal zone" that Vue cannot control
const shadow = legacyHost.value!.attachShadow({ mode: 'open' })
// Lock the jQuery plugin inside
shadow.innerHTML = `<div id="plugin-container"></div>`
// Manual bridge: Vue data changes → Notify jQuery plugin
const $container = $(shadow.getElementById('plugin-container'))
$container.legacyPlugin({ data: props.rawData })
// Reverse bridge: jQuery events → Trigger Vue events
$container.on('legacyChange', (e, data) => {
emit('update:modelValue', data)
})
})
onBeforeUnmount(() => {
// Must manually destroy, otherwise memory leaks until the end of time
$(legacyHost.value!.shadowRoot).find('*').legacyPlugin('destroy')
})
</script>
💣 Pitfall 2: The "Timing Hell" of document.ready
The original code:
$(document).ready(function() {
// Assumes #app already exists
$('#app').initPlugin()
})
After Vue mounts:
// Vue mounts asynchronously, jQuery's ready might run before Vue renders
// Result: #app is still empty, initPlugin initializes nothing
Solution: Fake document.ready
// utils/legacyReady.ts
const originalReady = $.fn.ready
export function patchjQueryReady() {
let legacyCallbacks: Function[] = []
// Hijack ready, store callbacks first
$.fn.ready = function(fn: Function) {
legacyCallbacks.push(fn)
}
// Execute after Vue has finished mounting
return () => {
$.fn.ready = originalReady // Restore
legacyCallbacks.forEach(fn => $(document).ready(fn))
legacyCallbacks = []
}
}
// main.ts
import { createApp } from 'vue'
import { patchjQueryReady } from './utils/legacyReady'
const releaseReady = patchjQueryReady()
const app = createApp(App)
app.mount('#app')
// After Vue rendering is complete, release jQuery's ready callbacks
nextTick(() => {
releaseReady()
})
💣 Pitfall 3: Global Style Pollution
The original app.css:
/* This line of code killed the game */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* And 3000 lines of styles without namespaces */
.table { border: 1px solid #ccc; }
.btn { background: blue; }
/* ... Overrides Element Plus's default styles */
Solution: CSS Modules + Scope Isolation
<style scoped>
/* Vue's scoped automatically adds data-v-hash */
/* But content dynamically generated by jQuery doesn't have the hash */
</style>
<style module="legacy">
/* An "isolation ward" specifically for old code */
:global(.legacy-wrapper) .table { /* original styles */ }
:global(.legacy-wrapper) .btn { /* original styles */ }
</style>
5. Results Acceptance: The Boss Really Didn't Notice
Because the refactoring guidelines were:
- URLs unchanged — User bookmarks won't break
- DOM structure unchanged — Automated test scripts don't need modification
- API response format unchanged — The backend thinks the frontend is still the same old frontend
- Bug behavior unchanged — Those "features" must be preserved as-is, otherwise tests will alert
The only changes:
- Build output went from 47
<script>tags to 3 chunks - First screen time went from 4.2s to 0.8s
- Code went from "unmaintainable" to "we can write unit tests now"
6. Why Did Colleagues All Come Asking for My Code?
Because I built an internal npm package called @company/legacy-bridge.
npm install @company/legacy-bridge
It contains:
// One-click integration of Vue3 + jQuery compatibility
export { useLegacyFormat } from './composables/format'
export { useLegacyAjax } from './composables/ajax' // Wraps $.ajax
export { LegacyContainer } from './components/Container' // Shadow DOM isolation container
export { patchjQueryReady } from './utils/ready'
export { createLegacyRouter } from './router/adapter' // Compatible with hash routing
// Usage example: 3 lines of code give an old page Vue superpowers
import { LegacyContainer, useLegacyAjax } from '@company/legacy-bridge'
Now, out of the 12 people in the group, 9 are secretly using it.
The remaining 3 are backend devs, and they want a @company/legacy-bridge-java version.
7. Final Words
Refactoring legacy code is like changing the engine of a moving car.
You can't stop the car (the business can't stop), you can't change the exterior (users must not notice), and you have to make the passengers think, "Why did this car suddenly become smoother?"
3 weekends, 37 cups of coffee, 0 production incidents.
Was it worth it?
Yesterday, the CTO suddenly approached me: "I heard you've been researching Vue3 recently?" My heart skipped a beat. He continued: "Not bad, do a tech talk for the whole company next week, the topic will be 'Progressive Refactoring in Practice'."
Watching him turn and walk away, I suddenly realized:
He probably knew all along.
Appendix: Tech Stack & Tools
| Category | Technology |
|---|---|
| Framework | Vue 3.4 + TypeScript 5.3 |
| Build | Vite 5.x |
| Compatibility | jQuery 1.7.2 (injected via vite-plugin-legacy-jquery) |
| State | Pinia (replaces global variables) |
| Styles | UnoCSS + original CSS isolation |
| Testing | Vitest + original Selenium scripts |
GitHub Example Code: (If you also have a "shit mountain" to climb)
git clone https://github.com/yourname/legacy-to-vue3.git
cd legacy-to-vue3
pnpm install
pnpm run dev:legacy # Start compatibility mode
Interaction Time: What's the most outrageous legacy code you've ever refactored? Feel free to share in the comments~