從零到一構建並打包 React + TypeScript + Less元件庫教程(二、元件庫編譯多產物及文件編寫)

风希落發表於2024-11-14

本系列目錄如下:

  1. 專案初始化搭建+程式碼規範整合
  2. 元件庫多產物編譯及文件編寫

上篇文章我們將元件庫的基本結構和規範進行了整理,本篇的核心基本全在 components 資料夾下

本篇的打包參考了文章 https://github.com/worldzhao/blog/issues/5 ,強烈建議閱讀一下此文章,而且討論區也能讓你收穫良多。

本章節分成了兩次 commit

  • 打包 es 和 lib 以及樣式:https://github.com/json-q/rc-library-templete/commit/c9d8a53c26d1189cd05939f57d07b07bfd2c1642
  • 初始化 storybook 測試打包產物:https://github.com/json-q/rc-library-templete/commit/1af4b5316ee2da0ed4febc69e28c19e166d6b5c9

安裝元件開發依賴

react 和 react-dom 依賴整合

  • package.json 檔案的 peerDependencies 寫入 react react-dom
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },

執行 pnpm install

為什麼是 peerDependencies 而不是 devDependencies

peerDependencies 可以約束使用該包的宿主環境,控制其相容依賴的版本在指定範圍內,而 devDependencies 則是純粹的開發依賴

  • 安裝 reactreact-dom 的 types 型別包
pnpm i @types/react @types/react-dom -D

clsx

安裝 clsx 作為樣式開發的庫,其它用到了再安裝即可

pnpm i clsx -D

編寫 less 樣式

  • componets/src 下新建 style 資料夾
  • 新建 index.less 作為入口檔案
  • 新建 normalize.less 重置樣式,直接去 arco design 倉庫 複製下來即可。
  • style 資料夾下新建 themes 資料夾,在 themes 下新建 default.less,宣告一些預設 less 變數
// packages/components/src/style/themes/default.less

@prefix: rclt;

@font-family:
  Inter,
  -apple-system,
  BlinkMacSystemFont,
  PingFang SC,
  Hiragino Sans GB,
  noto sans,
  Microsoft YaHei,
  Helvetica Neue,
  Helvetica,
  Arial,
  sans-serif;

@font-size-body: 14px;

@line-height-base: 1.5715;
  • index.less 中引入 default.lessnormalize.less
@import './themes/default.less';
@import './normalize.less';

此時的檔案結構應該是這樣的

- packages
  - components
    - src
      - style
        - themes
          - default.less
        - index.less
        - normalize.less

寫這麼多是為了在後續編譯 less 樣式時能看出明顯的效果。

編寫測試元件 Button

程式碼我就不列出來了,大家可以自行編寫,或者去倉庫看也可以,就是一個簡單的示例元件,編寫完成後,目錄結構如下

image

  • 由於樣式後續要做按需匯入,所以必須確保每個元件的樣式 style 有一個匯出的 index.(ts|js) 檔案和 index.less 檔案
  • index.(ts|js) 的內容就是 @import xxx.less

src/index.ts 中統一匯出元件

export type { ButtonProps } from './button/interface';
export { default as Button } from './button';

打包編譯

  • 匯出型別宣告檔案
  • 匯出 umd esmodule commonjs 3 種形式產物供使用者引入;
  • 支援樣式檔案 css 引入,而非只有 less,減少使用者的接入成本;
  • 支援按需載入。

匯出型別宣告檔案

tsconfig.json 上一節已經寫好了,直接使用 tsc 編譯即可。

執行命令之前需按照 cpr,執行 pnpm i cpr -D

  "scripts": {
    "build:types": "tsc -p tsconfig.json --outDir es  && cpr es lib"
  },

這個命令就是單純用 tsc 編譯出了型別 d.ts 檔案匯出到 es 資料夾裡,cpr es lib 就是將 es 中的 d.ts 檔案複製到了 lib 目錄下,此時執行 pnpm build:types 即可看到生成這兩個資料夾

