webpack的esm

4,965 阅读5分钟

关于webpack我心中有很多疑问。。。 本篇文章就简单看下webpack如何处理esm,尽力地了解下webpack的esm大致实现ass we can

本次分析的webpack版本为4.41.2

热身

  1. 了解tapable 用途类似EventEmitter (至于为啥要搞这个,不直接在EventEmitter基础上扩展sync,async,waterfall...,我猜测可能是为了调试方便)
  2. 了解下webpack流程(webpack 3.x)
    了解下webpack是 通过若干个关键类的"钩子",分为各个“核心阶段",组成它打包的流程
  3. 了解webpack-sources 提供几种类型CachedSource, PrefixSource, ConcatSource, ReplaceSource, 它们可以组合使用,方便对代码进行添加、替换、连接等操作 同时又含有一些source-map相关,updateHash等api 供webpack内部调用

示例

简单点说,本次就是想探知下webpack如何将如下示例(单entry的esm模块),经过build后生成bundle.js

src/index.js

// 引入hello函数
import sayHello from './hello.js'

let str = sayHello('1')
console.log(str)

上面代码共有3条语句

src/hello.js

export default function sayHelloFn(...args) {
  return 'hello, 参数值: ' + args[0]
}

上面代码共有1条语句 webpack配置就采用最基础的就行了

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry: {
    app: path.resolve(__dirname, './src/index.js')
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
}

经过分析,大致可以分为两个过程

parse过程

parse过程流程上是发生在Compilation的buildModule钩子之后,具体代码在NormalModule类的doBuild回调中 Parser.parse, 调用的是Parser的parse静态方法,而此方法中可以看出webpack使用了 acorn 模块来进行解析成 ast

接下来就是核心的部分

if (this.hooks.program.call(ast, comments) === undefined) {
  this.detectStrictMode(ast.body);
  // Prewalking iterates the scope for variable declarations
  this.prewalkStatements(ast.body);
  // Block-Prewalking iterates the scope for block variable declarations
  this.blockPrewalkStatements(ast.body);
  // Walking iterates the statements and expressions and processes them
  this.walkStatements(ast.body);
}

这里主要工作就是 遍历模块ast中的一条一条statement, 遇到一些类型做一些处理,并且可能调用预先在evaluate钩子(Parser构造函数中定义的HookMap)上为各种"表达式"tap的处理函数,这些表达式为"Literal", "LogicalExpression", "BinaryExpression", "UnaryExpression"... 同时会调用插件(HarmonyImportDependencyParserPlugin, HarmonyExportDependencyParserPlugin...)中的一些钩子,让外部插件做一些相对应的处理
其中prewalk, blockPrewalk, walk过程均要对每条语句进行解析
预解析时会操作scope对象,所以在这里处理import, export有点类似js的作用域提升

this.scope = {
  topLevelScope: true,
  inTry: false,
  inShorthand: false,
  isStrict: false,
  definitions: new StackedSetMap(),
  renames: new StackedSetMap()
};

也就是即使这样写,也是没问题的

let str = sayHello('1')
console.log(str)
import sayHello from './hello.js'

下面做些主要的分析

index.js

index ast

第一条语句

第二条语句

第三条语句

prewalk过程 第一条语句
 预解析语句类型时,即statement.type为ImportDeclaration,由调用prewalkImportDeclaration方法来处理
 获取source = statement.source.value,这里为 './hello.js,this.hooks.import.call(statement, source)

调用import钩子

  parser.state.lastHarmonyImportOrder = (parser.state.lastHarmonyImportOrder || 0) + 1
  添加ConstDependency到模块依赖中
  添加HarmonyImportSideEffectDependency到模块依赖中

 遍历 .specifiers, 即预解析指示符
 this.scope.renames.set('sayHello', null)
 this.scope.definitions.add('sayHello')
 判断类型,即specifier.type为ImportDefaultSpecifier,会调用this.hooks.importSpecifier.call(statement, source, 'default', name)

调用importSpecifier钩子
    parser.scope.definitions.delete('sayHello')
    parser.scope.renames.set('sayHello', 'imported var')
    parser.state.harmonySpecifier.set('sayHello', {
      source: './hello.js',  
      id: 'default',  
      // 1  
      sourceOrder: parser.state.lastHarmonyImportOrder  
    })

blockPrewalk过程 第二条语句
 预解析语句类型,即statement.type为VariableDeclaration,会调用blockPrewalkVariableDeclaration方法
 遍历 .declarations,这里就一个
 根据declarator的情况,this.scope.renames.set('str', null); this.scope.definitions.add('str')

