今天带来一篇不一样的组件库开发文章,强烈推荐先收藏后阅读。
当你看了很多的从0到1开发的组件库的文章后会发现跟你主流使用的组件库总是天差地别,但又不是不能用。这样的组件库其实就严重缺少了工程化的支撑,缺少了全局的考虑。如果你已经看过了那些文章,那么你将通过这次介绍的6件事让你的组件库提升到更高的高度~
1. 组件库的使用体验很重要
4.1 背景描述
在编码中使用组件库的时候你是因为什么原因去不停的翻找组件文档的?是因为组件名不会写?还是因为组件属性忘记了?还是因为其他?解决这些不大不小的问题也是提升组件库使用体验很关键一点,那么你有没有考虑找一些VSCode插件实时提示组件的一些信息呢?或者使用快捷键来生成代码片段?
1.2 回归案例
在组件库开发初期就决定使用Ts作为组件库开发的基本语言,良好的利用其类型系统的优势来达到后期使用组件时的便利性,那么就需要为组件生成它所对应的类型文件并且正确的进行配置,在SFC组件中使用时还需要配置Volar
插件使用。
1.3 实现过程
1.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
]
});
1.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组件中使用指定了类型入口的组件库时将获得类型的提示及约束的效果。
2. 高效维护属性列表文档
2.1 背景描述
在使用开源社区的组件库的时候主要关注的就是怎么安装?什么效果?有哪些属性?每个组件的属性列表将决定了你是否会使用这个组件库,因为属性列表决定了功能是否可以实现(轻松),而效果则是次要的。那你有没有想过一个组件库那么多的组件,每个组件又有那么多的属性你会怎么样来维护呢?
2.2 回归案例
在这次组件库开发时我选择了使用Babel
来对每个组件中定义的属性列表文件进行解析,得到属性列表文件中定义的属性和属性上附带的注释,将解析到的数据组合整理成组件库文档中属性列表的语法格式,并输出覆盖旧的属性列表,那么将这段脚本添加到组件库编译后的流程中就实现了每次构建完组件库后对应文档的属性列表也就是最新的。
2.3 实现过程
2.3.1 AST 结构分析:
下面两张图是通过 AST Explorer 工具对组件源码片段的分析;
- 在第一张图中可以看到
type
为Identifier
的对象中name
属性存放了组件选项的名称; - 在第二张图中可以看到
type
为CommentBlock
的对象中value
属性存放了选项上配置的所有注释数据; - 这两块内容及其它一些数据共同组成了
type
为ExportNamedDeclaration
表达式;
2.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', [
['属性名', '说明', '类型', '可选值', '默认值'],
['------', '----', '----', '-----', '-----']
]);
}
2.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);
}
}
2.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,
});
};
2.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);
}
}
2.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');
2.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);
}
2.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' });
}
}
3. 保证组件包一致性的关键
3.1 背景描述
当你参与到一个项目的开发过程中后你在做一块新的功能的时候总是会找一下以前的代码里有没有这样的踪影,有一部分原因是为了不重复编写代码,有一部分原因是想照搬照抄,但是还有一部分原因是想与现有的代码保持一致,就比如说组件的命名方式,目录的命名方式等。在你看到的所有糟糕的代码都可能是各写各的这种风格导致的。
3.2 回归案例
在这次组件库开发案例中我在准备好第一个组件的结构和风格后就着手准备组件模板和命令生成组件脚本编写,通过在终端交互输入组件的名称就可以生成规范的组件包,包括了组件包的内容、命名的风格等;
- 组件包目录示例:
Button
├─ __test__
│ └─ Button.spec.ts
├─ api-docs.md
├─ Button.tsx
├─ index.ts
├─ interface.ts
├─ style.less
└─ types.ts
3.3 技术调研
Plop.js 是一个微型生成器框架,其内置了Handlebars引擎,通过简单的编写得到获取终端交互的能力,通过编写Handlebars模板来固定组件包各个文件的风格,很适合这样的应用场景;
3.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 }}'}>
{{ titleCase name }}
</div>)
}
})
- 当这一切都搞定后就可以运行
plop
来启动脚本了,最好还是将脚本执行配置到package中使用:
{
"scripts": {
"new": "plop"
}
}
总结:
无论是刚参与到组件库开发、正处在组件库开发期间还是在使用组件库过程中都有考虑,倾力打造一款优秀体验的组件库,在组件库的工程化方面和组件开发当中还有哪些可以提升体验的地方欢迎一起讨论~