实现webpack对某段代码不编译与原理浅析

1,976 阅读3分钟

背景是希望在特定条件下加载对应模块,条件范围外这个模块可能根本不存在。

大致意图是在A环境加载A模块(此时B模块的路径不存在,):

if (特殊环境A) {
    require('moduleA');
} else {
    require('module')
}

运行时自然是支持的,而编译时是个问题。对webpack来说无论同步还是异步加载,都会提前对模块进行编译。

接下来以require为例(import()类似,最后说明),实现webpack构建时的某段代码不编译。

1.基于静态条件

先举个极端的例子,例如:

if (true) {
    console.log('ok');
} else {
    require('@#$%^&');
}

// 'ok'

这段代码直接在浏览器运行自然是没有问题的。

来看看webpack构建结果:

生产模式下:

if (true) {
    console.log('ok');
} else {
}

开发模式下:

eval("if (true) {\n  console.log('ok');\n} else {}\n\n//# sourceURL=webpack:///./src/apps/adhome/main.js?");

可见if条件为false的语句在构建结果中都没生成,直接被干掉了(原因后面再说)。构建与运行时自然也都没有问题。

2.基于变量表达式

当然在实际应用中不太可能写成固定boolean,我们换成基于变量的表达式再试试:

const flag = false;

if (flag) {
    require('@#$%^&*');
}

浏览器中运行依然没有问题。再看看webpack构建结果:

生产模式直接报错了:

[Build failed]. ModuleNotFoundError: Module not found: Error: Can't resolve '@#$%^&*' 

修改webpack配置bail: true,强行编译一下,看看devlopment模式的构建结果:

eval("var flag = false;\n\nif (flag) {\n  __webpack_require__(!(function webpackMissingModule() { var e = new Error(\"Cannot find module '@#$%^&*'\"); e.code = 'MODULE_NOT_FOUND'; throw e; }()));\n}\n\n//# sourceURL=webpack:///./src/apps/adhome/main.js?");

基于静态分析的webpack并不能感知到”变“量的值,依旧对条件内进行了全量的编译构建。自然__webpack_require__一个找不到的module,产生报错。

修改成一个有效路径,看看production模式产出是怎样的。

// test.js
export const name = 'test';

// index.js
const flag = false;
if (flag) {
    const { name } = require('./test');
    console.log(name);
}

构建结果:

var flag = false;
if (flag) {
    var _require = __webpack_require__(12),      name = _require.name;
    console.log(name);
}

// ...


/* 12 */
/***/ (function(module, exports, __webpack_require__) {
    // ...Object.defineProperty(exports, "__esModule", {  value: true});
    exports.name = void 0;
    var name = 'test';
    exports.name = name;
/***/ })

所以对于一个基于变量的表达式条件,require虽然在运行时不执行,但模块依然会被提前编译

因为这对webpack来说并不知晓。bundler不会排除这段”dead branch“因为它无法确认"if 条件声明”中是个准确的值。

既然无法排除,就会正常编译。复习一下webpack的加载逻辑:

(function(modules) { 

})
([
/* 0 */ (function () {}),/* 1 */ (function () {})
])

modules把模块提前编译,无论是基于require的同步加载,还是基于import()的异步加载。

回到正题,如果我们能在构建时就得到一个boolean值,是否就可以实现文章最开始的效果。

我们通过DefinePlugin定义构建时变量

new DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), // production
}),

加载条件改为

if (process.env.NODE_ENV === '$$$') {
    require('@#$%^&');
}

构建成功,结果为:

if (false) {}

所以,我理解在编译阶段实现某段代码的不编译,通过DefinePlugin定义构建时变量+模块动态加载(require/import())

然而,webpack在对if语句的条件表达式处理上,还有一些”小设计“。不规范的构建时变量定义,甚至会引发问题。

3.基于AST的表达式处理

如果我们把条件改为

if (process.env.NODE_ENV === 2) {
    require('@#$%^&');
}

理论上也是ok的,因为无论process.env.NODE_ENV是'production'还是'development',表达式亦为false。然而编译报错了:

[Build failed]. ModuleNotFoundError: Module not found: Error: Can't resolve '@#$%^&'

我们还是看看产出是什么(如果构建被中断看不到产出,可以先换成一个存在的地址):

if ("production" === 2) {
    __webpack_require__(1);
}

表达式并没有转译成一个boolean,且条件语句还出现__webpack_require__(1)。这就意味着如果是个不存在module,在编译过程中即会报错。

直接换两个简单例子看看编译结果:

// 代码
if (2 === '3') {  console.log('inner log');}

if (2 === 3) {  console.log('inner log');}


// 编译结果

if (2 === '3') {  console.log('inner log');}

if (false) {}

结果竟然不一样。

既然webpack有替换表达式成boolean类型结果的能力,怀疑是在ast parse的时候做了些工作。

在webpack parser里找找答案。

可见对于我们的运算符===来说,只有左右类型相同才会更新结果布尔值。

对于 2 === '3',bool是null。

对于2 === 3,bool有了值且为false。

所以,在我们前面的结论中还要再加一个限定条件。**表达式运算符左右需为同类型,才能直接在构建结果中转换成boolean值。**否则形如if(2 === ‘3’) 还是会编译内部require的。

特别是对于一些类型定义不规范或值为undefined的构建时变量,都不会转换成boolean值而产生错误。注意事项再参考下:webpack.docschina.org/plugins/def…

所以,更稳妥的办法最好直接把构建时变量声明成一个布尔值:

// webpack.config.js
new webpack.DefinePlugin({
  'IS_PROD': process.env.NODE_ENV === 'production'
});

4.对“dead branch”做了什么?

