使用babel避免webpack编译运行时模块依赖

栏目: 编程工具 · 发布时间: 8年前

内容简介:babel是一个非常强大的工具,作用远不止我们平时的ES6 -> ES5语法转换这么单一。在前端进阶的道路上,了解与学习babel及其灵活的插件模式将会为前端赋予更多的可能性。本文就是运用babel,通过编写babel插件解决了一个实际项目中的问题。本文相关代码已托管至github:

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语法,因此需要将它变为在前端运行时我们自定义的模块依赖语法。

使用babel避免webpack编译运行时模块依赖

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

3.1. babel相关工具介绍

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

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

使用babel避免webpack编译运行时模块依赖

由于篇幅限制,本文不会对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的灵活、强大,它给前端带来的更多的空间与可能性,在许多衍生的领域也都能发现它的身影。希望本文能成为一个引子,为你拓展解决问题的另一条思路。

参考资料


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Neural Networks for Applied Sciences and Engineering

Neural Networks for Applied Sciences and Engineering

Samarasinghe, Sandhya / CRC Pr I Llc / 2006-9 / $ 118.59

In response to the exponentially increasing need to analyze vast amounts of data, Neural Networks for Applied Sciences and Engineering: From Fundamentals to Complex Pattern Recognition provides scient......一起来看看 《Neural Networks for Applied Sciences and Engineering》 这本书的介绍吧!

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具