组件库现在基本上是每个公司前端团队的标配了,组件库的开发也是老生常谈的话题了。今天的内容不介绍组件是如何开发的,而是介绍如何搭建组件库文档展示平台,方便组内同事查阅组件相关信息。组件库文档展示平台基于vue3开发,vite构建,rollup打包。组件库最终展示效果如下图:
1. 项目特点
- 项目基于vue3开发,vite打包。
- 项目文档统一用markdown文件编写。
- 项目markdown文档的样式可随意定制。
- 项目markdown文件支持vue源码查看,源码执行,源码复制。
- 组件内容包括:组件源码,组件样式,组件ts文件,入口文件等。
- 组件文档中仅需开发者编写测试demo即可。
- 组件文档中的基础信息根据源码自动生成,包括组件标题,描述,props, events, methods, slots等。
- 项目路由表根据组件库目录自动生成,无需手动额外配置。
- 项目导航目录根据路由表自动生成,无需手额外配置。
- 组件支持按需加载或全量引入。
2. 项目结构
项目主要包括三大块内容:packages
,docs
,dist
。
- packages目录放包的源文件:组件源代码,一个完整的组件内容包括:index.ts, index.vue, types.ts, index.less。
- docs目录放文档内容:包括开发编写的组件demo,以及其他非组件相关文档内容。
- dist目录放组件打包后的文档:经过doc-loader插件处理后自动生成组件的基础描述文档,包括组件name, desc, props, events, methods, slots等内容;然后和开发者编写的demo文档合并生成完整的组件文档,并输出到dist目录里。
根据dist目录的组件文档和原始的非组件文档合并,自动生成项目路由表和侧边栏导航目录。
3. doc-loader插件
组件文档基础信息提取用到了doc-loader插件,doc-loader插件主要是将组件源代码转换成AST,通过遍历AST来提取组件关键信息。
3.1 使用方法
根目录下新增配置文件,doc.config.js,内容如下:
const fs = require("fs-extra");
const path = require("path");
const DocLoader = require("@htfed/doc-loader");
new DocLoader({
entry: path.resolve(__dirname, "../src"), // 定义入口文件,也就是组件库原始目录
output: path.resolve(__dirname, "../dist"), // 定义输出目录,也就是组件文档输出路径
scriptCompileOptions: {}, // js解析选项,用于@babel/parser parse方法传入
typeScriptCompileOptions: {}, // ts解析选项,用于@babel/parser parse方法传入
templateCompileOptions: {}, // template解析选项,用于@vue/compiler-core baseCompile方法传入
outputFileExtension: ".md", // 定义输出文件的格式,支持.md, .json
beforeOutputFileHook: (options) => {
// 文件输出之前的钩子函数
const { entry: fileEntry, mdContent } = options;
const fileName = fileEntry.match(/(\w+)\\index\.vue$/)[1];
const fullFileName = path.resolve(
__dirname,
`../docs/components/ui/${fileName}.md`
);
const newMdContent =
fs.existsSync(fullFileName) && fs.readFileSync(fullFileName, "utf8");
return {
fileName,
fileContent: newMdContent
? `${newMdContent}\n####\n${mdContent}`
: mdContent,
};
},
outputMdFileOptions: {},
});
3.2 实现逻辑
doc-loader插件主要处理两块内容:源码解析
,内容渲染
。
源码解析逻辑如下:
- 接收传入的参数:包括文件原始目录,打包输出文件,解析参数等等。
- 清空打包目录。
- 开始解析目标文档。
- 读取目标文档(组件)的源码内容,并通过正则读取.vue文件中的template, js, ts内容。如果组件定义了ts文件,通过读取ts文件来获取ts内容。
- 调用@babel/parser里的parse方法将js内容解析成组件AST(抽象语法树),再调用@babel/traverse遍历组件AST,通过调用CallExpression()获取组件events事件定义;ExportDefaultDeclaration()方法获取组件desc描述信息,name组件名称,props组件属性定义等;VariableDeclarator()方法获取组件methods方法定义。同时通过调用回调函数将这些基本信息保存到componentInfo中。
- 调用@babel/parser里的parse方法将ts内容解析成组件AST(抽象语法树),再调用@babel/traverse遍历组件AST,调用TSPropertySignature()来获取ts文件的interface接口定义和types类型定义,从而提取组件的props定义。同时通过调用回调函数将这些基本信息保存到componentInfo.props中。
- 调用@vue/compiler-core提供的baseCompile方法,或是@vue/compiler-sfc的compile方法来编译模板template内容,生成templateAst,遍历templateAst内容,定义slot()方法提取template中的slot插槽和slot标签上的相关属性。同时通过调用回调函数将这些基本信息保存到componentInfo.slots中。
比如template编译代码如下:
// 遍历模板AST
const traverserTemplateAst = (ast, visitor = {}) => {
function traverseNode(node, parent) {
visitor.enter && visitor.enter(node, parent);
visitor[node.tag] && visitor[node.tag](node, parent);
node.children && traverseArray(node.children, node);
visitor.exit && visitor.exit(node, parent);
}
function traverseArray(array, parent) {
array.forEach((child) => {
traverseNode(child, parent);
});
}
traverseNode(ast, null);
};
// template模板内容编译
const compileTemplate = (templateStr, options = {}, callback = () => {}) => {
if (!templateStr) return;
const { ast } = baseCompile(templateStr, {
...defaultTemplateCompileOptions,
...options,
});
// 遍历模板ast
traverserTemplateAst(ast, {
// 插槽标签
slot(node, parent) {
// 提取所有的插槽slot
const index = parent.children.findIndex((item) => item === node);
let desc = defaultText;
let name = defaultText;
if (index > 0) {
// 查询是否有插槽的注释标签
// @vue/compiler-core 里的parseComment方法,type: 3 /* COMMENT */
const tag = parent.children[index - 1];
if (tag.type === 3) {
desc = tag.content.trim();
}
}
// 获取插槽name名称 <slot name="header"></slot> 获取值"header"
if (node.props && node.props.length) {
const targetProp = node.props.filter(
(prop) => prop.type === 6 && prop.name === "name"
)[0];
targetProp && (name = targetProp.value.content);
}
// 执行回调,将值保存到componentInfo.slots中
callback({
type: "slots",
key: name,
content: {
name,
desc,
},
});
},
});
};
内容渲染逻辑如下:
- 组件源码解析完成后,基础信息保存在componentInfo中,key为文件路径,读取内容,并根据内容生成.md或是.json文件。
- 内容渲染分为.md和.json两种格式。
- .md文件就是遍历componentInfo数据,根据数据类型生成md格式的字符串,比如标题用 ### 表示等。最终生成符合md文件格式的字符串内容。
- 如果初始参数中定义了beforeOutputFileHook函数,则执行beforeOutputFileHook(),该方法用于在文档输出前对数据做补充处理。(此处我们是读取开发者定义的组件demo内容,与上面生成的md字符串合并生成新的文档数据)
- 最终调用fs.writeFile将数据写入,生成文件,输出到打包目录里。
核心代码如下:
render() {
const mdArr = [];
Object.keys(this.parserResult).forEach((key) => {
const content = this.parserResult[key];
if (content) {
switch (key) {
case "name":
mdArr.push(
...this.onRenderTitle({
content,
isNewLine: false,
weight: 2,
})
);
break;
case "desc":
mdArr.push(content);
break;
case "props":
if (this.options[key]) {
// props数据
mdArr.push(
...this.onRenderContent({
key,
content,
option: this.options[key],
})
);
// tsProps数据
mdArr.push(
...this.onRenderContent({
key: "tsProps",
content: this.onFilterTsProps(
content,
this.parserResult.tsProps
),
option: this.options.props, // 公用props的配置数据
})
);
}
break;
case "slots":
case "events":
case "methods":
this.options[key] &&
mdArr.push(
...this.onRenderContent({
key,
content,
option: this.options[key],
})
);
break;
default:
break;
}
}
});
return mdArr.join("\n");
}
4. 项目预览
打包目录文档生成后,遍历文档目录,和项目本身非组件文档目录,结合自动生成路由表,并生成左侧菜单数据。
import { Docs, Doc } from "../types";
function load({ fileEntry, fileExtension, callback }: any) {
if (!fileEntry) return;
Object.keys(fileEntry).reduce((total: Doc[], i: any) => {
if (!fileExtension || i.endsWith(fileExtension)) {
const fileArr = i.split("/");
const fileName = fileArr[fileArr.length - 1];
const nameArr = fileName.split(".");
const name = nameArr[nameArr.length - 2];
const options: Doc = {
name,
fileName,
fileExtension,
filePath: i,
fileContent: fileEntry[i],
};
total.push(options);
callback && callback(options);
}
return total;
}, []);
}
const docs: Docs[] = [];
// 除了组件之外的根目录文档,比如introduce.md
const componentDocs = import.meta.globEager("../components/*.md");
// 打包生成的组件文档
const disDocs = import.meta.globEager("../../dist/*.md");
load({
fileEntry: {
...componentDocs,
...disDocs,
},
fileExtension: ".md",
callback: (options: Doc) => {
docs.push({
name: options.name,
path: options.name,
meta: {
title:
options.fileContent?.default?.$vd?.toc[0]?.content || options.name,
},
component: options.fileContent.default,
});
},
});
export default docs;
每次组件有更新,需要执行下 node doc.config.js 命令生成新的文档内容,启动项目即可预览。
5. 写在最后
组件文档平台的核心就是利用babel插件将源码生成AST,遍历AST来提取文档信息。现在这个方案可能还会有很多不足点,后面在组件完善中再慢慢优化,如果大家有更好的想法和建议,欢迎留言补充。