ok我们实现了预期效果,那么webpack到底做了什么,使得所谓IfStatement的“dead branch”被替换成了“{}”?

// source code
if (false) {
    (async () => {
        await import('@#¥%……&*');
    })();
}

// 怎么就被编译成了??
if (false) {}

在Compiler里打个断点,发现到如图位置content已然被处理过,成了“if (false) {}”

'/******/ (function(modules) { // webpackBootstrap\n/******/ \t// The module cache\n/******/ \tvar installedModules = {};\n/******/\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(installedModules[moduleId]) {\n/******/ \t\t\treturn installedModules[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = installedModules[moduleId] = {\n/******/…property) { return Object.prototype.hasOwnProperty.call(object, property); };\n/******/\n/******/ \t// __webpack_public_path__\n/******/ \t__webpack_require__.p = "/";\n/******/\n/******/\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(__webpack_require__.s = 0);\n/******/ })\n/************************************************************************/\n/******/ ([\n/* 0 */\n/***/ (function(module, exports, __webpack_require__) {\n\n\nif (false) {}\n\n\n/***/ })\n/******/ ]);'

进source看看,逻辑是如果有_cachedSource直接读取缓存。否则执行并写入缓存。

进入_replaceString。发现参数str还是我们写的,输出resultStr就变了。有意思的是this.replacements竟然已经把要替换的部分算好了。

继续追踪修改this.replacements的堆栈。发现来自于与替换相关的expression以及range来自于this.dependencies。

ConstDependency似乎就是我们想要的,我们寻找下哪里对ConstDependency进行了实例化。

激动人心的时刻到了。找到ConstPlugin,发现handler里基于parser.hooks的几个包含statementIf、expressionConditionalOperator、expressionLogicalOperator等方法。那我们重点看statementIf,其他也涉及dead branch的处理,感兴趣的同学可以去看看。

parser.hooks.statementIf.tap("ConstPlugin", statement => {
    // ...
    const param = parser.evaluateExpression(statement.test);
    // 对应ast parse生成的BasicEvaluatedExpression结果,这里即为false
    const bool = param.asBool();
    if (typeof bool === "boolean") {
        // ...
        const branchToRemove = bool
            ? statement.alternate
            : statement.consequent;
        if (branchToRemove) {
            // Before removing the dead branch, the hoisted declarations
            // must be collected.
            let declarations;
            if (parser.scope.isStrict) {
                // If the code runs in strict mode, variable declarations
                // using `var` must be hoisted.
                declarations = getHoistedDeclarations(branchToRemove, false);
            } else {
                // Otherwise, collect all hoisted declaration.
                declarations = getHoistedDeclarations(branchToRemove, true);
            }
            let replacement;
            if (declarations.length > 0) {
                // 保留声明
                replacement = `{ var ${declarations.join(", ")}; }`;
            } else {
                // 也就是我们得到的最终结果
                replacement = "{}";
            }
            const dep = new ConstDependency(
                replacement,
                branchToRemove.range
            );
            dep.loc = branchToRemove.loc;
            parser.state.current.addDependency(dep);
        }
        return bool;
    }
});

bool取值即为前面提到的BasicEvaluatedExpression的this.bool。

也就是说,dead branch 移除核心基于ast parse后的表达式返回值结果 + 字符串替换实现与tree shaking基于es module静态分析的注释 + TerserPlugin****擦除有所不同

5.再说说import()

其实对import()而言与require区别即为一个是异步加载,一个是同步加载。

基于async/await一样可以同步来写嘛:

(async () => {
    if (false) {
        await import('@#$%^&');
    }
})();

又报错了。

这样一定是没问题的

if (false) {
    import('@#$%^&').then(data => console.log(data));
}

那我们给async/await再换个写法

(async () => {
    if (false) {
        // await import('@#$%^&');
        import('@#$%^&').then(data => console.log(data));
    }
})();

这样是ok的,编译结果出现了我们熟悉的 ” if (false) {} “

_asyncToGenerator( /*#__PURE__*/ _regeneratorRuntime.mark(function _callee() {
    return _regeneratorRuntime.wrap(function _callee$(_context) {
        while (1) {
            switch (_context.prev = _context.next) {
                case 0:
                    if (false) {}
                    case 1:
                    case "end":
                        return _context.stop();
            }
        }
    }, _callee);
}))();

报错的写法我们换一个有效路径看看产出:

_asyncToGenerator( /*#__PURE__*/ _regeneratorRuntime.mark(function _callee() {
    return _regeneratorRuntime.wrap(function _callee$(_context) {
        while (1) {
            switch (_context.prev = _context.next) {
                case 0:
                    if (true) {
                        _context.next = 3;
                        break;
                    }
                    _context.next = 3;
                    return __webpack_require__.e( /* import() */ 2).then(__webpack_require__.bind(null, 100));
                case 3:
                case "end":
                    return _context.stop();
            }
        }
    }, _callee);
}))();

虽然从运行时角度看运行不到__webpack_require__.e,还是被webpack提前编译了。

看起来应该是babel preset把代码处理成这样了。

注释掉@babel/preset-env试试,果不其然就正常了。

限制一下babel。调整一下写法,让babel编译在条件语句内部就好了。

if (false) {
    (async () => {
        await import('@#¥%……&*');
    })();
}

6.结论

实现webpack在编译阶段的某段代码不编译,可以基于if语句的“dead branch”,通过定义构建时变量+动态模块加载。

所谓“dead branch”去除原理基于AST分析与字符串替换。狭义上与基于模块的tree shaking有所不同。

babel对IIAFE(Immediately Invoked Async Function Expression)to generator协程的处理中,内部的if 语句表达会在编译过程中有所变化。可能会对webpack编译过程中的"dead branch"分析产生影响。