Skip to content

NutUI 项目文档的升级与改造

Published:

一、存在的问题

作为一个支持多端小程序开发的组件库,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.vuedemo.taro.vue 文件对应的是官网文档右侧的演示站点,每个文件中包含了该组件所有的示例,可能有若干个。

当我们给 Button 新增一个属性时,要求在 demo 中增加该属性的演示代码,此时需要:

这是初次提交代码时要做的事情,已经很麻烦了。在后续的维护过程中,还需要时刻保持文档示例与 demo 代码的一致性,避免文档中的 demo 是错误或者过时的,影响开发者使用。

二、理想中的效果

上述四个步骤中,1、3 是必须环节,2、4 则是可以避免的。如果我们能够实现,当修改了 demo.vuedemo.taro.vue 中的某个示例代码时,对应着的三份文档中的演示代码文本也能够同步地更新,就能将原本需要的 5 份代码的修改量减少到 2 份,极大地提高项目 demo 与文档的维护效率。

除此之外,由于文档内容是由 demo 源码自动生成的,一致性的问题也能够彻底解决。

为了实现这一目的,我们首先需要了解当前项目中文档的构建流程。

三、文档的构建流程

一个 md 文档项目本质上是把 md 格式的纯文本文件转换成了网页代码,即:

通过 markdown-it 插件我们很容易能实现从 md 到 html 的转换,但是如果我们想要在此基础上扩展一些能力,比如增加代码块解析与高亮,或者在 md 中使用自定义的 vue/react 组件等功能,则需要介入或者扩展这一转换过程,增加额外的处理逻辑。

以 NutUI Vue 仓库中的文档项目为例(使用 Vite + Vue 技术栈),完整的处理流程主要有以下三个步骤:

其中前两步是插件 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>&lt;</span>
    <span>nut-button</span>
    <span>/</span>
    <span>&gt;</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>&lt;</span>
      <span>nut-button</span>
      <span>/</span>
      <span>&gt;</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.jsmarkdown-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 ,它会依次经过以下流程:

通过 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 字段实现类型提示。