当进入一个节点时,这些插件会按照注册的顺序被执行。大部分插件是不需要开发者关心定义的顺序的,有少数的情况需要稍微注意以下,例如plugin-proposal-decorators:
{
“plugins”: [
“@babel/plugin-proposal-decorators”, // 必须在plugin-proposal-class-properties之前
“@babel/plugin-proposal-class-properties”
]
}
复制代码
所有插件定义的顺序,按照惯例,应该是新的或者说实验性的插件在前面,老的插件定义在后面。因为可能需要新的插件将 AST 转换后,老的插件才能识别语法(向后兼容)。下面是官方配置例子, 为了确保先后兼容,stage-*阶段的插件先执行:
{
“presets”: [“es2015”, “react”, “stage-2”]
}
复制代码
注意Preset的执行顺序相反,详见官方文档
节点的上下文
访问者在访问一个节点时, 会无差别地调用 enter 方法,我们怎么知道这个节点在什么位置以及和其他节点的关联关系呢?
通过上面的代码,读者应该可以猜出几分,每个visit方法都接收一个 Path 对象, 你可以将它当做一个‘上下文’对象,类似于JQuery的 JQuery(const $el = $(‘.el’)) 对象,这里面包含了很多信息:
-
当前节点信息
-
节点的关联信息。父节点、子节点、兄弟节点等等
-
作用域信息
-
上下文信息
-
节点操作方法。节点增删查改
-
断言方法。isXXX, assertXXX
下面是它的主要结构:
export class NodePath<T = Node> {
constructor(hub: Hub, parent: Node);
parent: Node;
hub: Hub;
contexts: TraversalContext[];
data: object;
shouldSkip: boolean;
shouldStop: boolean;
removed: boolean;
state: any;
opts: object;
skipKeys: object;
parentPath: NodePath;
context: TraversalContext;
container: object | object[];
listKey: string; // 如果节点在一个数组中,这个就是节点数组的键
inList: boolean;
parentKey: string;
key: string | number; // 节点所在的键或索引
node: T; // 当前节点
scope: Scope; // 当前节点所在的作用域
type: T extends undefined | null ? string | null : string; // 节点类型
typeAnnotation: object;
// … 还有很多方法,实现增删查改
}
复制代码
你可以通过这个手册来学习怎么通过 Path 来转换 AST. 后面也会有代码示例,这里就不展开细节了
副作用的处理
实际上访问者的工作比我们想象的要复杂的多,上面示范的是静态 AST 的遍历过程。而 AST 转换本身是有副作用的,比如插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 代码如下。
traverse(ast, {
ExpressionStatement(path) {
// 将 `console.log(‘hello’ + v + ‘!’)` 替换为 `return ‘hello’ + v`
const rtn = t.returnStatement(t.binaryExpression(‘+’, t.stringLiteral(‘hello’), t.identifier(‘v’)))
path.replaceWith(rtn)
},
}
复制代码
上面的代码, 将console.log(‘hello’ + v + ‘!’)语句替换为return “hello” + v;, 下图是遍历的过程:
我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点… 当这些操作’污染’了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果。
作用域的处理
访问者可以确保正确地遍历和修改节点,但是对于转换器来说,另一个比较棘手的是对作用域的处理,这个责任落在了插件开发者的头上。插件开发者必须非常谨慎地处理作用域,不能破坏现有代码的执行逻辑。
const a = 1, b = 2
function add(foo, bar) {
console.log(a, b)
return foo + bar
}
复制代码
比如你要将 add 函数的第一个参数 foo 标识符修改为a, 你就需要递归遍历子树,查出foo标识符的所有引用, 然后替换它:
traverse(ast, {
// 将第一个参数名转换为a
FunctionDeclaration(path) {
const firstParams = path.get(‘params.0’)
if (firstParams == null) {
return
}
const name = firstParams.node.name
// 递归遍历,这是插件常用的模式。这样可以避免影响到外部作用域
path.traverse({
Identifier(path) {
if (path.node.name === name) {
path.replaceWith(t.identifier(‘a’))
}
}
})
},
})
console.log(generate(ast).code)
// function add(a, bar) {
// console.log(a, b);
// return a + bar;
// }
复制代码
慢着,好像没那么简单,替换成 a 之后, console.log(a, b) 的行为就被破坏了。所以这里不能用 a,得换个标识符, 譬如c.
这就是转换器需要考虑的作用域问题,AST 转换的前提是保证程序的正确性。 我们在添加和修改引用时,需要确保与现有的所有引用不冲突。Babel本身不能检测这类异常,只能依靠插件开发者谨慎处理。
Javascript采用的是词法作用域, 也就是根据源代码的词法结构来确定作用域:
在词法区块(block)中,由于新建变量、函数、类、函数参数等创建的标识符,都属于这个区块作用域. 这些标识符也称为绑定(Binding),而对这些绑定的使用称为引用(Reference)
在Babel中,使用Scope对象来表示作用域。 我们可以通过Path对象的scope字段来获取当前节点的Scope对象。它的结构如下:
{
path: NodePath;
block: Node; // 所属的词法区块节点, 例如函数节点、条件语句节点
parentBlock: Node; // 所属的父级词法区块节点
parent: Scope; // ⚛️指向父作用域
bindings: { [name: string]: Binding; }; // ⚛️ 该作用域下面的所有绑定(即该作用域创建的标识符)
}
复制代码
Scope 对象和 Path 对象差不多,它包含了作用域之间的关联关系(通过parent指向父作用域),收集了作用域下面的所有绑定(bindings), 另外还提供了丰富的方法来对作用域仅限操作。
我们可以通过bindings属性获取当前作用域下的所有绑定(即标识符),每个绑定由Binding类来表示:
export class Binding {
identifier: t.Identifier;
scope: Scope;
path: NodePath;
kind: “var” | “let” | “const” | “module”;
referenced: boolean;
references: number; // 被引用的数量
referencePaths: NodePath[]; // ⚛️获取所有应用该标识符的节点路径
constant: boolean; // 是否是常量
constantViolations: NodePath[];
}
复制代码
通过Binding对象我们可以确定标识符被引用的情况。
Ok,有了 Scope 和 Binding, 现在有能力实现安全的变量重命名转换了。 为了更好地展示作用域交互,在上面代码的基础上,我们再增加一下难度:
const a = 1, b = 2
function add(foo, bar) {
console.log(a, b)
return () => {
const a = ‘1’ // 新增了一个变量声明
return a + (foo + bar)
}
}
复制代码
现在你要重命名函数参数 foo, 不仅要考虑外部的作用域, 也要考虑下级作用域的绑定情况,确保这两者都不冲突。
上面的代码作用域和标识符引用情况如下图所示:
来吧,接受挑战,试着将函数的第一个参数重新命名为更短的标识符:
// 用于获取唯一的标识符
const getUid = () => {
let uid = 0
return () => `_${(uid++) || ‘’}`
}
const ast = babel.parseSync(code)
traverse(ast, {
FunctionDeclaration(path) {
// 获取第一个参数
const firstParam = path.get(‘params.0’)
if (firstParam == null) {
return
}
const currentName = firstParam.node.name
const currentBinding = path.scope.getBinding(currentName)
const gid = getUid()
let sname
// 循环找出没有被占用的变量名
while(true) {
sname = gid()
// 1️⃣首先看一下父作用域是否已定义了该变量
if (path.scope.parentHasBinding(sname)) {
continue
}
// 2️⃣ 检查当前作用域是否定义了变量
if (path.scope.hasOwnBinding(sname)) {
// 已占用
continue
}
// 再检查第一个参数的当前的引用情况,
// 如果它所在的作用域定义了同名的变量,我们也得放弃
if (currentBinding.references > 0) {
let findIt = false
for (const refNode of currentBinding.referencePaths) {
if (refNode.scope !== path.scope && refNode.scope.hasBinding(sname)) {
findIt = true
break
}
}
if (findIt) {
continue
}
}
break
}
// 开始替换掉
const i = t.identifier(sname)
currentBinding.referencePaths.forEach(p => p.replaceWith(i))
firstParam.replaceWith(i)
},
})
console.log(generate(ast).code)
// const a = 1,
// b = 2;
// function add(_, bar) {
// console.log(a, b);
// return () => {
// const a = ‘1’; // 新增了一个变量声明
// return a + (_ + bar);
// };
// }
复制代码
上面的例子虽然没有什么实用性,而且还有Bug(没考虑label),但是正好可以揭示了作用域处理的复杂性。
Babel的 Scope 对象其实提供了一个generateUid方法来生成唯一的、不冲突的标识符。我们利用这个方法再简化一下我们的代码:
traverse(ast, {
FunctionDeclaration(path) {
const firstParam = path.get(‘params.0’)
if (firstParam == null) {
return
}
let i = path.scope.generateUidIdentifier(‘_’) // 也可以使用generateUid
const currentBinding = path.scope.getBinding(firstParam.node.name)
currentBinding.referencePaths.forEach(p => p.replaceWith(i))
firstParam.replaceWith(i)
},
})
复制代码
能不能再短点!
traverse(ast, {
FunctionDeclaration(path) {
const firstParam = path.get(‘params.0’)
if (firstParam == null) {
return
}
let i = path.scope.generateUid(‘_’) // 也可以使用generateUid
path.scope.rename(firstParam.node.name, i)
},
})
复制代码
查看generateUid的实现代码
generateUid(name: string = “temp”) {
name = t
.toIdentifier(name)
.replace(/^_+/, “”)
.replace(/[0-9]+$/g, “”);
let uid;
let i = 0;
do {
uid = this._generateUid(name, i);
i++;
} while (
this.hasLabel(uid) ||
this.hasBinding(uid) ||
this.hasGlobal(uid) ||
this.hasReference(uid)
);
const program = this.getProgramParent();
program.references[uid] = true;
program.uids[uid] = true;
return uid;
}
复制代码
非常简洁哈?作用域操作最典型的场景是代码压缩,代码压缩会对变量名、函数名等进行压缩… 然而实际上很少的插件场景需要跟作用域进行复杂的交互,所以关于作用域这一块就先讲到这里。
搞一个插件呗
等等别走,还没完呢,这才到2/3。学了上面得了知识,总得写一个玩具插件试试水吧?
现在打算模仿babel-plugin-import, 写一个极简版插件,来实现模块的按需导入. 在这个插件中,我们会将类似这样的导入语句:
import {A, B, C as D} from ‘foo’
复制代码
转换为:
import A from ‘foo/A’
import ‘foo/A/style.css’
import B from ‘foo/B’
import ‘foo/B/style.css’
import D from ‘foo/C’
import ‘foo/C/style.css’
复制代码
首先通过 AST Explorer 看一下导入语句的 AST 节点结构:
通过上面展示的结果,我们需要处理 ImportDeclaration 节点类型,将它的specifiers拿出来遍历处理一下。另外如果用户使用了默认导入语句,我们将抛出错误,提醒用户不能使用默认导入.
基本实现如下:
// 要识别的模块
const MODULE = ‘foo’
traverse(ast, {
// 访问导入语句
ImportDeclaration(path) {
if (path.node.source.value !== MODULE) {
return
}
// 如果是空导入则直接删除掉
const specs = path.node.specifiers
if (specs.length === 0) {
path.remove()
return
}
// 判断是否包含了默认导入和命名空间导入
if (specs.some(i => t.isImportDefaultSpecifier(i) || t.isImportNamespaceSpecifier(i))) {
// 抛出错误,Babel会展示出错的代码帧
throw path.buildCodeFrameError(“不能使用默认导入或命名空间导入”)
}
// 转换命名导入
const imports = []
for (const spec of specs) {
const named = MODULE + ‘/’ + spec.imported.name
const local = spec.local
imports.push(t.importDeclaration([t.importDefaultSpecifier(local)], t.stringLiteral(named)))
imports.push(t.importDeclaration([], t.stringLiteral(`${named}/style.css`)))
}
// 替换原有的导入语句
path.replaceWithMultiple(imports)
}
})
复制代码
逻辑还算简单,babel-plugin-import可比这复杂得多。
接下来,我们将它封装成标准的 Babel 插件。 按照规范,我们需要创建一个babel-plugin-*前缀的包名:
mkdir babel-plugin-toy-import
cd babel-plugin-toy-import
yarn init -y
touch index.js
复制代码
你也可以通过 generator-babel-plugin 来生成项目模板.
在 index.js 文件中填入我们的代码。index.js默认导出一个函数,函数结构如下:
// 接受一个 babel-core 对象
export default function(babel) {
const {types: t} = babel
return {
pre(state) {
// 前置操作,可选,可以用于准备一些资源
},
visitor: {
// 我们的访问者代码将放在这里
ImportDeclaration(path, state) {
// …
}
},
post(state) {
// 后置操作,可选
}
}
}
复制代码
我们可以从访问器方法的第二个参数state中获取用户传入的参数。假设用户配置为:
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以扫码获取(备注前端)
最后
在面试前我花了三个月时间刷了很多大厂面试题,最近做了一个整理并分类,主要内容包括html,css,JavaScript,ES6,计算机网络,浏览器,工程化,模块化,Node.js,框架,数据结构,性能优化,项目等等。
包含了腾讯、字节跳动、小米、阿里、滴滴、美团、58、拼多多、360、新浪、搜狐等一线互联网公司面试被问到的题目,涵盖了初中级前端技术点。
-
HTML5新特性,语义化
-
浏览器的标准模式和怪异模式
-
xhtml和html的区别
-
使用data-的好处
-
meta标签
-
canvas
-
HTML废弃的标签
-
IE6 bug,和一些定位写法
-
css js放置位置和原因
-
什么是渐进式渲染
-
html模板语言
-
meta viewport原理
图片转存中…(img-HYkXIv0h-1712036206457)]
[外链图片转存中…(img-ksNK7MhE-1712036206458)]
[外链图片转存中…(img-MkRDiflP-1712036206458)]
[外链图片转存中…(img-Rtigw9dV-1712036206459)]
[外链图片转存中…(img-YyM7gJk7-1712036206459)]
[外链图片转存中…(img-wFPBoBfV-1712036206459)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以扫码获取(备注前端)
[外链图片转存中…(img-SOcot6Nc-1712036206459)]
最后
在面试前我花了三个月时间刷了很多大厂面试题,最近做了一个整理并分类,主要内容包括html,css,JavaScript,ES6,计算机网络,浏览器,工程化,模块化,Node.js,框架,数据结构,性能优化,项目等等。
包含了腾讯、字节跳动、小米、阿里、滴滴、美团、58、拼多多、360、新浪、搜狐等一线互联网公司面试被问到的题目,涵盖了初中级前端技术点。
-
HTML5新特性,语义化
-
浏览器的标准模式和怪异模式
-
xhtml和html的区别
-
使用data-的好处
-
meta标签
-
canvas
-
HTML废弃的标签
-
IE6 bug,和一些定位写法
-
css js放置位置和原因
-
什么是渐进式渲染
-
html模板语言
-
meta viewport原理