all articles

file splitting plugin 1 - try to write a simple babel plugin

2017-10-12 @sunderls

webpack babel js

最近突然想做一个file splitting的功能,具体而言如下:

[compile之前]

在class的方法中添加@grow的decorator

a.js

class A {
    methodA(){
        ...
    }

    @grow
    methodB(){
        ...
    }
}

希望编译结果能和如下等同

a.js

class A {
    methodA() {
        ...
    }

    grow() {
        import(`./b.js`).then(({default: grown}) => {
            Object.assign(this, grown);
        });
    }
}

b.js

export default {
    methodB() {
        ...
    }
}

也就是说通过@grow的指示,一个file被分解为了两个file,这样传递给webpack compile的时候就能够使用dynamic import。

如何做到呢? 我也不知道,现在开始一点点研究,首先我来看看babel plugin如何写。 google一下可以找到如下手册:

Babel 插件手册

参考一下试试。

背景知识

babel是将最新的js 语法转换为es5的transpiler,是一个编译器, 这一点可以参考我的另外一篇文章 compiler in JavaScript 学习笔记 1 - 一个非常简单的compiler

我的理解是: babel接收一段js代码字符串 => 理解分析其语法 => 将某些es6语法转换为es5的语法 => 重新输出代码。

由于我知道一些内容,所以这里不详细说了。具体请到上面的插件手册链接阅读。

实战 - 手册上的现成例子

需要创建两个project,一个是插件babel-plugin-first-plugin和一个测试用的project test

1. 创建文件夹 babel-plugin-first-plugin

> mkdir babel-plugin-first-plugin
> cd babel-plugin-first-plugin
> npm init 
> ...

由于plugin也用es6编写,所以需要install babel和preset

> npm install babel-cli babel-preset-env -D

添加.babelrc

{
    "presets": [
        ["env"]
    ]
}

在package.json写入build命令

"scripts": {
"build": "babel index.js -d lib"
}

也就是说把index.js(es6)编译为lib/下的index.js

然后是最重要的一步,本地开发的module需要link一下,然后被test project使用

> npm link

2. 创建测试project test

> mkdir test
> cd test
> npm init 
> ...
> npm install babel-cli babel-preset-env -D

同样的添加.babelrc,这里加入我们的新plugin.

{
    "presets": [
        ["env"]
    ],

    "plugins": [
        "first-plugin"
    ]
}

然后在package.json中添加build命令

"scripts": {
    "build": "babel index.js -d dist"
  },

然后link我们之前的plugin

> npm link babel-plugin-first-plugin

在index.js中添加如下内容:

foo === bar

测试一下:

> npm run build

可以看出没啥问题,dist/中出现了编译好的文件,只不过内容还是foo === bar 没有变化。

这是因为我们的plugin中啥都没干。

3. 编写plugin

根据手册上所述,first-plugin的index.js中添加如下代码:

export default ({types: t}) => {
    return {
        visitor: {                                          // 一个遍历的处理器,想象为一个在语法树上不停走动的机器人
            BinaryExpression(path) {                        // 当遇到二项式 operand1 operator operand2,比如 a * b , a = b
                if (path.node.operator !== "===") {         // 只处理 a === b
                    return;
                }

                path.node.left = t.identifier("sebmck");    // 将左边改名
                path.node.right = t.identifier("dork");     // 将右边改名
            }
        }
    }
};

在plugin中npm run build,然后在test project中npm run build (以下简称这个过程为「测试一下」), 可以看到编译的结果发生了变化:

"use strict";

sebmck === dork;

嗯,第一个plugin就算完成了!

4. 接入webpack

我们的目标是webpack环境,所以在test 项目中加入webpack

> npm install webpack babel-loader -D

babel-cli可以删除了。

package.json 的build修改为webpack

"scripts": {
    "build": "webpack"
},

添加webpack.config.js

const webpack = require('webpack');

module.exports = {
    entry: {
        index: ['./index.js']
    },

    output: {
        path: __dirname + '/dist/',
        filename: '[name].js'
    },

    module: {
        rules: [
            {
                test: /js$/,
                use: 'babel-loader'
            }
        ]
    }
};

最后测试一下 npm run build,可以看到这次编译的结果多了很多webpack的代码,直接跳到最后几行,可以发现我们的first-plugin仍然有发生了作用。

"use strict";

sebmck === dork;

5. 调查class 语法

首先测试一下class的编译结果

class A {
    methodA() {
        console.log('method A');
    }
}

编译后为

// 创建class的helper function
var _createClass = function () {
    //给一个object添加属性 
    function defineProperties(target, props) { 
        for (var i = 0; i < props.length; i++) { 
            var descriptor = props[i]; 
            descriptor.enumerable = descriptor.enumerable || false; 
            descriptor.configurable = true; 
            if ("value" in descriptor) descriptor.writable = true; 

            Object.defineProperty(target, descriptor.key, descriptor); 
            } 
    } 

    // 给class 构造函数的prototype 添加方法
    return function (Constructor, protoProps, staticProps) { 
        if (protoProps) defineProperties(Constructor.prototype, protoProps); 
        if (staticProps) defineProperties(Constructor, staticProps); 
    return Constructor; }; 
}();

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

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

    _createClass(A, [{
        key: 'methodA',
        value: function methodA() {
            console.log('method A');
        }
    }]);

    return A;
}();

代码有点长,不过其实挺简单。首先理解es5环境下我们是怎么做class的。js下没有严格的class,只有prototype和构造方法。

比如一个构造方法:


function A(){
    this.name = 'name';
}

var a = new A();

为了让所有实例都有某个方法methodA,我们可以在构造方法中直接添加。

function A() {
    this.name = 'name';
    this.yell = function() {
        alert(this.name);
    }
}
var a = new A();
a.yell();

但是这么写每次都会创建一个新的yell方法,更好的写法是将yell写在A的prototype上

function A() {
    this.name = 'name';
}
A.prototype.yell = function() {
    alert(this.name);
}
var a = new A();
a.yell();

所以class编译结果就是把class中定义的method,assign到构造方法到prototype上而已。

然后我们看一看class的语法,我们使用astexplorer:

注意在菜单中选择 babylon6

输入:

class A {
    methodA(){
        console.log('methodA');
    }

    methodB(){
        console.log('methodB')
    }
}

可以看到class的定义如下(部分省略):

ClassDeclaration:
    id:
        name: "A"
    body:
        ClassMethod:
            key:
                name: "methodA"
            value:
                body:
                    body:
                        - ExpressionStatement:
                            expression:
                                ....

嗯,所以找到目标ClassDeclaration就可以了。

6. 调查 decorator的语法

同样在astexplorer中,输入:

class A {
    @grow
    methodA(){
    }
}

可以看到所在的ClassMethod中新增了decorators一栏,其中定义了decorator的语法。

ClassDeclaration:
    id:
        name: "A"
    body:
        ClassMethod:
            key:
                name: "methodA"
            decorators:
                - 
                    expression:
                        name: "grow"

6. 尝试个简单任务

  1. 找到一个有@grow的class
  2. 在class同级别新增一条语句 var sub = {}

这并不难:

export default ({types: t}) => {
    return {
        visitor: {
            // 遇到class的时候进行检查
            ClassDeclaration(path){
                const body = path.node.body.body;

                // 检查class 的body
                const targetMethods = body.filter(item => {
                    // 搜索其中有@grow的class method
                    return item.type === 'ClassMethod' && item.decorators && item.decorators.filter((decorator) => {
                        return decorator.expression.name === 'grow'
                    }).length > 0;
                });

                // 如果找到了这样的class
                if (targetMethods.length > 0) {
                    // 创建一个新的节点,代表 `var sub = {}`
                    const sub = t.variableDeclaration('const', [
                        t.variableDeclarator(t.identifier('sub'), t.objectExpression([]))
                    ]);

                    // 将其插入class的夫节点的body
                    path.parentPath.unshiftContainer('body', sub);
                }
            }
        }
    }
};

有两个api 文档要看

  1. 节点操纵方法,比如path.parentPath.unshiftContainer api在这里
  2. 节点创建方法。这里可以取巧,首先在astexplorer上面输入想要的结果,然后查看其语法树,然后在babel-types的文档上查找对应的创建方法。

测试一下,可以看见如下的结果,完美。

...
var sub = {};
var A = (_class = function () {
    function A() {
        _classCallCheck(this, A);
    }
...

7. 总结

先就到这里,我已经学会了最基本的babel插件知识,然后尝试性的进行了查询和节点操作。

接下来还需要完成的有:

  1. 将@grow的classmethod 移动到sub
  2. 在@grow的class中添加grow方法
  3. 在webpack中将sub输出为不同的chunk

这些内容将在以后继续尝试。