前言
無論團隊大小,隨著時間的推進,多多少少都會有一些可提取的業務元件,沉澱元件庫和對應的文件是一條必經之路。
直接進入正題,從 0 到 1 開始搞一個業務元件庫(可從註釋中生成)。
最終的 Demo 可看這裡,請使用 Mac 或者 Linux 終端來執行,windows 相容性未做驗證。
使用到工具
這三個工具是後續業務元件庫搭建使用到的,需要有一定的瞭解:
- Lerna ,Lerna是一個 Npm 多包管理工具,詳細可檢視官方文件。
- Docusaurus,是 Facebook 官網支援的文件工具,可以在極短時間內搭建漂亮的文件網站,詳細可檢視官網文件。
- Vite,Vite 是一種新型前端構建工具,能夠顯著提升前端開發體驗,開箱即用,用來代替 rollup 構建程式碼可以省掉一些繁瑣的配置。
初始化專案
注意 Node 版本需要在 v16 版本以上,最好使用 v16 版本。
初始化的檔案結構如下:
.
├── lerna.json
├── package.json
└── website
假設專案 root 資料夾:
第一步,初始化 Lerna 專案
$ npx lerna@latest init
lerna 會新增
package.json
和lerna.json
。第二步,初始化 Docusaurus 專案(typescript 型別的)
$ npx create-docusaurus@latest website classic --typescript
第三步,配置
package.json
npm run bootstrap
可初始化安裝所有分包的依賴包。npm run postinstall
是 npm 鉤子命令,在依賴包完成安裝後會觸發npm run postinstall
的執行。
{ "private": true, "dependencies": { "lerna": "^5.1.4" }, "scripts": { "postinstall": "npm run bootstrap", "bootstrap": "lerna bootstrap" } }
第四步,配置
lerna.json
packages
設定分包的位置,詳細配置可長 lerna 的文件。npmClient
可指定使用的npm
客戶端,可以替換為內部的 npm 客戶端或者yarn
。hoist
設定為true後,分包的同一個依賴包如果相同,會統一安裝到最上層專案的根目錄root/node_modules
中,如果不相同會有警告,同一個相同的版本安裝到最上層根目錄,不相同的依賴包版本安裝到當前分包的node_modules
目前下。
{ "packages": ["packages/*", "website"], "version": "0.0.0", "npmClient": "npm", "hoist": true }
使用 Vite 建立元件分包
最終資料夾路徑如下:
.
├── lerna.json
├── package.json
├── packages
│ └── components
│ ├── package.json
│ ├── src
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── website
- 第一步,建立
packages/components
資料夾 第二步,初始化 Vite 專案,選用
react-ts
的模板。$ npm init vite@latest
第三步,刪除不必要的檔案
由於只用 Vite 的打包功能,用不上 Vite 的服務開發功能,所以要做一些清理。
刪除
index.html
和 清空src
資料夾。第四步,配置
packages/components/vite.config.ts
Vite 的詳細配置可以檢視官方文件,可以細看配置 Vite 的庫模式,Vite 的打包其實是基於 rollup,這裡說明一下需要注意的配置:
rollupOptions.external 配置
確保外部化處理那些你不想打包進庫的依賴,如 React 這些公共的依賴包就不需要打包進來。
rollupOptions.globals 配置
在 UMD 構建模式下為這些外部化的依賴提供一個全域性變數。
Less 配置
Vite 預設是支援 Less 的,需要再 package.json 新增 less 依賴包後才生效。也預設支援 css module 功能。
由於是庫型別,所以 less 需要配置 classs 字首,這裡還根據
src/Table.module.less
或者src/Table/index.module.less
型別的路徑獲取 Table 為元件名字首。
import { defineConfig } from 'vite';
import path from 'path';
// 在 UMD 構建模式下為外部依賴提供一個全域性變數
export const GLOBALS = {
react: 'React',
'react-dom': 'ReactDOM',
};
// 處理類庫使用到的外部依賴
// 確保外部化處理那些你不想打包進庫的依賴
export const EXTERNAL = [
'react',
'react-dom',
];
// https://vitejs.dev/config/
export default defineConfig(() => {
return {
plugins: [react()],
css: {
modules: {
localsConvention: 'camelCaseOnly',
generateScopedName: (name: string, filename: string) => {
const match = filename.replace(/\\/, '/').match(/.*\/src\/(.*)\/.*\.module\..*/);
if (match) {
return `rabc-${decamelize(match[1], '-')}__${name}`;
}
return `rabc-${name}`;
},
},
preprocessorOptions: {
less: {
javascriptEnabled: true,
},
},
},
build: {
rollupOptions: {
external: EXTERNAL,
output: { globals: GLOBALS },
},
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'RbacComponents',
fileName: (format) => `rbac-components.${format}.js`,
},
},
};
});
第五步,配置
packages/components/package.json
需要注意三個欄位的配置:
- main,如果沒有設定
module
欄位,Webpack、Vite 等打包工具會以此欄位設定的檔案為依賴包的入口檔案。 - module,一般的工具包預設優先順序高於
main
,此欄位指向的應該是一個基於 ES6 模組規範的模組,這樣打包工具才能支援 Tree Shaking 的特性。 - files,設定釋出到 npm 上的檔案或者資料夾,預設 package.json 是不用做處理的。
{ "version": "0.0.0", "name": "react-antd-business-components", "main": "dist/rbac-components.umd.js", "module": "dist/rbac-components.es.js", "files": [ "dist" ], "scripts": { "build": "vite build" }, "dependencies": {}, "devDependencies": { "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", "@vitejs/plugin-react": "^1.3.2", "classnames": "2.3.1", "cross-spawn": "7.0.3", "decamelize": "4.0.0", "eslint": "8.18.0", "less": "^4.1.3", "prop-types": "^15.7.2", "react": "^17.0.2", "react-dom": "^17.0.2", "rimraf": "^3.0.2", "typescript": "^4.6.4", "vite": "^2.9.12" } }
- main,如果沒有設定
建立元件
在 package/components/src
目前下建立兩個檔案:
index.ts
export { default as Test } from './Test';
Test.tsx
import React from 'react'; export interface ProContentProps { /** * 標題 */ title?: React.ReactNode; /** * 內容 */ content: React.ReactNode; } /** * 展示標題和內容 */ const Test: { (props: ProContentProps): JSX.Element | null; displayName: string; defaultProps?: Record<string, any>; } = (props: ProContentProps) => { const { title, content } = props; return ( <div> <div>{title}</div> <div>{content}</div> </div>; ); }; Test.displayName = 'Card'; Test.defaultProps = { title: '標題', content: "內容", }; export default Test;
編寫元件文件
Docusaurus
是支援 mdx 的功能,但是並不能讀取註釋,也有沒元件可以一起展示 Demo 和 Demo 的程式碼。
所以在編寫文件前還需要做一些準備,支援 PropsTable
和 CodeShow
的用法,這裡實現的細節就不做細說,感興趣的可以檢視 react-doc-starter。
元件文件編寫準備
第一步,md 檔案支援直接使用 PropsTable 和 CodeShow 元件
新建以下幾個檔案,同時需要新增相應的依賴包,檔案內容可以在這個專案 react-doc-starter 中獲取。
website/loader/propsTable.js website/loader/codeShow.js website/plugins/mdx.js
第二步,支援 Less 功能
Less 的功能需要和 Vite 打包是配置的 Less 一致。
const decamelize = require('decamelize'); module.exports = function (_, opt = {}) { delete opt.id; const options = { ...opt, lessOptions: { javascriptEnabled: true, ...opt.lessOptions, }, }; return { name: 'docusaurus-plugin-less', configureWebpack(_, isServer, utils) { const { getStyleLoaders } = utils; const isProd = process.env.NODE_ENV === 'production'; return { module: { rules: [ { test: /\.less$/, oneOf: [ { test: /\.module\.less$/, use: [ ...getStyleLoaders(isServer, { modules: { mode: 'local', getLocalIdent: (context, _, localName) => { const match = context.resourcePath.replace(/\\/, '/').match(/.*\/src\/(.*)\/.*\.module\..*/); if (match) { return `rabc-${decamelize(match[1], '-')}__${localName}`; } return `rabc-${localName}`; }, exportLocalsConvention: 'camelCase', }, importLoaders: 1, sourceMap: !isProd, }), { loader: 'less-loader', options, }, ], }, { use: [ ...getStyleLoaders(isServer), { loader: 'less-loader', options, }, ], }, ], }, ], }, }; }, }; };
第三步,支援 alias
需要新增
website/plugin/alias.js
外掛和修改tsconfig.json
alias.js
const path = require('path'); module.exports = function () { return { name: 'alias-docusaurus-plugin', configureWebpack() { return { resolve: { alias: { // 支援當前正在開發元件依賴包(這樣依賴包就無需構建,可直接在文件中使用) 'react-antd-business-components': path.resolve(__dirname, '../../packages/components/src'), $components: path.resolve(__dirname, '../../packages/components/src'), // 用於縮短文件路徑 $demo: path.resolve(__dirname, '../demo'), // 用於縮短文件路徑 }, }, }; }, }; };
tsconfig.json
{ // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@tsconfig/docusaurus/tsconfig.json", "compilerOptions": { "baseUrl": ".", "paths": { "react-antd-business-components": ["../packages/components/src"] } }, "include": ["src/", "demo/"] }
第四步,配置
website/docusaurus.config.js
使用外掛:const config = { ... plugins: [ './plugins/less', './plugins/alias', './plugins/mdx', ], ... }; module.exports = config;
第五步,修改預設的文件路徑和預設的 sidebar 路徑
由於我們還可能有其他文件如 Utils 文件,我們需要而外配置一下
website/docusaurus.config.js
:把文件路徑修改為
website/docs/components
,sidebar 路徑改為webiste/componentsSidebars.js
,sidebar 檔案直接改名即可,無需做任何處理。const config = { ... presets: [ [ 'classic', /** @type {import('@docusaurus/preset-classic').Options} */ ({ docs: { path: 'docs/components', routeBasePath: 'components', sidebarPath: require.resolve('./componentsSidebars.js'), // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: 'https://github.com/samonxian/react-doc-starter/tree/master', }, }), ], ], ... }; module.exports = config;
正式編寫元件文件
在 website/demo
檔案中建立 Test/Basic.tsx
和 Test/Basic.css
,在 website/docs/components/data-show
中建立 Test.md
和 _category_.json
檔案:
demo/Test/Basic.tsx
import React from 'react';
import { Test } from 'react-antd-business-components';
import './Basic.css';
const Default = function () {
return (
<div className="pro-content-demo-container">
<Test title="標題" content="內容"/>
</div>
);
};
export default Default;
demo/Test/Basic.css
.test-demo-container {
background-color: #eee;
padding: 16px;
}
docs/components/data-show/\_category\_.json 是 Docusaurus 的用法,詳細點選這裡。
{
"position": 2,
"label": "資料展示",
"collapsible": true,
"collapsed": false,
"link": {
"type": "generated-index"
}
}
docs/components/data-show/Test.md
如果要定製化配置此 Markdown,如側邊欄文字、順序等等,可細看 Markdown 前言。
CodeShow 和 PropsTable 元件用法可看這裡。
## 使用
### 基本使用
<CodeShow fileList={['$demo/ProContent/Basic.tsx', '$demo/ProContent/Basic.css']} />
## Props
<PropsTable src="$components/ProContent" showDescriptionOnSummary />
執行文件服務
這裡執行文件服務,可以檢視 Demo 的效果,相當於一遍寫文件一邊除錯元件,無需另起開發服務除錯元件。
$ cd ./website
$ npm start
釋出元件
在專案 root 根目錄下執行:
$ npm run build:publish
此名會執行lerna build
和 lerna publish
命令,然後安裝提示進行釋出即可,詳細的用法可檢視 lerna 命令。
部署文件
詳細可看 Docusaurus。
如果是 Github 專案建議釋出到 GitHub Pages,命令如下:
$ cd ./website
$ npm run deploy
擴充
使用 Vite 轉換 src 中的檔案
打包時候,Vite 會把所有涉及到的檔案打包為一個檔案,而我們常常把 src 資料夾的所有 JavaScript 或者 Typescript 檔案轉換為 es5 語法,直接提供為 Webpack、Vite 這些開發服務工具使用。
Vite 不支援多入口檔案和多輸出檔案的模式,需要自己實現一套 npm run build:file
命令。
第一步,package.json 新增如下 scripts 命令
{ "scripts": { "tsc:es": "tsc --declarationDir es", "tsc:lib": "tsc --declarationDir lib", "tsc": "npm run tsc:lib && npm run tsc:es", "clean": "rimraf lib es dist", "vite": "vite", "build:lib": "vite build", "build:file": "node ./scripts/buildFiles.js", "build": "npm run clean && npm run tsc && npm run build:file && npm run build:lib" } }
第二步,新增
package/components/.env
檔案,確保構建環境未 productionNODE_ENV=production
第三步,建立
package/components/scripts/buildFile.js
檔案:- 通過讀取 src 資料夾中(包含子輩)的所有 js 或 ts 型別的檔案獲取所有待轉換的檔案路徑
- 然後遍歷檔案路徑,再通過 vite 的 mode 選項傳遞入口檔案到 vite.file.config.ts 配置中
const fs = require('fs'); const path = require('path'); const spawn = require('cross-spawn'); const srcDir = path.resolve(__dirname, '../src'); // 所有 src 資料夾包括子資料夾的 js、ts、jsx、tsx 檔案路徑陣列 const srcFilePaths = getTargetDirFilePaths(srcDir); srcFilePaths.forEach((file) => { const fileRelativePath = path.relative(srcDir, file); spawn( 'npm', ['run', 'vite', '--', 'build', '--mode', fileRelativePath, '--outDir', 'es', '--config', './vite.file.config.ts'], { stdio: 'inherit', }, ); }); /** * 獲取 src 資料夾下的所有檔案 * @param {String} [targetDirPath] 目標資料夾路徑 * @return {Array} 檔案列表陣列 */ function getTargetDirFilePaths(targetDirPath = path.resolve(__dirname, '../src')) { let fileList = []; fs.readdirSync(targetDirPath).forEach((file) => { const filePath = path.resolve(targetDirPath, file); const isDirectory = fs.statSync(filePath).isDirectory(); if (isDirectory) { fileList = fileList.concat(getTargetDirFilePaths(filePath)); } else { fileList.push(filePath); } }); return fileList .filter((f) => { if (/__tests__/.test(f)) { return false; } if (/\.d\.ts/.test(f)) { return false; } if (/\.[jt]?sx?$/.test(f)) { return true; } return false; }) .map((f) => // 相容 windows 路徑 f.replace(/\\/g, '/'), ); }
第四步,建立
package/components/vite.file.config.ts
檔案:其中需要注意的是
rollupOptions.external
欄位的配置,除了less
、css
、svg
的字尾名檔案外,都不打包進輸出檔案。import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; import decamelize from 'decamelize'; export const commonConfig = defineConfig({ plugins: [react()], css: { modules: { localsConvention: 'camelCaseOnly', generateScopedName: (name: string, filename: string) => { const match = filename.replace(/\\/, '/').match(/.*\/src\/(.*)\/.*\.module\..*/); if (match) { return `rabc-${decamelize(match[1], '-')}__${name}`; } return `rabc-${name}`; }, }, preprocessorOptions: { less: { javascriptEnabled: true, }, }, }, resolve: { alias: [ // fix less import by: @import ~ // less import no support webpack alias '~' · Issue #2185 · vitejs/vite { find: /^~/, replacement: '' }, ], }, }); // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { return { ...commonConfig, build: { emptyOutDir: false, rollupOptions: { external: (id) => { if (id.includes('.less') || id.includes('.css') || id.includes('.svg')) { return false; } return true; }, output: [ { file: `es/${mode.replace(/\.[jt]?sx?$/, '.js')}`, indent: false, exports: 'named', format: 'es', dir: undefined, }, { file: `lib/${mode.replace(/\.[jt]?sx?$/, '.js')}`, indent: false, exports: 'named', format: 'cjs', dir: undefined, }, ], }, lib: { // mode 特殊處理為檔名 entry: path.resolve(__dirname, 'src', mode), name: 'noop', // 這裡設定只有在 UMD 格式才有效,避免驗證報錯才設定的,在這裡沒用 }, minify: false, }, }; });
新增單元測試
單元測試使用 Vitest,Vitest 可共用 Vite 的配置,配置也很簡單,同時相容 Jest 的絕大部分用法。
下方的步驟基於分包 packages/components
來處理。
第一步,更新 package.json
{ "scripts": { "test": "vitest", "coverage": "vitest run --coverage" }, "devDependencies": { "happy-dom": "^6.0.2", "react-test-renderer": "^17.0.2", "vitest": "^0.18.0" } }
第二步,更新 vite.config.js
配置
vitest
本身,需要在 Vite 配置中新增test
屬性。如果你使用vite
的defineConfig
,還需要將 三斜線指令 寫在配置檔案的頂部。配置十分簡單,只需要關閉 watch 和 新增 dom 執行環境。
/// <reference types="vitest" /> export default defineConfig(() => { return { ... test: { environment: 'happy-dom', watch: false, }, ... }; });
第三步,新增
.env.test
,確保環境為 test 環境。NODE_ENV=test
第四步,新增測試用例
packages/components/src/__tests__/Test.spec.tsx
import { expect, it } from 'vitest'; import React from 'react'; import renderer from 'react-test-renderer'; import Test from '../index'; function toJson(component: renderer.ReactTestRenderer) { const result = component.toJSON(); expect(result).toBeDefined(); return result as renderer.ReactTestRendererJSON; } it('ProContent rendered', () => { const component = renderer.create( <Test />, ); const tree = toJson(component); expect(tree).toMatchSnapshot(); });
使用 Vite 初始化 Utils 分包
其實除了業務元件,我們還會有業務 Utils 工具類的函式,我們也會沉澱工具類庫和相應的文件。
得益於多包管理的方式,本人把元件庫和 Utils 類庫放在一起處理,在 package
中新建 utils 分包。
utils 分包和 components 分包大同小異,vite 和 package.json 配置就不細說了,可參考上方使用 Vite 建立元件分包。
建立工具函式
建立 utils/src/isNumber.ts
檔案(範例)。
/**
* @param value? 檢測的目標
* @param useIsFinite 是否使用 isFinite,設定為 true 時,NaN,Infinity,-Infinity 都不算 number
* @default true
* @returns true or false
* @example
* ```ts
* isNumber(3) // true
* isNumber(Number.MIN_VALUE) // true
* isNumber(Infinity) // false
* isNumber(Infinity,false) // true
* isNumber(NaN) // false
* isNumber(NaN,false) // true
* isNumber('3') // false
* ```
*/
export default function isNumber(value?: any, useIsFinite = true) {
if (typeof value !== 'number' || (useIsFinite && !isFinite(value))) {
return false;
}
return true;
}
編寫工具函式文件
由於工具類函式不適合使用 PropsTable 讀取註釋,手動編寫 markdown 效率又低,本人基於微軟 tsdoc 實現了一個 Docusaurus
外掛。
第一步,md 檔案支援直接使用 TsDoc 元件
新增 website/plugins/tsdoc.js,同時需要新增相應的依賴包,檔案內容可以在這個專案 react-doc-starter 中獲取。module.exports = function (context, opt = {}) { return { name: 'docusaurus-plugin-tsdoc', configureWebpack(config) { const { siteDir } = context; return { module: { rules: [ { test: /(\.mdx?)$/, include: [siteDir], use: [ { loader: require.resolve('ts-doc-webpack-loader'), options: { alias: config.resolve.alias, ...opt }, }, ], }, ], }, }; }, }; };
第二步,配置
docusaurus.config.js
配置 utils 文件路徑為
docs/utils
,sidebar 路徑為./utilsSidebars
,還要新增 tsdoc 外掛。const config = { ... plugins: [ [ 'content-docs', /** @type {import('@docusaurus/plugin-content-docs').Options} */ ({ id: 'utils', path: 'docs/utils', routeBasePath: 'utils', editUrl: 'https://github.com/samonxian/react-doc-starter/tree/master', sidebarPath: require.resolve('./utilsSidebars.js'), }), ], './plugins/tsdoc', ], ... }; module.exports = config;
第三步,新增
docs/utils/isNumber.md
檔案$utils
別名需要在./plugins/alias
和tsconfig.json
中新增對應的配置。sidebar_position
可以修改側邊欄同級選單項的順序。--- sidebar_position: 1 --- <TsDoc src="$utils/isNumber" />
釋出元件
同 components 分包,在專案 root 根目錄下執行:
$ npm run build:publish
此名會執行lerna build
和 lerna publish
命令,然後安裝提示進行釋出即可,詳細的用法可檢視 lerna 命令。
部署文件
同上的部署文件。
結語
經過支援 PropsTable
、CodeShow
和 TsDoc
三個便捷的元件,本人搭建的文件工具可以快速編寫並生成文件。
如果對你有所幫助,可以點個贊,也可以去 Github 專案收藏一下。