警告:本文包含大量「摸魚重構」行為,請謹慎模仿。如有雷同,說明你也在寫「屎山」。
上週五下午 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。
目標:無痛接入 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:保留所有原始 id 和 class,讓遺留的 jQuery 程式碼以為它們還在操作真實的 DOM。實際上,Vue 已經接管了渲染權。
同事週一看到頁面:「咦,好像載入快了一點?」我:「可能是 CDN 快取吧。」(心虛)
目標:把 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.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 週一晨會:「最近維運是不是加了頻寬?網站快了很多。」
維運:「沒啊,預算還沒批下來。」
我:(低頭喝水)
有些外掛會暴力修改 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>
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()
})
原來的 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>
因為重構的準則是:
唯一的變化:
<script> 標籤變成 3 個 chunk因為我建了一個內部 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 # 啟動相容模式
互動時間:你重構過最離譜的祖傳程式碼是什麼?歡迎在留言區分享~