其實大部分 npm 包需要做的都是編譯,打包基本用不到。

匯出 commonjs 程式碼

打包這個工作,rollup 也可以,不過我們是編譯,不是打成 bundler,而且還要處理 css,rollup 就不是很容易處理了。如果是直接打 bundler 的話,rollup 更簡單,而且 webpack 在這方面也很在行。

babel 相關配置

安裝 babel 及其相關依賴,這一部分解釋性的話術引用自開頭推薦的文章內容。

pnpm i @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-transform-runtime -D
pnpm i @babel/runtime-corejs3

新建 .babelrc.js,寫以下內容

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  // @babel/plugin-transform-runtime 的 helper 選項預設為 true
  plugins: [
    [
      '@babel/plugin-transform-runtime',
      {
        corejs: 3,
        helpers: true,
      },
    ],
  ],
};

關於 @babel/plugin-transform-runtime@babel/runtime-corejs3

  • 若 helpers 選項設定為 true,可抽離程式碼編譯過程重複生成的 helper 函式(classCallCheck, extends 等),減小生成的程式碼體積;
  • 若 corejs 設定為 3,可引入不汙染全域性的按需 polyfill,常用於類庫編寫(更推薦:不引用 polyfill,轉而告知使用者需要引入何種 polyfill,避免重複引入或產生衝突,後面會詳細提到)。
  • 更多參見官方文件 @babel/plugin-transform-runtime

為了避免轉譯瀏覽器原生支援的語法,新建 .browserslistrc 檔案,根據適配需求,寫入支援瀏覽器範圍,作用於 @babel/preset-env。(該檔案最終只會作用於 css,後續會有相關解釋)

> 1%
last 2 versions
Firefox ESR
not dead
IE 11
not IE 10

polyfill 相關思考

很遺憾的是,@babel/runtime-corejs3 無法在按需引入的基礎上根據目標瀏覽器支援程度再次減少 polyfill 的引入。

這意味著 @babel/runtime-corejs3 甚至會在針對現代引擎的情況下注入所有可能的 polyfill:不必要地增加了最終捆綁包的大小。

對於元件庫(程式碼量可能很大),建議將 polyfill 的選擇權交還給使用者,在宿主環境進行 polyfill。若使用者具有相容性要求,自然會使用 @babel/preset-env + core-js + .browserslistrc進行全域性 polyfill,這套組合拳引入了最低目標瀏覽器不支援 API 的全部 polyfill。

所以元件庫不用畫蛇添足,引入多餘的 polyfill,寫好文件說明,比什麼都重要。

現在 @babel/runtime-corejs3 更換為 @babel/runtime,只進行 helper 函式抽離。

pnpm uni @babel/runtime-corejs3

pnpm i @babel/runtime

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  // @babel/transform-runtime 的 helper 選項預設為 true
  plugins: ['@babel/plugin-transform-runtime'],
};

gulp 任務編排

安裝 gulp 相關依賴

pnpm i gulp gulp-babel @types/gulp @types/gulp-babel -D

注意: gulp-babel 目前存在問題,當 .browserslistrc 內不支援 IE 時會報錯,詳情可見 issue

新建 gulpfile.js,寫入以下內容

const gulp = require('gulp');
const babel = require('gulp-babel');

const paths = {
  dest: {
    lib: 'lib',
    esm: 'es',
    dist: 'dist',
  },
  compileStyles: 'src/**/index.less', // 編譯樣式的入口檔案,後續會解釋為什麼樣式處理分成兩部分
  copyStyles: 'src/**/*.less', // 樣式檔案路徑
  scripts: ['src/**/*.{ts,tsx,js,jsx}'], // 指令碼檔案路徑
};

function compileCJS() {
  const { dest, scripts } = paths;
  return gulp
    .src(scripts)
    .pipe(babel())
    .pipe(gulp.dest(dest.lib));
}

// 並行任務 後續加入樣式處理 可以並行處理
const build = gulp.parallel(compileCJS);