walk过程 第二条语句
 解析到sayHello, 发现其类型为Identifier,会获取钩子并调用 callHook = this.hooks.call.get('imported var'); callHook.call(expression)

调用call钩子

  parser.state.harmonySpecifier.get('sayHello'),其在importSpecifier钩子中set,作为参数传给HarmonyImportSpecifierDependency
  添加HarmonyImportSpecifierDependency到模块依赖中

hello.js

hello.js ast

一条语句

prewalk过程
 预解析语句函数声明的类型时,即statement.declaration.type为FunctionDeclartion,会调用钩子this.hooks.exportSpecifier.call(statement, 'sayHelloFn', 'default')

调用exportSpecifier钩子

  添加HarmonyExportSpecifierDependency到模块依赖

walk过程
 解析语句的类型时,即statement.type为ExportDefaultDeclaration, 会调用钩子 this.hooks.export.call(statement)

调用export钩子

  添加HarmonyExportHeaderDependency到模块依赖

 解析语句声明类型时,即statement.declarion.type为FunctionDeclartion,会调用钩子this.hooks.exportDeclaration.call(statement, statement.declaration)

调用exportDeclaration钩子

  钩子空载 即空函数目前不做处理

可以看出这些hook调用后,主要在做scope相关处理及添加模块依赖即添加到当前模块对象的属性dependencies列表中,留待后面的流程处理,而generate过程时就会对dependencies 中的Dependecy类对应的模板类进行调用

generate过程

genernate过程发生在,Compilation流程的beforeChunkAssets钩子后chunkAsset钩子之前,具体则是在MainTemplate的renderManifest钩子及modules钩子内
至于到这里为什么会先处理hello.js,应该是在buildChunkGraph的时候,判断出了hello.js是index.js依赖的模块,关于这个问题,以后再分析吧

接下来简述模板调用过程 (注意哦,这里发现了一个"bug",HarmonyCompatibilityDependency对应的模板类叫HarmonyExportDependencyTemplate,HarmonyExportHeaderDependency对应的模板类也叫HarmonyExportDependencyTemplate,什么是国际找bug工程师啊?战术后仰)

hello.js

(1) HarmonyCompatibilityDependency 它对应的Template类为HarmonyCompatibilityDependency
调用apply

// 调用runtimeTemplate的defineEsModuleFlagStatement, 其中参数exportsArgument为"__webpack_exports__"
// 得到 content = "__webpack_require__.r(__webpack_exports__)"
const content = runtime.defineEsModuleFlagStatement({
  exportsArgument: dep.originModule.exportsArgument
});

source.insert(-10, content)  // -10表示优先级,越小越会靠前执行

(2) HarmonyInitDependency 它对应的Template类为HarmonyInitDependencyTemplate
调用apply
 对module.dependencies遍历,尝试调用对应的template的getHarmonyInitOrder方法,获取order
 接着优先根据order,其次根据template所在位置,排序list,按从小到大的顺序
 再对list遍历,依次调用template的harmonyInit方法

(2.1) HarmonyExportSpecifierDependency 它对应的Template类 HarmonyExportSpecifierDependencyTemplate
调用getHarmonyInitOrder
 直接返回0 返回给HarmonyInitDependencyTemplate中
调用harmonyInit

const content = `/* harmony export (binding) */ webpack_require.d({exportsName}, {JSON.stringify(
  used
)}, function() { return ${dep.id}; });\n`

source.insert(-1, content);  // -1表示优先级,越小越会靠前执行

(3) HarmonyExportHeaderDependency 它对应的Template类为HarmonyExportDependencyTemplate (x)
调用apply

source.replace(dep.rangeStatement[0], replaceUntil, content);

实际作用就是将 "export default"替换为空字符串

经过处理后的代码

__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "default", function() { return sayHelloFn; });
function sayHelloFn(...args) {
  return 'hello, 参数值: ' + args[0]
}

index.js 其中第5行(Dependency为-表示没有)并不在模块依赖中,这里仅展示

(1) HarmonyCompatibilityDependency 它对应的Template类为HarmonyCompatibilityDependency
调用apply
 同hello.js (1)

(2) HarmonyInitDependency 它对应的Template类为HarmonyInitDependencyTemplate
调用apply
 同hello.js (2) 调用模板的getHarmonyInitOrder,harmonyInit方法

