前言 最近深感由于公司项目过于庞大,在开发调试时,改动某处代码,常常会让 devServer 崩溃,需要重新启动打包,打包又要等待至少 5 分钟时间,严重影响开发效率这一弊病。于是乎,周末的时候看看有没有优化打包速度的方法,然后就来到这篇文章的主题了。
正文 所谓的 DLL 其实是一个预编译好的 JS 文件。在使用时除了打包 app 文件的 webpack config 外,需要有一个用于打包 dll 的 webpack cofig 文件。打包 dll 端需要加入 webpack.DllPlugin,app 端需要加入 webpack.DllReferencePlugin。 假如不加入这个 DllPlugin,就只会生成普通的打包好的 JS 文件,加入以后就会多产出一个 manifest.json 文件,表明这个 library 的包信息。 manifest.json 的作用在于在 app 端引入时,配合 webpack.DllReferencePlugin,生成相应的 externals 配置和把 require dll 文件里的模块的路径转成先 require dll 的父模块然后再去 require 子模块的形式。e.g.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 console .log(require ("../dll/alpha" ));__webpack_require__("dll-reference alpha_21c1490edb92ec8e9390" )("./alpha.js" ) __webpack_require__("dll-reference alpha_21c1490edb92ec8e9390" ) function (module , exports ) {eval ("module.exports = alpha_21c1490edb92ec8e9390;\n\n" );})var alpha_21c1490edb92ec8e9390 = (function (modules ) { function __webpack_require__ (moduleId ) { ... return module .exports } return __webpack_require__ })({'./alpha' : ..., ...})
通过 plugin 的配置项进行进一步的讲解 这个 demo 来自于 webpack 官方的 example dll 目录结构如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Downloads/dll │ a.js │ alpha.js │ b.js │ beta.js │ build.js │ c.jsx │ README.md │ template.md │ webpack.config.js │ └───dist alpha-manifest.json beta-manifest.json MyDll.alpha.js MyDll.beta.js
/dll/webpack.config.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 var path = require ('path' );var webpack = require ('webpack' );module .exports = { mode: 'development' , resolve: { extensions: ['.js' , '.jsx' ], }, entry: { alpha: ['./alpha' , './a' , 'module' ], beta: ['./beta' , './b' , './c' ], }, output: { path: path.join(__dirname, 'dist' ), filename: 'MyDll.[name].js' , library: '[name]_[hash]' , }, plugins: [ new webpack.DllPlugin({ path: path.join(__dirname, 'dist' , '[name]-manifest.json' ), name: '[name]_[hash]' , }), ], };
上面的 output.libray 和 DllPlugin 的 options.name 需要一致,假如 output.libray 为'[name]'
,dll 端生成的是var alpha = ...
而 app 端生成的是module.exports = alpha_21c1490edb92ec8e9390
,会对应不上。 DllPlugin 的 options.path:manifest.json 的输出路径 options 里还有一个属性是 context:是一个文件路径,主要作用是 manifest.json 的 content 的 key 会转化为 js 文件路径相对于这个 context 的相对路径。 e.g.假如 alpha.js 的绝对路径是 C:\Users\Logicarlme\Downloads\dll\alpha.js,context 为 C:\Users\Logicarlme\Downloads\dll,那么 key 就等于’./alpha’
app 端的 webpack.config.js 目录结构如下
1 2 3 4 5 6 7 8 9 10 11 12 Downloads/dll-user/webpack.config.js │ build.js │ example.html │ example.js │ math.js │ README.md │ template.md │ webpack.config.js │ ├───dist └───js output.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 var path = require ('path' );var webpack = require ('webpack' );module .exports = { mode: 'development' , entry: path.join(__dirname, 'example.js' ), output: { path: path.join(__dirname, 'js' ), filename: 'output.js' , }, plugins: [ new webpack.DllReferencePlugin({ context: path.join(__dirname, '..' , 'dll' , 'dist' ), manifest: require ('../dll/dist/alpha-manifest.json' ), }), new webpack.DllReferencePlugin({ scope: 'beta' , manifest: require ('../dll/dist/beta-manifest.json' ), extensions: ['.js' , '.jsx' ], }), ], }; console .log(require ('../dll/alpha' ));console .log(require ('../dll/a' ));console .log(require ('beta/beta' ));console .log(require ('beta/b' ));console .log(require ('beta/c' ));
上面require的路径,一种是相对路径../dll/ 一种是scope类路径 beta/ ,对于路径解析下面会有进一步的说明。
plugins 里有两个 webpack.DllReferencePlugin,分别对应两个打包好的 dll 文件。 第一个 DllReferencePlugin 的 context 属性的意思是,当一个 require 解析后的 request 路径是以这个 context 开头时,那 webpack 就不会去把这个文件的内容打包进去, 而是把它作为 externals 处理,代理到 dll 包,从它里面去取。 源码部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 compiler.hooks.compile.tap('DllReferencePlugin' , params => { let name = this .options.name let sourceType = this .options.sourceType let content = 'content' in this .options ? this .options.content : undefined if ('manifest' in this .options) { let manifestParameter = this .options.manifest let manifest if (typeof manifestParameter === 'string' ) { if (params['dll reference parse error ' + manifestParameter]) { return } manifest = (params[ 'dll reference ' + manifestParameter ]) } else { manifest = manifestParameter } if (manifest) { if (!name) name = manifest.name if (!sourceType) sourceType = manifest.type if (!content) content = manifest.content } } const externals = {} const source = 'dll-reference ' + name externals[source] = name const normalModuleFactory = params.normalModuleFactory new ExternalModuleFactoryPlugin(sourceType || 'var' , externals).apply( normalModuleFactory ) new DelegatedModuleFactoryPlugin({ source: source, type: this .options.type, scope: this .options.scope, context: this .options.context || compiler.options.context, content, extensions: this .options.extensions }).apply(normalModuleFactory) })
需要注意的是假如是相对路径的require
,那么对应的文件必须真实存在于该路径。 这是由于当使用scope
类型的request
时,DelegatedModuleFactoryPlugin
会在normalModuleFactory
的factory
的钩子调用时 就已经创建了一个DelegatedModule
,如果是相对路径的情况,则要等到module
钩子的时候才创建。factory
和module
两个周期之间,还有resolver
钩子,假如resolver
阶段特定不到对应路径的文件,则会报错。
2020.09.04更新 scope
类型的request
的合法条件经过下面的步骤替换掉scope
后的innerRequest
需要在manifest.json
中存在。 相对路径类型的request
,除了要满足可在文件系统中找到这个条件外,同样也需要替换掉context
后的request
的key
在在manifest.json
中存在。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 apply (normalModuleFactory ) { const scope = this .options.scope; if (scope) { normalModuleFactory.hooks.factory.tap( "DelegatedModuleFactoryPlugin" , factory => (data, callback ) => { const dependency = data.dependencies[0 ]; const request = dependency.request; if (request && request.indexOf(scope + "/" ) === 0 ) { const innerRequest = "." + request.substr(scope.length); let resolved; if (innerRequest in this .options.content) { resolved = this .options.content[innerRequest]; return callback( null , new DelegatedModule( this .options.source, resolved, this .options.type, innerRequest, request ) ); } for (let i = 0 ; i < this .options.extensions.length; i++) { const extension = this .options.extensions[i]; const requestPlusExt = innerRequest + extension; if (requestPlusExt in this .options.content) { resolved = this .options.content[requestPlusExt]; return callback( null , new DelegatedModule( this .options.source, resolved, this .options.type, requestPlusExt, request + extension ) ); } } } return factory(data, callback); } ); } else { normalModuleFactory.hooks.module.tap( "DelegatedModuleFactoryPlugin" , module => { if (module .libIdent) { const request = module .libIdent(this .options); if (request && request in this .options.content) { const resolved = this .options.content[request]; return new DelegatedModule( this .options.source, resolved, this .options.type, request, module ); } } return module ; } ); } } source (depTemplates, runtime ) { const dep = (this .dependencies[0 ]); const sourceModule = dep.module; let str; if (!sourceModule) { str = WebpackMissingModule.moduleCode(this .sourceRequest); } else { str = `module.exports = (${runtime.moduleExports({ module : sourceModule, request: dep.request })} )` ; switch (this .type) { case "require" : str += `(${JSON .stringify(this .request)} )` ; break ; case "object" : str += `[${JSON .stringify(this .request)} ]` ; break ; } str += ";" ; } }
ExternalModule 说了 dll,其实也要顺带说一下 ExternalModule 的原理。概括来说就是把 require 模块的内容不直接写到 bundle 中,而是把他的引用作为 module 的 exports 具体可以看下下面的源码: lib/ExternalModule.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 getSourceForGlobalVariableExternal (variableName, type ) { if (!Array .isArray(variableName)) { variableName = [variableName]; } const objectLookup = variableName .map(r => `[${JSON .stringify(r)} ]` ) .join("" ); return `(function() { module.exports = ${type} ${objectLookup} ; }());` ; } getSourceForCommonJsExternal (moduleAndSpecifiers ) { if (!Array .isArray(moduleAndSpecifiers)) { return `module.exports = require(${JSON .stringify( moduleAndSpecifiers )} );` ; } const moduleName = moduleAndSpecifiers[0 ]; const objectLookup = moduleAndSpecifiers .slice(1 ) .map(r => `[${JSON .stringify(r)} ]` ) .join("" ); return `module.exports = require(${JSON .stringify( moduleName )} )${objectLookup} ;` ;} getSourceForAmdOrUmdExternal (id, optional, request ) { const externalVariable = `__WEBPACK_EXTERNAL_MODULE_${Template.toIdentifier( `${id} ` )} __` ; const missingModuleError = optional ? this .checkExternalVariable(externalVariable, request) : "" ; return `${missingModuleError} module.exports = ${externalVariable} ;` ; } getSourceForDefaultCase (optional, request ) { if (!Array .isArray(request)) { request = [request]; } const variableName = request[0 ]; const missingModuleError = optional ? this .checkExternalVariable(variableName, request.join("." )) : "" ; const objectLookup = request .slice(1 ) .map(r => `[${JSON .stringify(r)} ]` ) .join("" ); return `${missingModuleError} module.exports = ${variableName} ${objectLookup} ;` ; }
说一下 webpack.config.externals 的配置项,下面是官方示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 module .exports = { externals: [ { react: 'react' , lodash: { commonjs: 'lodash' , amd: 'lodash' , root: '_' , }, subtract: ['./math' , 'subtract' ], }, function (context, request, callback ) { if (/^yourregex$/ .test(request)) { return callback(null , 'commonjs ' + request); } callback(); }, /^(jquery|\$)$/i, ], };
之前我一直都不明白这些配置是怎么用的,尤其是以 function 使用时,callback 的第一个参数用的是 null,这到底指代什么。还有 umd,cmd,root 这些,他们是什么情况夏才会起效的。带着上面这些疑问,我阅读了下源码:lib/ExternalModuleFactoryPlugin.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 const handleExternal = (value, type, callback ) => { if (typeof type === "function" ) { callback = type; type = undefined ; } if (value === false ) return factory(data, callback); if (value === true ) value = dependency.request; if (type === undefined && /^[a-z0-9]+ / .test(value)) { const idx = value.indexOf(" " ); type = value.substr(0 , idx); value = value.substr(idx + 1 ); } callback( null , new ExternalModule(value, type || globalType, dependency.request) ); return true ; }; ... if (typeof externals === "string" ) { if (externals === dependency.request) { return handleExternal(dependency.request, callback); } } else if (Array .isArray(externals)) { let i = 0 ; const next = () => { let asyncFlag; const handleExternalsAndCallback = (err, module ) => { if (err) return callback(err); if (!module ) { if (asyncFlag) { asyncFlag = false ; return ; } return next(); } callback(null , module ); }; do { asyncFlag = true ; if (i >= externals.length) return callback(); handleExternals(externals[i++], handleExternalsAndCallback); } while (!asyncFlag); asyncFlag = false ; }; next(); return ; } else if (externals instanceof RegExp ) { if (externals.test(dependency.request)) { return handleExternal(dependency.request, callback); } } else if (typeof externals === "function" ) { externals.call( null , context, dependency.request, (err, value, type) => { if (err) return callback(err); if (value !== undefined ) { handleExternal(value, type, callback); } else { callback(); } } ); return ; } else if ( typeof externals === "object" && Object .prototype.hasOwnProperty.call(externals, dependency.request) ) { return handleExternal(externals[dependency.request], callback); } callback(); }; ...
上面的各个条件语句分别对应 externals 里各种形式的配置。 分别举例 1.externals: 'react'
会转成
1 callback(null , new ExternalModule('react' , undefined || globalType, 'react' ));
tips:from lib/WebpackOptionsApply.js
1 2 3 4 5 6 7 8 9 if (options.externals) { ExternalsPlugin = require ('./ExternalsPlugin' ); new ExternalsPlugin( options.output.libraryTarget, options.externals ).apply(compiler); }
2.externals: ['react', 'jquery']
只不过是单个 externals 推广为多个,把里面的每一个配置按除 2 以外的规则进行处理,数组里面可以是 string, regExp 和 Object 3.externals: /^(jquery|\$)$/i
当 require 的 request 符合正则的形式时,会把这个 request 与 1 一样处理
4.
1 2 3 4 5 6 externals: function (context, request, callback ) { if (/^yourregex$/ .test(request)) { return callback(null , 'commonjs ' + request) } callback() },
如果是 function 时就直接执行这个 function,而 callback 参数为
1 2 3 4 5 6 7 8 (err, value, type) => { if (err) return callback(err); if (value !== undefined ) { handleExternal(value, type, callback); } else { callback(); } };
这里可以回答上面的问题,callback 的第一个参数 null 到底指的是什么,指的是否出现 err。 第二个参数’commonjs ‘ + request,为包导出方式 + 空格 + 模块名的形式,它会在 handleExternal 中以第一个空格分成两个字符串,字符串 1 表示 ExternalModule 的导出形式,字符串 2 为模块名 5.
1 2 3 4 5 6 7 8 9 10 11 12 externals: { react: 'react' , lodash: { commonjs: 'lodash' , amd: 'lodash' , root: '_' }, subtract: ['./math' , 'subtract' ] },
上面 Object 的情况有三种形式:第一种 value 是 string,实际上是 value 是 Object 的特殊情况。表示不论是以 commonjs, amd, root 等哪种方式导出,他的变量名都是 react 第二种 value 是 Object, 表示根据导出的方式,返回对应的变量名 e.g. commonjs 时是 module.exports = require(‘loadsh’) root 时是 module.exports = _ 那这个导出方式是以什么决定的呢?如果是普通的 externals,那么就跟 1 中的 tips 代码注释写的一样,跟打包时 output.libraryTarget 一致的。如果是 dll 的情况就取 DllReferencePlugin 的 options.sourceType 或者 manifest.json 的 type 字段,如果都没有默认就是 var。这里就是 1 中的 tips 里说的伏笔,因为我之前没有在官方的例子中,找到有用过 var 字段的,以为默认是 root 或者 global 因为一般只看到 externals 中用到这两种表示全局的。但事实上他们不是划为同一种进行处理的, 看一下源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 switch (this .externalType) { case "this" : case "window" : case "self" : ... case "global" : ... case "commonjs" : case "commonjs2" : ... case "amd" : case "amd-require" : case "umd" : case "umd2" : ... default : ... }
并且在分情况返回变量名的处理方法是this.request[this.externalType]
,也就是说以’var’和上面的 lodash 为例子的话,那就相当于({commonjs: ‘lodash’,amd:’lodash’,root: ‘_‘ })[‘var’]。那这样的话自然在编译时就变成了module.exports=undefined
_第三种 value 是 Array,它的处理方式以当包导出方式是以 var 为例说明,根据上面的 switch condition 可知当为 var 时,按 defaultCase 处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 getSourceForDefaultCase (optional, request ) { ... const variableName = request[0 ]; const objectLookup = request .slice(1 ) .map(r => `[${JSON .stringify(r)} ]` ) .join("" ); return `${missingModuleError} module.exports = ${variableName} ${objectLookup} ;` ; }
总结 上面的解析写的比较乱,而且有很多文章内的引用,下次可以考虑使用锚点进行页内跳转。 dll 的工作流程大概是,通过 DllPlugin 打包 library 获得 js 和 manifest 文件,使用时通过 DllReferencePlugin 读取 manifest 文件,解析 dll 中包含的子模块名等信息。 DllReferencePlugin 内部,创建 ExternalModule,把 dll 加入到 externals 中,然后通过 DelegatedModule,把对实际文件的 require 请求,代理到 dll 包中。p.s.:使用HTMLWebpackPlugin
的默认配置并不会在使用DllReferencePlugin
后把加载dll
script标签加入到打包后的html里。