先叠甲:本文仅供技术交流,不建议在生产环境未经审批直接重构。但我确实这么干了。
一、事情是怎么开始的
公司有个 2016 年上线的后台管理系统,前端技术栈是 jQuery 1.12 + Bootstrap 3 + 手写模板引擎 。代码量大概 8 万行,没有模块化,没有构建工具,HTML 里直接 <script src="..."> 引入 30 多个 JS 文件,加载顺序全靠人工记忆。
我入职第一天,CTO 跟我说:"这个项目很稳定,你熟悉一下业务逻辑就行,别乱动。"
我打开代码一看,一个 app.js 文件 6000 多行,里面混着 DOM 操作、AJAX 请求、业务逻辑、工具函数,变量命名从 a 到 z 轮着用。最绝的是,里面有一行注释:
JavaScript
arduino
// 2018.3.15 老王加的,别删,删了报表页面崩
我问老王是谁,同事说老王 2019 年就离职了。
那一刻我知道,这不是维护,这是考古。
二、为什么必须重构
真正让我下定决心的是一次生产事故。
业务部门提了个需求:在订单列表页加一个"批量导出"按钮。听起来很简单对吧?
我在 jQuery 代码里找了 40 分钟,终于定位到列表渲染逻辑------在一个 800 行的函数里,第 347 行到 512 行负责拼接 HTML 字符串。我小心翼翼地加了 3 行代码,本地测试通过,提交上线。
结果全站样式崩了。
原因是那个 800 行的函数里,第 203 行有一个全局变量 i 被循环复用,我的新代码在第 600 行又声明了一个 i,导致前面某个循环提前退出,DOM 结构错位。
排查花了 4 小时,回滚花了 20 分钟。CTO 在群里@我:"下次改动先评估风险。"
我盯着屏幕,心想:这不是风险问题,这是架构问题。
三、偷换计划:渐进式入侵
直接重写 8 万行代码不现实,CTO 不可能批。我决定走一条"地下路线":在 jQuery 项目里逐步嵌入 Vue3,让用户无感知切换。
核心策略就三条:
- 页面级替换:一次只重构一个页面,不碰其他模块;
- 路由劫持:用 Vue Router 接管部分路由,jQuery 页面和 Vue 页面共存;
- 数据层复用:Vue 组件直接调用现有的 jQuery AJAX 封装,后端接口零改动。
3.1 第一步:在 jQuery 里"种"一个 Vue 应用
我在项目根目录偷偷加了 vue-app/ 文件夹,用 Vite 初始化了一个 Vue3 项目。然后修改了入口 HTML:
HTML
xml
<!-- 原来的 jQuery 入口 -->
<div id="legacy-app">
<!-- 所有 jQuery 页面渲染在这里 -->
</div>
<!-- 新增的 Vue 挂载点,默认隐藏 -->
<div id="vue-app" style="display:none;"></div>
在 main.js 里加了一段路由判断逻辑:
JavaScript
ini
// 路由映射表:哪些路径走 Vue,哪些走 jQuery
const VUE_ROUTES = ['/orders', '/users', '/dashboard-v2'];
const currentPath = window.location.pathname;
if (VUE_ROUTES.some(route => currentPath.startsWith(route))) {
// 隐藏 jQuery 容器,挂载 Vue
document.getElementById('legacy-app').style.display = 'none';
document.getElementById('vue-app').style.display = 'block';
createApp(App).use(router).mount('#vue-app');
} else {
// 继续走 jQuery 老逻辑
legacyInit();
}
关键点:Vue 页面和 jQuery 页面完全隔离,互不干扰。用户切换页面时,URL 正常跳转,只是底层渲染引擎换了。
3.2 第二步:复用 jQuery 的数据层
公司有一套用了 8 年的 jQuery AJAX 封装,大概长这样:
JavaScript
javascript
// legacy/api.js
window.LegacyAPI = {
get(url, params) { return $.ajax({ url, type: 'GET', data: params }); },
post(url, data) { return $.ajax({ url, type: 'POST', data: JSON.stringify(data) }); }
};
我在 Vue 项目里写了一个适配层,直接调用这个全局对象:
JavaScript
javascript
// vue-app/utils/legacyApi.js
export const legacyApi = {
get: (url, params) => window.LegacyAPI?.get(url, params) || Promise.reject('API not ready'),
post: (url, data) => window.LegacyAPI?.post(url, data) || Promise.reject('API not ready')
};
Vue 组件里这样用:
vue
xml
<script setup>
import { ref, onMounted } from 'vue';
import { legacyApi } from '@/utils/legacyApi';
const orders = ref([]);
onMounted(async () => {
const res = await legacyApi.get('/api/orders', { page: 1, size: 20 });
orders.value = res.data.list;
});
</script>
后端零改动,前端渐进替换。 这是整个方案能落地的核心。
3.3 第三步:用 Web Components 做组件桥接
有些页面是"半新半旧"------比如头部导航和侧边栏还是 jQuery 渲染的,但中间内容区想用 Vue 组件。
我封装了一个 Web Components 桥接器:
JavaScript
javascript
// vue-app/utils/webComponentAdapter.js
import { defineCustomElement } from 'vue';
import OrderTable from '@/components/OrderTable.vue';
const OrderTableElement = defineCustomElement(OrderTable);
customElements.define('order-table', OrderTableElement);
然后在 jQuery 页面里直接当原生标签用:
HTML
xml
<!-- 这是 jQuery 渲染的页面 -->
<div class="content">
<h2>订单管理</h2>
<!-- Vue 组件作为自定义元素嵌入 -->
<order-table :initial-data='<%= JSON.stringify(orders) %>'></order-table>
</div>
jQuery 负责页面框架,Vue 负责复杂交互组件,两边各干各的,互不污染。
四、最关键的战役:订单列表页
第一个完全用 Vue3 重构的页面是订单列表,这也是之前让我出事故的页面。
4.1 原来的 jQuery 写法(节选)
JavaScript
css
function renderOrderList(data) {
var html = '<table class="table"><thead><tr>';
html += '<th>订单号</th><th>客户</th><th>金额</th><th>状态</th>';
html += '</tr></thead><tbody>';
for (var i = 0; i < data.length; i++) {
var item = data[i];
var statusClass = item.status === 1 ? 'success' : 'danger';
html += '<tr data-id="' + item.id + '">';
html += '<td>' + item.orderNo + '</td>';
html += '<td>' + item.customerName + '</td>';
html += '<td>¥' + item.amount.toFixed(2) + '</td>';
html += '<td><span class="label label-' + statusClass + '">' + getStatusText(item.status) + '</span></td>';
html += '</tr>';
}
html += '</tbody></table>';
$('#order-list-container').html(html);
// 绑定事件
$('tr[data-id]').click(function() {
var id = $(this).data('id');
openOrderDetail(id);
});
}
问题:字符串拼接 HTML、全局事件委托、状态管理全靠 DOM 属性。加一个功能要改 5 个地方,删一行代码可能崩 3 个页面。
4.2 重构后的 Vue3 写法
vue
ini
<template>
<div class="order-page">
<SearchFilter v-model="filters" @search="handleSearch" />
<el-table :data="orders" @row-click="handleRowClick">
<el-table-column prop="orderNo" label="订单号" />
<el-table-column prop="customerName" label="客户" />
<el-table-column label="金额">
<template #default="{ row }">
¥{{ row.amount.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ statusMap[row.status] }}
</el-tag>
</template>
</el-table-column>
</el-table>
<Pagination v-model:page="pagination.page" v-model:size="pagination.size"
:total="pagination.total" @change="handleSearch" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { legacyApi } from '@/utils/legacyApi';
import SearchFilter from './components/SearchFilter.vue';
import Pagination from '@/components/Pagination.vue';
const filters = reactive({ keyword: '', status: '' });
const orders = ref([]);
const pagination = reactive({ page: 1, size: 20, total: 0 });
const statusMap = { 0: '待处理', 1: '已完成', 2: '已取消' };
const handleSearch = async () => {
const res = await legacyApi.get('/api/orders', {
...filters,
page: pagination.page,
size: pagination.size
});
orders.value = res.data.list;
pagination.total = res.data.total;
};
const handleRowClick = (row) => {
window.openOrderDetail?.(row.id); // 兼容老系统的全局函数
};
onMounted(handleSearch);
</script>
变化:
- 模板和逻辑分离,不再拼接字符串;
- 状态管理用
ref/reactive,不再依赖 DOM; - 事件绑定声明式处理,不再手动
$.click; - 分页、筛选、表格全部组件化,可复用。
重构完这个页面,我测了 3 天,上线后零事故。更关键的是,业务方说"这个页面怎么变快了",但他们不知道底层已经换了引擎。
五、全组都来抄代码了
订单列表页上线两周后,组里陆续有人问我:
- "你这个表格组件能借我用一下吗?"
- "分页逻辑怎么写的?我那个页面也想要。"
- "Vue 文件怎么在咱们项目里跑起来的?"
我表面上"勉为其难"地分享了代码,心里暗爽。
一个月后,组里 6 个人中有 4 个开始用 Vue3 写新页面。CTO 某天开周会时说:"最近前端代码质量好像有所提升,大家继续保持。"
我在下面憋笑。
六、技术方案总结
这套"渐进式入侵"方案的核心可以总结为一张图:
plain
bash
┌─────────────────────────────────────────┐
│ 用户看到的统一页面 │
├─────────────────────────────────────────┤
│ Vue Router 接管部分路由 │
│ ┌─────────┐ ┌─────────┐ │
│ │ Vue3 页面 │ │ Vue3 页面 │ │
│ │ /orders │ │ /users │ │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
│ ┌────┴──────────────┴────┐ │
│ │ legacyApi 适配层 │ │
│ │ (复用 jQuery AJAX 封装) │ │
│ └──────────┬─────────────┘ │
│ │ │
│ ┌──────────┴─────────────┐ │
│ │ jQuery 老系统 │ │
│ │ (其他页面继续运行) │ │
│ └────────────────────────┘ │
└─────────────────────────────────────────┘
关键设计原则:
- 零后端改动:所有接口复用,降低推进阻力;
- 页面级隔离:Vue 和 jQuery 互不污染,可灰度上线;
- 工具链独立:Vue 用 Vite,jQuery 用原生,构建产物通过 CDN 或同域部署引入;
- 渐进替换:风险可控,随时可回滚。
七、踩过的坑
- 全局样式污染 :jQuery 用的 Bootstrap 3 全局样式会作用到 Vue 组件。解决方案是在 Vue 根节点加
scoped样式 + CSS Modules。
- 内存泄漏 :Vue 组件卸载时,jQuery 绑定的事件没清理。解决方案是在
onUnmounted里手动$.off()。 - 路由刷新问题 :Vue Router 的
history模式需要服务端配置,老项目不支持。改用hash模式,完美兼容。 - 构建产物体积 :Vue3 + Element Plus 打包后 400KB+,老用户首次加载慢。用 Vite 的
manualChunks拆包 + CDN 引入,首屏降到 200KB 以内。
八、写在最后
重构这件事,技术上最难的不是写代码,而是在组织阻力下找到一条能推进的路。
如果我当时直接跟 CTO 说"我要把 8 万行 jQuery 重写成 Vue3",大概率会被驳回。但"渐进式替换"让重构变成了一个页面一个页面的优化,每次改动都有明确的业务价值,风险可控,证据清晰。
三个月后,Vue3 页面占了项目 60% 的访问量。CTO 终于主动找我:"要不......我们把剩下的也迁了吧?"
我笑了笑:"好,我出个方案。"
这就是那个方案:👉👉👉典我