Stories

Detail Return Return

使用 AST 遷移複雜前端項目的探索 - Stories Detail

寫在前面

也不知道為什麼,每一次工作變動,所接手的第一個項目,都和項目遷移有關。這次也不例外,在 5 月初入職樂天之後,處理完雜七雜八的事情,第一個接手的項目是將一個大概有 5 年開發週期的 nuxt 2 前端項目,遷移到 nuxt 3 版本。

項目遷移與“屎山”

談及項目遷移,尤其是複雜項目,很容易讓人把它和“屎山”聯繫起來,而事實上也是如此,大多數規模較大的項目,往往都具備“屎山”的各種特徵,比如:

  • 代碼中充斥着各種不得已而為之的反模式,如滿天飛的 if..else 以及副作用
  • 依賴庫年久失修,比如項目使用的版本是 1.x 版本,社區版本可能早就 3.x 開外了,這個現象在前端開發中尤其常見
  • 代碼的可復性、可維護性低,換另外一種形容方式就是,項目十分脆弱,牽一髮而動全身
  • 項目具有一個需要持續運營的生產環境,這一點基本上是各種“屎山”越積越大的根本原因

一開始我對接手“屎山”項目是非常排斥的,但經過這麼多年的工作,我現在認為是否能與“屎山”代碼和解,其實能反向證明一個軟件工程師在解決問題時,心智模型的成熟程度(也可以算作我自己 PUA 自己吧):

  • 不成熟的心智模型: 先對“屎山”代碼及其之前的作者一頓抨擊,並揚言自己可以以重構的方式,一攬子解決所有問題,balabala..(省略若干字)
  • 成熟的心智模型: 鄙視“屎山”,理解“屎山”,與“屎山”和解(不是成為“屎山”)

其中最核心的區別在於解決問題的方式,但凡有過複雜項目開發經驗的軟件工程師,都知道對一個複雜項目進行重寫的可能性,非技術原因才是決定性因素,除了技術能力之外,要考慮的因素非常多,同時所花費的成本也難以量化估計,大部分情況都是,不是不能重寫,而是不敢重寫、不願重寫。

而我接手的這個項目,雖然不至於説它是一個“屎山”項目,但情況也不算太樂觀,類似規模和場景的遷移工作我之前大概做過兩次,基本上都是按照重寫的方式來實施,這次雖然也可以這樣做,但我認為還有其他的可能性可以嘗試,因此經過調研,發現可以借用 AST 的概念來極大地提升遷移過程中的開發體驗。

如何衡量複雜前端項目

對於複雜前端項目,我認為可以從以下幾個維度參考:

  • 頁面個數規模大於 100+
  • 組件個數(通用、業務組件都算)大於 500+
  • 使用了狀態管理框架,幷包含較複雜的邏輯
  • 項目類型為 To C 項目

一個前端項目如果具備以上幾點,基本上就可以認定它是一個複雜項目了。

關於重寫項目

讓我們先來重新審視“重寫項目”這種解決方案。

大多數場景下,為了説服管理層,我們往往會歸納關於重寫項目可帶來的若干優點,比如:

  • 可以償還技術債
  • 可以更新技術棧
  • 極大的提升性能、可維護性、可讀性
  • 有效提升開發效率
  • ...

針對缺點可能往往只會用一句需要額外花費人力物力不了了之,但實際上,還有很多潛在的其他缺點和風險,比如:

  • 可能需要凍結代碼,以保證重寫項目可以平穩進行

    • 這一點對於許多 To B 管理系統是能夠容忍的,但對於已上線的 To C 項目,基本無法容忍
  • 重寫的成功率是一個未知數,不可量化

    • 很難以一種可量化的方式來衡量遷移成功率、進度以及影響範圍
  • 遷移的過程不可複製,不具備冪等性

    • 同一個人,對一個項目遷移兩次,無法保證兩次遷移的結果完全相同,這其中充滿了隨機性
    • 那麼問題來了,為什麼要對一個項目進行二次遷移,這是因為遷移的過程可能會被一些客觀因素中斷或終止

這些缺點和風險與項目的複雜性成正相關關係,即項目越複雜,重寫的風險越高(好像説了句廢話),核心的原因在於,這些缺點在簡單的項目場景下,並不是不存在,只是因為項目足夠簡單,這些缺點產生的問題可以在很短的時間被解決,但在複雜場景下,由於無法在短時間內解決問題,這些問題會持續在重寫過程中,蠶食重寫的可能性,最終導致遷移失敗。

