3

浅谈 babel

 2 years ago
source link: https://blog.dteam.top/posts/2021-06/babel-theory.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

什么是 babel?

babel 是一个 Javascript 编译器,是目前前端开发最常用的工具之一,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境。

a ?? 1;
a?.b;

//babel转换后
"use strict";

var _a;

(_a = a) !== null && _a !== void 0 ? _a : 1;
(_a2 = a) === null || _a2 === void 0 ? void 0 : _a2.b;

可以在babel官网自己测试。

babel 的用途

  1. 转译 esnext、typescript、flow 等到目标环境支持的 js。

  2. 一些特定用途的代码转换。 babel 暴露了很多 api,用这些 api 可以完成代码到 AST 的 parse,AST 的转换,以及目标代码的生成。 开发者可以用它来来完成一些特定用途的转换,比如函数插桩(函数中自动插入一些代码,例如埋点代码)、自动国际化、default import 转 named import 等。

  3. 代码的静态分析。 对代码进行 parse 之后,能够进行转换,是因为通过 AST 的结构能够理解代码。理解了代码之后,除了进行转换然后生成目标代码之外,也同样可以用于分析代码的信息,进行一些检查。

  • linter 工具就是分析 AST 的结构,对代码规范进行检查。

  • api 文档自动生成工具,可以提取源码中的注释,然后生成文档。

  • type checker 会根据从 AST 中提取的或者推导的类型信息,对 AST 进行类型是否一致的检查,从而减少运行时因类型导致的错误。

  • 压缩混淆工具,这个也是分析代码结构,进行删除死代码、变量名混淆、常量折叠等各种编译优化,生成体积更小、性能更优的代码。

babel 的编译流程

babel 整体编译流程分为三步:

  • parse:通过 parser 把源码转成抽象语法树(AST)
  • transform:遍历 AST,调用各种 transform 插件对 AST 进行增删改
  • generate:把转换后的 AST 打印成目标代码,并生成 sourcemap

image1

parse

parse 阶段的目的是把源码字符串转换成机器能够理解的 AST,这个过程分为词法分析、语法分析。

比如 let name = ‘guang’; 这样一段源码,我们要先把它分成一个个不能细分的单词(token),也就是 let, name, =, ‘guang’,这个过程是词法分析,按照单词的构成规则来拆分字符串成单词。

image2

transform

transform 阶段是对 parse 生成的 AST 的处理,会进行 AST 的遍历,遍历的过程中处理到不同的 AST 节点会调用注册的相应的 visitor 函数,visitor 函数里可以对 AST 节点进行增删改。

image3

generate

generate 阶段会把 AST 打印成目标代码字符串,并且会生成 sourcemap。

image4

while (node condition) {
  // node contet
}

AST 节点

字面量、标识符、表达式、语句、模块语法、class 语法都有各自的 AST。

比如 字面Literal:

‘a’ 就是 StringLiteral,123 就是 NumberLiteral,变量名,属性名等标识符都是Identifier。

我们可以去 AST可视化工具了解各个节点。

image5

也可以去 @babel/types 查看所有 AST 类型。

AST 的公共属性
  • type: AST 节点的类型。
  • start、end、loc:start 和 end 代表该节点对应的源码字符串的开始和结束下标,不区分行列。而 loc 属性是一个对象,有 line 和 column 属性分别记录开始和结束行列号。
  • leadingComments、innerComments、trailingComments: 表示开始的注释、中间的注释、结尾的注释,因为每个 AST 节点中都可能存在注释,而且可能在开始、中间、结束这三种位置,通过这三个属性来记录和 Comment 的关联。
  • extra:记录一些额外的信息,用于处理一些特殊情况。

babel api

  • @babel/parser

它提供了有两个 api:parse 和 parseExpression。两者都是把源码转成 AST,不过 parse 返回的 AST 根节点是 File(整个 AST),parseExpression 返回的 AST 根节点是是 Expression(表达式的 AST)。可以指定 parse 的内容以及 parse 的方式。最常用的 option 就是 plugins、sourceType 这两个。