exports.build = build;

exports.default = build;

有 eslint 報錯,不允許 require 匯入,去 eslint.config.mjs 關閉一下

    rules: {
      // ...
      '@typescript-eslint/no-require-imports': 'off',
    },

package.json 新增指令碼命令 clean:build build

  "scripts": {
    "build:types": "tsc -p tsconfig.json --outDir es  && cpr es lib",
    "clean:build": "rimraf lib es dist",
    "build": "npm run clean:build && npm run build:types && gulp"
  },

由於 rimraf 基本子包都要使用,就安裝的根目錄的依賴下全域性共享

pnpm i rimraf -D -w

然後執行命令 pnpm build,就能在 lib 目錄下看到 commonjs 的程式碼了,且諸多 helper 方法已被抽離至 @babel/runtime

image

匯出 ESM 程式碼

修改 babel 配置

為了讓 ES Module 更好的支援 Tree Shaking,需要對 babel 配置做一些改動

  • 關閉 @babel/preset-env 對模組語法的轉換,即設定 modules 為 false
  • 但是又需要只針對 esm,所以就根據環境變數做一個區分,esm 環境下才關閉(當任務執行時,設定當前的執行環境即可)
module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  // @babel/plugin-transform-runtime 的 helper 選項預設為 true
  plugins: ['@babel/plugin-transform-runtime'],

  env: {
    esm: {
      presets: [['@babel/env', { modules: false }]],
    },
  },
};

gulp 新增 esm 構建任務

esm 和 cjs 都走 babel 編譯,流程基本一致,可以將編譯方法抽離出去,兩個任務共用

// ...

/**
 * 編譯指令碼檔案
 * @param {("esm"|"cjs")} babelEnv babel環境變數
 * @param {String} destDir 目標目錄
 */
function compileScripts(babelEnv, destDir) {
  const { scripts } = paths;
  process.env.BABEL_ENV = babelEnv;

  return gulp
    .src(scripts)
    .pipe(babel())
    .pipe(gulp.dest(destDir));
}

/**
 * 編譯cjs
 */
function compileCJS() {
  const { dest } = paths;
  return compileScripts('cjs', dest.lib);
}

/**
 * 編譯esm
 */
function compileESM() {
  const { dest } = paths;
  return compileScripts('esm', dest.esm);
}

// 序列執行編譯指令碼任務(cjs,esm) 避免環境變數影響 gulp.series(compileCJS, compileESM)
// 並行任務 後續加入樣式處理 可以並行處理  gulp.parallel(...)
const build = gulp.parallel(gulp.series(compileCJS, compileESM));

// ...

執行 pnpm build,觀察 es 目錄下的編譯結果,都是 import 的 esm 寫法

image

Less 樣式處理

  • 瞭解流行元件庫的樣式為什麼會是那樣的結構
  • 模仿流行元件庫的樣式結構進行編譯,實現樣式的按需載入

為什麼是 less?而非 sass 或 css in js?

  • 個人更對 less 熟悉一些,所以是 less 而不是 sass
  • 對於 cssinjs,個人不喜歡
    • 好處就是定製化極為方便,而且由於是 js,完全不用在編譯時處理樣式,自動帶有 tree shaking,文章後續對樣式的編譯處理什麼的統統沒有
    • 被人詬病的就是效能,雖說拋開劑量談毒量都是扯淡,但真拿劑量說事兒時一問一個不吱聲。
    • 此處就稍微吐槽一下 antd5,cssinjs 效能做得真的很有問題,元件文件開啟速度相對於 v4 來說不知道慢了多少倍(載入時大量的 style 標籤動態插入),issue 裡也經常有人反映效能問題。
    • 雖說有些零執行時的 cssinjs,但是那些隨機類名個人看著也確實不舒服。

瞭解流行元件庫的樣式打包構成

這裡就舉例國內採用同樣技術的元件庫:antd 4.xarco designtdesign ,可以參考一下他們的打包後的結構目錄

image

