跪拜 Guibai
← Back to the summary

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:

  1. URLs unchanged — User bookmarks won't break
  2. DOM structure unchanged — Automated test scripts don't need modification
  3. API response format unchanged — The backend thinks the frontend is still the same old frontend
  4. Bug behavior unchanged — Those "features" must be preserved as-is, otherwise tests will alert

The only changes:


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~