plugins: 指定jsx、typescript、flow 等插件来解析对应的语法。

sourceType: 指定是否支持解析模块语法,有 module、script、unambiguous 3个取值,module 是解析 es module 语法,script 则不解析 es module 语法,当作脚本执行,unambiguous 则是根据内容是否有 import 和 export 来确定是否解析 es module 语法。

require("@babel/parser").parse("code", {
  sourceType: "module",
  plugins: [
    "jsx",
    "typescript"
  ]
});
  • @babel/traverse

parse 出的 AST 由 @babel/traverse 来遍历和修改,babel traverse 包提供了 traverse 方法: function traverse(parent, opts)

parent 指定要遍历的 AST 节点,opts 指定 visitor 函数。babel 会在遍历 parent 对应的 AST 时调用相应的 visitor 函数。enter 是在遍历当前节点的子节点前调用,exit 是遍历完当前节点的子节点后调用。

visitor: {
    Identifier (path, state) {},
    StringLiteral: {
        enter (path, state) {},
        exit (path, state) {}
    }
}

参数 path 是遍历过程中的路径,会保留上下文信息。通过 path 可以获取节点信息:

path.scope 获取当前节点的作用域信息
path.isXxx 判断当前节点是不是 xx 类型
path.assertXxx 判断当前节点是不是 xx 类型,不是则抛出异常
isXxx、assertXxx 系列方法可以用于判断 AST 类型
path.insertBefore、path.insertAfter 插入节点
path.replaceWith、path.replaceWithMultiple、replaceWithSourceString 替换节点
path.remove 删除节点
这些方法可以对 AST 进行增删改

参数 state 可以在不同节点之间传递数据。

  • @babel/types

创建 AST 和判断 AST 的类型。 如果要创建 IfStatement 就可以调用 t.ifStatement(test, consequent, alternate);

而判断节点是否是 IfStatement 就可以调用 isIfStatement 或者 assertIfStatement t.isIfStatement(node, opts);t.assertIfStatement(node, opts);

  • @babel/template

批量创建节点

const ast = template(code, [opts])(args);
const ast = template.ast(code, [opts]);
const ast = template.program(code, [opts]);
  • @babel/generate

打印成目标代码字符串

const { code, map } = generate(ast, { sourceMaps: true })
function (ast: Object, opts: Object, code: string): {code, map} 
  • @babel/core

基于上面的包来完成 babel 的编译流程,可以从源码字符串、源码文件、AST 开始。

npm i @babel/core @babel-cli
  • package.json配置
{
  "scripts": {
    "build": "babel src -d dist"
  }, 
}
  • webpack中需要配置babel-loader
// webpack.config.js
const path = require('path');
module.exports = {
    entry: './src/app.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js'
    },
    module: {
        rules: [
            { test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader' }
        ]
    }
};

我们需要告诉 babel 要怎么去编译,编译哪些内容。 配置文件的方式有以下几种:

  • 在package.json中设置babel字段。
//package.json
{
   "name":"babel-test",
   "version":"1.0.0",
   "devDependencies": {
       "@babel/core":"^7.4.5",
       "@babel/cli":"^7.4.4",
       "@babel/preset-env":"^7.4.5"
   }
   "babel": {
       "presets": ["@babel/preset-env"]
   }
}

  • .babelrc 文件或 .babelrc.js

.babelrc

{
    "presets": ["@babel/preset-env"]
}

.babelrc.js

//webpack的配置文件也是这种写法
module.exports = {
    presets: ['@babel/preset-env']
};
  • babel.config.js文件

同.babelrc.js,但是babel.config.js是针对整个项目。

通过配置文件告诉 babel 编译哪些内容,然后还要引入对应的编译插件(Plugins),比如箭头函数的转换需要的是 @babel/plugin-transform-arrow-functions 这个插件。

npm i @babel/plugin-transform-arrow-functions 
// babel.config.js
module.exports = {
    plugins: ['@babel/plugin-transform-arrow-functions']
};

现在我们代码中的箭头函数就会被编译成普通函数。

