小工具知识网

当前位置: 首页 > 行业知识 > 2024年最新深入浅出 Babel 上篇:架构和原理 + 实战,前端高级面试题汇总

行业知识

2024年最新深入浅出 Babel 上篇:架构和原理 + 实战,前端高级面试题汇总

2026-11-14 08:31:17 来源:互联网转载
算法刷题

大厂面试还是很注重算法题的,尤其是字节跳动,算法是问的比较多的,关于算法,推荐《LeetCode》和《算法的乐趣》,这两本我也有电子版,字节跳动、阿里、美团等大厂面试题(含答案+解析)、学习笔记、Xmind思维导图均可以分享给大家学习。

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

写在最后

最后,对所以做Java的朋友提几点建议,也是我的个人心得:

  1. 疯狂编程

  2. 学习效果可视化

  3. 写博客

  4. 阅读优秀代码

  5. 心态调整

注意Preset的执行顺序相反,详见官方文档

节点的上下文

访问者在访问一个节点时, 会无差别地调用 enter 方法,我们怎么知道这个节点在什么位置以及和其他节点的关联关系呢?

通过上面的代码,读者应该可以猜出几分,每个visit方法都接收一个 Path 对象, 你可以将它当做一个‘上下文’对象,类似于JQuery的 JQuery(const $el = $(‘.el’)) 对象,这里面包含了很多信息:

  • 当前节点信息

  • 节点的关联信息。父节点、子节点、兄弟节点等等

  • 作用域信息

  • 上下文信息

  • 节点操作方法。节点增删查改

  • 断言方法。isXXX, assertXXX

    下面是它的主要结构:

    export class NodePath {

    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中获取用户传入的参数。假设用户配置为:

    {

    plugins: [[‘toy-plugin’, {name: ‘foo’}]]

    }

    复制代码

    我们可以这样获取用户传入的参数:

    export default function(babel) {

    const {types: t} = babel

    return {

    visitor: {

    ImportDeclaration(path, state) {

    const mod = state.opts && state.opts.name

    if (mod == null) {

    return

    }

    // …

    }

    },

    常用的JavaScript设计模式

    • 单体模式

    • 工厂模式

    • 例模式

      函数

      • 函数的定义

      • 局部变量和全局变量

      • 返回值

      • 匿名函数

      • 自运行函数

      • 闭包

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

上一篇:【Spring云原生系列】Spring RabbitMQ:异步处理机制的基础--消息队列 原理讲解+使用教程

下一篇:服务器开机报警怎么回事?