可以看到以上元件庫的樣式結構基本都一樣(tdesign 直接使用 cssvar,但是對外提供了 less 能力),提供了 index.js(內部是 less 檔案的匯入)、css.js(內部是 css 檔案的匯入)index.css(內部是合併後的純 css)以及原樣的 less 檔案

為什麼要做的這麼麻煩,給使用者提供這麼多種格式的 css/less 樣式檔案?元件庫底層抹平差異,改善使用者體驗。

  • 提供 less 檔案是為了給使用 less 的使用者提供主題定製的能力(變數覆蓋)
  • 提供 css 是為了相容非 less 使用者的使用,可以直接匯入 css 而無需額外裝 less-loader,屬於 dx 最佳化。
  • 靈活的樣式型別拆分可以給開發者更多的選擇,選擇 less 還是 css 進行開發都是可以的。
  • 拆分成多種型別的入口檔案,還可以讓使用者做按需匯入
  • 由於要靈活配置,所以開發的元件內部是不能直接匯入樣式的(不然的話就是寫死樣式,就不存在多個樣式型別的引入方式),樣式匯入交給使用者去做

複製 less 檔案至打包目錄

將上述的 less 打包結構理解之後,就可以按照這種結構開始打包樣式了。

將開發中使用的 less 檔案複製至 npm 包中,使用者使用時,就可以按需引入 less 檔案,也可以做 less 變數的覆蓋。

gulpfile.js 中新建 copyLess 任務

/**
 * 複製less檔案
 */
function copyLess() {
  return gulp.src(paths.copyStyles)
             .pipe(gulp.dest(paths.dest.lib))
             .pipe(gulp.dest(paths.dest.esm));
}

// gulp.parallel 的 args 是同時執行,gulp.series 的 args 是一個執行完畢執行下一個
const build = gulp.parallel(gulp.series(compileCJS, compileESM), copyLess);

可以看到 less 樣式已經按照原來的結構 copy 到 es 和 lib 包中,然後就是生成 css 的步驟。

image

less 編譯成 css

安裝相關依賴,gulp-less 將 less 編譯成 css,gulp-autoprefixer 新增 css 字首,由於之前設定了 .browserslistrcgulp-autoprefixer 會自動識別相容的版本去新增字首

pnpm i gulp-less gulp-autoprefixer@^8 @types/gulp-less @types/gulp-autoprefixer -D

必須安裝 gulp-autoprefixe 8.x,9.x 只支援 esm 匯入。

gulpfile.js 中新增編譯方法

/**
 * 生成css檔案
 */
function less2css() {
  return gulp
    .src(paths.compileStyles)
    .pipe(less()) // 編譯 less 檔案
    .pipe(autoprefixer()) // 根據 browserslistrc 增加字首
    .pipe(gulp.dest(paths.dest.lib))
    .pipe(gulp.dest(paths.dest.esm));
}

const build = gulp.parallel(gulp.series(compileCJS, compileESM), copyLess, less2css);

執行 pnpm build,檢查打包檔案,如圖所示就是成功的。

image

這裡沒有對 css 進行壓縮,esm 和 lib 會被使用者以 npm 方式使用,使用者打包時,自然會對 css 進行壓縮。

為什麼只對 index.less 進行編譯?

  • 每個元件的樣式都需要一個合併起來的入口檔案,這個檔案裡引入了該元件所需的所有 less 樣式,方便開發者匯入。
  • 如果每個 less 都進行編譯,那 index.less 編譯出來的就是該元件所有的 css,然後其餘的拆分元件也會再編譯對應的 css,相當於重複編譯了
    • index.less 編譯後具有全量的該元件 css
    • index.less 引用了 a.lessa.less 再次被編譯成 css,這個是沒有意義的,反而造成了重複編譯
    • 最大的問題是,如果所有 less 都進行編譯,在編譯完 index.less 後,會去單獨編譯其它的 less 檔案,如果這些檔案內使用了的 less 變數是透過 index.less 間接引入的,而 gulp-less 將其視為獨立檔案,就會產生 less 變數未定義的錯誤。

