DTeam 技术日志

Doer、Delivery、Dream

浅谈 babel

小纪同学 Posted at — Jun 29, 2021 阅读

什么是 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 的结构能够理解代码。理解了代码之后,除了进行转换然后生成目标代码之外,也同样可以用于分析代码的信息,进行一些检查。

babel 的编译流程

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

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 的公共属性

babel api

它提供了有两个 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"
  ]
});

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 可以在不同节点之间传递数据。

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

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

批量创建节点

const ast = template(code, [opts])(args);
const ast = template.ast(code, [opts]);
const ast = template.program(code, [opts]);

打印成目标代码字符串

const { code, map } = generate(ast, { sourceMaps: true })
function (ast: Object, opts: Object, code: string): {code, map} 

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

使用方式

  1. 下载
npm i @babel/core @babel-cli
{
  "scripts": {
    "build": "babel src -d dist"
  }, 
}
// 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' }
        ]
    }
};
  1. 配置

我们需要告诉 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

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

.babelrc.js

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

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

  1. 使用

通过配置文件告诉 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);
  }
}


友情链接


相关文章