why migration of large-scale project is hard

上圖中的三個圓圈的含義分別是:

  • no tech debt(無技術債): 表示項目的遷移完整程度,完美的狀態是無任何技術負債
  • low cost(低成本): 表示項目遷移所花費的時間,時間和成本越低越好
  • high tability(高穩定性): 表示項目遷移後的穩定程度,越穩定代表遷移越成功

可以發現,上述三者彼此無法同時滿足,高穩定性肯定是優先考慮的,因此遷移項目的策略,通常會在無技術債和時間成本兩者之前取捨,如果是偏向成本,則遷移策略偏向於重構或局部重寫,如果是偏向無技術債,則策略偏向於完整重寫。

我認為,大部分的開發者都比較傾向於無技術債的完整重寫策略的主要原因在於,別人的技術債不是我的技術債,因此無論是重構還是局部重寫,某種程度上算是在替別人還債,這在心理上本身就是抗拒的,其次就是大部分項目的複雜程度,都不足以在完整重寫策略之下,暴露無法解決問題。

migration with manual works

但這種策略對於 To C 下的項目基本無法實施,主要原因在於 To C 類型的項目的迭代週期通常非常短,同時凍結代碼的成本也非常高。如上圖所示,在遷移過程中,需求變更請求隨時可能發生,開發者無法專注於遷移項目的工作,有時甚至會迫不得已的擱置遷移計劃以對應緊急需求,隨着項目發展,新代碼勢必會和已完成的遷移在代碼層面形成衝突,同時開發者也會遺忘之前遷移工作中的若干細節,從而使遷移工作變成了不可能完成的任務。

AST 及 Codemod

在 2024 年,我想 AST 的概念對於每個前端開發者應該都不在陌生,雖然在日常工作中,很少與它直接打交道,但現代前端開發已經無法脱離 typescriptbabeleslint 等工具,因此可以説我們的日常工作與 AST 息息相關。

AST(Abstract Syntax Tree),即抽象語法樹,是一種用於表示源代碼結構的樹狀數據結構。它將源代碼分解成更小的、具有層次關係的節點,每個節點代表源代碼中的一個語法結構。AST 是編譯器和解釋器在代碼分析和轉換過程中使用的核心數據結構,比如 estree,它是 javascript 生態中一個較流行的 AST 規範。

由於 AST 是用來表達源碼的一種數據結構,對於 AST 進行編輯等同於編輯源碼,這也是各類生態工具的核心工作原理,如 babel, 它的工作原理即是將使用新語法的 js 代碼轉化為 AST ,再生成具備等價邏輯的、不使用新語法、兼容性更好的 js 代碼。

那麼是否可以將 AST 應用到項目遷移和重寫過程中呢?答案是肯定的,它叫作 Codemod,是一種用於大規模代碼重構的工具或技術,特別適用於在大型代碼庫中進行批量修改。它通常用於自動化地執行重複性高、容易出錯的代碼更改任務。

值得注意的是,Codemod 是這類遷移工具的統稱,它內部的實現可以基於 AST,也可以基於其他實現方式,在 AST 方面的 Codemod 工具,js 生態中已經有很多嘗試,常見的諸如 recastjscodeshift,還有很多以某個框架為目標的 Codemod 工具,如 react-codemodangular-cli 自帶的 codemod 命令等。

由於我手上的這個項目基於 nuxt,經過調研,我決定使用 vue-metamorph 這個 codemod 框架來實現項目遷移工作,之所以稱它為框架,是因為它本身不提供任何 codemod 的實現,而是暴露了一系列工具方法,讓開發者自行來實現 codemod 邏輯並以 plugin 的形式供它調用,它的作者還有一個項目叫作 vue-upgrade-tool,其中實現了若干用於從 vue 2 遷移至 vue 3 的 codemod 實現。

how codemod works

使用 codemod 來遷移項目,最主要的優勢包含以下幾點:

  • 速度快,且不受人為主觀因素(如粗心、精力等)影響
  • 遷移結果通過單元測試進行驗證,以確保可靠性,同時遷移結果具備冪等性
  • 可以同時在相同特徵(如使用相同的框架)的項目或不同版本中複用
  • 遷移的影響範圍可量化,可以通過在 codemod 執行過程中,記錄變更狀態實現
  • 遷移的顆粒度具備原子性,可以和編程語言 AST 本身節點保持一樣的顆粒度