如果以上文字解釋看不太明白,可以看如下舉例:

// var.less
@font-size: 14px

// component.less
.cp{
  font-size: @font-size;
}

// index.less
@import "./var.less";
@import "./component.less";

這種情況下,component.less 使用了 var.less 的變數,但檔案內沒有直接匯入 var.less,而是使用 index.less 做了間接使用,所以 component.les 是不具備獨立編譯的能力的。

如果全部 less 檔案都做編譯

  • gulp-less 在編譯 index.less 時,由於有引入順序,相當於把 var.lesscomponent.less 做了合併,這種是完全沒問題的,可以正常編譯。
  • gulp-less 編譯完 index.less,再去編譯 component.less,發現內部有一個未知的 less 變數,因為此時 gulp-less 不透過 index.less 這個橋樑走,而是直接編譯 component.less,那自然會報錯。

生成 css.js

生成 css.js 讓不安裝 less 外掛的使用者也可以正常使用。

功能實現參考 antd-tools,由於 antd5 現在不使用 less 了,就直接找到之前的 commit 把程式碼貼出來了,如下圖所示

image

這段程式碼做的就是匹配到 style/index.js 時,生成 style/css.js,並透過正則將檔案內容中引入的 less 檔案字尾改成 css。

  • 安裝 through2
pnpm i through2 -D

compileScripts 進行補充

/**
 * 編譯指令碼檔案
 * @param {("esm"|"cjs")} babelEnv babel環境變數
 * @param {String} destDir 目標目錄
 */
function compileScripts(babelEnv, destDir) {
  const { scripts } = paths;
  process.env.BABEL_ENV = babelEnv;

  return gulp
    .src(scripts)
    .pipe(babel())
    .pipe(
      through2.obj(function (file, encoding, next) {
        this.push(file.clone());

        if (file.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
          const content = file.contents.toString(encoding);
          file.contents = Buffer.from(cssInjection(content)); // 檔案內容處理
          file.path = file.path.replace(/index\.js/, 'css.js'); // 檔案重新命名
          this.push(file); // 新增該檔案
          next();
        } else {
          next();
        }
      }),
    )
    .pipe(gulp.dest(destDir));
}

其中的 cssInjection 實現,還是在 gulpfile.js

/**
 * 當前元件樣式 import './index.less' => import './index.css'
 * 依賴的其他元件樣式 import '../test-comp/style' => import '../test-comp/style/css.js'
 * 依賴的其他元件樣式 import '../test-comp/style/index.js' => import '../test-comp/style/css.js'
 * @param {String} content
 */
