Canvas簡歷編輯器-Monorepo+Rspack工程實踐
在之前我們圍繞Canvas
聊了很多程式碼設計層面的東西,在這裡我們聊一下工程實踐。在之前的文中我也提到過,因為是本著學習的態度以及對技術的好奇心來做的,所以除了一些工具類的庫例如 ArcoDesign
、ResizeObserve
、Jest
等包之外,關於 資料結構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
個獨立的包分別引用,各司其職,就沒再出現過這個問題。
說起來打包的問題,我還踩過一個坑,不知道大家是不是見到過React
的Invalid 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
的基礎上設計了外掛化的能力,主要為了實現編輯器的功能模組化,例如Text
、Image
、Rect
等外掛都是在這裡實現的。packages/react
:React
模組,主要是為了透過實現編輯器的檢視層,在這裡有比較重要的一點,我們的核心模組是檢視框架無關的,如果有必要的話同樣可以使用Vue
、Angular
等框架來實現檢視層。packages/utils
: 工具模組,主要是一些工具函式的封裝,例如FixedNumber
、Palette
等等,這些工具函式在整個編輯器中都有使用,是作為基礎包在整個workspace
中引用的。package.json
: 整個workspace
的package.json
,在這裡配置了一些專案的資訊,EsLint
、StyleLint
相關的配置也都在這裡實現。pnpm-lock.yaml
:pnpm
的鎖檔案,用於鎖定整個workspace
的依賴版本。pnpm-workspace.yaml
:pnpm
的workspace
配置檔案,用於配置monorepo
的能力。tsconfig.json
: 整個workspace
的tsconfig
配置檔案,用於配置整個workspace
的TypeScript
編譯配置,在這裡是作為基準配置以提供給專案中的模組引用。
pnpm
自身是非常優秀的包管理器,透過硬連結和符號連結來節省磁碟空間,每個版本的包只需要儲存一次,最重要的是pnpm
建立了一個非扁平化的node_modules
結構,從而確保依賴與宣告嚴格匹配,嚴格控制了依賴提升,能夠避免依賴升級的意外問題,這提高了專案的一致性和可預測性。
而說回到monorepo
,pnpm
不光是非常優秀的包管理器,其還提供了一個開箱即用的monorepo
能力。在pnpm
中存在一個pnpm-workspace.yaml
檔案,這個檔案是用來配置workspack
的,而pnpm
的workspace
就可以作為monorepo
的能力,而我們的配置也非常簡單,我們認為在packages
目錄下的所有目錄都作為子專案。
packages:
- 'packages/*'
透過monorepo
我們可以很方便的管理所有子專案,特別是對於需要發Npm
包的專案,將子模組拆分是個不錯的選擇,特別如果能夠做到檢視層框架無關的話就顯得更加有意義。此外,monorepo
對於整個專案的管理也有很多益處,例如在打包整個應用的時候,我們不需要對每個子專案發新的包之後才能打包,而是可以直接將編譯過程放在workspace
層面,這樣就可以保證整個專案的一致性,簡化了構建過程和持續整合流程,讓所有專案可以共享構建指令碼和工具配置。此外所有專案和模組共用同一個版本控制系統,便於進行統一的版本管理和變更跟蹤,而且還有助於同步更新這些專案間的依賴關係。
TS+Rspack最佳實踐
說了這麼多使用pnpm + monorepo
管理專案帶來的好處,我們再來聊聊我對TS
與Rspack
應用於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.json
的types
欄位指向的TS
宣告檔案,那麼我們有沒有什麼辦法可以修改這個行為呢,當然是有的,我們在整個專案的根tsconfig.json
配置path
就可以完美解決這個問題。當我們配置好如下的內容之後,透過按住Ctrl
加滑鼠左鍵點選的時候,就可以跳轉到子專案的根目錄宣告瞭。此外這裡有個要關注的點是,在專案中不建議配置"baseUrl": "."
,在這裡會有一些奇奇怪怪的路徑引用問題,所以在簡歷編輯器專案中除了要打包Npm
的tsconfig.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_modules
的TS
檔案也會編譯,而對於一些透過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
編譯、專案編譯的兩個實際問題,分別在Monorepo
、Rspack
、Webpack
專案中相關的部分實踐了一下,最後還簡單聊了一下利用GitHub Action
直接在Git Pages
部署線上DEMO
。那麼再往後邊的文章中,我們就需要聊一聊如何實現 層級渲染與事件管理 的能力設計。