Canvas簡歷編輯器-Monorepo+Rspack工程實踐

WindrunnerMax發表於2024-09-18

Canvas簡歷編輯器-Monorepo+Rspack工程實踐

在之前我們圍繞Canvas聊了很多程式碼設計層面的東西,在這裡我們聊一下工程實踐。在之前的文中我也提到過,因為是本著學習的態度以及對技術的好奇心來做的,所以除了一些工具類的庫例如 ArcoDesignResizeObserveJest 等包之外,關於 資料結構packages/delta、外掛化packages/plugin、核心引擎packages/core 等都是手動實現的,所以在這裡除了學習了Canvas之外,實際上還做了一些專案工程化的實踐。

  • 線上編輯: https://windrunnermax.github.io/CanvasEditor
  • 開源地址: https://github.com/WindrunnerMax/CanvasEditor

關於Canvas簡歷編輯器專案的相關文章:

  • 社群老給我推Canvas,我也學習Canvas做了個簡歷編輯器
  • Canvas圖形編輯器-資料結構與History(undo/redo)
  • Canvas圖形編輯器-我的剪貼簿裡究竟有什麼資料
  • Canvas簡歷編輯器-圖形繪製與狀態管理(輕量級DOM)
  • Canvas簡歷編輯器-Monorepo+Rspack工程實踐
  • Canvas簡歷編輯器-層級渲染與事件管理能力設計
  • Canvas簡歷編輯器-選中繪製與拖拽多選互動方案

Pnpm+Monorepo

我們先來聊聊為什麼要用monorepo,先舉一個我之前踩過的坑作為例子,在之前我的富文字編輯器專案 DocEditor 就是完全寫在了獨立的單個src目錄中,在專案本身的執行過程中是沒什麼問題的,但是當時我想將編輯器獨立出來作為NPM包用,打包的過程是藉助了Rollup也沒什麼問題,問題就出在了引用方上。當時我在簡歷編輯器中引入文件編輯器的NPM包時,發現有一個模組被錯誤的TreeShaking了,現在都還能在編輯器中看到這部分相容。

module: {
  rules: [
    {
      // 對`doc-editor-light`的`TreeShaking`有點問題
      test: /doc-editor-light\/dist\/tslib.*\.js/,
      sideEffects: true,
     },
   ]
}

這個問題導致了我在dev模式下沒有什麼問題,但是在build之後這部分程式碼被錯誤地移除掉了,導致編輯器的wrapper節點出現了問題,列表等元素不能正確新增。當然實際上這不能說明獨立包專案不好,只能說整個管理的時候可能並不是那麼簡單,尤其是打包為NPM包的時候需要注意各個入口問題。那麼現在引用我的富文字編輯器包已經變成了4個獨立的包分別引用,各司其職,就沒再出現過這個問題。

說起來打包的問題,我還踩過一個坑,不知道大家是不是見到過ReactInvalid hook call這個經典報錯。之前我將其獨立拆包的時候之後,發現會報這個錯,但是我在package.json中是標註的peerDependencies "react": ">=16",按理說這裡會直接應用安裝該包的React,不可能出現版本不一致的問題,至於Rules of Hooks肯定也不可能,因為我之前是好好的,拆完包才出的問題。最後發現是我在rollup中沒把peerDependencies這部分解析,導致jsx-runtime被打進了包裡,雖然React的版本都是17.0.2但是實際上是執行了兩個獨立詞法作用域的React Hooks,這才導致了這個問題。

Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:

1.  You might have mismatching versions of React and the renderer (such as React DOM)
2.  You might be breaking the Rules of Hooks
3.  You might have more than one copy of React in the same app See for tips about how to debug and fix this problem.

接著回到專案本身,當前專案已經抽離出來獨立的RspackMonoTemplate,平時開發也會基於這個模版建立倉庫。當前簡歷編輯器專案的結構tree -L 2 -I node_modules --dirsfirst如下:

CanvasEditor
│── packages
│   ├── core
│   ├── delta
│   ├── plugin
│   ├── react
│   └── utils
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── tsconfig.json
  • packages/core: 編輯器核心引擎模組,對於 剪貼簿操作、事件管理、狀態管理、History模組、Canvas操作、選區操作 等等都封裝在這裡,相當於實現了基本的Canvas引擎能力。
  • packages/delta: 資料結構模組,設計了基準資料結構,實現了DeltaSet資料結構以及原子化的Op操作,主要用於描述整個編輯器的資料結構以及操作,實現了invert等能力,對於實現History模組有很大的意義。
  • packages/plugin: 外掛模組,在packages/delta的基礎上設計了外掛化的能力,主要為了實現編輯器的功能模組化,例如TextImageRect等外掛都是在這裡實現的。
  • packages/react: React模組,主要是為了透過實現編輯器的檢視層,在這裡有比較重要的一點,我們的核心模組是檢視框架無關的,如果有必要的話同樣可以使用VueAngular等框架來實現檢視層。
  • packages/utils: 工具模組,主要是一些工具函式的封裝,例如FixedNumberPalette等等,這些工具函式在整個編輯器中都有使用,是作為基礎包在整個workspace中引用的。
  • package.json: 整個workspacepackage.json,在這裡配置了一些專案的資訊,EsLintStyleLint相關的配置也都在這裡實現。
  • pnpm-lock.yaml: pnpm的鎖檔案,用於鎖定整個workspace的依賴版本。
  • pnpm-workspace.yaml: pnpmworkspace配置檔案,用於配置monorepo的能力。
  • tsconfig.json: 整個workspacetsconfig配置檔案,用於配置整個workspaceTypeScript編譯配置,在這裡是作為基準配置以提供給專案中的模組引用。

pnpm自身是非常優秀的包管理器,透過硬連結和符號連結來節省磁碟空間,每個版本的包只需要儲存一次,最重要的是pnpm建立了一個非扁平化的node_modules結構,從而確保依賴與宣告嚴格匹配,嚴格控制了依賴提升,能夠避免依賴升級的意外問題,這提高了專案的一致性和可預測性。

而說回到monorepopnpm不光是非常優秀的包管理器,其還提供了一個開箱即用的monorepo能力。在pnpm中存在一個pnpm-workspace.yaml檔案,這個檔案是用來配置workspack的,而pnpmworkspace就可以作為monorepo的能力,而我們的配置也非常簡單,我們認為在packages目錄下的所有目錄都作為子專案。

packages:
  - 'packages/*'

透過monorepo我們可以很方便的管理所有子專案,特別是對於需要發Npm包的專案,將子模組拆分是個不錯的選擇,特別如果能夠做到檢視層框架無關的話就顯得更加有意義。此外,monorepo對於整個專案的管理也有很多益處,例如在打包整個應用的時候,我們不需要對每個子專案發新的包之後才能打包,而是可以直接將編譯過程放在workspace層面,這樣就可以保證整個專案的一致性,簡化了構建過程和持續整合流程,讓所有專案可以共享構建指令碼和工具配置。此外所有專案和模組共用同一個版本控制系統,便於進行統一的版本管理和變更跟蹤,而且還有助於同步更新這些專案間的依賴關係。

TS+Rspack最佳實踐

說了這麼多使用pnpm + monorepo管理專案帶來的好處,我們再來聊聊我對TSRspack應用於Monorepo的最佳實踐,不知道大家是不是遇到過這樣的兩個問題:

  • 子專案的TS宣告更改後不能實時生效,必須要編譯一次子專案才可以,而子專案編譯的過程中如果將dist等產物包刪除,那麼在vsc或者其他編輯器中就會報TS找不到引用宣告的錯誤,這個時候就必須要用命令重新Reload TypeScript Project來去掉報錯。而如果不將產物包刪除的話,就會出現一些隱性的問題,例如原來某個檔案命名為a.tsx,此時因為一些原因需要將其移動到同名的a目錄並且重新命名為index.tsx,那麼執行了這一頓操作之後,發現如果更改此時的index.tsx程式碼不會更新,必須要重啟應用的webpack等編譯器才行,因為其還是引用了原來的檔案,產生類似的問題雖然不復雜但是排查起來還是需要時間的。
  • 更改子專案的TS程式碼必須要重新編譯子專案,因為專案是monorepo管理的,在package.json中會有workspace引用,而workspace實際上是在node_modules被引用的,所以雖然是子專案但是仍然需要遵循node_modules的規則才可以,那麼其通常需要被編譯為js才可以被執行,所以每次修改程式碼都必須要全量執行一遍很是麻煩,當然通常我們可以透過-w命令來觀察變動,但是畢竟多了一道步驟,且如果是存在alias的專案可能僅僅使用tsc來編譯還不夠。此外在monorepo中我們通常會有很多子專案,如果每個子專案都需要這樣的話,特別在這種編譯時全量編譯而不是增量編譯的情況下,那麼整個專案的編譯時間就會變得非常長。