function cssInjection(content) {
  return content
    .replace(/\/style\/?'/g, "/style/css'")
    .replace(/\/style\/?"/g, '/style/css"')
    .replace(/\.less/g, '.css');
}

執行 pnpm build,即可看到 css.js 檔案

image

其實這一部分很多可以最佳化的地方,做的更細緻一點,大家在看懂之後可以自行嘗試最佳化,比如 token.css 並不存在(token.less 未被編譯),可以去掉,再比如可以把 less 變數編譯 cssvar,css.js 可以再額外引入 css 變數,就可以做到動態換膚功能。

按需載入

實際上只要結構上寫出來,按需載入的核心就已經完成了。

package.json 中增加 sideEffects 屬性,配合 ES module 達到 tree shaking 效果(將樣式依賴檔案標註為side effects,避免被誤刪除)。

cssinjs 的庫都不需要這個,因為 cssinjs 只有 js,天然支援 tree shaking

  "sideEffects": [
    "dist/*",
    "es/**/style/*",
    "lib/**/style/*",
    "**/*.less"
  ],

好,此時按需載入的步驟就已經完成了,大家如果用 webpack,可以藉助 babel-plugin-import 實現按需匯入樣式,如果是 vite,可以使用 vite-plugin-imp

以上內容作為一次 commit 暫存一下:https://github.com/json-q/rc-library-templete/commit/c9d8a53c26d1189cd05939f57d07b07bfd2c1642

整理 package.json 的入口以及匯出模組的方式和指向

{
  "main": "lib/index.js",
  "module": "es/index.js",
  "types": "es/index.d.ts",

  "files": [
    "dist",
    "es",
    "lib"
  ],
  "exports": {
    ".": {
      "types": "./es/index.d.ts",
      "require": "./lib/index.js",
      "import": "./es/index.js"
    },
    "./es/*": "./es/*",
    "./lib/*": "./lib/*",
    "./dist/*": "./dist/*"
  },
  "sideEffects": [
    "dist/*",
    "es/**/style/*",
    "lib/**/style/*",
    "**/*.less"
  ],
}

除了 sideEffects 之外,其它的簡單介紹一下:

  • files 就是釋出到 npm 時包含的檔案
  • mainmoduletypes 分別指向不同環境下的不同包,types 是在 TS 環境下的地址指向
  • exports 欄位內和 mainmoduletypes 作用差不多,只不過可以更細粒度的去區分

其實 exports 是用來替代 @babel/plugin-transform-runtimeuseESModules 的(對匯出這塊我也不是很熟,同樣是查資料和摸索出來的)

image

而後邊又新增了 ./es/*": "./es/* 等的指向,是因為我在使用按需載入的過程中發現外掛無法從 ./es 中讀取檔案,找不到檔案路徑,而且編輯器無法給出路徑提示(看樣子確實是沒有指定到檔案下),所以才有了這一系列的指向,大家可以去掉之後自行嘗試一下。

新增元件實時編譯能力

本地使用元件庫時,當修改了元件想要看到最新效果,就只能重新 pnpm build 打包,這顯然很不方便,好在 gulp 提供了相關的實時編譯支援。

gulpfile.js

// 監視 src 目錄下的檔案變化
function watchFiles() {
  gulp.watch('src/**/*', build);
}

exports.watch = watchFiles;

package.json 新增 dev 命令

  "scripts": {
    "dev": "gulp watch",
    // ...
  },

後續執行 pnpm dev,當 src 下的元件發生變化,就會自動重新編譯

安裝 storybook 驗證打包成果

上一章節我們把 monorepo 的基本檔案結構搭建好了,storybook 是直接處於根目錄下的子包,此時在該目錄下執行初始化命令,暫時不要選擇 Next,因為 SSR 環境下目前不確定是否會發生意外情況

pnpm dlx storybook@latest init

安裝之後會自動開啟頁面,這個我們暫時先不關心,因為我們是測試元件的打包,所以先安裝元件依賴

pnpm add rclt-components --workspace

storybook 分兩個執行介面,一個是 vite + react 預設的模板頁面,就是 src 下的檔案,這個也可以作為自己的元件庫文件進行開發。一個是根據 *.storeis.(tsx|...) 生成的 storybook 文件。

測試元件可用性

App.tsx 中匯入元件

image

執行 pnpm dev,開啟即可看到一個很醜的按鈕 button,因為沒有樣式,接下來我們做樣式的按需匯入

測試樣式的按需載入

安裝 vite-plugin-imp

pnpm i vite-plugin-imp -D

vite.config.ts 中增加配置

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
import vitePluginImp from 'vite-plugin-imp';

export default defineConfig({
  resolve: {
    // sb 打包時,由於元件庫為本地目錄,sb 找不到路徑會打包錯誤,需使用 alias 指向正確路徑
    alias: {
      'rclt-components': fileURLToPath(new URL('../packages/components', import.meta.url)),
    },
  },
  plugins: [
    react(),
    vitePluginImp({
      libList: [
        {
          libName: 'rclt-components',
          style: (name) => `rclt-components/es/${name}/style/index.css`,
        },
      ],
    }),
  ],
});

此時一個漂亮的按鈕就成功出來了

image

這麼看來在專案結構搭建時可以把 storybook 作為根目錄作為專案共享的配置,這樣可以直接在 components 資料夾下寫 story 文件,結構上更方便檢視

編寫文件

storybook 的約定檔案格式可以在 main.ts 中看到,我們把它進行稍加修改

const config: StorybookConfig = {
  docs: {
    autodocs: true,
  },
  stories: ['../src/**/index.stories.@(js|jsx|mjs|ts|tsx)'],
};
  • autodocs 就是用來全域性配置 stories.ts 中的 tags:["autotag"]
  • stories 的路徑現在只匹配 index.stories,是為了當文件示例過多時,以 index.stories 作為入口,其餘可以匯入進來,條理更清晰一點。

刪除 stories 下的所有檔案,新建 button 資料夾,新建 Button 元件的 story 文件

// storybook/stories/button/index.stories.tsx

import { Button } from 'rclt-components';
import type { Meta, StoryObj } from '@storybook/react';
// export {OtherButton} from "./source/other-button.store"

const meta = {
  title: '基礎元件/Button',
  component: Button,
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const ButtonType: Story = {
  name: '按鈕型別',
  render: () => (
    <div style={{ display: 'flex', gap: '10px' }}>
      <Button type="default">Default Button</Button>
      <Button type="primary">Primary Button</Button>
      <Button type="danger">Danger Button</Button>
    </div>
  ),
};

export const ButtonSize: Story = {
  name: '按鈕大小',
  render: () => (
    <div style={{ display: 'flex', gap: '10px' }}>
      <Button type="primary" size="small">
        Small Button
      </Button>
      <Button type="primary">Default Button</Button>
      <Button type="primary" size="large">
        Large Button
      </Button>
    </div>
  ),
};

執行 pnpm storybook,執行 storybook 文件

image

樣式的按需載入也被 storybook 文件享受到了,不需要單獨匯入 css。

打包 umd 格式的元件 bundler

umd 可以直接在瀏覽器環境使用,各大元件庫基本都提供有 umd 格式的元件產物。

這裡使用的是 webpack 打包 umd,沒有選擇 rollup,大家根據喜好選擇即可,這個比較簡單。

webpack 打包 bulder

安裝 webpack 相關依賴和打包用到的 loader:

pnpm i webpack webpack-cli webpack-merge terser-webpack-plugin babel-loader ts-loader -D

components 下新建 webpack 資料夾,思路如下

  • dist 包提供兩個 bundler 產物,一個壓縮過的 min.js 和未壓縮的 .js
  • dist 包提供兩個 css 產物,一個壓縮過的 min.css 和未壓縮的 .css

webpack 下新建三個檔案 webpack.common.jswebpack.dev.jswebpack.prod.js

webpack.common.js 寫入以下內容

const path = require('path');

/** @type {import("webpack").Configuration} */
module.exports = {
  bail: true,
  // devtool: 'source-map',
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },
  entry: {
    index: path.resolve(__dirname, '../src/index.ts'),
  },
  output: {
    // filename: 'jwstwe-ui.min.js',
    path: path.join(__dirname, '../dist'),
    library: 'rclt',
    libraryTarget: 'umd',
  },
  module: {
    rules: [
      {
        test: /.js(x?)$/,
        use: [{ loader: 'babel-loader' }],
        exclude: /node_modules/,
      },
      {
        test: /.ts(x?)$/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              transpileOnly: true,
            },
          },
        ],
        exclude: /node_modules/,
      },
    ],
  },

  // 元件庫不直接整合 react 和 react-dom
  externals: {
    react: {
      root: 'React',
      commonjs2: 'react',
      commonjs: 'react',
      amd: 'react',
    },
    'react-dom': {
      root: 'ReactDOM',
      commonjs2: 'react-dom',
      commonjs: 'react-dom',
      amd: 'react-dom',
    },
  },
};

