【webpack进阶】使用babel避免webpack编译运行时模块依赖

引言

babel是一个非常强大的工具,作用远不止我们平时的ES6 -> ES5语法转换这么单一。在前端进阶的道路上,了解与学习babel及其灵活的插件模式将会为前端赋予更多的可能性。

本文就是运用babel,通过编写babel插件解决了一个实际项目中的问题。

本文相关代码已托管至github: babel-plugin-import-customized-require

1. 遇到的问题

最近在项目中遇到这样一个问题:我们知道,使用webpack作为构建工具是会默认自动帮我们进行依赖构建;但是在项目代码中,有一部分的依赖是运行时依赖/非编译期依赖(可以理解为像requirejs、seajs那样的纯前端模块化),对于这种依赖不做处理会导致webpack编译出错。

为什么需要非编译期依赖呢?例如,在当前的业务模块(一个独立的webpack代码仓库)里,我依赖了一个公共业务模块的打点代码

// 这是home业务模块代码
// 依赖了common业务模块的代码
import log from 'common:util/log.js'

log('act-1');

然而,可能是由于技术栈不统一,或是因为common业务代码遗留问题无法重构,或者仅仅是为了业务模块的分治……总之,无法在webpack编译期解决这部分模块依赖,而是需要放在前端运行时框架解决。

为了解决webpack编译期无法解析这种模块依赖的问题,可以给这种非编译期依赖引入新的语法,例如下面这样:

// __my_require__是我们自定义的前端require方法
var log = __my_require__('common:util/log.js')

log('act-1');

但这样就导致了我们代码形式的分裂,拥抱规范让我们希望还是能够用ESM的标准语法来一视同仁。

我们还是希望能像下面这样写代码:

// 标准的ESM语法
import * as log from 'common:util/log.js';

log('act-1');

此外,也可以考虑使用webpack提供了externals配置来避免某些模块被webpack打包。然而,一个重要的问题是,在已有的common代码中有一套前端模块化语法,要将webpack编译出来的代码与已有模式融合存在一些问题。因此该方式也存在不足。

针对上面的描述,总结来说,我们的目的就是:

  • 能够在代码中使用ESM语法,来进行非编译期分析的模块引用
  • 由于webpack会尝试打包该依赖,需要不会在编译期出错

2. 解决思路

基于上面的目标,首先,我们需要有一种方式能够标识不需要编译的运行期依赖。例如util/record这个模块,如果是运行时依赖,可以参考标准语法,为模块名添加标识:runtime:util/record。效果如下:

// 下面这两行是正常的编译期依赖
import React from 'react';
import Nav from './component/nav';

// 下面这两个模块,我们不希望webpack在编译期进行处理
import record from 'runtime:util/record';
import {Banner, List} from 'runtime:ui/layout/component';

其次,虽然标识已经可以让开发人员知道代码里哪些模块是webpack需要打包的依赖,哪些是非编译期依赖;但webpack不知道,它只会拿到模块源码,分析import语法拿到依赖,然后尝试加载依赖模块。但这时webpack傻眼了,因为像runtime:util/record这样的模块是运行时依赖,编译期找不到该模块。那么,就需要通过一种方式,让webpack“看不见”非编译期的依赖。

最后,拿到非编译期依赖,由于浏览器现在还不支持ESM的import语法,因此需要将它变为在前端运行时我们自定义的模块依赖语法。

image

3. 使用babel对源码进行分析

3.1. babel相关工具介绍

对babel以及插件机制不太了解的同学,可以先看这一部分做一个简单的了解。

babel是一个强大的javascript compiler,可以将源码通过词法分析与语法分析转换为AST(抽象语法树),通过对AST进行转换,可以修改源码,最后再将修改后的AST转换会目标代码。

image

由于篇幅限制,本文不会对compiler或者AST进行过多介绍,但是如果你学过编译原理,那么对词法分析、语法分析、token、AST应该都不会陌生。即使没了解过也没有关系,你可以粗略的理解为:babel是一个compiler,它可以将javascript源码转化为一种特殊的数据结构,这种数据结构就是树,也就是AST,它是一种能够很好表示源码的结构。babel的AST是基于ESTree的。

例如,var alienzhou = 'happy'这条语句,经过babel处理后它的AST大概是下面这样的