那麼在這裡我們先來看第一個問題,子專案的TS宣告更改後不能實時生效,因為我們也提到了monorepo子專案實際上是透過node_modules來管理和引用的,所以其在預設情況下依然需要遵循node_modules的規則,即packages.jsontypes欄位指向的TS宣告檔案,那麼我們有沒有什麼辦法可以修改這個行為呢,當然是有的,我們在整個專案的根tsconfig.json配置path就可以完美解決這個問題。當我們配置好如下的內容之後,透過按住Ctrl加滑鼠左鍵點選的時候,就可以跳轉到子專案的根目錄宣告瞭。此外這裡有個要關注的點是,在專案中不建議配置"baseUrl": ".",在這裡會有一些奇奇怪怪的路徑引用問題,所以在簡歷編輯器專案中除了要打包Npmtsconfig.build.json之外,都是直接使用相對路徑配置的。

{
  "compilerOptions": {
    "...": "...",
    "paths": {
      "sketching-core": ["./packages/core/src"],
      "sketching-delta": ["./packages/delta/src"],
      "sketching-plugin": ["./packages/plugin/src"],
      "sketching-utils": ["./packages/utils/src"],
    },
  },
  "include": [
    "packages/*/src"
  ]
}

那麼解決了專案的TS宣告問題之後,我們再來看編譯的問題,這裡的問題看起來會複雜一些,因為TS宣告就單純只是型別宣告而已,不會影響到專案本身程式碼的編譯,編譯型別檢查除外。那麼在Rspack中應該配置才能讓我們的程式碼直接指向子專案,而不是必須要走node_modules這套規則,實際上這裡也很簡單,只需要配置resolve.alias就可以了,這樣當我們直接修改TS程式碼時,也能讓編輯器立即響應增量編譯。

{
// ....
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "sketching-core": path.resolve(__dirname, "../core/src"),
      "sketching-delta": path.resolve(__dirname, "../delta/src"),
      "sketching-plugin": path.resolve(__dirname, "../plugin/src"),
      "sketching-utils": path.resolve(__dirname, "../utils/src"),
    },
  },
// ....
}

實際上對於Rspack而言其幫我們做了很多事,比如即使是node_modulesTS檔案也會編譯,而對於一些透過CRA建立的webpack專案來說,這個配置就麻煩一些,當然我們同樣也可以藉助customize-cra來完成這件事,此外我們還要關閉一些類似於ModuleScopePlugin的外掛才可以,下面是富文字編輯器專案 DocEditor 的配置。

const src = path.resolve(__dirname, "src");
const index = path.resolve(__dirname, "src/index.tsx");
const core = path.resolve(__dirname, "../core/src");
const delta = path.resolve(__dirname, "../delta/src");
const plugin = path.resolve(__dirname, "../plugin/src");
const utils = path.resolve(__dirname, "../utils/src");

module.exports = {
  paths: function (paths) {
    paths.appSrc = src;
    paths.appIndexJs = index;
    return paths;
  },
  webpack: override(
    ...[
      // ...
      addWebpackResolve({
        alias: {
          "doc-editor-core": core,
          "doc-editor-delta": delta,
          "doc-editor-plugin": plugin,
          "doc-editor-utils": utils,
        },
      }),
      babelInclude([src, core, delta, plugin, utils]),
      // ...
      configWebpackPlugins(),
    ].filter(Boolean)
  ),
};

此外,簡歷編輯器是純前端的專案,這樣的專案有個很大的優勢是可以直接使用靜態資源就可以執行,而如果我們藉助GitHub Action就可以透過Git Pages在倉庫中直接部署,並且可以直接透過GitHub Pages訪問,這樣在倉庫中就能呈現一個完整的DEMO

// .github/workflows/deploy.yml
name: deploy gh-pages

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
          persist-credentials: false
          
      - name: install node-v16
        uses: actions/setup-node@v3
        with:
          node-version: '16.16.0'

      - name: install dependencies
        run: |
          node -v
          npm install -g pnpm
          pnpm config set registry https://registry.npmjs.org/
          pnpm install --registry=https://registry.npmjs.org/

      - name: build project
        run: |
          npm run build:react

      - name: deploy project
        uses: JamesIves/github-pages-deploy-action@releases/v3
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: gh-pages
          FOLDER: packages/react/build

最後

在這裡我們聊了為什麼要用Monorepo以及簡單聊了一下pnpm workspace的優勢,然後解決了在子專案開發中會遇到的TS編譯、專案編譯的兩個實際問題,分別在MonorepoRspackWebpack專案中相關的部分實踐了一下,最後還簡單聊了一下利用GitHub Action直接在Git Pages部署線上DEMO。那麼再往後邊的文章中,我們就需要聊一聊如何實現 層級渲染與事件管理 的能力設計。

相關文章