预设(Presets)

当 plugin 比较多或者 plugin 的 options 比较多的时候就会导致使用成本升高。这时候可以封装成一个 preset,用户可以通过 preset 来批量引入 plugin 并进行一些配置。preset 就是对 babel 配置的一层封装。

只要安装这一个preset,就会根据你设置的目标浏览器,自动将代码中的新特性转换成目标浏览器支持的代码。

npm i @babel/preset-env
// babel.config.js
module.exports = {
    presets: [
        [
            '@babel/preset-env',
            {
                targets: {
                    chrome: '58'
                }
            }
        ]
    ]
};

plugin-transform-runtime 和 runtime 插件

当用 babel 编译 class 的时候,需要一些工具函数来辅助实现。

class People{
}

// babel编译后
'use strict';

function _classCallCheck(instance, Constructor) {
    if (!(instance instanceof Constructor)) {
        throw new TypeError('Cannot call a class as a function');
    }
}

var People = function People() {
    _classCallCheck(this, People);
};

每个 class 都会生成 _classCallCheck,最后就会产生大量重复代码。plugin-transform-runtime就是为了解决这个问题。

npm i @babel/plugin-transform-runtime
//生产依赖
npm i @babel/runtime
module.exports = {
    presets: ['@babel/preset-env'],
    plugins: ['@babel/plugin-transform-runtime']
};
"use strict";

// babel 编译后
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));

var People = function People() {
  (0, _classCallCheck2["default"])(this, People);
};

babel-polyfill

babel可以转化一些新的特性,但是对于新的内置函数(Promise,Set,Map),静态方法(Array.from,Object.assign),实例方法(Array.prototype.includes)这些就需要babel-polyfill来解决,babel-polyfill会完整模拟一个 ES2015+环境。

因为 @babel/polyfill 体积比较大,整体引入既增加项目体积,又污染了过多的变量,所以更推荐使用 preset-env 来按需引入polyfill。

// corejs 是一个给低版本的浏览器提供接口的库
npm i core-js@2
// babel.config.js
module.exports = {
    presets: [
        [
            '@babel/preset-env',
            {
                useBuiltIns: 'usage', // usage-按需引入 entry-入口引入(整体引入) false-不引入polyfill
                corejs: 2  // 2-corejs@2  3-corejs@3
            }
        ]
    ]
};

const a = Array.from([1])

//babel编译后
"use strict";

require("core-js/modules/es6.string.iterator");

require("core-js/modules/es6.array.from");

var a = Array.from([1]); 

一个简单案例

希望通过 babel 能够自动在 console.log 等 api 中插入文件名和行列号的参数,方便定位到代码,这段代码不影响其他逻辑,这种函数插入不影响逻辑的代码的手段叫做函数插桩。

思路: 在遍历 AST 的时候对 console.log 等 api 自动插入一些参数,也就是要通过 visitor 指定对函数调用表达式 CallExpression 做一些处理。CallExrpession 节点有两个属性,callee 和 arguments,分别对应调用的函数名和参数, 所以我们要判断当 callee 是 console.xx 时,在 arguments 的数组中插入一个 AST 节点,创建 AST 节点需要用到 @babel/types 包。

主要代码:

const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous',
    plugins: ['jsx']
});

const targetCalleeName = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`);
traverse(ast, {
    CallExpression(path, state) {
        const calleeName = generate(path.node.callee).code;
         if (targetCalleeName.includes(calleeName)) {
            const { line, column } = path.node.loc.start;
            path.node.arguments.unshift(types.stringLiteral(`filename: (${line}, ${column})`))
        }
    }
});

// babel编译前
const sourceCode = `
    console.log(1);

    function func() {
        console.info(2);
    }

    export default class Clazz {
        say() {
            console.debug(3);
        }
    }
`;

// babel编译后
console.log("filename: (2, 4)", 1);

function func() {
  console.info("filename: (5, 8)", 2);
}

export default class Clazz {
  say() {
    console.debug("filename: (10, 12)", 3);
  }
}


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK