2024年深入浅出 Babel 上篇:架构和原理 + 实战,毕业一年萌新的前端大厂面经

最后

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

给大家分享一些关于HTML的面试题。


traverse(ast, {

// 访问标识符

Idenfifier(path) {

console.log(`enter Identifier`)

},

// 访问调用表达式

CallExpression(path) {

console.log(`enter CallExpression`)

},

// 上面是enter的简写,如果要处理exit,也可以这样

// 二元操作符

BinaryExpression: {

enter(path) {},

exit(path) {},

},

// 更高级的, 使用同一个方法访问多种类型的节点

“ExportNamedDeclaration|Flow”(path) {}

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

那么 Babel 插件是怎么被应用的呢?

Babel 会按照插件定义的顺序来应用访问方法,比如你注册了多个插件,babel-core 最后传递给访问器的数据结构大概长这样:

{

Identifier: {

enter: [plugin-xx, plugin-yy,] // 数组形式

}

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

当进入一个节点时,这些插件会按照注册的顺序被执行。大部分插件是不需要开发者关心定义的顺序的,有少数的情况需要稍微注意以下,例如plugin-proposal-decorators:

{

“plugins”: [

“@babel/plugin-proposal-decorators”, // 必须在plugin-proposal-class-properties之前

“@babel/plugin-proposal-class-properties”

]

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

所有插件定义的顺序,按照惯例,应该是新的或者说实验性的插件在前面,老的插件定义在后面。因为可能需要新的插件将 AST 转换后,老的插件才能识别语法(向后兼容)。下面是官方配置例子, 为了确保先后兼容,stage-*阶段的插件先执行:

{

“presets”: [“es2015”, “react”, “stage-2”]

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

注意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;

// … 还有很多方法,实现增删查改

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

你可以通过这个手册来学习怎么通过 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)

},

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

上面的代码, 将console.log(‘hello’ + v + ‘!’)语句替换为return “hello” + v;, 下图是遍历的过程:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点… 当这些操作’污染’了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果

作用域的处理

访问者可以确保正确地遍历和修改节点,但是对于转换器来说,另一个比较棘手的是对作用域的处理,这个责任落在了插件开发者的头上。插件开发者必须非常谨慎地处理作用域,不能破坏现有代码的执行逻辑。

const a = 1, b = 2

function add(foo, bar) {

console.log(a, b)

return foo + bar

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

比如你要将 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;

// }

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

慢着,好像没那么简单,替换成 a 之后, console.log(a, b) 的行为就被破坏了。所以这里不能用 a,得换个标识符, 譬如c.

这就是转换器需要考虑的作用域问题,AST 转换的前提是保证程序的正确性。 我们在添加和修改引用时,需要确保与现有的所有引用不冲突。Babel本身不能检测这类异常,只能依靠插件开发者谨慎处理。

Javascript采用的是词法作用域, 也就是根据源代码的词法结构来确定作用域:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

词法区块(block)中,由于新建变量、函数、类、函数参数等创建的标识符,都属于这个区块作用域. 这些标识符也称为绑定(Binding),而对这些绑定的使用称为引用(Reference)

在Babel中,使用Scope对象来表示作用域。 我们可以通过Path对象的scope字段来获取当前节点的Scope对象。它的结构如下:

{

path: NodePath;

block: Node; // 所属的词法区块节点, 例如函数节点、条件语句节点

parentBlock: Node; // 所属的父级词法区块节点

parent: Scope; // ⚛️指向父作用域

bindings: { [name: string]: Binding; }; // ⚛️ 该作用域下面的所有绑定(即该作用域创建的标识符)

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

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[];

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

通过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)

}

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

现在你要重命名函数参数 foo, 不仅要考虑外部的作用域, 也要考虑下级作用域的绑定情况,确保这两者都不冲突。

上面的代码作用域和标识符引用情况如下图所示:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

来吧,接受挑战,试着将函数的第一个参数重新命名为更短的标识符:

// 用于获取唯一的标识符

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);

// };

// }

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

上面的例子虽然没有什么实用性,而且还有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)

},

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

能不能再短点!

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)

},

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

查看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;

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

非常简洁哈?作用域操作最典型的场景是代码压缩,代码压缩会对变量名、函数名等进行压缩… 然而实际上很少的插件场景需要跟作用域进行复杂的交互,所以关于作用域这一块就先讲到这里。

搞一个插件呗

等等别走,还没完呢,这才到2/3。学了上面得了知识,总得写一个玩具插件试试水吧?

现在打算模仿babel-plugin-import, 写一个极简版插件,来实现模块的按需导入. 在这个插件中,我们会将类似这样的导入语句:

import {A, B, C as D} from ‘foo’

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

转换为:

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’

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

首先通过 AST Explorer 看一下导入语句的 AST 节点结构:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

通过上面展示的结果,我们需要处理 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 上篇:架构和原理 + 实战

逻辑还算简单,babel-plugin-import可比这复杂得多。

接下来,我们将它封装成标准的 Babel 插件。 按照规范,我们需要创建一个babel-plugin-*前缀的包名:

mkdir babel-plugin-toy-import

cd babel-plugin-toy-import

yarn init -y

touch index.js

最后

在面试前我花了三个月时间刷了很多大厂面试题,最近做了一个整理并分类,主要内容包括html,css,JavaScript,ES6,计算机网络,浏览器,工程化,模块化,Node.js,框架,数据结构,性能优化,项目等等。

包含了腾讯、字节跳动、小米、阿里、滴滴、美团、58、拼多多、360、新浪、搜狐等一线互联网公司面试被问到的题目,涵盖了初中级前端技术点。

  • HTML5新特性,语义化

  • 浏览器的标准模式和怪异模式

  • xhtml和html的区别

  • 使用data-的好处

  • meta标签

  • canvas

  • HTML废弃的标签

  • IE6 bug,和一些定位写法

  • css js放置位置和原因

  • 什么是渐进式渲染

  • html模板语言

  • meta viewport原理

t.csdnimg.cn/aHR0cDovL3AxLnBzdGF0cC5jb20vbGFyZ2UvcGdjLWltYWdlLzM2MjYyN2M5Y2QxZjQ1OWI4MjNhZmY4ZTViYjM3MTQy?x-oss-process=image/format,png)

逻辑还算简单,babel-plugin-import可比这复杂得多。

接下来,我们将它封装成标准的 Babel 插件。 按照规范,我们需要创建一个babel-plugin-*前缀的包名:

mkdir babel-plugin-toy-import

cd babel-plugin-toy-import

yarn init -y

touch index.js

最后

在面试前我花了三个月时间刷了很多大厂面试题,最近做了一个整理并分类,主要内容包括html,css,JavaScript,ES6,计算机网络,浏览器,工程化,模块化,Node.js,框架,数据结构,性能优化,项目等等。

包含了腾讯、字节跳动、小米、阿里、滴滴、美团、58、拼多多、360、新浪、搜狐等一线互联网公司面试被问到的题目,涵盖了初中级前端技术点。

  • HTML5新特性,语义化

  • 浏览器的标准模式和怪异模式

  • xhtml和html的区别

  • 使用data-的好处

  • meta标签

  • canvas

  • HTML废弃的标签

  • IE6 bug,和一些定位写法

  • css js放置位置和原因

  • 什么是渐进式渲染

  • html模板语言

  • meta viewport原理

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值