{
    type: 'VariableDeclaration',
    kind: 'var',
    // ...其他属性
    decolarations: [{
        type: 'VariableDeclarator',
        id: {
            type: 'Identifier',
            name: 'alienzhou',
            // ...其他属性
        },
        init: {
            type: 'StringLiteral',
            value: 'happy',
            // ...其他属性
        }
    }],
}

这部分AST node表示,这是一条变量声明的语句,使用var关键字,其中id和init属性又是两个AST node,分别是名称为alienzhou的标识符(Identifier)和值为happy的字符串字面量(StringLiteral)。

这里,简单介绍一些如何使用babel及其提供的一些库来进行AST的分析和修改。生成AST可以通过babel-core里的方法,例如:

const babel = require('babel-core');
const {ast} = babel.transform(`var alienzhou = 'happy'`);

然后遍历AST,找到特定的节点进行修改即可。babel也为我们提供了traverse方法来遍历AST:

const traverse = require('babel-traverse').default;

在babel中访问AST node使用的是vistor模式,可以像下面这样指定AST node type来访问所需的AST node:

traverse(ast, {
    StringLiteral(path) {
        console.log(path.node.value)
        // ...
    }
})

这样就可以得到所有的字符串字面量,当然你也可以替换这个节点的内容:

let visitor = {
    StringLiteral(path) {
        console.log(path.node.value)
        path.replaceWith(
            t.stringLiteral('excited');
        )
    }
};
traverse(ast, visitor);

注意,AST是一个mutable对象,所有的节点操作都会在原AST上进行修改。

这篇文章不会详细介绍babel-core、babel-traverse的API,而是帮助没有接触过的朋友快速理解它们,具体的使用方式可以参考相关文档。

由于大部分的webpack项目都会在loader中使用babel,因此只需要提供一个babel的插件来处理非编译期依赖语法即可。而babel插件其实就是导出一个方法,该方法会返回我们上面提到的visitor对象。

那么接下来我们专注于visitor的编写即可。

3.2 编写一个babel插件来解决非编译期依赖

ESM的import语法在AST node type中是ImportDeclaration

export default function () {
    return {
        ImportDeclaration: {
            enter(path) {
                // ...
            }
            exit(path) {
                let source = path.node.source;
                if (t.isStringLiteral(source) && /^runtime:/.test(source.value)) {
                    // ...
                }
            }
        }
    }
}

在enter方法里,需要收集ImportDeclaration语法的相关信息;在exit方法里,判断当前ImportDeclaration是否为非编译期依赖,如果是则进行语法转换。

收集ImportDeclaration语法相关信息需要注意,对于不同的import specifier类型,需要不同的分析方式,下面列举了这五种import:

import util from 'runtime:util';
import * as util from 'runtime:util';
import {util} from 'runtime:util';
import {util as u} from 'runtime:util';
import 'runtime:util';

对应了三类specifier:

  • ImportSpecifier:import {util} from 'runtime:util',import {util as u} from 'runtime:util';
  • ImportDefaultSpecifier:import util from 'runtime:util'
  • ImportNamespaceSpecifier:import * as util from 'runtime:util'

import 'runtime:util'中没有specifier

可以在ImportDeclaration的基础上,对子节点进行traverse,这里新建了一个visitor用来访问Specifier,针对不同语法进行收集:

const specifierVisitor = {
    ImportNamespaceSpecifier(_path) {
        let data = {
            type: 'NAMESPACE',
            local: _path.node.local.name
        };

        this.specifiers.push(data);
    },

    ImportSpecifier(_path) {
        let data = {
            type: 'COMMON',
            local: _path.node.local.name,
            imported: _path.node.imported ? _path.node.imported.name : null
        };

        this.specifiers.push(data);
    },

    ImportDefaultSpecifier(_path) {
        let data = {
            type: 'DEFAULT',
            local: _path.node.local.name
        };

        this.specifiers.push(data);
    }
}

在ImportDeclaration中使用specifierVisitor进行遍历:

export default function () {
    // store the specifiers in one importDeclaration
    let specifiers = [];
    return {
        ImportDeclaration: {
            enter(path) {
                path.traverse(specifierVisitor, { specifiers });
            }
            exit(path) {
                let source = path.node.source;
                if (t.isStringLiteral(source) && /^runtime:/.test(source.value)) {
                    // ...
                }
            }
        }
    }
}

到目前为止,我们在进入ImportDeclaration节点时,收集了import语句相关信息,在退出节点时,通过判断可以知道目前节点是否是非编译期依赖。因此,如果是非编译期依赖,只需要根据收集到的信息替换节点语法即可。