我認為前三條優點都是十分吸引人的,因為這些優點都是人工遷移項目時的痛點。

由於 codemod 本身的邏輯可以通過單元測試進行覆蓋和驗證,因此可以引入 DDD(測試驅動開發)的開發模式來推進遷移工作,遷移工作的規劃不在以項目代碼本身為目標進行,而是以提前歸納和整理好的測試用例為目標,這樣即使因對應緊急需求而暫時擱置了遷移計劃,由於目標本身並非代碼本身,它不會受到新代碼的影響,同時測試用例也會提交到代碼倉庫中,在任何時間點通過測試用例都可以回憶起當時遷移的所有細節。

實踐案例

這裏以 nuxt 2 遷移 nuxt 3 為場景,以官方文檔提供的遷移指南,簡單列舉幾個實踐案例。例子中關於 codemod 的代碼,涉及一些 vue-metamorph 中內容,可以參考這裏瞭解。

defineNuxtComponent

在 nuxt 3 中,雖然官方文檔已推薦使用 Composable API 來實現,但 nuxt 2 的代碼仍然是基於 Options API,因此,官方文檔推薦針對所有使用 Options API 的組件,均通過 defineNuxtComponent 來定義,如下:

遷移前:

// nuxt 2
<script>
export default {
  // several properties with Options API
};
</script>

遷移後:

// nuxt 3
<script>
export default defineNuxtComponent({
  // several properties with Options API
});
</script>

關於 vue-matemorph 插件的實現源碼:

import { namedTypes as n } from 'ast-types';
import { CodemodPlugin } from 'vue-metamorph';

export const defineNuxtComponentCodemod: CodemodPlugin = {
  type: 'codemod',
  name: 'define-nuxt-component',
  transform({ scriptASTs, sfcAST, utils: { traverseScriptAST, traverseTemplateAST, astHelpers, builders }, opts }) {
    let transformCount = 0;

    for (const scriptAST of scriptASTs) {
      // 僅遷移 Options API
      if (scriptAST.isScriptSetup) continue;

      // 查找 AST 語法樹中,是否已包含名稱為 defineNuxtComponent 的 CallExpression
      const transformed = astHelpers.findFirst(scriptAST, {
        type: 'CallExpression',
        callee: {
          type: 'Identifier',
          name: 'defineNuxtComponent'
        }
      });

      // 如果存在,則跳過遷移邏輯
      if (transformed) continue;

      // 遍歷 AST 語法樹
      traverseScriptAST(scriptAST, {
        // 遍歷 ExportDefaultDeclaration 節點的回調方法
        visitExportDefaultDeclaration(path) {
          // 當節點類型是 ObjectExpression 時,即聲明 Options API 的默認導出對象
          if (path.node.declaration.type === 'ObjectExpression') {
            // 構建一個 名稱為 defineNuxtComponent 的 CallExpression 節點
            const defineNuxtComponentCallExpression = builders.callExpression(builders.identifier('defineNuxtComponent'), [path.node.declaration]);

            // 變更 ObjectExpression 的 declaration 屬性
            path.node.declaration = defineNuxtComponentCallExpression;

            transformCount++;
          }

          // 繼續遍歷
          this.traverse(path);
        }
      });
    }

    return transformCount;
  }
};

async components

類似地,對於異步加載組件的方式,vue 3 也要求遷移至使用 defineAsyncComponent 來完成,如下:

遷移前:

// nuxt 2
<script>
export default {
  components: {
    DatePicker: () => import('@/components/common/DatePicker')
  }
};
</script>

遷移後:

// nuxt 3
<script>
export default {
  components: {
    DatePicker: defineAsyncComponent(() => import('@/components/common/DatePicker'))
  }
};
</script>

關於 vue-matemorph 插件的實現源碼:

import { namedTypes as n } from 'ast-types';
import { CodemodPlugin } from 'vue-metamorph';

