用过 ant-design 的同窗可能对 babel-plugin-import 有印象,它能够帮助实现模块的按需引用,好比:前端
import { Button } from 'antd'复制代码
在使用该 Plugin 以后会被转换成:node
import Button from 'antd/lib/button'复制代码
在一个没有使用 antd 所有组件的项目里,这样作能够明显减小打包后的代码体积。
但是,若是你在一个没有使用 Babel 的 TypeScript 项目里,想要实现相似的功能,该怎么办呢?git
这就要用到本文的主角:custom transformation,这是从 TypeScript@2.3 开始引入的新能力,他让咱们能够部分修改 TS 从源码转换成的语法树,从而控制生成的 JavaScript 代码,最终完成上述的转换。github
先让咱们从 TS 中代码语法树的样子提及。typescript
AST 是为了方便计算机理解源代码、用于表达源代码语法结构的树状结构,由称做节点(Node)的数据结构组成。babel
例如:前端工程师
const name: string = 'Tom'复制代码
上面这段代码在 TS 会解析成下图所示的 AST:antd
确切来讲,上图其实是语法树而不是抽象语法树,由于节点里面仍然包含了「冒号」等多余信息,还不够「抽象」,可是,由于在以后处理的过程当中实际面对的就是这样的语法树,所以在这里不作严格的区分。数据结构
TS 中全部 AST 的根节点都是 SourceFile,顾名思义,这是一个附加了源文件信息的 AST 节点(Node)。编辑器
源码中只有一个变量声明语句,该声明生成了如下结构:
在 TypeScript/typescript.d.ts 源码中,用枚举类型 SyntaxKind 定义了全部的 AST 节点类型,到目前为止近 300 个,能够看出来 AST 的树形结构很是得精确细致,想手动分析记忆比较困难,能够借助 AST explorer 这个可视化工具帮助理解代码的 AST 结构。
和 Babel 以及其余编译到 JavaScript 的工具相似,TS 的编译流程包含如下三步:
解析 -> 转换 -> 生成
包含了如下几个关键部分:
图示以下:
咱们的标题中所指的 transformer Plugin 就是在 Emitter 阶段起做用。
tsc
命令不支持直接配置 transformer 的参数,你能够手动引入 typescript 来本身编译,固然,目前最方便的办法是在 Webpack + ts-loader 的项目中,给 ts-loader 配置 getCustomTransformers 选项:
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
... // other loader's options
getCustomTransformers: () => ({ before: [yourImportedTransformer] })
}
}复制代码
详见 ts-loader 文档。
咱们的目标就是实现文章开头代码示例中的转换:
// before
import { Button } from 'antd'
// after
import Button from 'antd/lib/button'复制代码
Custom Transformer 操做是 AST,因此咱们须要了解代码转换先后的 AST 区别在哪里。
转换前:
import { Button } from 'antd'复制代码
代码的 AST 以下:
转换后:
import Button from 'antd/lib/button'复制代码
代码的 AST 以下:
能够看出,咱们须要作的转换有两处:
那么,该如何找到并替换对应的节点呢?
TS 提供了两个方法遍历 AST:
ts.forEachChild
ts.visitEachChild
两个方法的区别是:
forEachChild 只能遍历 AST,visitEachChild 在遍历的同时,提供给此方法的 visitor 回调的返回节点,会被用来替换当前遍历的节点,所以咱们能够利用 visitEachChild 来遍历并替换节点。
先看一下这个方法的签名:
/** * Visits each child of a Node using the supplied visitor, possibly returning a new Node of the same kind in its place. * * @param node The Node whose children will be visited. * @param visitor The callback used to visit each child. * @param context A lexical environment context for the visitor. */
function visitEachChild<T extends Node>(node: T, visitor: Visitor, context: TransformationContext): T复制代码
假设咱们已经拿到了 AST 的根节点 SourceFile 和 TransformationContext,咱们就能够用如下代码遍历 AST:
ts.visitEachChild(SourceFile, visitor, ctx)
function visitor(node) {
if(node.getChildCount()) {
return ts.visitEachChild(node, visitor, ctx)
}
return node
}复制代码
注意:visitor 的返回节点会被用来替换 visitor 正在访问的节点。
TS 中 AST 节点的工厂函数全都以 create 开头,在编辑器里敲下:ts.create,代码补全列表里就能看到不少不少和节点建立有关的方法:
好比,建立一个 1+2 的节点:
ts.createAdd(ts.createNumericLiteral('1'), ts.createNumericLiteral('2'))复制代码
前面说过,ts.SyntaxKind
里存储了全部的节点类型。同时,每一个节点中都有一个 kind 字段标明它的类型。咱们能够用如下代码判断节点类型:
if(node.kind === ts.SyntaxKind.ImportDeclaration) {
// Get it!
}复制代码
也能够用 ts-is-kind 模块简化判断:
import * as kind from 'ts-is-kind'
if(kind.isImportDeclaration(node)) {
// Get it!
}复制代码
那么,咱们以前的 visitor 就能够继续补充下去:
import * as kind from 'ts-is-kind'
function visitor(node) {
if(kind.isImportDeclaration(node)) {
const updatedNode = updateImportNode(node, ctx)
return updateNode
}
return node
}复制代码
由于 Import 语句不能嵌套在其余语句下面,因此 ImportDeclaration 只会出如今 SourceFile 的下一级子节点上,所以上面的代码并无对 node 作深层递归遍历。
只要 updateImportNode 函数完成了以前图中表现出的 AST 转换,咱们的工做就完成了。
下面关注 updateImportNode 怎么实现。
咱们已经拿到了 ImportDeclaration 节点,还记获得底要干什么吗?
为了方便找到须要的节点,咱们对 ImportDeclaration 作递归遍历,只对 NamedImports 和 StringLiteral 作特殊处理:
function updateImportNode(node: ts.Node, ctx: ts.TransformationContext) {
const visitor: ts.Visitor = node => {
if (kind.isNamedImports(node)) {
// ...
}
if (kind.isStringLiteral(node)) {
// ...
}
if (node.getChildCount()) {
return ts.visitEachChild(node, visitor, ctx)
}
return node
}
}复制代码
首先处理 NamedImports。
在 AST explorer 的帮助下,能够发现 NamedImports 包含了三部分,两个大括号和一个叫 Button 的 Identifier,咱们在 isNamedImports 的判断下,直接返回这个 Identifier,就能够取代原先的 NamedImports:
if (kind.isNamedImports(node)) {
const identifierName = node.getChildAt(1).getText()
// 返回的节点会被用于取代原节点
return ts.createIdentifier(identifierName)
}复制代码
再处理 StringLiteral。
发现要返回新的 StringLiteral,要用到 isNamedImports 判断里提取出来的 identifierName。所以咱们先把 identifierName 提取到外层定义,做为 updateImportNode 的内部状态。
同时,antd/lib 目录下的文件名没有大写字母,所以要把 identifierName 中首字母大写去掉:
if (kind.isStringLiteral(node)) {
const libName = node.getText().replace(/[\"\']/g, '')
if (identifierName) {
const fileName = camel2Dash(identifierName)
return ts.createLiteral(`${libName}/lib/${fileName}`)
}
}
// from: https://github.com/ant-design/babel-plugin-import
function camel2Dash(_str: string) {
const str = _str[0].toLowerCase() + _str.substr(1)
return str.replace(/([A-Z])/g, ($1) => `-${$1.toLowerCase()}`)
}复制代码
完整的 updateImportNode 实现以下:
function updateImportNode(node: ts.Node, ctx: ts.TransformationContext) {
const visitor: ts.Visitor = node => {
if (kind.isNamedImports(node)) {
const identifierName = node.getChildAt(1).getText()
return ts.createIdentifier(identifierName)
}
if (kind.isStringLiteral(node)) {
const libName = node.getText().replace(/[\"\']/g, '')
if (identifierName) {
const fileName = camel2Dash(identifierName)
return ts.createLiteral(`${libName}/lib/${fileName}`)
}
}
if (node.getChildCount()) {
return ts.visitEachChild(node, visitor, ctx)
}
return node
}
}复制代码
以上,咱们就成功实现了以下代码转换:
// before
import { Button } from 'antd'
// after
import Button from 'antd/lib/button'复制代码
以上代码整合起来,就是一个完整的 Transformer Plugin,完整代码请见:newraina/learning-ts-transfomer-plugin
刚才实现的只是一个最最精简的版本,距离 babel-plugin-import 的完整功能还有很远,好比:
import { Button, Alert } from 'antd'
import { Button as Btn } from 'antd'
以上均可以在 AST explorer 的帮助下找到 AST 转换先后的区别,而后按照本文介绍的流程实现。
做者:newraina
简介:百姓网前端工程师。
原文连接:知乎专栏本文仅为做者我的观点,不表明百姓网立场。