生成新节点可以使用babel-types。不过推荐使用babel-template,会令代码更简便与清晰。下面这个方法,会根据不同的import信息,生成不同的运行时代码,其中假定my_require方法就是自定义的前端模块require方法。

const template = require('babel-template');

function constructRequireModule({
    local,
    type,
    imported,
    moduleName
}) {

    /* using template instead of origin type functions */
    const namespaceTemplate = template(`
        var LOCAL = __my_require__(MODULE_NAME);
    `);

    const commonTemplate = template(`
        var LOCAL = __my_require__(MODULE_NAME)[IMPORTED];
    `);

    const defaultTemplate = template(`
        var LOCAL = __my_require__(MODULE_NAME)['default'];
    `);

    const sideTemplate = template(`
        __my_require__(MODULE_NAME);
    `);
    /* ********************************************** */

    let declaration;
    switch (type) {
        case 'NAMESPACE':
            declaration = namespaceTemplate({
                LOCAL: t.identifier(local),
                MODULE_NAME: t.stringLiteral(moduleName)
            });
            break;

        case 'COMMON':
            imported = imported || local;
            declaration = commonTemplate({
                LOCAL: t.identifier(local),
                MODULE_NAME: t.stringLiteral(moduleName),
                IMPORTED: t.stringLiteral(imported)
            });
            break;

        case 'DEFAULT':
            declaration = defaultTemplate({
                LOCAL: t.identifier(local),
                MODULE_NAME: t.stringLiteral(moduleName)
            });
            break;

        case 'SIDE':
            declaration = sideTemplate({
                MODULE_NAME: t.stringLiteral(moduleName)
            })

        default:
            break;
    }

    return declaration;
}

最后整合到一开始的visitor中:

export default function () {
    // store the specifiers in one importDeclaration
    let specifiers = [];
    return {
        ImportDeclaration: {
            enter(path) {
                path.traverse(specifierVisitor, { specifiers });
            }
            exit(path) {
                let source = path.node.source;
                let moduleName = path.node.source.value;
                if (t.isStringLiteral(source) && /^runtime:/.test(source.value)) {
                    let nodes;
                    if (specifiers.length === 0) {
                        nodes = constructRequireModule({
                            moduleName,
                            type: 'SIDE'
                        });
                        nodes = [nodes]
                    }
                    else {
                        nodes = specifiers.map(constructRequireModule);
                    }
                    path.replaceWithMultiple(nodes);
                }
                specifiers = [];
            }
        }
    }
}

那么,对于一段import util from 'runtime:util'的源码,在该babel插件修改后变为了var util = require('runtime:util')['default'],该代码也会被webpack直接输出。

这样,通过babel插件,我们就完成了文章最一开始的目标。

4. 处理dynamic import

细心的读者肯定会发现了,我们在上面只解决了静态import的问题,那么像下面这样的动态import不是仍然会有以上的问题么?

import('runtime:util').then(u => {
    u.record(1);
});

是的,仍然会有问题。因此,进一步我们还需要处理动态import的语法。要做的就是在visitor中添加一个新的node type:

{
    Import: {
        enter(path) {
            let callNode = path.parentPath.node;
            let nameNode = callNode.arguments && callNode.arguments[0] ? callNode.arguments[0] : null;

            if (t.isCallExpression(callNode)
                && t.isStringLiteral(nameNode)
                && /^runtime:/.test(nameNode.value)
            ) {
                let args = callNode.arguments;
                path.parentPath.replaceWith(
                    t.callExpression(
                        t.memberExpression(
                            t.identifier('__my_require__'), t.identifier('async'), false),
                            args
                ));
            }
        }
    }
}

这时,上面的动态import代码就会被替换为:

__my_require__.async('runtime:util').then(u => {
    u.record(1);
});

非常方便吧。

5. 写在最后

本文相关代码已托管至github: babel-plugin-import-customized-require

本文是从一个关于webpack编译期的需求出发,应用babel来使代码中部分模块依赖不在webpack编译期进行处理。其实从中可以看出,babel给我们赋予了极大的可能性。

文中解决的问题只是一个小需求,也许你会有更不错的解决方案;然而这里更多的是展示了babel的灵活、强大,它给前端带来的更多的空间与可能性,在许多衍生的领域也都能发现它的身影。希望本文能成为一个引子,为你拓展解决问题的另一条思路。

参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266