本文将会收录到 组件库 专栏中,该专栏主要用来写一些组件库内部实现细节等,并且本文相对来讲有一点深入,对于想了解 typescript 原理的人可以去阅读下,本人也是因为需要实现一个需求,才去看了下相关原理。
背景
当我们一般写组件库文档的时候,都会通过 markdown 去编写,然后 自定义 loader 去做一个动态渲染,而组件库的 api 文档 通常也都是以 markdown 的表格去编写的。
然而,我们可能有时候写着写着组件库的代码,里面的代码稍微改了下参数,或者类型什么的。这时候你其实还要去改组件库的文档,或者你也有可能忘记改了。这种事情也算比较常见。
众所周知,Typescript 具有强大的类型能力,那我们干嘛不借助 typescript 的类型能力,来帮助我们去自动生成 组件库的 api 文档 和对应的类型描述呢?
先上个最终效果图:
需求分析
现在我们有了一个需求:如何自动生成 组件库 api 文档 和类型描述?然而需求有了,但是要怎么实现呢。
我们先来思考下,语言是怎么执行的。这里我们可以以 babel 为例,babel实现原理主要分为几步:
- 解析你的代码,生成 AST(抽象语法树)
- 然后对你的代码去做进一步的转换,从而可以在低版本进行运行(这一步 babel 主要是自己维护了一个包,里面有各个浏览器支持 api 的最低版本)
- 然后针对转换后的代码,去做进一步的生成就可以了。
上面就是一个 babel 实现的一个基本原理。然后这和我们的需求有啥关系呢?
对于这个需求也可以套用上面的流程,只不过我们只 需要解析这一个阶段就可以了。拿到代码,生成对应的 ast 语法树就可以了。我们拿到 ast 之后,就和处理普通数组和对象没啥区别了。
当然这里我们还需要涉及一个知识点,我们既然要拿到对应的 ts 类型的话,我们还需要了解下 ts 的编译过程,对于 大部分场景来说,我们不需要知道这个过程,然而对于这个需求来讲,我们有必要了解下这个,从而可以更好的实现这个需求。
typescirpt 运行原理
ts 执行代码,主要有几个关键部分:
- Scanner 扫描器 (解析 token )
- Parser 解析器 (生成 ast )
- Binder 绑定器 ( 在检查器进行类型检查的时候,进行调用)
- Checker 检查器 (执行类型检查)
- Emitter 发射器 (生成
js文件)
typescript在编译过程中,会通过解析器去调用扫描器去生成对应的AST语法树,
在 typescript 中,如果我们想拿到 对应的 AST 语法结构,可以通过 createSourceFile 这个 api,从而让我们拿到对应的 AST 结构。
其中 statments就是当前文件的子节点。在 ts 中,我们如果需要遍历当前 AST 的子节点的话,我们可以使用 ts.forEachChild 进行遍历。
import * as ts from 'typescript';
function printAllChildren(node: ts.Node, depth = 0) {
console.log(new Array(depth + 1).join('----'), ts.formatSyntaxKind(node.kind), node.pos, node.end);
depth++;
node.getChildren().forEach(c => printAllChildren(c, depth));
}
var sourceCode = `
var foo = 123;
`.trim();
var sourceFile = ts.createSourceFile('foo.ts', sourceCode, ts.ScriptTarget.ES5, true);
printAllChildren(sourceFile);
效果:
其中 node.kind 是当前节点的类型描述,查看类型可以得知该类型为SyntaxKind的枚举类型。 可以在typescript/lib/typesript.d.ts 进行查看。
ts 模块解析规则
我们的目的是生成组件的类型,我们还有必要了解下 TypeScript 模块解析规则,因为我们可能并不在当前文件进行编写类型。
为了简单省事,我这边将解析规则简化下,对于 node_modules 内的将不做解析处理,其余将按照下面规则进行分析
- 解析当前文件
- 模块内引入的文件(本地相对路径,本地绝对路径,具有类型别名的文件, node_modules路径)
- 解析 d.ts 全局类型文件
实现
现在我们知道了如何拿到 ts 的 ast,也知道了如何遍历 AST,以及 ts 的路径解析规则,现在我们就可以动手实现了。
类型解析的 case
// import
import A from 'b'
import { A } from 'b'
import { A, type B } from 'b'
import type { A } from 'b'
// interface
interface A {
a: number
b?: string
c: () => void
}
// 具有继承的情况
interface A extends B, C {}
/**
type
type 算是 类型分析过程最复杂的一个,里面包括联合类型,泛型、交集、等各种情况,
这里只是放个最简单的例子
*/
type A = 'small' | 'middle' | 'large'
// function
/**
* 因为我们最终导出的是函数组件,所以函数也要一并分析,并且记录当前是否导出,方便后续处理
*/
function A (props: A) {}
实现思路
- 读取文件,之后通过
ts.createSourceFile生成 AST - 借用
ts.forEachChild对 AST 节点进行遍历处理,并通过一个对象,来保存处理后的结果 - 返回处理后的结果
现在我们已经有了一些基本知识,也对这个需求,有了一个大概的思路,现在就可以动手实现了。
这里我们建立一个 utils/index.js 的文件,里面用来放一些处理的工具函数吧。
// 获取对应的 AST 节点类型
const getDeclaration = kind => ts.SyntaxKind[kind]
module.exports.getDeclaration = getDeclaration
这里我们建个 generator/core.js。
const fs = require('fs')
const ts = require('typescript')
const path = require('path')
const libPath = path.resolve(__dirname, '../../../../otaku-ui')
const entryPath = path.resolve(libPath, './src/index.ts')
const { getDeclaration, parser, readFile, getAbsolutePath } = require('../utils')
const tsconfig = require('../../../../otaku-ui/tsconfig.json')
const program = ts.createProgram([entryPath], tsconfig)
function generator (filePath) {
const currentFile = {}
program.forEachChild(node => {
if (getDeclaration(node.kind) === 'ExportDeclaration') {
// 拿到 export * from xxx 的路径
const value = node.moduleSpecifier.text
// 这里我们需要拿到绝对路径,从而方便我们后续解析
const absolutePath = getAbsolutePath(path.resolve(libPath, './src'), value)
currentFile[absolutePath] = null
}
})
}
这里我们简单的写了一个 generator 函数,用来解析入口文件的内容,并通过 一个对象去保存解析后的结果,为什么这里我们需要一个对象去保存结果呢?
当然是因为对象简单啦,获取比较方便,这是一方面原因,还有就是方便我们后续处理,如果一个文件内的内容引入了其他文件的内容,我们这时候也要去解析你引入的文件,而且由于文件路径是唯一的,如果我们解析过了当前文件,直接返回即可。
还有这里用到了一个getAbsolutePath的 函数,用来处理 import 或者 export 的路径,毕竟我们一般是不写后缀的,而且路径会有相对路径、具有别名的路径、node_modules 路径,这里我们可以通过 webpack 的enhanced-resolve 这个库来进行路径解析,这个是 webpack 用来处理 alias 和路径解析的一个库。
utils/index.js 新增一个getAbsolutePath 的函数
const enhancedResolve = require('enhanced-resolve')
// 获取模块的绝对路径
const getAbsolutePath = (basePath, relativePath) => {
// 相对路径
const reg = /^\.\S+$/g
// 如果不是相对路径 返回 node_modules
if (!relativePath.match(reg)) return 'node_modules'
// 调用 enhanced-resolve 解析路径,拿到完整的文件路径
const parserPath = enhancedResolve.create.sync({
extensions: ['.ts', '.tsx', '.js'],
})
const absolutePath = parserPath({
resolveToContext: true,
mainFields: ['main', 'exports']
}, basePath, relativePath, {})
return absolutePath
}
module.exports.getAbsolutePath = getAbsolutePath
这里解释下吧,最开始其实并不是这个实现方式,也是通过 enhanced-resolve 进行模块解析,不过解析的时候挂掉了,所以就换成了这种方式。
上面的代码,简单的对入口文件做了下处理,并通过一个对象来保存解析后的路径,之后我们需要对对象里面的所有内容进行解析,并且保存当前文件解析到的内容。这里我们简单的来看下数据结构。
interface Property {
name: string
type: string
required: boolean
defaultValue: unknown
typeReference: unknown
jsDoc: {
tagName: string
content: string
}[]
}
interface Parser {
[filePath: string]: {
export: {
[key: string]: unknown
}
import: {
[name: string]: {
// 解构
deconstruct: boolean
// 默认导出
default: boolean
// 引入的名字
name: string
// import 的路径
importPath: string
}
}
exportFile: Parser
type: {
[key: string]: {
type: 'interface' | 'type'
name: string
code: string
// 继承的 interface
extendProperty: {
name: string
code: string
property: Propery
}[],
property: Property[]
}
class: {
[name: string]: {
export: boolean
exportDefault: boolean
name: string
property: {
name: string
type: string
defaultValue: string
typeReference: unknown
}
}
}
function: {
[fn: string]: {
type: 'function'
export: boolean
exportDefault: boolean
functionName: string,
args: {
name: string
type: unknown
}[]
}
}
}
}
数据结构大概就是这个样子了,现在我们已经有了一个基本的数据结构,也通过我们写的generator函数,解析到了文件 import 的内容,现在我们需要把这些 import 的内容去记录下,并且我们解析到发现文件又引用了其他文件的时候,我们也需要进行记录,从而解决掉循环引用的问题。
function generator (filePath, fileMap = {}) {
// 保存解析的结果
const currentFile = {
// 定义的类型
type: {},
// import 引入的数据
import: {},
// 当前文件的函数声明
function: {},
// 当前文件所导出的内容
export: {},
// export * from 导出的内容
exportFile: {}
}
program.forEachChild(node => {
const type = getDeclaration(node.kind)
switch (type) {
case 'ExportDeclaration':
/**
* 由于传入的是绝对路径,所以这里需要后退到对应的相对路径,
* 从而获取到引入的模块绝对路径,主要是因为 enhance-resolve 解析报错
*/
if (node.exportClause) {
node.exportClause.elements?.reduce((obj, current) => {
obj[current.name.escapedText] = {
type: undefined
}
return obj
}, currentFile.export)
} else {
// 拿到 export * from xxx 的路径
const value = node.moduleSpecifier.text
// 根据当前的文件路径,来对解析到的文件路径后退
const relativePath = backPath(filePath, value)
const exportPath = getAbsolutePath(relativePath, value)
// 这里我们需要拿到绝对路径,从而方便我们后续解析
generator(exportPath, filePath)
currentFile.exportFile[exportPath] = fileMap[exportPath]
}
break
})
}
这里,我们用到了一个backPath 的一个函数,这个函数的作用主要是后退文件,从而拿到引入的绝对路径
const backPath = (absolutePath, relativePath) => {
const arr = absolutePath.split(path.sep)
const relative = relativePath.split('/')
for (let i = 0; i < relative.length - 1; i++) {
if (relativePath[i] === '.') {
arr.pop()
}
if (relativePath[i] === '..') {
arr.pop()
}
}
return arr.join(path.sep)
}
现在我们已经处理好了export的内容,现在我们接着来处理import相关的内容,这里先看下需要解析的 case
// import
import A from 'b'
import { A } from 'b'
import { A, type B } from 'b'
import type { A } from 'b'
现在我们就来实现下相关代码:
function generator (filePath, fileMap = {}) {
// 保存解析的结果
const currentFile = {
// 定义的类型
type: {},
// import 引入的数据
import: {},
// 当前文件的函数声明
function: {},
// 当前文件所导出的内容
export: {},
// export * from 导出的内容
exportFile: {}
}
program.forEachChild(node => {
const type = getDeclaration(node.kind)
switch (type) {
// ...
case 'ImportDeclaration':
if (!node.importClause) return
const importClause = node.importClause
const importPath = path.resolve(filePath, '../')
const absolutePath = getAbsolutePath(
importPath,
node.moduleSpecifier.text
)
if (absolutePath.includes('.scss')) return
if (importClause?.namedBindings) {
// 解构引入
importClause.namedBindings.elements.reduce((obj, current) => {
obj[current.name.escapedText] = {
// 解构
deconstruct: true,
// 默认导出的
default: importClause.name
? importClause.name.escapedText
: undefined,
name: current.name.escapedText,
importPath: absolutePath
}
return obj
}, currentFile.import)
generator(absolutePath, fileMap)
} else {
// 命名导出
const name = importClause.name.escapedText
currentFile.import[name] = {
name,
nameExport: true,
importPath: node.moduleSpecifier.text
}
}
break
})
}
这里先说明下:
importClause是你import的内容importClause.namedBindings.elements是通过解构引入的- 对于
import引入的东西,不管是命名引入,还是解构,或者类型引入,我们都放到import这个对象下,key就是引入的名字,value则记录引入的方式,以及引入的文件路径 - 对于引入的文件,我们依然会去调用函数进行解析,并通过路径保存
现在我们 import 也已经处理过了,然后我们接着来处理 interface 和 type,interface 会相对比较简单,就来简单实现下:
function generator (filePath, fileMap = {}) {
// 保存解析的结果
const currentFile = {
// 定义的类型
type: {},
// import 引入的数据
import: {},
// 当前文件的函数声明
function: {},
// 当前文件所导出的内容
export: {},
// export * from 导出的内容
exportFile: {}
}
program.forEachChild(node => {
const type = getDeclaration(node.kind)
switch (type) {
// ...
case 'InterfaceDeclaration':
// 继承的接口
const extendsInterface = node.heritageClauses?.[0].types?.map(
item => {
return {
name: item.expression.escapedText
}
}
)
currentFile.type[node.name.escapedText] = {
type: 'interface',
export: isExport(node),
exportDefault: isExportDefault(node),
name: node.name.escapedText,
code: content.substring(node.pos, node.end).trimStart(),
extendProperty: extendsInterface?.reduce((total, current) => {
// 当前文件定义的类型
if (currentFile.type[current.name]) {
return total.concat(currentFile.type[current.name])
} else {
// 引入的类型
const importType = currentFile.import[current.name]
if (importType) {
const referer = fileMap[importType.importPath]
if (referer.type[current.name]) {
const type = referer.type[current.name]
return total.concat(type)
} else {
const result = Object.keys(referer.exportFile).find(file => {
return Object.keys(fileMap[file].type).some(typeName => typeName === current.name)
})
if (result) return total.concat(
fileMap[result].type[current.name]
)
}
}
}
}, []),
property: node.members.map(item => {
const type = content
.substring(item.type.pos, item.type.end)
.trimStart()
return {
name: item.name.escapedText,
type: type,
required: item.questionToken ? false : true,
defaultValue: undefined,
typeReference: getReferenceType(item, currentFile, fileMap),
jsDoc: ts.getJSDocTags(item).map(children => {
return {
tagName: children.tagName.escapedText,
content: children.comment
}
})
}
})
}
saveExport(node, node.name.escapedText, currentFile, 'interface')
break
})
}
interface 其实主要就是根据对应的数据结构进行处理就是了,这里主要说下如何处理引用的类型,针对引用的类型,我们可以封装一个函数来进行获取,名字就叫 getReferenceType,该函数接收三个参数,第一个参数是node,当前处理的节点,第二个是当前解析好的数据对象,也就是 currentFile,第三个是存放所有解析数据的对象,现在来实现下:
const getReferenceType = (node, currentFile, fileMap) => {
if (!node) return
let typeName
// 获取定义的类型名称
if (node.type?.typeName?.escapedText) {
typeName = node.type?.typeName?.escapedText
} else if (node.type?.elementType?.typeName?.escapedText) {
typeName = node.type?.elementType?.typeName?.escapedText
}
// 判断当前已经解析好的数据,是否存在,如果存在就返回,如果不存在,则开始读取 import 对象,来找到对应的属性,从而解析该路径,拿到结果
if (currentFile.type[typeName]) {
return currentFile.type[typeName]
} else if (currentFile.import[typeName]) {
// 说明是引入的
const absolutePath = currentFile.import[typeName].importPath
if (absolutePath) {
if (absolutePath.includes('node_modules')) {
return 'node_modules'
}
// 判断该文件时否已经被解析过
if (currentFile[absolutePath]) {
currentFile.type[typeName].typeReference = currentFile[absolutePath].type[typeName]
return currentFile.type[typeName].typeReference
} else {
generator(absolutePath, fileMap)
currentFile.import[typeName].typeReference = fileMap[absolutePath].type[typeName]
return currentFile.import[typeName].typeReference
}
}
} else {
// 都不存在
return null
}
}
实现依然比较简单,获取 node 的类型名称,判断当前文件是否已经解析过,如果解析过就返回,没有解析过,就从import去拿,如果 import 也没有就返回 null,这里其实并没有处理 d.ts这种情况,对于 d.ts,这种 会当做全局类型文件进行查找,一般情况下,不建议把组件或者函数类型放到d.ts 这种文件里面。
还有对于多个同名interface 来讲,ts 会去做类型合并,这个也没有去处理。
现在我们接着来处理 type 类型
function generator (filePath, fileMap = {}) {
// 保存解析的结果
const currentFile = {
// 定义的类型
type: {},
// import 引入的数据
import: {},
// 当前文件的函数声明
function: {},
// 当前文件所导出的内容
export: {},
// export * from 导出的内容
exportFile: {}
}
program.forEachChild(node => {
const type = getDeclaration(node.kind)
switch (type) {
// ...
case 'TypeAliasDeclaration':
currentFile.type[node.name.escapedText] = {
type: 'type',
name: node.name.escapedText,
export: isExport(node),
exportDefault: isExportDefault(node),
code: content.substring(node.pos, node.end).trimStart(),
typeReference: node.type?.types?.reduce((arr, current) => {
// 说明是联合类型
if (current.kind === 197) {
current.templateSpans.forEach(children => {
// 说明是引用的类型
if (children.type.kind === 177) {
const referenceName = children.type.typeName.escapedText
if (currentFile.type[referenceName]) {
// 简单的做个去重,因为 type 可能会引入多个类型,所以这里会先去重,
// 感觉还是换成对象会好点,方便处理,数组的话,还需要遍历一次才行。
const find = arr.find(item => item.name === referenceName)
if (!find) arr.push(currentFile.type[referenceName])
return arr
}
}
})
}
if (current.kind === 177) {
// 是否已经添加过
const find = arr.find(item => item.name === current.typeName.escapedText)
if (find) return arr
}
return arr
}, [])
}
saveExport(node, node.name.escapedText, currentFile, 'type')
break
})
}
目前 type 只处理了联合类型这一种情况,对于一些其他情况,暂时还没去整,后面在去完善。
现在我们还剩 function 这一种还没有进行处理,对于 function, 来讲,我们一般会有这几种情况去写:
function a () {}
const a = () => {}
const a = async () => {}
然而在 ts 中,只有第一种会被当做函数,对于变量生成的函数,并不会认识是函数类型,他是属于一种匿名函数。
function generator (filePath, fileMap = {}) {
// 保存解析的结果
const currentFile = {
// 定义的类型
type: {},
// import 引入的数据
import: {},
// 当前文件的函数声明
function: {},
// 当前文件所导出的内容
export: {},
// export * from 导出的内容
exportFile: {}
}
program.forEachChild(node => {
const type = getDeclaration(node.kind)
switch (type) {
// ...
case 'FunctionDeclaration':
// function 声明的函数
currentFile.function[node.name.escapedText] = {
exportDefault: isExportDefault(node),
export: isExport(node),
functionName: node.name.escapedText,
args: transformArgs(node, currentFile, fileMap)
}
setDefaultValue(node, currentFile, content)
saveExport(node, node.name.escapedText, currentFile, 'function')
break
default:
// 匿名函数等
isFunction(node, currentFile, fileMap, content)
break
})
}
定义一个 isFunction 来处理 function
const isFunction = (node, currentFile, fileMap, content) => {
// 是否变量定义的函数
const isVariableFunction = node => {
const initializer = node?.declarationList?.declarations?.[0]?.initializer
if (initializer) {
if (ts.isArrowFunction(initializer)) {
return initializer
}
return false
}
return false
}
const isArrowFunction = ts.isArrowFunction(node)
const isFunctionExpression = ts.isFunctionExpression(node)
// const isFunctionLike = ts.isFunctionLike(node)
const isVariFunctionNode = isVariableFunction(node)
if (isVariFunctionNode) {
const functionName = isVariFunctionNode.parent.name.escapedText
currentFile.function[functionName] = {
export: isExport(node),
functionName: functionName,
arrowFunction: ts.isArrowFunction(isVariFunctionNode),
asyncFunction: ts.isAsyncFunction(isVariFunctionNode),
args: transformArgs(isVariFunctionNode, currentFile, fileMap)
}
setDefaultValue(isVariFunctionNode, currentFile, content)
saveExport(node, functionName, currentFile, 'function')
return isVariFunctionNode
} else if (ts.isFunctionDeclaration(node)) {
// 普通函数
return node
} else if (isArrowFunction) {
return isArrowFunction
} else if (isFunctionExpression) {
return isFunctionExpression
}
}
// 处理函数参数
const transformArgs = (node, currentFile, fileMap) => {
return node.parameters?.map(args => {
// 获取函数参数的类型
const typeName = args?.type?.kind === 177
? getReferenceType(args, currentFile, fileMap)
: ''
return {
name: args.name.escapedText,
type: typeName
}
})
}
// 处理默认值
const setDefaultValue = (node, currentFile, content) => {
node.body.forEachChild(current => {
const declarations = current?.declarationList?.declarations?.[0]
if (declarations?.initializer) {
const fnName = node?.name?.escapedText || node.parent.name.escapedText
// 获取函数名
const fn = currentFile?.function[fnName]
const args = fn?.args?.[0]
// 获取 props 的名字
const propsName = node.parameters?.[0]?.name.escapedText
// 遍历节点,从而给每个对应的 interface 声明的属性,添加默认值
const init = (property) => {
property.map(item => {
const property = declarations?.name?.elements?.find(children => children.name.escapedText === item.name)
if (property) {
item.defaultValue = property.initializer
? property.initializer.text : undefined
}
return item
})
}
if (args.name === propsName) {
if (args?.type?.extendProperty) {
init(args.type.extendProperty)
}
if (args?.type?.property) {
init(args.type.property)
}
}
}
})
}
生成文档
至此,本文的主要内容就算是写完了,对于生成部分的话,其实也很简单,由于我们已经记录好了哪些内容已经被导出了,所以我们只需要在生成的时候,去拿到这些内容去渲染就可以了。这里以 组件库内部文档实现方式为例:
这里用了一个from-matter 来定义了一个 api,里面保存了一个 module,用来决定生成哪个组件。可以看到我下面还定义了一个 为 api 的 container。接下来的渲染的话,需要你自定义一个loader 来处理,这里我提前写过了,就不赘述。
new MarkdownIt()
.use(container, 'api', {
render (tokens, index) {
if (tokens[index].nesting === 1) {
const apiType = generatorAPI(entryPath)
const findExport = () => {
return data.api.module.map(item => {
const filePath = Object.keys(apiType).find(children => {
if (apiType[children].export[item]) {
return true
}
})
return {
name: item,
type: filePath ? apiType[filePath].export[item].reference : null
}
})
}
const result = findExport()
const interface = []
const type = []
const apiData = []
const property = []
const appendType = (property) => {
const find = property.find(property => property.typeReference)
if (find) {
find.typeReference.type === 'interface' ? interface.push(find.typeReference.code) : type.push(find.typeReference.code)
}
}
result.forEach(item => {
const type = item.type.args[0].type
if (type.type === 'interface') {
if (type.extendProperty) {
type.extendProperty.forEach(children => {
appendType(children.property)
interface.push(children.code)
})
}
appendType(type.property)
interface.push(type.code)
}
apiData.push({
name: item.name,
data: type.property
})
})
const header = ['属性', '是否必填', '类型', '默认值', '描述']
const mdTableData = apiData.reduce((total, current) => {
const { data } = current
const table = [
header.join('|'),
new Array(5).fill('---').join('|'),
data.map(children => {
return [
children.name,
children.required ? '是' : '否',
children.type,
children.defaultValue
].join('|')
}).join('\n')
]
total.push(
table.join('\n')
)
return total
}, [])
return `<>
<Api
code={\`${interface.join('\n\n')}\`}
data={\`${JSON.stringify(apiData)}\`}
></Api>`
} else {
return `</>`
}
}
})
这个是我自定义的一个 container, container 里面返回了一个 Api 的一个组件,用于渲染 api 文档。之后就和 loader一样,把 处理好的结果丢给 webpack 去执行渲染即可。
效果
再来放一张效果图:
收获
这个需求是去年 10 月做的了好像,拖到了现在才写了文章总结,目前大概对 ts 的编译流程倒是有了简单的了解,虽然自己也对 ts 不是很熟就是了。
这篇算是专栏的第二篇文章了,本专栏将不定期更新,毕竟我懒,如果各位 dalao 们,对于本文有什么建议的欢迎评论区,顺便来个 star啥的。
顺便丢个地址:OTAKU-UI