export const asyncComponentCodemod: CodemodPlugin = {
  type: 'codemod',
  name: 'async-component',
  transform({ scriptASTs, sfcAST, utils: { traverseScriptAST, traverseTemplateAST, builders, astHelpers }, opts }) {
    let transformCount = 0;

    for (const scriptAST of scriptASTs) {
      // 僅遷移 Options API
      if (scriptAST.isScriptSetup) continue;

      // 查找 AST 語法樹中,聲明 Options API 的默認導出對象
      const obj = astHelpers.findFirst(scriptAST, {
        type: 'ObjectExpression'
      });

      if (obj) {
        // 查找默認對象中的 components 屬性
        const componentsProperty = astHelpers.findFirst(obj, {
          type: 'Property',
          key: {
            type: 'Identifier',
            name: 'components'
          }
        });

        if (componentsProperty) {
          // 查找 components 中所有通過 import() 導入的組件
          astHelpers
            .findAll(componentsProperty, { type: 'Property' })
            .filter((node) => {
              return n.ArrowFunctionExpression.check(node.value);
            })
            .forEach((node) => {
              // 構建名稱為 defineAsyncComponent 的 CallExpression
              // 並變更 Property 節點的 value 屬性,它表示 : 號後的源碼部分
              node.value = builders.callExpression(builders.identifier('defineAsyncComponent'), [<n.ArrowFunctionExpression>node.value]);

              transformCount++;
            });
        }
      }
    }

    return transformCount;
  }
};

programmatic navigation

在 nuxt 3 中,vue-router 的命令式跳轉 API 也存在 breaking change,需要進行如下變更:

遷移前:

// nuxt 2
<template>
  <button @click="$router.push('/foo')">nav</button>
</template>
<script>
export default {
  methods: {
    navigate() {
      this.$router.push({
        path: '/search',
        query: {
          name: 'first name',
          type: '1'
        }
      });
    }
  }
};
</script>

遷移後:

// nuxt 3
<template>
  <button @click="navigateTo('/foo')">nav</button>
</template>
<script>
export default {
  methods: {
    navigate() {
      navigateTo({
        path: '/search',
        query: {
          name: 'first name',
          type: '1'
        }
      });
    }
  }
};
</script>

關於 vue-matemorph 插件的實現源碼:

import { namedTypes as n } from 'ast-types';
import { CodemodPlugin } from 'vue-metamorph';

export const programmaticNavigationCodemod: CodemodPlugin = {
  type: 'codemod',
  name: 'programmatic-navigation',
  transform({ scriptASTs, sfcAST, utils: { traverseScriptAST, traverseTemplateAST, astHelpers, builders }, opts }) {
    let transformCount = 0;

    // 遍歷 template 和 script 代碼塊中 CallExpression 節點的通用方法
    const traverse = (isTpl: boolean, ast: n.ASTNode) =>
      traverseScriptAST(ast, {
        // 該方法就不仔細寫註釋了,就是實現了 router.push 到 navigateTo 的變更
        visitCallExpression(path) {
          if (
            n.MemberExpression.check(path.node.callee) &&
            (isTpl
              ? n.Identifier.check(path.node.callee.object) && path.node.callee.object.name === '$router'
              : n.MemberExpression.check(path.node.callee.object) &&
                n.Identifier.check(path.node.callee.object.property) &&
                path.node.callee.object.property.name === '$router') &&
            n.Identifier.check(path.node.callee.property) &&
            path.node.callee.property.name === 'push'
          ) {
            const navigateToCallee = builders.identifier('navigateTo');

            path.node.callee = navigateToCallee;

            transformCount++;
          }

          this.traverse(path);
        }
      });

    if (sfcAST) {
      traverseTemplateAST(sfcAST, {
        enterNode(node) {
          if (node.type === 'VOnExpression') {
            // 對 template 代碼塊中的 VOnExpression 節點調用變更遍歷邏輯
            node.body.forEach((ast) => traverse(true, ast));
          }
        }
      });
    }

    for (const scriptAST of scriptASTs) {
      // 僅遷移 Options API
      if (scriptAST.isScriptSetup) continue;

      // 對 script 代碼塊中的根節點調用變更遍歷邏輯
      traverse(false, scriptAST);
    }

    return transformCount;
  }
};

@nuxt/i18n v8

一些通用解決方案的依賴庫,如 @nuxt/i18n,v8 版本和 v7 版本存在較多 breaking change,也可以通過 codemod 的方式進行遷移,如下:
遷移前:

// nuxt 2
<template>
  <div>{{ $t('foo') }}</div>
</template>

<script>
import messages from '@/locales';

export default {
  i18n: {
    messages
  },
  methods: {
    foo() {
      console.log(this.$t('foo'));
    }
  }
};
</script>

遷移後:

// nuxt 3
<template>
  <div>{{ t('foo') }}</div>
</template>

<script>
export default {
  setup() {
    const { t } = useI18n({
      useScope: 'local'
    });

    return {
      t
    };
  },
  methods: {
    foo() {
      console.log(this.t('foo'));
    }
  }
};
</script>

<i18n lang="json">
{
  "ja": {
    "foo": "foo_ja"
  },
  "en": {
    "foo": "foo_en"
  }
}
</i18n>

針對該插件的實現,因為源碼較長,就不復制粘貼了,這裏大概展示一些執行該插件的效果錄屏。

針對 700+ 數量的 .vue 文件遷移時間,只需要大約 15s 左右的時間,如果這些工作是通過手動完成的話,算每個文件只需要 10s 的時間,700+ 文件作一次遷移大約需要 2 小時,時間差距已經在數量級上拉開了差距。

同時,codemod 的遷移方式,插件內部的遷移邏輯可以通過單元測試進行覆蓋,以保證它一定按照預想的結果執行,否則就會報錯,只要用例準備充分,理論上不會發生遷移故障,而手動的方式則只能靠主觀意識和代碼審查來保障達到這些標準。

這裏簡單列舉上面 async component 例子中的測試用例:

import { transform } from 'vue-metamorph';
import { expect, test } from 'vitest';
import { asyncComponentCodemod } from './async-component';

const baseOptions = {
  alias: '@:../'
};

test('change the import syntax with defineAsyncComponent', () => {
  const source = `<template>
<div>test</div>
</template>
<script>
export default {
  components: {
    DatePicker: () => import('@/components/common/DatePicker'),
  },
};
</script>
`;

  const expected = `<template>
<div>test</div>
</template>
<script>
export default {
  components: {
    DatePicker: defineAsyncComponent(() => import('@/components/common/DatePicker')),
  },
};
</script>
`;

  expect(transform(source, 'file.vue', [asyncComponentCodemod], baseOptions).code).toBe(expected);
});

可以發現,遷移工作的推進,都可以按照 DDD 的模式來進行,開發、驗證、迭代,都面向了測試用例,而非源代碼本身,當測試用例足夠充足和完整時,只要執行這些插件對源代碼進行遷移即可。

沒有銀彈

雖然 codemod 工具可以有效提升遷移效率,並具備若干優點,但被遷移的項目也需要符合一定的前提,才適合它發揮作用:

  • 項目本身要足夠複雜,簡單的項目使用 codemod 遷移好比大炮打蚊子
  • 遷移邏輯要具備一定的模式和規模,如果一個遷移邏輯只涉及很少的源文件,這種場景則通過手動方式進行遷移更快捷、更合適
  • 更適合做框架、依賴庫相關的遷移

同時,編寫 codemod 工具也需要花費額外的精力和時間,如果這些成本無法抵消它所節省的收益,那就會發生得不償失的結果。

總結

截止目前為止,除了 vue-upgrade-tool 中包含的 plguin 之外,當前這個項目,額外實現了大約 30+ 個 plugin 來完成 nuxt 2 到 nuxt 3 框架的遷移工作,這些插件的實現邏輯,主要參考自下列這些文章:

  • the official discussion topic
  • the official guideline for migration of nuxt 2 to nuxt 3
  • the official guideline for migration of vue 2 to vue 3
  • a check sheet article from community

主要覆蓋 nuxt、vue 以及第三方依賴庫在升級過程中,需要解決的各類 breaking changes 以及 API 調用方式。

雖然這個項目是針對 nuxt 2 到 nuxt 3 做遷移,但利用相同的原理,基於任何框架的項目都可以使用類似地方式進行類似的遷移過程,以達到可以無痛升級技術棧的目標。

通過 codemod 的方式,雖然無法 100% 完成遷移任務(請牢記任何解決方案都不是銀彈),但我們至少可以通過它來完成,那些在遷移過程中,重複的、機械的、瑣碎的部分,而將有限的時間,投入到那些真正需要關注的部分,如複雜業務邏輯優化、性能優化等方面。

user avatar cyzf Avatar haoqidewukong Avatar nihaojob Avatar freeman_tian Avatar qingzhan Avatar kobe_fans_zxc Avatar inslog Avatar Dream-new Avatar xiaoxxuejishu Avatar zero_dev Avatar solvep Avatar dunizb Avatar
Favorites 149 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.