我偷偷把公司的祖傳 jQuery 專案改成了 Vue3,CTO 沒發現,但全組都來抄我的程式碼了

警告:本文包含大量「摸魚重構」行為,請謹慎模仿。如有雷同,說明你也在寫「屎山」。


一、事情是這樣的

上週五下午 5:47,距離下班還有 13 分鐘。

我盯著螢幕上那坨 2016 年的 jQuery 程式碼,第 847 行的 $(document).ready 像一雙眼睛,也在盯著我。

JavaScript

ini 代碼解讀複製代碼// 這是真實存在的程式碼,我發誓只改了變數名
function doSomething() {
    var that = this;
    var self = that;
    var _this = self;
    // ... 200行後
    _this.init();
}

那一刻,我做出了違背祖宗(和 KPI)的決定:我要重構它。

不是那種「跟老闆申請兩個月排期」的重構。是偷偷摸摸、週末加班、週一驚豔所有人的那種。


二、為什麼老闆不會發現?

因為這套系統的核心邏輯是: 「能跑就行,別動」

它長這樣:

plain

perl 代碼解讀複製代碼📁 legacy-system/
├── 📄 index.html          # 3.2MB,包含 47 個 <script> 標籤
├── 📄 app.js              # 單行 1.4萬字元,webpack 看到會哭
├── 📄 utils.js            # 工具函式,共 89 個,命名從 a 到 z 不夠用,用了 aa
├── 📄 fix-ie8.js          # 2024年了,IE8 的棺材板在震動
└── 📄 jquery-1.7.2.min.js # 考古級文物,比有些同事工齡還長

重構原則:外表不變,內臟全換。

就像給兵馬俑做心臟搭橋——外觀必須保持「歷史的厚重感」,但裡面得通上 5G。


三、3 個週末的「犯罪」時間線

🌙 第 1 個週末:偷梁換柱

目標:無痛接入 Vue3,但頁面看起來還是 jQuery 的。

JavaScript

javascript 代碼解讀複製代碼// 原來:jQuery 操作 DOM 的「義大利麵條」
$('#btn-submit').click(function() {
    var name = $('#input-name').val();
    if (name === '') {
        $('#error-msg').text('不能為空').show();
        return;
    }
    $.ajax({
        url: '/api/submit',
        data: {name: name},
        success: function(res) {
            $('#result').html('<div class="success">' + res.msg + '</div>');
        }
    });
});

vue

xml 代碼解讀複製代碼<!-- 現在:Vue3 元件,但 DOM 結構 100% 復刻 -->
<template>
  <!-- 一模一樣的 id,jQuery 外掛們以為它們還在工作 -->
  <div id="legacy-container">
    <input id="input-name" v-model="form.name" />
    <button id="btn-submit" @click="handleSubmit">送出</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'  // 封裝了原來的 ajax

const form = ref({ name: '' })
const error = ref('')
const resultHtml = ref('')

// 關鍵:保留原生的 DOM id,讓其他 jQuery 程式碼以為一切正常
onMounted(() => {
  // 偷偷註冊全域事件,兼容那些還沒重構的模組
  window.LegacyBridge = {
    refresh: () => { /* ... */ }
  }
})

const handleSubmit = async () => {
  if (!form.value.name) {
    error.value = '不能為空'
    return
  }
  const res = await submitForm(form.value)
  resultHtml.value = `<div class="success">${res.msg}</div>`
}
</script>

核心 trick:保留所有原始 idclass,讓遺留的 jQuery 程式碼以為它們還在操作真實的 DOM。實際上,Vue 已經接管了渲染權。

同事週一看到頁面:「咦,好像載入快了一點?」我:「可能是 CDN 快取吧。」(心虛)


🌙 第 2 個週末:暗度陳倉

目標:把 89 個 utils.js 函式,改成 TypeScript + 組合式函式。

utils.js 精選:

JavaScript

javascript 代碼解讀複製代碼// aa.js 到 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);
}

// 另一個檔案裡還有一個 formatDate2,功能一樣但回傳格式不同
// 還有一個 formatDate3,處理閏年 bug

改成這樣:

TypeScript

typescript 代碼解讀複製代碼// composables/useLegacyFormat.ts
import { computed } from 'vue'

export function useLegacyFormat() {
  // 兼容層:先支援舊介面,再逐步替換
  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' // 原來會回傳 'NaN-NaN-NaN'

    const pad = (n: number) => n.toString().padStart(2, '0')

    if (pattern === 'legacy') {
      // 某些老介面依賴這種格式,暫時保留
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
    }
    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
  }

  // 自動快取:那些重複 format 的列表渲染
  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 }
}

為什麼同事開始找我要程式碼?

因為週五下午,產品突然說:「這個日期列表,5000 筆資料有點卡,能優化嗎?」

我默默把原來的:

JavaScript

c 代碼解讀複製代碼// 渲染時即時計算,O(n) 複雜度,每次捲動都重新 format
list.map(item => formatDate(item.createTime))

改成了:

vue

xml 代碼解讀複製代碼<script setup>
const { createMemoFormat } = useLegacyFormat()
const memoFormat = createMemoFormat()

// 虛擬捲動 + 記憶化格式化
const visibleItems = computed(() => 
  virtualList.value.map(item => ({
    ...item,
    displayTime: memoFormat(item.createTime) // 命中快取,O(1)
  }))
)
</script>

從 8 秒卡頓到 120ms 順暢捲動。

同事小王:「你用了什麼黑魔法?」我:「就... 正常的 Vue3 寫法啊。」小王:「發我一份。」我:「行,但別說是我寫的。」(遞出 GitHub 連結)


🌙 第 3 個週末:李代桃僵

目標:把那個 3.2MB 的 index.html,拆成 Vite + 按需載入。

原來的載入瀑布:

plain

diff 代碼解讀複製代碼index.html (3.2MB) ──► jquery.js ──► bootstrap.js ──► 47個外掛 ──► app.js
                          │              │                │
                          ▼              ▼                ▼
                     阻塞渲染        阻塞渲染          阻塞渲染
                     共計 8.4s      白畫面時間感人

現在的架構:

TypeScript

php 代碼解讀複製代碼// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 把 jQuery 外掛們關進「相容牢房」
          'legacy-jail': ['jquery', 'bootstrap', 'select2', 'datetimepicker'],
          // 核心業務邏輯
          'core': ['./src/main.ts'],
          // 按路由拆分
          'dashboard': ['./src/views/Dashboard.vue'],
          'report': ['./src/views/Report.vue']
        }
      }
    }
  },
  // 關鍵:開發時保留 jQuery 全域變數,讓老程式碼不報錯
  define: {
    'window.$': 'window.jQuery'
  }
})

載入對比:

表格

指標 重構前 重構後
首屏資源 8.4MB 340KB
白畫面時間 4.2s 0.8s
可互動時間 6.8s 1.4s
Lighthouse 32 分 91 分

CTO 週一晨會:「最近維運是不是加了頻寬?網站快了很多。」
維運:「沒啊,預算還沒批下來。」
我:(低頭喝水)


四、那些「不能說的秘密」:踩坑實錄

💣 坑 1:jQuery 外掛的「奪舍」行為

有些外掛會暴力修改 DOM,Vue 會一臉懵逼。

解決方案:Shadow DOM 隔離 + 手動同步

vue

xml 代碼解讀複製代碼<template>
  <div ref="legacyHost"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const legacyHost = ref<HTMLDivElement>()

onMounted(() => {
  // 建立一個 Vue 管不到的「法外之地」
  const shadow = legacyHost.value!.attachShadow({ mode: 'open' })

  // 把 jQuery 外掛關進去
  shadow.innerHTML = `<div id="plugin-container"></div>`

  // 手動橋接:Vue 資料變 → 通知 jQuery 外掛
  const $container = $(shadow.getElementById('plugin-container'))
  $container.legacyPlugin({ data: props.rawData })

  // 反向橋接:jQuery 事件 → 觸發 Vue 事件
  $container.on('legacyChange', (e, data) => {
    emit('update:modelValue', data)
  })
})

onBeforeUnmount(() => {
  // 必須手動銷毀,否則記憶體洩漏到地老天荒
  $(legacyHost.value!.shadowRoot).find('*').legacyPlugin('destroy')
})
</script>

💣 坑 2:document.ready 的「時序地獄」

原來的程式碼:

JavaScript

javascript 代碼解讀複製代碼$(document).ready(function() {
    // 假設 #app 已經存在
    $('#app').initPlugin()
})

Vue 掛載後:

JavaScript

arduino 代碼解讀複製代碼// Vue 是非同步掛載的,jQuery 的 ready 可能跑在 Vue 渲染之前
// 結果:#app 還是空的,initPlugin 初始化了個寂寞

解決方案:偽造 document.ready

TypeScript

javascript 代碼解讀複製代碼// utils/legacyReady.ts
const originalReady = $.fn.ready

