「推荐收藏」提高组件库Level必做好这六件事
今天带来一篇不一样的组件库开发文章,强烈推荐先收藏后阅读。
当你看了很多的从0到1开发的组件库的文章后会发现跟你主流使用的组件库总是天差地别,但又不是不能用。这样的组件库其实就严重缺少了工程化的支撑,缺少了全局的考虑。如果你已经看过了那些文章,那么你将通过这次介绍的6件事让你的组件库提升到更高的高度~
1. 留一个好的印象给Contributor
1.1 背景描述
在 Workspace 模式的项目中各个子包都会有一定的相互依赖存在,当你未能构建某个特定子包时就会造成另外一个子包无法启动,为了避免这样的问题出现,也为了给Contributor留下一个好的印象,我决定在你拿到项目安装依赖之后就帮你把该做的事情都做好,达到开箱即可体验的目的。
1.2 回归案例
在这次的案例中按 Workspace 模式开发的组件库项目包含了有ui、docs和example三个子包,其中docs和example都依赖ui子包构建后的产物,那么我需要做的就是在你安装项目依赖后自动实现ui包构建。
1.3 技术调研
在查看NPM文档后得知在执行依赖安装前后其实都会触发特定的钩子,我将利用这一特性,在触发到postinstall
钩子后自动执行ui包构建。
1.4 实现过程
在 Workspace 下的 package 下添加构建ui包的脚本;
- 通过
--filter
来限制脚本执行的子包集;
{
"scripts": {
"postinstall": "pnpm -r --filter=@gfe/ui run build"
}
}
2. Build Tools API 更适合
2.1 背景描述
在一些常规的项目中通常只需要应用到webpack
或者vite
的配置文件就可以让项目正常的运行及构建,当你的项目变得复杂的时候就或不停的往配置文件中增加更多的loader
或plugins
来充实构建工具的功能,在组件库开发的时候如果你仅使用配置文件来实现的话这一切将变得很复杂,所以就需要应用到构建工具提供的API来在构建脚本的函数中动态调用,实现更加灵活的执行。
2.2 回归案例
在这次的案例中将利用Vite构建工具来实现每个组件的打包,在后期使用组件时既可以引入全量的组件包又可以选择性的使用某一个指定的组件包。在组件包中我们还将利用脚本来充实组件包的内容达到更好的使用体验,这也是纯配置文件不那么容易搞定的事情。
2.3 实现过程
2.3.1 组件全量编译
全量编译不需要配置过多的信息,因为它会按照你在vite配置执行编译,只需要调用vite提供的build
函数就可以了~
import { build } from "vite";
const buildAll = async () => {
// 全量打包
await build();
}
buildAll();
2.3.2 组件分包编译
分包编译就需要通过遍历组件目录得到符合组件包特征的组件列表,在遍历组件列表的时候实时配置组件编译选项再执行build
函数。
// 按组件分别打包
const srcDir = path.resolve(__dirname, "../src/");
// 提取包含index.ts入口的组件目录
const componentsDir = fs.readdirSync(srcDir).filter(filename => {
const componentDir = path.resolve(srcDir, filename);
const isDir = fs.lstatSync(componentDir).isDirectory();
return isDir && fs.readdirSync(componentDir).includes("index.ts");
})
// 遍历需要打包的组件分别打包
for (let name of componentsDir) {
const outDir = path.resolve(output, name)
const customConfig = {
lib: {
entry: path.resolve(srcDir, name),
name,
fileName: "index",
formats: ["esm", "umd"]
},
outDir,
}
await build({ build: customConfig, } as InlineConfig);
};
2.3.3 调整package信息
在全量构建完成和每个分包构建完成都应该给它们维护其专属的package信息,在使用组件时将很有用。
// 输出package信息函数
function outputPkgFile(filepath, pkg) {
fs.outputFile(path.resolve(filepath, `package.json`), JSON.stringify(pkg, null, 2), `utf-8`);
}
全量构建的package信息不需要全部重写,可以导入ui包下的package信息,在此基础上进行修改,补充main、module、types信息,分别指向umd输出产物路径、esm输出产物路径、.d.ts输出产物路径(在组件库的使用体验很重要一节会详细说明);
outputPkgFile(output, {
...require("../package.json"),
main: `gfe-ui.umd.js`,
module: `gfe-ui.esm.js`,
types: `gfe-ui.d.ts`,
});
每个分包构建后的package信息除了上面的三个属性外name属性也需要调整成每个组件自己的名称,因为它属于这个组件;
outputPkgFile(outDir, {
name: `@gfe-ui/${name.toLocaleLowerCase()}`,
main: `index.umd.js`,
module: `index.esm.js`,
types: `../types/${name}/index.d.ts`
});
2.3.4 输出自述文档
自述文档在没有项目的根目录下都会有一份,在ui包的根目录下的自述文档描述了组件库的安装、导入及使用的方式,在输出的产物中也需要包含这个文件;
fs.copyFileSync(path.resolve("./README.md"), path.resolve(output, `README.md`));
输出结构:
dist
├─ Button
│ ├─ styles
│ │ ├─ index.css
│ │ └─ style.css
│ ├─ index.esm.js
│ ├─ index.esm.js.map
│ ├─ index.iife.js
│ ├─ index.iife.js.map
│ ├─ index.umd.js
│ ├─ index.umd.js.map
│ └─ package.json
├─ gfe-ui.d.ts
├─ gfe-ui.esm.js
├─ gfe-ui.esm.js.map
├─ gfe-ui.iife.js
├─ gfe-ui.iife.js.map
├─ gfe-ui.umd.js
├─ gfe-ui.umd.js.map
├─ package.json
└─ README.md
3. 顺手的工具远胜与一切
3.1 背景描述
前端项目从不需要构建到使用webpack
、vite
构建,还有最近新出现的Turbopack
,无论选择哪种构建工具都会遇到某一些文件是没办法直接处理的,那么首先就会去寻找对应的插件来看是否可能满足要求,我想说的是其实没有那么必要拘泥于使用一种构建工具,有更顺手的构建工具配合将是一种不错的选择。
3.2 回归案例
在这次的案例中构建组件的主要是基于vite
来做的,但是在less模块的构建中我选择了相对熟悉的gulp
来编写构建脚本,通过遍历组件文件夹来提取到所有的less模块文件,在分别注册gulp
任务,最后交由gulp
来统一执行,定制化程度高,我想不到什么样的vite
插件可以这么灵活的实现;
3.3 实现过程
- 配置
gulp
构建less
模块的脚本,使用到了gulp-less
模块支持,如果需要对less构建完的结果做进一步处理,比如要压缩,就可以继续使用pipe
处理,因为gulp
基于 node 强大的流(stream)能力:
import gulp from "gulp";
import less from "gulp-less";
// gulp core code
const register = (name, src, dist) => {
gulp.task(name, function () {
return gulp.src(src)
.pipe(less())
.pipe(gulp.dest(dist));
});
}
// TODO register task
// 导出所有 gulp task
export default gulp.series(...tasks);
- 确定组件文件的和输出文件的路径,如果在
vite.config.ts
中指定了outDir
选项将会被优先使用:
const srcDir = path.resolve(__dirname, "../src/");
const output = path.resolve(require("../vite.config.ts").build?.outDir || "../dist");
- 通过组件包特征(包含入口
index.ts
文件)来确定所有组件的目录:
const componentsDir = fs.readdirSync(srcDir).filter(filename => {
const componentDir = path.resolve(srcDir, filename);
const isDir = fs.lstatSync(componentDir).isDirectory();
return isDir && fs.readdirSync(componentDir).includes("index.ts");
})
- 最后遍历组件注册任务:
register(`${name}Task`,
path.resolve(entryDir, 'style.less'),
path.resolve(outDir, 'styles')
);
4. 组件库的使用体验很重要
4.1 背景描述
在编码中使用组件库的时候你是因为什么原因去不停的翻找组件文档的?是因为组件名不会写?还是因为组件属性忘记了?还是因为其他?解决这些不大不小的问题也是提升组件库使用体验很关键一点,那么你有没有考虑找一些VSCode插件实时提示组件的一些信息呢?或者使用快捷键来生成代码片段?
4.2 回归案例
在组件库开发初期就决定使用Ts作为组件库开发的基本语言,良好的利用其类型系统的优势来达到后期使用组件时的便利性,那么就需要为组件生成它所对应的类型文件并且正确的进行配置,在SFC组件中使用时还需要配置Volar
插件使用。
4.3 实现过程
4.3.1 生成dts文件:
利用vite-plugin-dts
插件来实现dts文件的生成,插件的配置不建议配置到vite.config.ts
中,在分包构建的时候发现依然会触发一次该插件的执行,快速的组件编译将导致内存被快速消耗殆尽,建议在全局构建时调用build
函数时动态传入。
import dts from "vite-plugin-dts";
// 全量打包
await build({
// 支持生成.d.ts类型文件
// 修改为仅全量打包阶段生成dts
plugins: [
dts({
outputDir: "./dist/types",
insertTypesEntry: false, // 插入TS 入口
copyDtsFiles: true, // 是否将源码里的 .d.ts 文件复制到 outputDir
}) as unknown as PluginOption
]
});
4.3.2 入口文件改造
上面的配置我们禁用的入口文件的插入,因为插件生成的入口不太符合我们的要求,我们需要进一步的利用脚本改造,使得组件的类型可以在使用是得到识别;
- 确认全量包的package信息,让types属性与类型入口文件对应;
- 确认每个分包的package信息,让types属性与每个组件的类型入口文件对应;
- 定义类型入口文件模板,这里应用了 Handlebars 模板引擎:
export * from './types/index'
import GFEUI from './types/index'
export default GFEUI
declare module 'vue' {
export interface GlobalComponents {
{{#each components}}
{{name}}: typeof import("./types/index").{{component}},
{{/each}}
}
}
- 获取组件信息列表生成模板所需的元数据,这里使用了import动态导入组件入口文件进行分析获取:
async function getComponents(input) {
const entry = await import(`file://${input}`);
return Object.keys(entry)
.filter(k => k !== 'default')
.map(k => ({
name: entry[k].name,
component: k,
}))
}
- 利用模板引擎替换生成入口文件并输出到指定位置:
function generateCode(meta, filePath: string, templatePath: string) {
if (fs.existsSync(templatePath)) {
const content = fs.readFileSync(templatePath).toString();
const result = handlebars.compile(content)(meta);
fs.writeFileSync(filePath, result);
}
console.log(`🚀 ${filePath} 创建成功`)
}
- 脚本编写完成后可以将脚本的执行放置到全量构建之后,因为这个时候既生成的各个dts文件,有利用脚本生成了类型入口文件,在SFC组件中使用指定了类型入口的组件库时将获得类型的提示及约束的效果。
5. 高效维护属性列表文档
5.1 背景描述
在使用开源社区的组件库的时候主要关注的就是怎么安装?什么效果?有哪些属性?每个组件的属性列表将决定了你是否会使用这个组件库,因为属性列表决定了功能是否可以实现(轻松),而效果则是次要的。那你有没有想过一个组件库那么多的组件,每个组件又有那么多的属性你会怎么样来维护呢?
5.2 回归案例
在这次组件库开发时我选择了使用Babel
来对每个组件中定义的属性列表文件进行解析,得到属性列表文件中定义的属性和属性上附带的注释,将解析到的数据组合整理成组件库文档中属性列表的语法格式,并输出覆盖旧的属性列表,那么将这段脚本添加到组件库编译后的流程中就实现了每次构建完组件库后对应文档的属性列表也就是最新的。
5.3 实现过程
5.3.1 AST 结构分析:
下面两张图是通过 AST Explorer 工具对组件源码片段的分析;
- 在第一张图中可以看到
type
为Identifier
的对象中name
属性存放了组件选项的名称; - 在第二张图中可以看到
type
为CommentBlock
的对象中value
属性存放了选项上配置的所有注释数据; - 这两块内容及其它一些数据共同组成了
type
为ExportNamedDeclaration
表达式;
5.3.2 插件开始前初始化容器:
在pre()
函数中可以使用this.set(key, value)
方式来存储attributeList
数据,在这个函数中可以将MD表格的header
和split line
部分先存储起来;
// 插件执行前初始化存储容器
pre(this: PluginPass, file: BabelFile) {
console.info(
`\u001b[33m将要生成的组件属性列表将合并至对应组件库文档.\u001b[39m\n`
)
this.set('attributeList', [
['属性名', '说明', '类型', '可选值', '默认值'],
['------', '----', '----', '-----', '-----']
]);
}
5.3.3 存储每一次解析数据:
因为通过AST工具分析可以看到我们的每块属性都属于一个ExportNamedDeclaration
,那么在 visitor 中就配置ExportNamedDeclaration(path, state)
函数来解析,在每次进入到ExportNamedDeclaration(path, state)
函数时需要通过state
获取到已经存储的属性列表数据,并在每次操作结束后将新解析到的数据再存储到state
中;
visitor: {
ExportNamedDeclaration(
path: NodePath<t.ExportNamedDeclaration>,
state: PluginPass
) {
const attributeList = state.get('attributeList');
// TODO
state.set('attributeList', attributeList);
}
}
5.3.4 解析注释数据:
解析注释需要使用到doctrine
模块,在Babel将组件源码解析为对应的AST结构后,注释信息将存储在对应的leadingComments
属性中,通过doctrine
模块提供的parse
函数将解析注释信息为方便操作的对象模式;
接着需要定义一个Comment
类型结构来存储每一个选项的注释数据;
import doctrine from "doctrine";
/**
* 定义注释所对应的对象类型
*/
type Comment = {
describe: string
type: any
options?: any
default?: any
} | undefined
/**
* 使用doctrine模块解析在AST结构leadingComments中存在的每一个元素
* @param comment
* @returns
*
const parseComment = (comment) => {
if (!comment) {
return;
}
return doctrine.parse(comment, {
unwrap: true,
});
};
5.3.5 完成注释数据解析&属性列表数组组合:
通过Debug发现每次进入ExportNamedDeclaration(path, state)
函数后通过path.node.leadingComments
取出的数据将增加当前解析到选项的注释数据,为避免重复处理可以通过一个skip
标识来跳过已经处理过的数据;
将解析到的注释数据和declaration
中取到的选项名称组合到数组中并存储到state
,完成一次解析~
visitor: {
ExportNamedDeclaration(
path: NodePath<t.ExportNamedDeclaration>,
state: PluginPass
) {
const attributeList = state.get('attributeList');
let _comment: Comment = undefined;
path.node.leadingComments?.forEach(comment => {
if (!Reflect.has(comment, "skip")) {
// 解析注释数据
const tags = parseComment(comment.value)?.tags;
_comment = {
describe: tags?.find(v => v.title === "gDescribe")?.description || '--',
type: tags?.find(v => v.title === "gType")?.description || '--',
options: tags?.find(v => v.title === "gOptions")?.description || '--',
default: tags?.find(v => v.title === "gDefault")?.description || '--',
};
Reflect.set(comment, "skip", true);
}
});
attributeList.push([
(path.node.declaration as t.TypeAlias).id.name.substr(1).toLocaleLowerCase(),
_comment!.describe,
_comment!.type,
_comment!.options,
_comment!.default,
])
state.set('attributeList', attributeList);
}
}
5.3.6 拼装属性列表文档&合并到组件文档
下面通过“|”分割的数据组成的格式即为MD文档表格的风格;
属性名 | 说明 | 类型 | 可选值 | 默认值
------ | ---- | ---- | ----- | -----
size | 尺寸 | string | "large"<br> "default"<br> "small" | --
在 **Babel **解析期间将组件属性和属性上的注释组合成一个属性列表的二维数组,通过transformMarkdown
函数将这个二维数组通过join(' | ')
函数组合成目标风格;
/**
* 整合属性列表表格
* @param attributeList
* @returns
*/
const transformMarkdown = (table: Array<Array<String>>) => table.map(v => v.join(' | ')).join('\n');
5.3.7 输出Markdown表格:
在post()
函数中取出最终得到的属性列表数据,通过提供的transformMarkdown()
函数将属性列表二维数组转换成MD表格风格的文本;
// 将所有的命名导出表达式解析完成后,将容器中存储的数据转换为MD文件并输出
post(this: PluginPass, file: BabelFile) {
const attributeList = this.get('attributeList');
const output = transformMarkdown(attributeList);
const root = path.parse(file.opts.filename).dir;
fs.writeFileSync(path.join(root, 'api-docs.md'), output);
}
5.3.8 合并表格至原组件文档:
默认属性列表为文档最后一部分,通过分割文本取出无属性列表的第一部分文档拼接新的属性列表后重写组件文档,将rewriteCompDocs
函数重新加到post()
函数的末尾将实现合并功能~
const rewriteCompDocs = (root: string, output: string) => {
const compName = path.parse(root).name;
const compPath = path.resolve(__dirname, `../../docs/components/${compName}`);
if (fs.existsSync(compPath)) {
const compDocs = path.resolve(compPath, 'index.md');
const raw = fs.readFileSync(compDocs, { encoding: 'utf-8' });
const noAttrPart = raw.split(/^##[\s][\S]{1,}[\s]属性列表$/gm)[0];
const content = `${noAttrPart.trimEnd()}\n\n## ${compName} 属性列表\n${output}`;
fs.writeFileSync(compDocs, content, { encoding: 'utf-8' });
}
}
6. 保证组件包一致性的关键
6.1 背景描述
当你参与到一个项目的开发过程中后你在做一块新的功能的时候总是会找一下以前的代码里有没有这样的踪影,有一部分原因是为了不重复编写代码,有一部分原因是想照搬照抄,但是还有一部分原因是想与现有的代码保持一致,就比如说组件的命名方式,目录的命名方式等。在你看到的所有糟糕的代码都可能是各写各的这种风格导致的。
6.2 回归案例
在这次组件库开发案例中我在准备好第一个组件的结构和风格后就着手准备组件模板和命令生成组件脚本编写,通过在终端交互输入组件的名称就可以生成规范的组件包,包括了组件包的内容、命名的风格等;
- 组件包目录示例:
Button
├─ __test__
│ └─ Button.spec.ts
├─ api-docs.md
├─ Button.tsx
├─ index.ts
├─ interface.ts
├─ style.less
└─ types.ts
6.3 技术调研
Plop.js 是一个微型生成器框架,其内置了Handlebars引擎,通过简单的编写得到获取终端交互的能力,通过编写Handlebars模板来固定组件包各个文件的风格,很适合这样的应用场景;
6.4 实现过程
通过组件包中其中一个模板的编写来体验 Plop.js 的使用;
- 定义
plopfile.js
文件,使用统一入口注册Generator
:
const componentGenerator = require('./plop-templates/component/prompt')
module.exports = function(plop) {
plop.setGenerator('component', componentGenerator)
}
- 定义
component
的Generator
,完成收集组件名称和组件文件输出的目的:
"use strict";
module.exports = {
description: "generate a component",
prompts: [
{
type: "input",
name: "name",
message: "Tips:Component name should be UpperCamelCase,\nPlease enter a component name:",
validate: (v) => {
return !v || v.trim() === "" ? `${name} is required` : true;
},
},
],
actions: (data) => {
const name = "{{ titleCase name }}";
const actions = [
{
type: "add",
path: `packages/ui/src/${name}/${name}.tsx`,
templateFile: "plop-templates/component/component.hbs",
data: { name },
},
];
return actions;
},
};
- 编写
component.hbs
,替换了组件和样式class的命名,且命名方式使用titleCase
模式:
import { defineComponent } from "vue";
// import { } from "./interface";
export default defineComponent({
name: '{{ titleCase name }}',
setup(props, { slots }) {
return () => (<div class={'g-{{ kebabCase name }}'}>
<h1>{{ titleCase name }}</h1>
</div>)
}
})
- 当这一切都搞定后就可以运行
plop
来启动脚本了,最好还是将脚本执行配置到package中使用:
{
"scripts": {
"new": "plop"
}
}
总结:
无论是刚参与到组件库开发、正处在组件库开发期间还是在使用组件库过程中都有考虑,倾力打造一款优秀体验的组件库,在组件库的工程化方面和组件开发当中还有哪些可以提升体验的地方欢迎一起讨论~
转载自:https://juejin.cn/post/7166497449194815524