背景是希望在特定条件下加载对应模块,条件范围外这个模块可能根本不存在。
大致意图是在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"分析产生影响。