警告:本文包含大量"摸鱼重构"行为,请谨慎模仿。如有雷同,说明你也在写"屎山"。
一、事情是这样的
上周五下午 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 :保留所有原始 id 和 class,让遗留的 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>
五、成果验收:老板真的没发现
因为重构的准则是:
- URL 不变 --- 用户 bookmark 不会失效
- DOM 结构不变 --- 自动化测试脚本不用改
- API 响应格式不变 --- 后端以为前端还是原来那个前端
- 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 # 启动兼容模式
互动时间:你重构过最离谱的祖传代码是什么?欢迎在评论区分享~