本文包含如下內容:github
eslint
/commit lint
/typescript
等等;umd
cjs
/esm
、types、polyfill 以及按需加載;若是本文幫助到了你請給倉庫 一顆 ✨✨。web
若是有錯誤煩請在評論區指正交流,謝謝。typescript
新建一個happy-ui
文件夾,並初始化。
mkdir happy-ui cd happy-ui npm init --y mkdir components && cd components && touch index.ts # 新建源碼文件夾以及入口文件
此處直接使用@umijs/fabric的配置。
yarn add @umijs/fabric --dev yarn add prettier --dev # 由於@umijs/fabric沒有將prettier做爲依賴 因此咱們須要手動安裝
.eslintrc.js
module.exports = { extends: [require.resolve('@umijs/fabric/dist/eslint')], };
.prettierrc.js
const fabric = require('@umijs/fabric'); module.exports = { ...fabric.prettier, };
.stylelintrc.js
module.exports = { extends: [require.resolve('@umijs/fabric/dist/stylelint')], };
想自行配置的同窗能夠參考如下文章:
進行pre-commit
代碼規範檢測。
yarn add husky lint-staged --dev
package.json
"lint-staged": { "components/**/*.ts?(x)": [ "prettier --write", "eslint --fix", "git add" ], "components/**/*.less": [ "stylelint --syntax less --fix", "git add" ] }, "husky": { "hooks": { "pre-commit": "lint-staged" } }
進行 Commit Message 檢測。
yarn add @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog --dev
新增.commitlintrc.js
寫入如下內容
module.exports = { extends: ['@commitlint/config-conventional'] };
package.json 寫入如下內容:
// ... "scripts": { "commit": "git-cz", } // ... "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", "pre-commit": "lint-staged" } }, "config": { "commitizen": { "path": "cz-conventional-changelog" } }
後續使用 yarn commit
替代 git commit
生成規範的 Commit Message,固然爲了效率你能夠選擇手寫,可是要符合規範。
yarn add typescript --dev
新建tsconfig.json
並寫入如下內容
{ "compilerOptions": { "baseUrl": "./", "target": "esnext", "module": "commonjs", "jsx": "react", "declaration": true, "declarationDir": "lib", "strict": true, "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "resolveJsonModule": true }, "include": ["components", "global.d.ts"], "exclude": ["node_modules"] }
在components
文件夾下新建alert
文件夾,目錄結構以下:
alert ├── alert.tsx # 源文件 ├── index.ts # 入口文件 ├── interface.ts # 類型聲明文件 └── style ├── index.less # 樣式文件 └── index.ts # 樣式文件裏爲何存在一個index.ts - 按需加載樣式 管理樣式依賴 後面章節會提到
安裝React
相關依賴:
yarn add react react-dom @types/react @types/react-dom --dev # 開發時依賴,宿主環境必定存在 yarn add prop-types # 運行時依賴,宿主環境可能不存在 安裝本組件庫時一塊兒安裝
此處依舊安裝了prop-types
這個庫,由於沒法保證宿主環境也使用typescript
,從而可以進行靜態檢查,故使用prop-types
保證javascript
用戶也能獲得友好的運行時報錯信息。
components/alert/interface.ts
export type Kind = 'info' | 'positive' | 'negative' | 'warning'; export type KindMap = Record<Kind, string>; export interface AlertProps { /** * Set this to change alert kind * @default info */ kind?: 'info' | 'positive' | 'negative' | 'warning'; }
components/alert/alter.tsx
import React from 'react'; import t from 'prop-types'; import { AlertProps, KindMap } from './interface'; const prefixCls = 'happy-alert'; const kinds: KindMap = { info: '#5352ED', positive: '#2ED573', negative: '#FF4757', warning: '#FFA502', }; const Alert: React.FC<AlertProps> = ({ children, kind = 'info', ...rest }) => ( <div className={prefixCls} style={{ background: kinds[kind], }} {...rest} > {children} </div> ); Alert.propTypes = { kind: t.oneOf(['info', 'positive', 'negative', 'warning']), }; export default Alert;
components/alert/index.ts
import Alert from './alert'; export default Alert; export * from './interface';
components/alert/style/index.less
@popupPrefix: happy-alert; .@{popupPrefix} { padding: 20px; background: white; border-radius: 3px; color: white; }
components/alert/style/index.ts
import './index.less';
components/index.ts
export { default as Alert } from './alert';
此處組件參考的docz
項目typescript
以及less
示例。
git 一把梭,能夠看到控制檯已經進行鉤子檢測了。
git add . yarn commit # 或 git commit -m'feat: chapter-1 準備工做' git push
準備工做完成。代碼能夠在倉庫的chapter-1
分支獲取,若存在與本文內容不符的地方,以master
分支以及文章爲準。
本節解決開發組件時的預覽以及調試問題,順路解決文檔編寫。
此處選擇docz來輔助預覽調試。
docz
基於MDX
(Markdown + JSX),能夠在 Markdown 中引入 React 組件,使得一邊編寫文檔,一邊預覽調試成爲了可能。並且得益於 React 組件生態,咱們能夠像編寫應用通常編寫文檔,不只僅是枯燥的文字。docz
也內置了一些組件,好比<Playground>
。
yarn add docz --dev yarn add rimraf --dev # 清空目錄的一個輔助庫
增長 npm scripts
至 package.json
。
"scripts": { "dev": "docz dev", // 啓動本地開發環境 "start": "npm run dev", // dev命令別名 "build:doc": "rimraf doc-site && docz build", // 後續會配置打包出來的文件目錄名爲doc-site,故每次build前刪除 "preview:doc": "docz serve" // 預覽文檔站點 },
注意:本節全部操做都是針對站點應用。
打包
指代文檔站點打包,而非組件庫。
新建doczrc.js
配置文件,並寫入如下內容:
doczrc.js
export default { files: './components/**/*.{md,markdown,mdx}', // 識別的文件後綴 dest: 'doc-site', // 打包出來的文件目錄名 title: 'happy-ui', // 站點標題 typescript: true, // 組件源文件是經過typescript開發,須要打開此選項 };
因爲使用了less
做爲樣式預處理器,故須要安裝 less 插件。
yarn add less gatsby-plugin-less --dev
新建gatsby-config.js
,並寫入如下內容:
gatsby-config.js
module.exports = { plugins: ['gatsby-theme-docz', 'gatsby-plugin-less'], };
新建components/alert/index.mdx
,並寫入如下內容:
--- name: Alert 警告提示 route: /Alert menu: 組件 --- import { Playground } from 'docz'; import Alert from './alert'; // 引入組件 import './style'; // 引入組件樣式 # Alert 警告提示 警告提示,展示須要關注的信息。 ## 代碼演示 ### 基本用法 <Playground> <Alert kind="warning">這是一條警告提示</Alert> </Playground> ## API | 屬性 | 說明 | 類型 | 默認值 | | ---- | -------- | -------------------------------------------- | ------ | | kind | 警告類型 | 'info'/'positive'/'negative'/'warning'非必填 | 'info' |
執行腳本命令:
yarn start # or yarn dev
能夠在localhost:3000
看到以下頁面 :
如今能夠在index.mdx
中愉快地進行文檔編寫和調試了!
假若本文到了這裏就結束(其實也能夠結束了(_^▽^_)
),那我只是官方文檔的翻譯復讀機罷了,有興趣的同窗能夠繼續向下看。
若是代碼演示
部分的demo
較多(好比基本用法、高級用法以及各類用法等等),在組件複雜的狀況下(畢竟<Alert/>
着實太簡單了),會致使文檔很長難以維護,你究竟是在寫文檔呢仍是在寫代碼呢?
那就抽離吧。
在components/alert/
文件夾下新建demo
文件夾,存放咱們在編寫文檔時須要引用的 demo
。
components/alert/demo/1-demo-basic.tsx
import React from 'react'; import Alert from '../alert'; import '../style'; export default () => <Alert kind="warning"></Alert>;
components/alert/index.mdx
- import Alert from './alert'; // 引入組件 - import './style'; // 引入組件樣式 + import BasicDemo from './demo/1-demo-basic'; ... <Playground> - <Alert kind="warning">這是一條警告提示</Alert> + <BasicDemo /> </Playground>
這樣咱們就將 demo 與文檔進行了分隔。預覽以下:
等等,下面顯示的是<BasicDemo />
,而非demo
源碼。
<Playground />
組件暫時沒法支持上述形式的展現:自定義下方展現的代碼,而非<Playground />
內部的代碼。相關討論以下:
其實第一條 PR
已經解決了問題,可是被關閉了,無奈。
不過既然都能引入 React 組件了,在MDX
的環境下自定義一個Playground
組件又有何難呢,無非就是渲染組件(MDX 自帶)和展現源碼,簡單開放的東西你們都是喜聞樂見的,就叫HappyBox
吧。
<HappyBox />
組件安裝依賴:
yarn add react-use react-tooltip react-feather react-simple-code-editor prismjs react-copy-to-clipboard raw-loader styled-components --dev
hooks
這些依賴都是服務於文檔站點應用,和組件庫自身毫無關聯。
最終效果以下:
根目錄下新建doc-comps
文件夾,存放文檔中使用的一些工具組件,好比<HappyBox />
。
doc-comps
├── happy-box │ ├── style.ts │ └── index.tsx └── index.ts
components/doc-comps/happy-box/index.tsx
import React from 'react'; import Editor from 'react-simple-code-editor'; import CopyToClipboard from 'react-copy-to-clipboard'; import { useToggle } from 'react-use'; import ReactTooltip from 'react-tooltip'; import IconCopy from 'react-feather/dist/icons/clipboard'; import IconCode from 'react-feather/dist/icons/code'; import { highlight, languages } from 'prismjs/components/prism-core'; import { StyledContainer, StyledIconWrapper } from './style'; import 'prismjs/components/prism-clike'; import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-markup'; require('prismjs/components/prism-jsx'); interface Props { code: string; title?: React.ReactNode; desc?: React.ReactNode; } export const HappyBox: React.FC<Props> = ({ code, title, desc, children }) => { const [isEditVisible, toggleEditVisible] = useToggle(false); return ( <StyledContainer> <section className="code-box-demo"> {children}</section> <section className="code-box-meta"> <div className="text-divider"> <span>{title || '示例'}</span> </div> <div className="code-box-description"> <p>{desc || '暫無描述'}</p> </div> <div className="divider" /> <div className="code-box-action"> <CopyToClipboard text={code} onCopy={() => alert('複製成功')}> <IconCopy data-place="top" data-tip="複製代碼" /> </CopyToClipboard> <StyledIconWrapper onClick={toggleEditVisible}> <IconCode data-place="top" data-tip={isEditVisible ? '收起代碼' : '顯示代碼'} /> </StyledIconWrapper> </div> </section> {renderEditor()} <ReactTooltip /> </StyledContainer> ); function renderEditor() { if (!isEditVisible) return null; return ( <div className="container_editor_area"> <Editor readOnly value={code} onValueChange={() => {}} highlight={code => highlight(code, languages.jsx)} padding={10} className="container__editor" style={{ fontFamily: '"Fira code", "Fira Mono", monospace', fontSize: 14, }} /> </div> ); } }; export default HappyBox;
alias
別名,樣例源碼展現相對路徑不夠友好,讓用戶直接拷貝纔夠省心新建gatsby-node.js
,寫入如下內容以開啓alias
:
const path = require('path'); exports.onCreateWebpackConfig = args => { args.actions.setWebpackConfig({ resolve: { modules: [path.resolve(__dirname, '../src'), 'node_modules'], alias: { 'happy-ui/lib': path.resolve(__dirname, '../components/'), 'happy-ui/esm': path.resolve(__dirname, '../components/'), 'happy-ui': path.resolve(__dirname, '../components/'), }, }, }); };
tsconfig.json
打包時須要忽略demo
,避免組件庫打包生成types
時包含其中,同時增長paths
屬性用於 vscode 自動提示:
tsconfig.json
{ "compilerOptions": { "baseUrl": "./", + "paths": { + "happy-ui": ["components/index.ts"], + "happy-ui/esm/*": ["components/*"], + "happy-ui/lib/*": ["components/*"] + }, "target": "esnext", "module": "commonjs", "jsx": "react", "declaration": true, "declarationDir": "lib", "strict": true, "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "resolveJsonModule": true }, "include": ["components", "global.d.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/demo/**"] }
新的問題出現了,vscode 的 alias 提示依賴 tsconfig.json,忽略 demo 文件夾後,demo 內的文件模塊類型找不到聲明(paths 失效),因此不能將 demo 在 tsconfig.json 中移除:
{ - "exclude": ["node_modules", "**/demo/**"] + "exclude": ["node_modules"] }
新建一個 tsconfig.build.json 文件:
tsconfig.build.json
{ "extends": "./tsconfig.json", "exclude": ["**/demo/**", "node_modules"] }
後續使用 tsc 生成類型聲明文件指定tsconfig.build.json
便可。
components/alert/demo/1-demo-basic.tsx
- import Alert from '../alert'; + import Alert from 'happy-ui/lib/alert'; - import '../style'; + import 'happy-ui/lib/alert/style';
components/alert/index.mdx
- import { Playground } from 'docz'; + import { HappyBox } from '../../doc-comps'; + import BasicDemoCode from '!raw-loader!./demo/1-demo-basic.tsx'; ... - <Playground> - <BasicDemo /> - </Playground> + <HappyBox code={BasicDemoCode} title="基本用法" desc="使用kind控制Alert類型"> + <BasicDemo /> + </HappyBox>
yarn start
卡住時嘗試刪除根目錄.docz
文件夾,然後從新執行命令。
如今能夠愉快地開發組件了。代碼能夠在倉庫的chapter-2
分支獲取,若存在與本文內容不符的地方,以master
分支以及文章爲準。
宿主環境各不相同,須要將源碼進行相關處理後發佈至 npm。
明確如下目標:
umd
/Commonjs module
/ES module
等 3 種形式供使用者引入css
引入,而非只有less
既然是使用typescript
編寫的組件庫,那麼使用者應當享受到類型系統的好處。
咱們能夠生成類型聲明文件,並在package.json
中定義入口,以下:
package.json
{ "typings": "lib/index.d.ts", // 定義類型入口文件 "scripts": { "build:types": "tsc -p tsconfig.build.json && cpr lib esm" // 執行tsc命令生成類型聲明文件 } }
值得注意的是:此處使用cpr
(須要手動安裝)將lib
的聲明文件拷貝了一份,並將文件夾重命名爲esm
,用於後面存放 ES module 形式的組件。這樣作的緣由是保證用戶手動按需引入組件時依舊能夠獲取自動提示。最開始的方式是將聲明文件單獨存放在
types
文件夾,但這樣只有經過'happy-ui'引入才能夠獲取提示,而'happy-ui/esm/xxx'和'happy-ui/lib/xxx'就沒法獲取提示。
tsconfig.build.json
{ "extends": "./tsconfig.json", "compilerOptions": { "emitDeclarationOnly": true }, // 只生成聲明文件 "exclude": ["**/__tests__/**", "**/demo/**", "node_modules", "lib", "esm"] // 排除示例、測試以及打包好的文件夾 }
執行yarn build:types
,能夠發現根目錄下已經生成了lib
文件夾(tsconfig.json
中定義的declarationDir
字段),目錄結構與components
文件夾保持一致,以下:
types
├── alert │ ├── alert.d.ts │ ├── index.d.ts │ ├── interface.d.ts │ └── style │ └── index.d.ts └── index.d.ts
這樣使用者引入npm
包時,便能獲得自動提示,也可以複用相關組件的類型定義。
接下來將ts(x)
等文件處理成js
文件。
須要注意的是,咱們須要輸出Commonjs module
以及ES module
兩種模塊類型的文件(暫不考慮umd
),如下使用cjs
指代Commonjs module
,esm
指代ES module
。<br/> 對此有疑問的同窗推薦閱讀: import、require、export、module.exports 混合詳解
其實徹底可使用babel
或tsc
命令行工具進行代碼編譯處理(實際上不少工具庫就是這樣作的),但考慮到還要處理樣式及其按需加載,咱們藉助 gulp
來串起這個流程。
首先安裝babel
及其相關依賴
yarn add @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-class-properties @babel/plugin-transform-runtime --dev
yarn add @babel/runtime-corejs3
新建.babelrc.js
文件,寫入如下內容:
.babelrc.js
module.exports = { presets: ['@babel/env', '@babel/typescript', '@babel/react'], plugins: [ '@babel/proposal-class-properties', [ '@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
。
.browserslistrc
>0.2% not dead not op_mini all
很遺憾的是,@babel/runtime-corejs3
沒法在按需引入的基礎上根據目標瀏覽器支持程度再次減小polyfill
的引入,參見@babel/runtime for target environment 。
這意味着@babel/runtime-corejs3
甚至會在針對現代引擎的狀況下注入全部可能的 polyfill
:沒必要要地增長了最終捆綁包的大小。
對於組件庫(代碼量可能很大),我的建議將polyfill
的選擇權交還給使用者,在宿主環境進行polyfill
。若使用者具備兼容性要求,天然會使用@babel/preset-env + core-js + .browserslistrc
進行全局polyfill
,這套組合拳引入了最低目標瀏覽器不支持API
的所有 polyfill
。
業務開發中,將@babel/preset-env
的useBuiltIns
選項值設置爲usage
,同時把node_modules
從babel-loader
中exclude
掉的同窗可能想要這個特性: "useBuiltIns: usage" for node_modules without transpiling #9419,在未支持該issue
提到的內容以前,仍是乖乖地將useBuiltIns
設置爲entry
,或者不要把node_modules
從babel-loader
中exclude
。
因此組件庫不用多此一舉,引入多餘的polyfill
,寫好文檔說明,比什麼都重要(就像zent和antd這樣)。
如今@babel/runtime-corejs3
更換爲@babel/runtime
,只進行helper
函數抽離。
yarn remove @babel/runtime-corejs3 yarn add @babel/runtime
.babelrc.js
module.exports = { presets: ['@babel/env', '@babel/typescript', '@babel/react'], plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'], };
@babel/transform-runtime
的helper
選項默認爲true
。
再來安裝gulp
相關依賴
yarn add gulp gulp-babel --dev
新建gulpfile.js
,寫入如下內容:
gulpfile.js
const gulp = require('gulp'); const babel = require('gulp-babel'); const paths = { dest: { lib: 'lib', // commonjs 文件存放的目錄名 - 本塊關注 esm: 'esm', // ES module 文件存放的目錄名 - 暫時不關心 dist: 'dist', // umd文件存放的目錄名 - 暫時不關心 }, styles: 'components/**/*.less', // 樣式文件路徑 - 暫時不關心 scripts: ['components/**/*.{ts,tsx}', '!components/**/demo/*.{ts,tsx}'], // 腳本文件路徑 }; function compileCJS() { const { dest, scripts } = paths; return gulp .src(scripts) .pipe(babel()) // 使用gulp-babel處理 .pipe(gulp.dest(dest.lib)); } // 並行任務 後續加入樣式處理 能夠並行處理 const build = gulp.parallel(compileCJS); exports.build = build; exports.default = build;
修改package.json
package.json
{ - "main": "index.js", + "main": "lib/index.js", "scripts": { ... + "clean": "rimraf lib esm dist", + "build": "npm run clean && npm run build:types && gulp", ... }, }
執行yarn build
,獲得以下內容:
lib
├── alert │ ├── alert.js │ ├── index.js │ ├── interface.js │ └── style │ └── index.js └── index.js
觀察編譯後的源碼,能夠發現:諸多helper
方法已被抽離至@babel/runtime
中,模塊導入導出形式也是commonjs
規範。
lib/alert/alert.js
生成ES module
能夠更好地進行tree shaking,基於上一步的babel
配置,更新如下內容:
@babel/preset-env
的modules
選項爲false
,關閉模塊轉換;@babel/plugin-transform-runtime
的useESModules
選項爲true
,使用ES module
形式引入helper
函數。.babelrc.js
module.exports = { presets: [ [ '@babel/env', { modules: false, // 關閉模塊轉換 }, ], '@babel/typescript', '@babel/react', ], plugins: [ '@babel/proposal-class-properties', [ '@babel/plugin-transform-runtime', { useESModules: true, // 使用esm形式的helper }, ], ], };
目標達成,咱們再使用環境變量區分esm
和cjs
(執行任務時設置對應的環境變量便可),最終babel
配置以下:
.babelrc.js
module.exports = { presets: ['@babel/env', '@babel/typescript', '@babel/react'], plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'], env: { esm: { presets: [ [ '@babel/env', { modules: false, }, ], ], plugins: [ [ '@babel/plugin-transform-runtime', { useESModules: true, }, ], ], }, }, };
接下來修改gulp
相關配置,抽離compileScripts
任務,增長compileESM
任務。
gulpfile.js
// ... /** * 編譯腳本文件 * @param {string} babelEnv babel環境變量 * @param {string} destDir 目標目錄 */ function compileScripts(babelEnv, destDir) { const { scripts } = paths; // 設置環境變量 process.env.BABEL_ENV = babelEnv; return gulp .src(scripts) .pipe(babel()) // 使用gulp-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) 避免環境變量影響 const buildScripts = gulp.series(compileCJS, compileESM); // 總體並行執行任務 const build = gulp.parallel(buildScripts); // ...
執行yarn build
,能夠發現生成了lib
/esm
三個文件夾,觀察esm
目錄,結構同lib
一致,js 文件都是以ES module
模塊形式導入導出。
esm/alert/alert.js
別忘了給package.json
增長相關入口。
package.json
{ + "module": "esm/index.js" }
咱們會將less
文件包含在npm
包中,用戶能夠經過happy-ui/lib/alert/style/index.js
的形式按需引入less
文件,此處能夠直接將 less 文件拷貝至目標文件夾。
在gulpfile.js
中新建copyLess
任務。
gulpfile.js
// ... /** * 拷貝less文件 */ function copyLess() { return gulp .src(paths.styles) .pipe(gulp.dest(paths.dest.lib)) .pipe(gulp.dest(paths.dest.esm)); } const build = gulp.parallel(buildScripts, copyLess); // ...
觀察lib
目錄,能夠發現 less
文件已被拷貝至alert/style
目錄下。
lib
├── alert │ ├── alert.js │ ├── index.js │ ├── interface.js │ └── style │ ├── index.js │ └── index.less # less文件 └── index.js
可能有些同窗已經發現問題:若使用者沒有使用less
預處理器,使用的是sass
方案甚至原生css
方案,那現有方案就搞不定了。經分析,有如下 3 種預選方案:
less-loader
;css
文件,進行全量引入;style/css.js
文件,引入的是組件 css
樣式文件依賴,而非 less
依賴,組件庫底層抹平差別;css in js
方案。方案 1 會致使業務方使用成本增長。
方案 2 沒法進行按需引入。
方案 4 須要詳細聊聊。
css in js
除了賦予樣式編寫更多的可能性以外,在編寫第三方組件庫時更是利器。
若是咱們寫一個react-use
這種hooks
工具庫,不涉及到樣式,只須要在package.json
中設置sideEffects
爲false
,業務方使用 webpack 進行打包時,只會打包被使用到的 hooks(優先使用 ES module)。
入口文件index.js
中導出的但未被使用的其餘 hooks 會被tree shaking
,第一次使用這個庫的時候我很好奇,爲何沒有按需引入的使用方式,結果打包分析時我傻了,原來人家天生支持按需引入。
可能經常使用的antd
以及lodash
都要配一配,致使產生了慣性思惟。
回到正題。若是將樣式使用javascript
來編寫,在某種維度上講,組件庫和工具庫一致了,配好sideEffects
,自動按需引入,美滋滋。
並且每一個組件都與本身的樣式綁定,不須要業務方或組件開發者去維護樣式依賴,什麼是樣式依賴,後面會講到。
缺點:
styled-components
,麻煩了點。須要看取捨了,偷偷說一句styled-components
作主題定製也極其方便。
方案 3 是antd
使用的這種方案。
在搭建組件庫的過程當中,有一個問題困擾了我好久:爲何須要alert/style/index.js
引入less
文件或alert/style/css.js
引入css
文件?
答案是管理樣式依賴。
由於咱們的組件是沒有引入樣式文件的,須要用戶去手動引入。
假設存在如下場景:引入<Button />
,<Button />
依賴了<Icon />
,使用者須要手動去引入調用的組件的樣式(<Button />
)及其依賴的組件樣式(<Icon />
),遇到複雜組件極其麻煩,因此組件庫開發者能夠提供一份這樣的js
文件,使用者手動引入這個js
文件,就能引入對應組件及其依賴組件的樣式。
那麼問題又來了,爲何組件不能本身去import './index.less'
呢?
能夠,不過業務方要配置less-loader
,什麼,業務方不想配,要你import './index.css'
?🙃
能夠,業務方爽了,組件開發方不爽。
因此咱們要找一個你們都爽的方案:
答案就是css in js單獨提供一份style/css.js
文件,引入的是組件 css
樣式文件依賴,而非 less
依賴,組件庫底層抹平差別。
以前瞭解到father能夠在打包的時候將index.less
轉成index.css
,這卻是個好法子,可是一些重複引入的樣式模塊(好比動畫樣式),會被重複打包,不知道有沒有好的解決方案。
安裝相關依賴。
yarn add gulp-less gulp-autoprefixer gulp-cssnano --dev
將less
文件生成對應的css
文件,在gulpfile.js
中增長less2css
任務。
// ... /** * 生成css文件 */ function less2css() { return gulp .src(paths.styles) .pipe(less()) // 處理less文件 .pipe(autoprefixer()) // 根據browserslistrc增長前綴 .pipe(cssnano({ zindex: false, reduceIdents: false })) // 壓縮 .pipe(gulp.dest(paths.dest.lib)) .pipe(gulp.dest(paths.dest.esm)); } const build = gulp.parallel(buildScripts, copyLess, less2css); // ...
執行yarn build
,組件style
目錄下已經存在css
文件了。
接下來咱們須要一個alert/style/css.js
來幫用戶引入css
文件。
此處參考antd-tools的實現方式:在處理scripts
任務中,截住style/index.js
,生成style/css.js
,並經過正則將引入的less
文件後綴改爲css
。
安裝相關依賴。
yarn add through2 --dev
gulpfile.js
// ... /** * 編譯腳本文件 * @param {*} babelEnv babel環境變量 * @param {*} destDir 目標目錄 */ function compileScripts(babelEnv, destDir) { const { scripts } = paths; process.env.BABEL_ENV = babelEnv; return gulp .src(scripts) .pipe(babel()) // 使用gulp-babel處理 .pipe( through2.obj(function z(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'); }
再進行打包,能夠看見組件style
目錄下生成了css.js
文件,引入的也是上一步less
轉換而來的css
文件。
lib/alert
├── alert.js ├── index.js ├── interface.js └── style ├── css.js # 引入index.css ├── index.css ├── index.js └── index.less
在 package.json 中增長sideEffects
屬性,配合ES module
達到tree shaking
效果(將樣式依賴文件標註爲side effects
,避免被誤刪除)。
// ... "sideEffects": [ "dist/*", "esm/**/style/*", "lib/**/style/*", "*.less" ], // ...
使用如下方式引入,能夠作到js
部分的按需加載,但須要手動引入樣式:
import { Alert } from 'happy-ui'; import 'happy-ui/esm/alert/style';
也可使用如下方式引入:
import Alert from 'happy-ui/esm/alert'; // or import Alert from 'happy-ui/lib/alert'; import 'happy-ui/esm/alert/style'; // or import Alert from 'happy-ui/lib/alert';
以上引入樣式文件的方式不太優雅,直接入口處引入全量樣式文件又和按需加載的本意相去甚遠。
使用者能夠藉助babel-plugin-import來進行輔助,減小代碼編寫量(說好的不加入其餘使用成本的呢~)。
import { Alert } from 'happy-ui';
⬇️
import Alert from 'happy-ui/lib/alert'; import 'happy-ui/lib/alert/style';
沒用上,這一塊標記爲 todo 吧。
本節代碼能夠在倉庫的chapter-3
分支獲取,若存在與本文內容不符的地方,以master
分支以及文章爲準。
與軟件操做行爲越接近的測試,越能給予你信心。
本節主要講述如何在組件庫中引入jest以及@testing-library/react,而不會深刻單元測試的學習。
若是你對下列問題感興趣:
那麼能夠看看如下文章:
<Counter />
的例子延伸,闡述了選擇React Testing Library
而非Enzyme
的理由,並對其進行了一些入門教學;@testing-library/react
的官方文檔,該庫提供的 API 在某個程度上就是在指引開發者進行單元測試的最佳實踐;@testing-library/react
的一些實例,提供了各類常見場景的測試;安裝依賴:
yarn add jest ts-jest @testing-library/react @testing-library/jest-dom identity-obj-proxy @types/jest @types/testing-library__react --dev
TypeScript
編寫jest
測試用例提供支持;React DOM
測試工具,鼓勵良好的測試實踐;jest
匹配器(matchers
),用於測試DOM
的狀態(即爲jest
的except
方法返回值增長更多專一於DOM
的matchers
);mock
樣式文件。新建jest.config.js
,並寫入相關配置,更多配置可參考jest 官方文檔-配置,只看幾個經常使用的就能夠。
jest.config.js
module.exports = { verbose: true, roots: ['<rootDir>/components'], moduleNameMapper: { '\\.(css|less|scss)$': 'identity-obj-proxy', '^components$': '<rootDir>/components/index.tsx', '^components(.*)$': '<rootDir>/components/$1', }, testRegex: '(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], testPathIgnorePatterns: ['/node_modules/', '/lib/', '/esm/', '/dist/'], preset: 'ts-jest', testEnvironment: 'jsdom', };
修改package.json
,增長測試相關命令,而且代碼提交前,跑測試用例,以下:
package.json
"scripts": { ... + "test": "jest", # 執行jest + "test:watch": "jest --watch", # watch模式下執行 + "test:coverage": "jest --coverage", # 生成測試覆蓋率報告 + "test:update": "jest --updateSnapshot" # 更新快照 }, ... "lint-staged": { "components/**/*.ts?(x)": [ "prettier --write", "eslint --fix", + "jest --bail --findRelatedTests", "git add" ], ... }
修改gulpfile.js
以及tsconfig.json
,避免打包時,把測試文件一併處理了。
gulpfile.js
const paths = { ... - scripts: ['components/**/*.{ts,tsx}', '!components/**/demo/*.{ts,tsx}'], + scripts: [ + 'components/**/*.{ts,tsx}', + '!components/**/demo/*.{ts,tsx}', + '!components/**/__tests__/*.{ts,tsx}', + ], };
tsconfig.json
{ - "exclude": ["components/**/demo"] + "exclude": ["components/**/demo", "components/**/__tests__"] }
<Alert />
比較簡單,此處只做示例用,簡單進行一下快照測試。
在對應組件的文件夾下新建__tests__
文件夾,用於存放測試文件,其內新建index.test.tsx
文件,寫入如下測試用例:
components/alert/tests/index.test.tsx
import React from 'react'; import { render } from '@testing-library/react'; import Alert from '../alert'; describe('<Alert />', () => { test('should render default', () => { const { container } = render(<Alert>default</Alert>); expect(container).toMatchSnapshot(); }); test('should render alert with type', () => { const kinds: any[] = ['info', 'warning', 'positive', 'negative']; const { getByText } = render( <> {kinds.map(k => ( <Alert kind={k} key={k}> {k} </Alert> ))} </>, ); kinds.forEach(k => { expect(getByText(k)).toMatchSnapshot(); }); }); });
更新一下快照:
yarn test:update
能夠看見同級目錄下新增了一個__snapshots__
文件夾,裏面存放對應測試用例的快照文件。
再執行測試用例:
yarn test
能夠發現咱們經過了測試用例。。。額,這裏固然能經過,主要是後續咱們進行迭代重構時,都會從新執行測試用例,與最近的一次快照進行比對,若是與快照不一致(結構發生了改變),那麼相應的測試用例就沒法經過。
對於快照測試,褒貶不一,這個例子也着實簡單得很,甚至連擴展的 jest-dom
提供的 matchers
都沒用上。
如何編寫優秀的測試用例,我也是一個新手,只能說多看多寫多嘗試,前面推薦的文章很不錯。
本節代碼能夠在倉庫的chapter-4
分支獲取,若存在與本文內容不符的地方,以master
分支以及文章爲準。
本節主要是講解如何經過一行命令完成如下六點內容:
若是你不想代碼,很好,用np(若是我一開始就知道這個工具,我也不會去寫代碼,我真傻,真的)。
package.json
"scripts": { + "release": "ts-node ./scripts/release.ts" },
直接甩代碼吧,實在不復雜。
/* eslint-disable import/no-extraneous-dependencies,@typescript-eslint/camelcase, no-console */ import inquirer from 'inquirer'; import fs from 'fs'; import path from 'path'; import child_process from 'child_process'; import util from 'util'; import chalk from 'chalk'; import semverInc from 'semver/functions/inc'; import { ReleaseType } from 'semver'; import pkg from '../package.json'; const exec = util.promisify(child_process.exec); const run = async (command: string) => { console.log(chalk.green(command)); await exec(command); }; const currentVersion = pkg.version; const getNextVersions = (): { [key in ReleaseType]: string | null } => ({ major: semverInc(currentVersion, 'major'), minor: semverInc(currentVersion, 'minor'), patch: semverInc(currentVersion, 'patch'), premajor: semverInc(currentVersion, 'premajor'), preminor: semverInc(currentVersion, 'preminor'), prepatch: semverInc(currentVersion, 'prepatch'), prerelease: semverInc(currentVersion, 'prerelease'), }); const timeLog = (logInfo: string, type: 'start' | 'end') => { let info = ''; if (type === 'start') { info = `=> 開始任務:${logInfo}`; } else { info = `✨ 結束任務:${logInfo}`; } const nowDate = new Date(); console.log( `[${nowDate.toLocaleString()}.${nowDate .getMilliseconds() .toString() .padStart(3, '0')}] ${info} `, ); }; /** * 獲取下一次版本號 */ async function prompt(): Promise<string> { const nextVersions = getNextVersions(); const { nextVersion } = await inquirer.prompt([ { type: 'list', name: 'nextVersion', message: `請選擇將要發佈的版本 (當前版本 ${currentVersion})`, choices: (Object.keys(nextVersions) as Array<ReleaseType>).map(level => ({ name: `${level} => ${nextVersions[level]}`, value: nextVersions[level], })), }, ]); return nextVersion; } /** * 更新版本號 * @param nextVersion 新版本號 */ async function updateVersion(nextVersion: string) { pkg.version = nextVersion; timeLog('修改package.json版本號', 'start'); await fs.writeFileSync(path.resolve(__dirname, './../package.json'), JSON.stringify(pkg)); await run('npx prettier package.json --write'); timeLog('修改package.json版本號', 'end'); } async function generateChangelog() { timeLog('生成CHANGELOG.md', 'start'); await run(' npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0'); timeLog('生成CHANGELOG.md', 'end'); } /** * 將代碼提交至git */ async function push(nextVersion: string) { timeLog('推送代碼至git倉庫', 'start'); await run('git add package.json CHANGELOG.md'); await run(`git commit -m "v${nextVersion}" -n`); await run('git push'); timeLog('推送代碼至git倉庫', 'end'); } /** * 組件庫打包 */ async function build() { timeLog('組件庫打包', 'start'); await run('npm run build'); timeLog('組件庫打包', 'end'); } /** * 發佈至npm */ async function publish() { timeLog('發佈組件庫', 'start'); await run('npm publish'); timeLog('發佈組件庫', 'end'); } /** * 打tag提交至git */ async function tag(nextVersion: string) { timeLog('打tag並推送至git', 'start'); await run(`git tag v${nextVersion}`); await run(`git push origin tag v${nextVersion}`); timeLog('打tag並推送至git', 'end'); } async function main() { try { const nextVersion = await prompt(); const startTime = Date.now(); // =================== 更新版本號 =================== await updateVersion(nextVersion); // =================== 更新changelog =================== await generateChangelog(); // =================== 代碼推送git倉庫 =================== await push(nextVersion); // =================== 組件庫打包 =================== await build(); // =================== 發佈至npm =================== await publish(); // =================== 打tag並推送至git =================== await tag(nextVersion); console.log(`✨ 發佈流程結束 共耗時${((Date.now() - startTime) / 1000).toFixed(3)}s`); } catch (error) { console.log('💣 發佈失敗,失敗緣由:', error); } } main();
每次初始化一個組件就要新建許多文件以及文件夾,複製粘貼也可,不過還可使用更高級一點的偷懶方式。
常規思路,新建一個組件模板文件夾,裏面包含一個組件所須要的全部文件,同時寫好文件內容。
至於一些動態內容,譬如組件中英文名稱,選一個你喜歡的模板語言(如 handlebars),用其方式留空{{componentName}}
。
package.json
"scripts": { + "new": "ts-node ./scripts/new.ts" },
接下來咱們在new.ts
中編寫相關步驟,無非是:
inquirer.js
詢問一些基本組件信息你覺得我會寫new.ts
嗎,不,我不會(雖然我真寫過)。
主要是使用metalsmith進行數據與模板結合,寫腳手架的同窗可能比較熟悉。
自從我知道了plop.js這個庫,那麼又能夠偷懶了(爲何以前沒有人告訴我有這麼多好用的工具???)
"scripts": { - "new": "ts-node ./scripts/new.ts", + "new": "plop --plopfile ./scripts/plopfile.ts", },
因而上述流程能夠大大簡化,不須要寫代碼去詢問,不須要手動渲染模板,咱們要作的就是寫好模板,而且配置好問題以及渲染目的地。
詳情可見:
文章很長,也是我我的學習中的總結,若是本文幫助到了你請給倉庫一顆 ✨✨ 和本文一個贊。
若是有錯誤煩請在評論區指正交流,謝謝。