You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
值得注意的是:此处使用cpr将lib的声明文件拷贝了一份,并将文件夹重命名为esm,用于后面存放 ES module 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。最开始的方式是将声明文件单独存放在types文件夹,但这样只有通过@block-org/block-ui引入才可以获取提示,而@block-orgblock-ui/esm/xxx和@block-orgblock-ui/lib/xxx就无法获取提示。
/** * Get config from pre / root project tsconfig.json ... * @param tsconfigPath * @param subConfig * @returns */constgetTSConfig=(tsconfigPath=path.resolve(CWD,"tsconfig.json"),subConfig={compilerOptions: {}})=>{if(fs.pathExistsSync(tsconfigPath)){constconfig=fs.readJsonSync(tsconfigPath);constcompilerOptions=(config&&config.compilerOptions)||{};constsubCompilerOptions=(subConfig&&subConfig.compilerOptions)||{};// Avoid overwriting of the compilation options of subConfigsubConfig.compilerOptions={ ...compilerOptions, ...subCompilerOptions};Object.assign(config,subConfig);if(config.extends){returngetTSConfig(path.resolve(path.dirname(tsconfigPath),config.extends),config);}returnconfig;}return{ ...subConfig};};
tscConfig 来自于/config/tsc.config的 getConfigProcessor,会根据 type 匹配 组件库(用户)目录下.config对应类型的配置
/** * Get project .config/ processor * @param configType * @returns */exportdefaultfunctiongetConfigProcessor<T=Function>(configType: "jest"|"webpack"|"babel"|"docgen"|"style"|"tsc"): T{constconfigFilePath=`${CWD}/.config/${configType}.config.js`;letprocessor=null;if(fs.existsSync(configFilePath)){try{processor=require(configFilePath);}catch(error){print.error("[block-scripts]",`Failed to extend configuration from ${configFilePath}`);console.error(error);process.exit(1);}}returnprocessor;}
综上,配置优先级别为 组件库/用户目录> blocks-script子包> block-org根
下面着重看下 babel编译 tsx的处理,withBabel.ts:
/** * Build TS with babel * @param param0 * @returns */asyncfunctionwithBabel({ type, outDir, watch }: CompileOptions){consttsconfig=getTSConfig();// The base path of the matching directory patternsconstsrcPath=tsconfig.include[0].split("*")[0].replace(/\/[^/]*$/,"");consttargetPath=path.resolve(CWD,outDir);consttransform=(file)=>{// ...returnbabelTransform(file.contents,{
...babelConfig,filename: file.path,// Ignore the external babel.config.js and directly use the current incoming configurationconfigFile: false,}).code;};constcreateStream=(globs)=>{// ...transform(file);};returnnewPromise((resolve)=>{constpatterns=[path.resolve(srcPath,"**/*"),`!${path.resolve(srcPath,"**/demo{,/**}")}`,`!${path.resolve(srcPath,"**/__test__{,/**}")}`,`!${path.resolve(srcPath,"**/*.md")}`,`!${path.resolve(srcPath,"**/*.mdx")}`,];createStream(patterns).on("end",()=>{if(watch){print.info("[block-scripts]",`Start watching file in ${srcPath.replace(`${CWD}/`,"")}...`);// https://www.npmjs.com/package/chokidarconstwatcher=chokidar.watch(patterns,{ignoreInitial: true,});constfiles=[];constdebouncedCompileFiles=debounce(()=>{while(files.length){createStream(files.pop());}},1000);watcher.on("all",(event,fullPath)=>{print.info(`[${event}] ${path.join(fullPath).replace(`${CWD}/`,"")}`);if(fs.existsSync(fullPath)&&fs.statSync(fullPath).isFile()){if(!files.includes(fullPath)){files.push(fullPath);}debouncedCompileFiles();}});}else{resolve(null);}});});}
/** * Match the resource that matches the entry glob and copy it to the /asset * @returns Stream */functioncopyAsset(){returngulp.src(assetConfig.entry,{allowEmpty: true}).pipe(gulp.dest(assetConfig.output));}
copyFileWatched方法:将监听目录的文件拷贝到 esm/lib 下 入图片字体等
***Copythefilesthatneedtobemonitoredtotheesm/libdirectory* @returns*/functioncopyFileWatched(){const patternArray =cssConfig.watch;constdestDirs=[cssConfig.output.esm,cssConfig.output.cjs].filter((path)=>!!path);if(destDirs.length){returnnewPromise((resolve,reject)=>{letstream: NodeJS.ReadWriteStream=mergeStream(patternArray.map((pattern)=>gulp.src(pattern,{allowEmpty: true,base: cssConfig.watchBase[pattern]})));destDirs.forEach((dir)=>{stream=stream.pipe(gulp.dest(dir));});stream.on('end',resolve).on('error',(error)=>{print.error('[block-scripts]','Failed to build css, error in copying files');console.error(error);reject(error);});});}returnPromise.resolve(null);}
compileLes方法:编译 less 文件并输出到 esm / lib 目录下
/** * Compile less, and output css to at esm/lib * @returns */functioncompileLess(){constdestDirs=[cssConfig.output.esm,cssConfig.output.cjs].filter((path)=>path);if(destDirs.length){letstream=gulp.src(cssConfig.entry,{allowEmpty: true}).pipe(cssConfig.compiler(cssConfig.compilerOptions)).pipe(cleanCSS());destDirs.forEach((dir)=>{stream=stream.pipe(gulp.dest(dir));});returnstream.on("error",(error)=>{print.error("[block-scripts]","Failed to build css, error in compiling less");console.error(error);});}returnPromise.resolve(null);}
exportdefaultasyncfunctionhandleStyleJSEntry(){awaitcompileCssJsEntry({styleJSEntry: jsEntryConfig.entry,outDirES: cssConfig.output.esm,outDirCJS: cssConfig.output.cjs,});if(jsEntryConfig.autoInjectBlockDep){awaitinjectBlockDepStyle(getComponentDirPattern([DIR_NAME_ESM]));}renameStyleEntryFilename();}/** * Generate /style/css.js */asyncfunctioncompileCssJsEntry({
styleJSEntry,
outDirES,
outDirCJS,}: {/** Glob of css entry file */styleJSEntry: string[];/** Path of ESM */outDirES: string;/** Path of CJS */outDirCJS: string;}){// ...}
distLess方法:dist 目录 less 生成
/** * Dist all less files to dist * @param cb */functiondistLess(cb){const{path: distPath, rawFileName }=cssConfig.output.dist;letentries=[];cssConfig.entry.forEach((e)=>{entries=entries.concat(glob.sync(e));});if(entries.length){consttexts=[];entries.forEach((entry)=>{// Remove the first level directoryconstesEntry=cssConfig.output.esm+entry.slice(entry.indexOf("/"));constrelativePath=path.relative(distPath,esEntry);consttext=`@import "${relativePath}";`;if(esEntry.startsWith(`${cssConfig.output.esm}/style`)){texts.unshift(text);}else{texts.push(text);}});fs.outputFileSync(`${distPath}/${rawFileName}`,texts.join("\n"));}cb();}
distCss方法:dist 目录 css 生成
/** * Compile the packaged less into css * @param isDev * @returns */functiondistCss(isDev: boolean){const{path: distPath, rawFileName, cssFileName }=cssConfig.output.dist;constneedCleanCss=!isDev&&(!BUILD_ENV_MODE||BUILD_ENV_MODE==="production");conststream=gulp.src(`${distPath}/${rawFileName}`,{allowEmpty: true}).pipe(cssConfig.compiler(cssConfig.compilerOptions));// Errors should be thrown, otherwise it will cause the program to exitif(isDev){notifyLessCompileResult(stream);}returnstream.pipe(// The less file in the /dist is packaged from the less file in /esm, so its static resource path must start with ../esmreplace(newRegExp(`(\.{2}\/)+${cssConfig.output.esm}`,"g"),path.relative(cssConfig.output.dist.path,assetConfig.output))).pipe(gulpIf(needCleanCss,cleanCSS())).pipe(rename(cssFileName)).pipe(gulp.dest(distPath)).on("error",(error)=>{print.error("[block-scripts]","Failed to build css, error in dist all css");console.error(error);});}
一套组合拳下来就实现了组件和样式的分离,dist 目录主要是为 umd 预留。现在用户就可以通过block-ui/esm[lib]/alert/style/index.less的形式按需引入 less 样式。或者,通过block-ui/esm[lib]/alert/style/css.js的形式按需引入 css 样式,如下
虽然参与了项目组的组件库架构设计和讨论,但是终究不是在自己完全愿景下实施。总想着自己造一个的组件库,于是就有了下面从 0 到 1 包含源起,构建,测试,测试,站点,发布等部分。
万物从起名开始,思来想去也没想到什么高大上的名字,姑且就叫
block-ui
所有个代码都放在 @block-org 组织下一,源起
新建项目
代码规范
.eslintrc
.prettierrc
.stylelintrc.js
Commit Lint
pre-commit
代码规范检测新增
package.json
信息2.进行 Commit Message 检测
新增.commitlintrc.js 写入以下内容
package.json 写入以下内容:
husky
在代码提交的时候执行一些 bash 命令,lint-staged
只针对当前提交/改动过的文件进行处理TypeScript
新建
tsconfig.json
并写入以下内容组件
安装 React 相关依赖:
# 开发时依赖,宿主环境一定存在 yarn add react react-dom @types/react @types/react-dom -D
src/alert/interface.ts
src/alert/index.ts
src/alert/style/index.less
src/alert/style/index.ts
src/index.ts
调试开发
引入 storybook ,这个步骤依赖 build 后的产物
# Add Storybook: npx sb init
接下来,要让这个 Alert 在 storybook 里跑起来,帮助调试组件;同时在开发不同组件功能时,可以创建不同的 demo,除了用作调试,也是极好的使用文档
修改
.storybook/main.js
,并写入以下内容:添加
stories/index.stories.js
,并写入以下内容:添加 alert 组件 demo 新增
stories/components/alert.jsx
,并写入以下内容:最终效果如下
二,构建
组件打包逻辑已单独拆分到
block-cli
中:https://github.com/block-org/block-cliblock-cli
会** 根据宿主环境和配置的不同将源码进行相关处理 **,主要完成以下目标:Commonjs module
/ES module
/UMD
等多种形式产物供使用者引入;css
引入,而非只有less
,减少使用者接入成本;block-scripts
先介绍下 block-org/block-cli 中
block-scripts
的总体设计思路构建部分单独拆分为子任务,具体实现拆分如下
后面介绍各部实现
导出类型声明文件
既然是使用
typescript
编写的组件库,那么使用者应当享受到类型系统的好处,可以生成类型声明文件,并在package.json
中定义入口,如下:package.json
tsconfig.json
执行
yarn build:types
,可以发现根目录下已经生成了lib
文件夹(tsconfig.json
中定义的declarationDir
字段)以及esm
文件夹(拷贝而来),目录结构与components
文件夹保持一致,如下:这样使用者引入
npm
包时,便能得到自动提示,也能够复用相关组件的类型定义。但是这样有个问题是要单独打包 types。下面采用的办法是将ts(x)
等文件处理成js
文件的同时生成类型声明文件,这样就可以将两步合并为一个步骤导出 CJS / ESM 模块
这里将 cjs / ems 模块的处理统一到
compileTS
方法中然后使用
babel
或tsc
命令行工具进行代码编译处理(实际上很多工具库就是这样做的)首先,根目录下维护了一份各种模式下输入/输出的常量(已作为默认值)
先看
tsc
的封装withTSC
ts options 的核心就是递归向上遍历
tsconfig.json
并就近合并 ,如下tscConfig
来自于/config/tsc.config
的getConfigProcessor
,会根据 type 匹配 组件库(用户)目录下.config
对应类型的配置综上,配置优先级别为
组件库/用户目录
>blocks-script子包
>block-org根
下面着重看下
babel
编译tsx
的处理,withBabel.ts
:通过
createStream
方法匹配所有文件模式创建流并执行编译transform
方法,如果开启了watch
模式会在当次执行完成后监听文件的变化并重新执行createStream
方法并做了防抖处理createStream
方法使用gulp
的流式处理,见下导出 UMD 模块
这部分使用
webpack
进行打包,相关插件和配置较为繁琐,优化细节较多,这里不赘述了,源码查看处理样式文件
在
block-script
中入口函数是buildCSS
:核心实现都在
buildStyle
方法中:为了同时执行多个子任务这里又借助了
gulp.series()
和gulp.parallel()
.其中编译配置会优先使用组件库目录.config
下的配置逻辑和处理 ts 文件一致。下面依次介绍各个任务的细节copyAsset
方法:匹配文件模式到指定目录copyFileWatched
方法:将监听目录的文件拷贝到 esm/lib 下 入图片字体等compileLes
方法:编译 less 文件并输出到 esm / lib 目录下handleStyleJSEntry
方法:处理样式入口(css.js/index.js)并注入依赖的样式distLess
方法:dist 目录 less 生成distCss
方法:dist 目录 css 生成一套组合拳下来就实现了组件和样式的分离,dist 目录主要是为 umd 预留。现在用户就可以通过
block-ui/esm[lib]/alert/style/index.less
的形式按需引入 less 样式。或者,通过block-ui/esm[lib]/alert/style/css.js
的形式按需引入 css 样式,如下理由如下:
css
文件,进行全量引入,无法进行按需引入;block-ui/esm[lib]/alert/style/index.less
的形式按需引入 less 样式)增加less-loader
,会导致使用成本增加;style/css.js
文件,引入组件css
样式依赖,而非less
依赖,可以帮助管理样式依赖 (每个组件都与自己的样式绑定,不需要使用者或组件开发者去维护样式依赖),可以以抹平组件库底层差异,既可以保障组件库开发者的开发体验 又能够使用者的使用成本按需加载
在 package.json 中增加
sideEffects
属性,配合ES module
达到tree shaking
效果(将样式依赖文件标注为side effects
,避免被误删除)。使用以下方式引入,可以做到
js
部分的按需加载,但需要手动引入样式:也可以使用以下方式引入:
以上引入样式文件的方式不太优雅,直接入口处引入全量样式文件又和按需加载的本意相去甚远。
使用者可以借助 babel-plugin-import 来进行辅助,减少代码编写量(还是增加了使用成本)
三,测试
本节主要讲述如何在组件库中引入jest以及@testing-library/react,而不会深入单元测试的学习,也不会集成到
block-ui
中如果你对下列问题感兴趣:
那么可以看看以下文章:
<Counter />
的例子延伸,阐述了选择React Testing Library
而非Enzyme
的理由,并对其进行了一些入门教学;@testing-library/react
的官方文档,该库提供的 API 在某个程度上就是在指引开发者进行单元测试的最佳实践;@testing-library/react
的一些实例,提供了各种常见场景的测试;相关配置
安装依赖:
TypeScript
编写jest
测试用例提供支持;React DOM
测试工具,鼓励良好的测试实践;jest
匹配器(matchers
),用于测试DOM
的状态(即为jest
的except
方法返回值增加更多专注于DOM
的matchers
);mock
样式文件。新建
jest.config.js
,并写入相关配置,更多配置可参考jest 官方文档-配置,只看几个常用的就可以。jest.config.js
修改
package.json
,增加测试相关命令,并且代码提交前,跑测试用例,如下:package.json
修改
gulpfile.js
以及tsconfig.json
,避免打包时,把测试文件一并处理了。gulpfile.js
tsconfig.json
编写测试用例
<Alert />
比较简单,此处只作示例用,简单进行一下快照测试。在对应组件的文件夹下新建
__tests__
文件夹,用于存放测试文件,其内新建index.test.tsx
文件,写入以下测试用例:src/alert/tests/index.test.tsx
更新一下快照:
可以看见同级目录下新增了一个
__snapshots__
文件夹,里面存放对应测试用例的快照文件。再执行测试用例:
yarn test
可以发现通过了测试用例,主要是后续我们进行迭代重构时,都会重新执行测试用例,与最近的一次快照进行比对,如果与快照不一致(结构发生了改变),那么相应的测试用例就无法通过。
对于快照测试,褒贬不一,这个例子也着实简单得很,甚至连扩展的
jest-dom
提供的matchers
都没用上。四,站点
站点搭建可选项挺多,国内的
Dumi
国外的Next.js
Gatsby.js
,或者自己写一套,这里采用Next.js
集成 Next.js
npx create-next-app@latest --typescript # or yarn create next-app --typescript
针对
site
目录下站点应用增加npm scripts
至site/package.json
站点骨架
这一步主要是处理站点首页、组件文档、快速开始、指南、定制化等,页面级路由都会放在站点根目录
pages
文件夹下。目录结构
站点启动前会执行
collect-meta
指令收集pages
目录下的所有的.md(x)
文件做一层转换并分组并生成一份路由 meta 信息 放在lib/data
下在
pages/**/*.md(x)
定义 meta 信息scripts/collect-meta.js
核心逻辑最终按照
/local/folder/title
的格式生成路由,比如/en-us/components/alert
;一旦pages
目录变化页面路由就会动态更新最终站点如下
文档补全
集成
mdx
修改next.config.js
经过前面的操作,可以愉快地进行文档的开发了,例如添加
alert
组件的文档en-us/components/alert.mdx
只需再补充一些组件定义,这篇 markdown 就是所需要的最终组件文档
部署
包括 Next 文档站点 和 storybook 构建的静态站点部署
Next.js
site 目录集成了
scripts
命令如下文档站点直接托管到了 vercel
文档站点:https://block-ui-alpha.vercel.app/en-us
StoryBook
执行 以下命令 构建静态产物
也可以使用 Github Actions 自动触发部署,在项目根目录新建
.github/workflows/gh-pages.yml
文件,后续 master 触发 push 操作时则会自动触发站点部署,更多配置可自行参阅文档。StoryBook 站点:https://block-org.github.io/block-ui/
五,发布
前言
本节主要是讲解如何编写脚本完成以下内容:
如果你对这一节不感兴趣,也可以直接使用 np 进行发布,只需要自定义配置一些钩子。
package.json
"scripts": { + "release": "ts-node ./scripts/release.ts" },
scripts/release.ts
其他
每次初始化一个组件就要新建许多文件(夹),复制粘贴也可,不过还可以使用更高级一点的偷懒方式。
思路如下:
inquirer.js
询问动态信息;components
文件夹下;只需要配置好模板以及问题,至于询问以及渲染就交给plop.js
新增脚本命令。
package.json
"scripts": { + "new": "plop --plopfile ./scripts/plopfile.ts", },
新增配置文件
scripts/plopfile.ts
新增组件模板
templates/component
见 https://github.com/block-org/block-ui/tree/master/templates/componentreferences
The text was updated successfully, but these errors were encountered: