通过 jscodeshift 向类型文件增加注释
背景
在 NutUI-React 2.x 版本的构建产物中,增加了更完善的类型,不过组件 Props 相关的类型字段并未增加注释,开发者没有办法借助 IDE 的提示功能,在不脱离 IDE 的环境下查看每个 Props 的功能描述。不过在 NutUI 的文档中已经有详细的 Props 描述,是否可以将文档中的 Props 描述插入到产物中去?
基于上面的问题,我们需要分析已有文档中的 Props 描述,将描述注入到 Props 的类型文件中。下面是 ActionSheet 组件 Props 处理的前后对照。
未加注释版本:
export interface ActionSheetProps extends BasicComponent {
visible: boolean
title: ReactNode
description: ReactNode
options: ItemType<string | boolean>[]
optionKey: ItemType<string>
cancelText: ReactNode
onCancel: () => void
onSelect: (item: ItemType<string | boolean>, index: number) => void
}
注入注释版本:
export interface ActionSheetProps extends BasicComponent {
/**
* 遮罩层可见
* @default false
*/
visible: boolean
/**
* 设置列表面板标题
* @default -
*/
title: ReactNode
/**
* 设置列表面板副标题/描述
* @default -
*/
description: ReactNode
/**
* 列表项
* @default []
*/
options: ItemType<string | boolean>[]
/**
* 列表项的自定义设置
* @default -
*/
optionKey: ItemType<string>
/**
* 取消文案
* @default 取消
*/
cancelText: ReactNode
/**
* 点击取消文案时触发
* @default -
*/
onCancel: () => void
/**
* 选择之后触发
* @default -
*/
onSelect: (item: ItemType<string | boolean>, index: number) => void
}
实现思路
NutUI-React 仓库中每个组件有各自的 doc.md 文件,在 doc.md 文件中,Props 相关的内容都是按照如下规范编写的:
## 组件名称
### Props
| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
因为组件文档遵循此规范,所以可以很方便的通过脚本进行处理。首先我们要使用 markdown-it
对 doc.md 文档进行解析。由此得到 markdown 的 token。通过对 token 进行迭代处理,提取出 Props 相关的表格,并将 table 相关的 token 转换为二维数组。
在获取到每个组件的 Props 数据后,需要对 NutUI-React 构建产物中的 typings 进行代码转换。代码转换可以通过 jscodeshift 来处理。
上面这一过程主要描述了单个组件的处理情况,然而 NutUI-React 有 70 多个组件都需要进行相关的处理。所以我们要获取到所有的组件,应用上述的处理过程。
实践细节
markdown-it
markdown-it 是一个流行的基于 JavaScript 的 Markdown 解析器。它具有灵活的插件系统,可扩展性和高性能。它支持标准化的 Markdown 格式,同时还支持 GFM(GitHub Flavored Markdown)和 CommonMark。它的输入和输出都是纯文本字符串,可以轻松地集成到任何 Web 应用程序中。markdown-it 还支持同步和异步模式的解析,可以用于服务器端和客户端的应用程序。
首先在项目中安装 markdown-it
npm install markdown-it --save-dev
之后在脚本中引入它,const markdown = require('markdown-it')()
,并解析 doc.md 的内容 const tokens = markdown.parse(documentData)
得到
tokens。
相关 API 可参考Markdown 文档
为了更方便的查看 tokens,我们可以通过 https://markdown-it.github.io/ 中提供的 debug 模式,观察 tokens。
例如 ActionSheet Props 的片段,
## 组件名称
### Props
| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| visible | 遮罩层可见 | `boolean` | `false` |
解析后的 tokens 如下:
[
{
"type": "heading_open",
"tag": "h2",
"attrs": null,
"map": [
0,
1
],
"nesting": 1,
"level": 0,
"children": null,
"content": "",
"markup": "##",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": [
0,
1
],
"nesting": 0,
"level": 1,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "组件名称",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "组件名称",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "heading_close",
"tag": "h2",
"attrs": null,
"map": null,
"nesting": -1,
"level": 0,
"children": null,
"content": "",
"markup": "##",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "heading_open",
"tag": "h3",
"attrs": null,
"map": [
1,
2
],
"nesting": 1,
"level": 0,
"children": null,
"content": "",
"markup": "###",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": [
1,
2
],
"nesting": 0,
"level": 1,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "Props",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "Props",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "heading_close",
"tag": "h3",
"attrs": null,
"map": null,
"nesting": -1,
"level": 0,
"children": null,
"content": "",
"markup": "###",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "table_open",
"tag": "table",
"attrs": null,
"map": [
2,
5
],
"nesting": 1,
"level": 0,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "thead_open",
"tag": "thead",
"attrs": null,
"map": [
2,
3
],
"nesting": 1,
"level": 1,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "tr_open",
"tag": "tr",
"attrs": null,
"map": [
2,
3
],
"nesting": 1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_open",
"tag": "th",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "属性",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "属性",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_close",
"tag": "th",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_open",
"tag": "th",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "说明",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "说明",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_close",
"tag": "th",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_open",
"tag": "th",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "类型",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "类型",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_close",
"tag": "th",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_open",
"tag": "th",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "默认值",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "默认值",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "th_close",
"tag": "th",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "tr_close",
"tag": "tr",
"attrs": null,
"map": null,
"nesting": -1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "thead_close",
"tag": "thead",
"attrs": null,
"map": null,
"nesting": -1,
"level": 1,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "tbody_open",
"tag": "tbody",
"attrs": null,
"map": [
4,
5
],
"nesting": 1,
"level": 1,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "tr_open",
"tag": "tr",
"attrs": null,
"map": [
4,
5
],
"nesting": 1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_open",
"tag": "td",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "visible",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "visible",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_close",
"tag": "td",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_open",
"tag": "td",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "遮罩层可见",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "遮罩层可见",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_close",
"tag": "td",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_open",
"tag": "td",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "code_inline",
"tag": "code",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "boolean",
"markup": "`",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "`boolean`",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_close",
"tag": "td",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_open",
"tag": "td",
"attrs": null,
"map": null,
"nesting": 1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 4,
"children": [
{
"type": "code_inline",
"tag": "code",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "false",
"markup": "`",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "`false`",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "td_close",
"tag": "td",
"attrs": null,
"map": null,
"nesting": -1,
"level": 3,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "tr_close",
"tag": "tr",
"attrs": null,
"map": null,
"nesting": -1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "tbody_close",
"tag": "tbody",
"attrs": null,
"map": null,
"nesting": -1,
"level": 1,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "table_close",
"tag": "table",
"attrs": null,
"map": null,
"nesting": -1,
"level": 0,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
}
]
可以看出 markfown 文档解析后的 tokens 是一个数组。通过迭代处理数组,可以很容易的生成 HTML 标签。针对 tokens 这样的特征,可以采用识别 token 的 type 进行处理(markdown 每个 token 都带有 type、tag、content 属性)。
我们可以根据 NutUI-React 文档的规范,对 token 进行解析。解析的方式主要是识别特定的 token,向前读取 1 个 token,进行匹配,之后开启 table 的数据收集。主要代码逻辑如下:
function extractPropsTable(doc) {
const MarkdownIt = require('markdown-it')()
let sources = MarkdownIt.parse(doc, {})
const tables = []
sources.forEach((token, index) => {
// if 语句中的代码匹配的 markdown 结构如下:
// ## 组件名称
// ### Props
if (
token.type == 'heading_open' &&
token.tag == 'h3' &&
// 查看下一个 token 的类型,是不是能匹配到 Props
sources[index + 1].type == 'inline' &&
sources[index + 1].content === 'Props'
) {
// index 位置的 token 向后加 3 个,正好是 table_open,然后循环读到 table_close
let startIndex = index + 3
while (startIndex < sources.length) {
tables.push(sources[startIndex])
if (sources[startIndex].type == 'table_close') {
startIndex = null
break
}
startIndex = startIndex + 1
}
}
})
return tables
}
通过上面的 extractPropsTable
方法,可以提取出 table 相关的 token。然后在将这些 token 转换为 JSON 格式。
例如 ActionSheet 组件 Props 表格对应的 JSON 数据:
[
["visible", "遮罩层可见", "`boolean`" , "`false`"]
]
接下来要分析 typescript 生成的 .d.ts
文件了。在这里仍然通过 ActionSheet 组件举例。ActionSheet 组件的 .d.ts
文件路径:dist/types/packages/actionsheet/actionsheet.d.ts
。文件待处理部分内容如下:
import React, { FunctionComponent, ReactNode } from 'react';
import { BasicComponent } from '../../utils/typings';
export type ItemType<T> = {
[key: string]: T;
};
export interface ActionSheetProps extends BasicComponent {
visible: boolean
title: ReactNode
description: ReactNode
options: ItemType<string | boolean>[]
optionKey: ItemType<string>
cancelText: ReactNode
onCancel: () => void
onSelect: (item: ItemType<string | boolean>, index: number) => void
}
export declare const ActionSheet: FunctionComponent<Partial<ActionSheetProps> & Omit<React.HTMLAttributes<HTMLDivElement>, 'title' | 'onSelect'>>;
jscodeshift
jscodeshift 是一个用于编写 JavaScript 代码转换的工具,它基于 AST。通过使用 jscodeshift,可以是代码重构更容易和安全,它支持自定义转换规则,可以将重复的代码转换为可重用的组件,可以自动化执行代码重构,还可以帮助升级代码库。例如:react-codemod 是基于 jscodeshift 开发的帮助开发者更新 React API 的工具。
首先在项目中安装 jscodeshift
npm install jscodeshift --dev-save
在脚本中通过 const j = require('jscodeshift')
引入 jscodeshift。然后将读取的代码文本传给 jscodeshift。
const j = require('jscodeshift')
const coverted = transform({sorce}, {jscodeshift: j})
在上面的代码中,出现了 transform
调用。从 jscodeshift 的 README.md 中,也可以看到 Transform module 的介绍:” transform 是具有特定格式的一个导出函数”。
接下来我们需要告诉 jscodeshift 采用哪个解析器,它支持:”babel”, “babylon”, “flow”, “ts”, or “tsx”。由于我们的 .d.ts
文件是纯粹的 ts,所以我们通过设置 parser 为 ts 来告诉 jscodeshift 如何解释代码。
const transform = (file, api) => {
const j = api.jscodeshift.withParser('ts')
}
由于 jscodeshift 是基于 AST 的代码转换工具,所以要具备一定的 AST 相关的知识。首先如果能方便的查看 AST 的结构,可以方便我们在头脑中形成如何处理 AST 节点的观念。AST 查看可以通过 https://astexplorer.net/。针对本文中待处理的代码,我们需要在 astexplorer.net 中将编译设置为 @typescript-eslint/parser
。因为同样的代码不同的编译器有不同的 AST 表达,@typescript-eslint/parser
构造出来的 AST 与 jscodeshift 设置 ts 解析后的形式一致。
因为我们要处理 ActionSheetProps 中的属性,而 ActionSheetProps 在 AST 中对应 TSInterfaceDeclaration 类型的节点。所以只需要遍历 AST,就可以查询到 TSInterfaceDeclaration 类型且标识符是 ActionSheetProps
的节点。继续遍历这些节点,直接更新节点的值,就可以更改 AST。关键代码逻辑如下:
const transform = (file, api) => {
const j = api.jscodeshift.withParser('ts')
return j(file.source)
.find(j.TSInterfaceDeclaration, {
id: {
name: componentName + 'Props',
type: 'Identifier',
},
})
.forEach((path) => {
path.value?.body?.body?.forEach((item) => {
if (!item.key) return
const info = findInTable(item.key.name)
if (!info) return
item['comments'] = [
j.commentBlock(`*\n* ${info[1]}\n* @default ${info[3]}\n`),
]
})
})
.toSource()
}
到这里就完成了通过 jscodeshift 向类型文件增加注释的功能。代码细节可以参考NutUI-React 仓库中的 add-comments-to-dts.js 文件。