(2.1) HarmonyImportSideEffectDependency继承自HarmonyImportDependency,它对应的Template类HarmonyImportSideEffectDependencyTemplate它继承自HarmonyImportDependencyTemplate
调用getHarmonyInitOrder
 返回dep.sourceOrder,该属性是添加模块依赖new HarmonyImportSideEffectDependency时传入,其值为parser.state.lastHarmonyImportOrder,此处为1
调用getHarmonyInit

let sourceInfo = importEmittedMap.get(source);
if (!sourceInfo) {
  importEmittedMap.set(
    source,
    (sourceInfo = {
      emittedImports: new Map()
    })
  );
}
const key = dep._module || dep.request;
if (key && sourceInfo.emittedImports.get(key)) return;
sourceInfo.emittedImports.set(key, true);
// dep为HarmonyImportSideEffectDependency实例,getImportStatement方法是在HarmonyImportSideEffectDependency父类中定义
const content = dep.getImportStatement(false, runtime);
source.insert(-1, content);

这里的content为/* harmony import */ var _hello_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./hello.js */ "./analysis/harmonymodule-analysis/hello.js");

(2.2) HarmonyImportSpecifierDependency继承自HarmonyImportDependency,它对应的Template类HarmonyImportSideEffectDependencyTemplate它继承自HarmonyImportDependencyTemplate
调用getHarmonyInitOrder
 同(2.1)
调用getHarmonyInit

// 由于(2.1)
// 此条件为true 不做处理返回
if (key && sourceInfo.emittedImports.get(key)) return;

(3) ConstDependency 它对应的Template类为ConstDependencyTemplate
调用apply

// dep即为ConstDependency实例
source.replace(dep.range[0], dep.range[1] - 1, dep.expression);

实际作用是将位置13~46(import sayHello from './hello.js' )替换为空字符串

(4) HarmonyImportSpecifierDependency继承自HarmonyImportDependency,它对应的Template类HarmonyImportSideEffectDependencyTemplate它继承自HarmonyImportDependencyTemplate

HarmonyImportSpecifierDependency (2.2)不是处理过了吗? 没看错它的模板类继承自HarmonyImportDependencyTemplate其apply方法为空函数,getHarmonyInitOrder,getHarmonyInit却有定义,所以这里apply调用是HarmonyImportSpecifierDependencyTemplate中的apply方法
调用apply

// dep即为HarmonyImportSpecifierDependency实例
const content = this.getContent(dep, runtime);
source.replace(dep.range[0], dep.range[1] - 1, content)

它的作用是将位置58~65(sayHello)替换为Object(_hello_js__WEBPACK_IMPORTED_MODULE_0__["default"])

经过处理后的代码

__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _hello_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./hello.js */ "./analysis/harmonymodule-analysis/hello.js");
// 引入hello函数

let str = Object(_hello_js__WEBPACK_IMPORTED_MODULE_0__["default"])('1')
console.log(str)

最后它们会在MainTemplate的render钩子中与bootstrap代码拼接成整个bundle

一个疑问

前面不是说有很多疑问吗?看到这,你心中有什么疑问?come in... 没有?好吧,我来提个问题吧

问:webpack会解析esm,babel-loader也会让babel将ES6转为ES5,那它们在转换esm时岂不是"冲突"了?
webpack添加配置

module: {
  rules: [{
    test: /\.js$/,
    exclude: /node_modules/,
    use: {
      loader: 'babel-loader',
      options: {
        presets: [
          '@babel/preset-env'
        ]
      }
    }
  }]
}

答:实际上babel-loader会扩展选项 { caller: Object.assign({ name: "babel-loader", supportsStaticESM: true, supportsDynamicImport: true }, opts.caller) } 这是babel7提供的caller metadata特性,这样@babel/core就会传递给presets/plugins,这里@babel/preset-env就不会使用@babel/transform-modules-commonjs插件去转换import export代码了,而这里的parse过程都在runLoader之后。

总结

联系我们日常工作,就好比一个项目(module),分析需求后(parse),来决定需要几个角色(Dependency),如设计、前端、后端,它们各司其职来完成(generate)项目。有的项目呢可能只需要前端和后端,而有些前端呢甚至还能帮一下后端,后端可能就说了,你帮了我就没饭碗了,后端就赶紧先完成了手头工作(HarmonyImportSideEffectDependency和HarmonyImportSpecifierDependency),当然项目(hello.js)和项目(index.js)之间也是有依赖的。

其他的先埋个坑,待以后再去探索吧。