我偷偷把公司的祖传 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 不变 --- 用户 bookmark 不会失效
  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  # 启动兼容模式

互动时间:你重构过最离谱的祖传代码是什么?欢迎在评论区分享~

相关推荐
用户2136610035721 小时前
Vue2非父子通信与动态组件
前端·vue.js
PedroQue991 小时前
Vite插件体系1.0.0:API稳定,生产就绪
前端·vite
用户059540174461 小时前
把LLM记忆测试从手工脚本换成Pytest参数化,回归时间从2小时降到10分钟
前端·css
donecoding1 小时前
3 条命令搞定闭环 Monorepo:Lerna 版本管理 + 拓扑构建 + 自定义分发
前端·前端框架·node.js
IT_陈寒1 小时前
Vue的这个响应式陷阱让我熬到凌晨三点
前端·人工智能·后端
爱勇宝10 小时前
大多数人不是在使用 AI 赚钱,而是在帮 AI 公司赚钱
前端·后端·程序员
冬奇Lab11 小时前
每日一个开源项目(第143篇):page-agent - 纯 JS 的网页 GUI Agent,无需截图、无需插件、无需后端
前端·人工智能·agent
IT_陈寒15 小时前
React的这个渲染问题连官方文档都没说清楚
前端·人工智能·后端
追逐时光者17 小时前
别再满网找零散工具了,腾讯 QQ 浏览器这个“帮小忙”工具箱真能省时间
前端·后端