一、存在的问题
作为一个支持多端小程序开发的组件库,NutUI Vue 仓库内的每个组件都有两份源码,分别对应了 @nutui/nutui
与 @nutui/nutui-taro
两个包中的组件。demo 代码同样有两份,并且前者附带有中英文两份文档,后者附带 Taro 版本的文档。
例如一个 Button 组件的目录结构可能是这样的:
- button
- index.vue // H5 组件源码
- index.taro.vue // Taro 组件源码
- demo.vue // H5 demo
- demo.taro.vue // Taro demo
- doc.md // H5 中文文档
- doc.en-US.md // H5 英文文档
- doc.taro.md // Taro 文档
- 其他共用逻辑代码……
其中 demo.vue
和 demo.taro.vue
文件对应的是官网文档右侧的演示站点,每个文件中包含了该组件所有的示例,可能有若干个。
当我们给 Button 新增一个属性时,要求在 demo 中增加该属性的演示代码,此时需要:
- 1、在
demo.vue
中增加演示代码 A - 2、在
doc.md
、doc.en-US.md
中增加对应的段落描述,增加与 A 相同的代码 - 3、在
demo.taro.vue
中增加演示代码 B - 4、在
doc.taro.md
中增加对应的段落描述,增加与 B 相同的代码
这是初次提交代码时要做的事情,已经很麻烦了。在后续的维护过程中,还需要时刻保持文档示例与 demo 代码的一致性,避免文档中的 demo 是错误或者过时的,影响开发者使用。
二、理想中的效果
上述四个步骤中,1、3 是必须环节,2、4 则是可以避免的。如果我们能够实现,当修改了 demo.vue
和 demo.taro.vue
中的某个示例代码时,对应着的三份文档中的演示代码文本也能够同步地更新,就能将原本需要的 5 份代码的修改量减少到 2 份,极大地提高项目 demo 与文档的维护效率。
除此之外,由于文档内容是由 demo 源码自动生成的,一致性的问题也能够彻底解决。
为了实现这一目的,我们首先需要了解当前项目中文档的构建流程。
三、文档的构建流程
一个 md 文档项目本质上是把 md 格式的纯文本文件转换成了网页代码,即:
- 输入:md
- 输出:html/js/css
通过 markdown-it
插件我们很容易能实现从 md 到 html 的转换,但是如果我们想要在此基础上扩展一些能力,比如增加代码块解析与高亮,或者在 md 中使用自定义的 vue/react 组件等功能,则需要介入或者扩展这一转换过程,增加额外的处理逻辑。
以 NutUI Vue 仓库中的文档项目为例(使用 Vite + Vue 技术栈),完整的处理流程主要有以下三个步骤:
- a、md —> html
- b、html —> vue
- c、vue —> html/js/css
其中前两步是插件 unplugin-vue-markdown
实现的,最后一步由 @vitejs/plugin-vue
实现。
步骤 a
unplugin-vue-markdown
插件的内部使用了 markdown-it
插件来进行步骤 a 的转换,并对外提供了可扩展的能力。一般情况下,我们会在这个步骤中实现代码块的解析与高亮逻辑。
MarkdownIt 配置逻辑如下:
// 使用 unplugin-vue-markdown 导出的 vite 插件
import Markdown from 'unplugin-vue-markdown/vite';
export default {
plugins: [
Markdown({
// 使用该插件对外暴露的 markdown-it 配置
markdownItOptions: {
typographer: false,
highlight: function (str, lang) {
// 由于 highlight.js 并不支持 vue 语法,且 vue 最大程度地遵循了 html 的标准
// 因此可以将 vue 代码按照 html 方式进行解析与高亮
if (lang && (lang === 'vue' || hljs.getLanguage(lang))) {
return hljs.highlight(str, {
language: lang === 'vue' ? 'html' : lang
}).value;
}
return '';
}
}
}
]
}
下面是一份简洁版的 md 文件:
# h1 标题
普通段落文本
```vue
<nut-button />
```
经过步骤 a 处理后会变成:
<h1>标题</h1>
<p>普通段落文本</p>
<pre>
<code class="language-vue">
<span><</span>
<span>nut-button</span>
<span>/</span>
<span>></span>
</code>
</pre>
步骤 b
unplugin-vue-markdown
插件会继续进行步骤 b 的处理,将上一步生成的 html 文件包裹一层 Vue 语法,转换为 Vue SFC 文件。
最常见的「在 markdown 文件中使用自定义 Vue 组件」的场景,就是在这个过程中实现的。
上面的 html 文件经过步骤 b 的处理后变成:
<template>
<h1>标题</h1>
<p>普通段落文本</p>
<pre>
<code class="language-vue">
<span><</span>
<span>nut-button</span>
<span>/</span>
<span>></span>
</code>
</pre>
</template>
<script setup>
// 其他可扩展的逻辑,比如自定义 vue 组件的引入语句
</script>
将这样一份 Vue SFC 文件继续传递给 @vitejs/plugin-vue
插件进行步骤 c 的处理,就能生成最终的页面了。
四、文档的升级与改造
原有的所有 demo 放在同一个文件里的方式是无法实现与文档同步的,第一个步骤就是将它们拆分为单独的文件,即:
1、将 demo.vue
文件拆分为一个个单独的 demo 文件,并在入口文件中引用它们。
—— 改造前 ——
- button
- demo.vue
—— 改造后 ——
- button
- demo
- index.vue // 入口文件
- demo1.vue // 第一个 demo
- demo2.vue
- demo3.vue
- ......
入口文件格式可参考:button/demo/index.vue
2、在文档中使用文件引用的标记,而不直接使用源码。
—— 改造前 ——
## 演示代码 1
这里是演示代码 1 的描述信息
```vue
<template>
<!-- 这里可能是超级长的模板 -->
<nut-button />
</template>
<script setup>
// 这里可能是超级长的代码
</script>
```
—— 改造后 ——
## 演示代码 1
这里是演示代码 1 的描述信息
> demo: button demo1
其中 > demo: button demo1
为自定义的标记格式,我们可以在文档构建的某个阶段去扫描所有满足这种格式的标记,并将它替换为对应目录文件的源码。
根据本文第三部分的介绍,步骤 a 阶段是把 md 转换为 html,当此过程结束时,代码块已经被转换为一个个分割出来的 html 标签了。因此我们的替换逻辑应该至少在步骤 a 过程中,或者在步骤 a 之前进行处理。
如果在步骤 a 过程中进行处理,则需要编写一个 markdown-it
的插件,并前置于所有其他插件(比如 highlight.js
、markdown-it-container
等)的处理逻辑,比较复杂。出于简单考虑,我们可以在步骤 a 之前完成这个过程,仅编写一个 vite 插件实现演示代码内容的替换,执行顺序放在 unplugin-vue-markdown
插件之前。
这里的逻辑有点类似 markdown-it-container
插件,它允许开发者使用 ::: tag
和 :::
这种标记包裹一段文本,并对其内容进行改造。而我们选择了 > demo:
作为被识别的标记,后面跟着的是组件名、组件 demo 名称等参数,用于生成文件路径。
自定义 vite 插件如下:
const TransformMarkdownDemo = (options: MarkdownOptions): Plugin => {
return {
name: "nutui-transform-markdown-demo",
enforce: "pre",
transform(src, id) {
if (fileRegex.test(id)) {
return {
code: src.replace(
/> demo: ([-0-9a-z .]*)[\n|\r\n]/g,
(_match, $1: string) => {
// 获取 md 中的标记
const [comp, demo, type] = $1.split(" ");
// 区分 H5、Taro 两种 demo 的文件路径
const docPath = type
? path.resolve(
options.docTaroRoot,
type,
"pages",
comp,
`${demo}.vue`
)
: path.resolve(options.docRoot, comp, "demo", `${demo}.vue`);
let code = "";
try {
// 根据标记找到对应的 demo 源码
code = fs.readFileSync(docPath, "utf-8");
} catch (err) {
code = "[@nutui/vite-plugins] File not found: " + docPath;
console.warn(code);
}
// 生成符合后续处理流程的文档结构
return code
? `:::demo
\`\`\`vue
${code}
\`\`\`
:::\n`
: "";
}
),
map: null // 如果可行将提供 source map
};
}
}
};
};
完成以上两个步骤后,最终的文档与 demo 目录结构参考:
五、其他细节
1、文档构建流程的分析
在分析文档构建过程时,项目中使用了 vite-plugin-inspect
插件(目前已内置在 vite-plugin-vue-devtools
插件中)。
vite-plugin-inspect
插件能够展示出单个源文件在 vite 框架下完整的生命周期,以及在每一个插件处理前、后的代码变更。
对于 markdown 文件 button/doc.md
,它会依次经过以下流程:
__load__
:vite 加载源文件,由空白 —> 文件源码vite-plugin-vue-devtools
:调试工具插件,对该文件无修改逻辑nutui-transform-markdown-demo
:NutUI 项目中自定义的 vite 插件,会将所有标记语句转换为演示代码unplugin-vue-markdown
:它会进行上文中提到的步骤 a、b 两个步骤,如图所示从左侧的 md 文件转换为右侧的 vue 文件vite:vue
:@vitejs/plugin-vue
,即 vue 的编译过程,从 vue sfc 到 jsvite:import-analysis
:vite 开发阶段的 import 分析插件,主要涉及 import 语句的解析与重写
通过 vite-plugin-inspect
插件,可以更好地理解 vite 插件的运行机制,也能准确地找到解决问题的关键。
2、路径引用问题
—— 改造前 ——
import { showToast } from '../toast'
import Button from '../button'
import type { ButtonShape } from '../button'
—— 改造后 ——
import { showToast } from '@nutui/nutui'
import { Button } from '@nutui/nutui'
import type { ButtonShape } from '@nutui/nutui'
Vue 本身支持组件的全局注册后使用(对比 React),并且 NutUI 默认推荐使用 unplugin-vue-components
的自动按需引入方式,因此文档 demo 中一般不会包含 import
相关的代码。
但依然有一些代码需要引入后再使用,比如函数组件 showToast
,或者导出的组件类型 ButtonShape
等等,改造时会将这类相对引用的路径调整为从包中引用。这需要调整 vite 的 alias 配置实现文件路径替换,以及修改 tsconfig 中的 paths 字段实现类型提示。