webpack.dev.js 實際上就是來打包未壓縮版本的產物

const { merge } = require('webpack-merge');
const commonConfig = require('./webpack.common');

/** @type {import("webpack").Configuration} */
const devConfig = {
  mode: 'development',
  output: {
    filename: 'rclt.js',
  },
};

module.exports = merge(devConfig, commonConfig);

webpack.prod.js 打包壓縮版本的產物

const { merge } = require('webpack-merge');
const TerserPlugin = require('terser-webpack-plugin');
const commonConfig = require('./webpack.common');

/** @type {import("webpack").Configuration} */
const prodConfig = {
  mode: 'production',
  output: {
    filename: 'rclt.min.js',
  },
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};

module.exports = merge(prodConfig, commonConfig);

package.json 新增命令 build:dist 命令

  "scripts": {
    // ...
    "build:dist": "webpack --config ./webpack/webpack.dev.js && webpack --config ./webpack/webpack.prod.js",
    // ...
  },

執行 pnpm build:dist,就可以看到生成了 dist 資料夾

image

細節最佳化

  • webpackbar 打包進度條
  • case-sensitive-paths-webpack-plugin 檔案大小寫敏感檢測
pnpm i webpackbar case-sensitive-paths-webpack-plugin @types/case-sensitive-paths-webpack-plugin -D

webpack.common.js 中新增

const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const WebpackBarPlugin = require('webpackbar');

/** @type {import("webpack").Configuration} */
module.exports = {
  // ...
  plugins: [new CaseSensitivePathsPlugin(), new WebpackBarPlugin()],
};

打包 css

webpack 就算安裝 less-loader ,想打包出 css,也是完全沒用的,因為 less 樣式是完全獨立的,元件內部不引入樣式,webpack 在打包時,完全找不到使用的 less,自然就打包不出來。

其實也是因為有更簡單的方法。

之前是怎麼編譯 css 的?gulp 使用 gulp-less 將 less 編譯成 css 分別輸出到產物目錄下,那也可以順便再生成一下 dist的 css

先安裝 gulp-concact 合併檔案, gulp-cleaner-css 壓縮 css(是 gulp-clean-css 的一個維護分支)

pnpm i gulp-concact @types/gulp-concact gulp-cleaner-css -D

完善 gulpfile.js

/**
 * 生成css檔案
 */
function less2css() {
  return gulp
    .src(paths.compileStyles)
    .pipe(less()) // 編譯 less檔案
    .pipe(autoprefixer()) // 根據browserslistrc增加字首
    .pipe(gulp.dest(paths.dest.lib))
    .pipe(gulp.dest(paths.dest.esm))
    .pipe(concat('rclt.css'))
    .pipe(gulp.dest(paths.dest.dist))
    .pipe(cleanCSS()) // 壓縮 CSS
    .pipe(concat('rclt.min.css'))
    .pipe(gulp.dest(paths.dest.dist)); // 輸出壓縮版到 dist
}

此時 dist 的 css 也可以生成了。回到 package.json,將 build 命令裡新增 build:dist

  "scripts": {
    "dev": "gulp watch",
    "build": "npm run clean:build && npm run build:types && gulp && npm run build:dist",
    "build:types": "tsc -p tsconfig.json --outDir es  && cpr es lib",
    "build:dist": "webpack --config ./webpack/webpack.dev.js && webpack --config ./webpack/webpack.prod.js",
    "clean:build": "rimraf lib es dist"
  },

執行 pnpm build,如圖所示,就大功告成了

image

測試 dist

新建一個 test-dist.html,引入 react、react-dom、babel 的 umd 連結

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="./packages/components/dist/rclt.min.css" />
  </head>
  <body>
    <div id="root"></div>
  </body>
  <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin></script>
  <script src="./packages/components/dist/rclt.min.js"></script>
  <script type="text/babel">
    const { Button } = rclt;

    function App() {
      return (
        <div style={{ display: 'flex', gap: '10px' }}>
          <Button type="default">Button</Button>
          <Button type="primary">Button</Button>
          <Button type="danger">Button</Button>
        </div>
      );
    }
    ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  </script>
</html>

image

元件庫的打包到此結束!

相關文章