公司技術債堆積如山,我一人之力用 Vue3 偷天換日了整個前端架構

> 先打個預防針:本文僅供技術交流,不建議在正式環境未經審批直接重構。但我確實這麼做了。

一、事情是怎麼開始的

公司有個 2016 年上線的後台管理系統,前端技術棧是 jQuery 1.12 + Bootstrap 3 + 手寫模板引擎。程式碼量大概 8 萬行,沒有模組化,沒有建置工具,HTML 裡直接 <script src="..."> 引入 30 多個 JS 檔案,載入順序全靠人工記憶。

我入職第一天,CTO 跟我說:「這個專案很穩定,你熟悉一下業務邏輯就好,別亂動。」

我打開程式碼一看,一個 app.js 檔案 6000 多行,裡面混著 DOM 操作、AJAX 請求、業務邏輯、工具函式,變數命名從 az 輪著用。最絕的是,裡面有一行註解:

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,讓使用者無感切換。

核心策略就三條:

  1. 頁面級替換:一次只重構一個頁面,不碰其他模組;
  2. 路由劫持:用 Vue Router 接管部分路由,jQuery 頁面和 Vue 頁面共存;
  3. 資料層復用: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 舊系統        │            │
│  │  (其他頁面繼續運行)      │            │
│  └────────────────────────┘            │
└─────────────────────────────────────────┘

關鍵設計原則

  1. 零後端改動:所有介面復用,降低推進阻力;
  2. 頁面級隔離:Vue 和 jQuery 互不污染,可灰度上線;
  3. 工具鏈獨立:Vue 用 Vite,jQuery 用原生,建置產物透過 CDN 或同網域部署引入;
  4. 漸進替換:風險可控,隨時可回滾。

七、踩過的坑

  1. 全域樣式污染:jQuery 用的 Bootstrap 3 全域樣式會作用到 Vue 元件。解決方案是在 Vue 根節點加 scoped 樣式 + CSS Modules。

  2. 記憶體洩漏:Vue 元件卸載時,jQuery 綁定的事件沒清理。解決方案是在 onUnmounted 裡手動 $.off()

  3. 路由重新整理問題:Vue Router 的 history 模式需要伺服器設定,舊專案不支援。改用 hash 模式,完美相容。

  4. 建置產物體積:Vue3 + Element Plus 打包後 400KB+,老使用者首次載入慢。用 Vite 的 manualChunks 拆包 + CDN 引入,首屏降到 200KB 以內。


八、寫在最後

重構這件事,技術上最難的不是寫程式碼,而是在組織阻力下找到一條能推進的路

如果我當時直接跟 CTO 說「我要把 8 萬行 jQuery 重寫成 Vue3」,大概率會被駁回。但「漸進式替換」讓重構變成了一個頁面一個頁面的優化,每次改動都有明確的業務價值,風險可控,證據清楚。

三個月後,Vue3 頁面占了專案 60% 的存取量。CTO 終於主動找我:「要不……我們把剩下的也遷了吧?」

我笑了笑:「好,我出個方案。」

這就是那個方案:👉👉👉點我


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


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

共有 0 則留言


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