export function patchjQueryReady() {
  let legacyCallbacks: Function[] = []

  // 劫持 ready,先存起來
  $.fn.ready = function(fn: Function) {
    legacyCallbacks.push(fn)
  }

  // 等 Vue 掛載完成後再執行
  return () => {
    $.fn.ready = originalReady // 恢復
    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')

// Vue 渲染完成後,釋放 jQuery 的 ready 回調
nextTick(() => {
  releaseReady()
})

💣 坑 3:全域樣式污染

原來的 app.css

css

css 代碼解讀複製代碼/* 這行程式碼殺死了比賽 */
* { margin: 0; padding: 0; box-sizing: border-box; }

/* 以及 3000 行沒有命名空間的樣式 */
.table { border: 1px solid #ccc; }
.btn { background: blue; }
/* ... 覆蓋了 Element Plus 的預設樣式 */

解決方案:CSS Modules + 作用域隔離

vue

xml 代碼解讀複製代碼<style scoped>
/* Vue 的 scoped 會自動加 data-v-hash */
/* 但 jQuery 動態生成的內容沒有 hash */
</style>

<style module="legacy">
/* 專門給老程式碼用的「隔離病房」 */
:global(.legacy-wrapper) .table { /* 原來的樣式 */ }
:global(.legacy-wrapper) .btn { /* 原來的樣式 */ }
</style>

五、成果驗收:老闆真的沒發現

因為重構的準則是:

  1. URL 不變 — 使用者書籤不會失效
  2. DOM 結構不變 — 自動化測試腳本不用改
  3. API 回應格式不變 — 後端以為前端還是原來那個前端
  4. Bug 表現不變 — 那些「特性」(feature)要原樣保留,否則測試會警報

唯一的變化:

  • 建置產物從 47 個 <script> 標籤變成 3 個 chunk
  • 首屏時間從 4.2s 變成 0.8s
  • 程式碼從「不可維護」變成「可以寫單元測試了」

六、同事為什麼都來要程式碼?

因為我建了一個內部 npm 套件,叫 @company/legacy-bridge

bash

bash 代碼解讀複製代碼npm install @company/legacy-bridge

裡面包含:

TypeScript

javascript 代碼解讀複製代碼// 一鍵接入 Vue3 + 相容 jQuery
export { useLegacyFormat } from './composables/format'
export { useLegacyAjax } from './composables/ajax'      // 封裝了 $.ajax
export { LegacyContainer } from './components/Container'   // Shadow DOM 隔離容器
export { patchjQueryReady } from './utils/ready'
export { createLegacyRouter } from './router/adapter'      // 相容 hash 路由

// 使用範例:3 行程式碼讓老頁面獲得 Vue 超能力
import { LegacyContainer, useLegacyAjax } from '@company/legacy-bridge'

現在全組 12 個人,有 9 個在偷偷用。

剩下 3 個是後端,他們想要一個 @company/legacy-bridge-java 版。


七、寫在最後

重構祖傳程式碼,就像給行駛中的汽車換引擎

你不能停車(業務不能停),不能改外觀(使用者無感知),還要讓乘客覺得「這車怎麼突然變穩了」。

3 個週末,37 杯咖啡,0 次線上事故。

值嗎?

昨天 CTO 突然找我:「聽說你最近在研究 Vue3?」我心跳漏了一拍。 他接著說:「不錯,下週給全公司做個技術分享吧,主題就叫《漸進式重構實戰》。」

我看著他轉身離去的背影,突然意識到:

他可能早就知道了。


附錄:技術棧 & 工具

表格

類別 技術
框架 Vue 3.4 + TypeScript 5.3
建置 Vite 5.x
相容 jQuery 1.7.2(透過 vite-plugin-legacy-jquery 注入)
狀態 Pinia(替代全域變數)
樣式 UnoCSS + 原有 CSS 隔離
測試 Vitest + 原有 Selenium 腳本

GitHub 範例程式碼:(如果你也有一座「屎山」要爬)

bash

bash 代碼解讀複製代碼git clone https://github.com/yourname/legacy-to-vue3.git
cd legacy-to-vue3
pnpm install
pnpm run dev:legacy  # 啟動相容模式

互動時間:你重構過最離譜的祖傳程式碼是什麼?歡迎在留言區分享~


原文出處:https://juejin.cn/post/7655631091143704595


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝14   💬2   ❤️1
585
🥈
我愛JS
📝1   ❤️1
72
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登