webpack原理
webpack流程
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程 :
- 初始化参数:读取webpack配置参数,从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。
- 开始编译:启动webpack,创建compiler对象。用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。
- 确定入口:从入口文件(entry)开始解析,并且找到其导入的依赖模块,递归遍历分析,形成依赖关系树。
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。
- 完成模块编译:在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
打包器
https://mp.weixin.qq.com/s/PeSbdScrvsO0ctECGGY1bQ
https://mp.weixin.qq.com/s/gW_2sDfX5o4wamoiZMsxCw
所谓打包器,就是前端开发人员用来将 JavaScript 模块打包到一个可以在浏览器中运行的优化的 JavaScript 文件的工具,例如 webapck、rollup、gulp 等。
<html>
<script src="/src/entry.js"></script>
<script src="/src/message.js"></script>
<script src="/src/hello.js"></script>
<script src="/src/name.js"></script>
</html>
主要工作
模块化 在 HTML 引入时,我们需要注意这 4 个文件的引入顺序(如果顺序出错,项目就会报错),如果将其扩展到具有实际功能的可用的 web 项目中,那么可能需要引入几十个文件,依赖关系更是复杂。打包器需要管理模块依赖关系
打包 当浏览器打开该网页时,每个 js 文件都需要一个单独的 http 请求,即 4 个往返请求,才能正确的启动你的项目。
实现思路
1. 解析入口文件,获取所有的依赖项
首先我们唯一确定的是入口文件的地址,通过入口文件的地址可以
- 获取其文件内容
- 获取其依赖模块的相对地址
由于依赖模块的引入是通过相对路径(import './message.js'
),所以,我们需要保存入口文件的路径,结合依赖模块的相对地址,就可以确定依赖模块绝对地址,读取它的内容。
如何在依赖关系中去表示一个模块,以方便在依赖图中引用
将模块表示为:
- code: 文件解析内容,注意解析后代码能够在当前以及旧浏览器或环境中运行;
- dependencies: 依赖数组,为所有依赖模块路径(相对)路径;
- filename: 文件绝对路径,当
import
依赖模块为相对路径,结合当前绝对路径,获取依赖模块路径;
其中 filename(绝对路径) 可以作为每个模块的唯一标识符,通过 key: value 形式,直接获取文件的内容一依赖模块:
// 模块
'src/entry': {
code: '', // 文件解析后内容
dependencies: ["./message.js"], // 依赖项
}
2. 递归解析所有的依赖项,生成一个依赖关系图
我们已经确定了模块的表示,那怎么才能将这所有的模块关联起来,生成一个依赖关系图,通过这个依赖关系可以直接获取所有模块的依赖模块、依赖模块的代码、依赖模块的来源、依赖模块的依赖模块。
如何去维护依赖文件间的关系
现在对于每一个模块,可以唯一表示的就是 filename
,而我们在由入口文件递归解析时,我们可以获取到每个文件的依赖数组 dependencies
,也就是每个依赖项的相对路径,所以我们需要定义一个:
// 关联关系
let mapping = {}
用来在运行代码时,由 import
相对路径映射到 import
绝对路径。
所以我们模块可以定义为[filename: {}]:
// 模块
'src/entry': {
code: '', // 文件解析后内容
dependencies: ["./message.js"], // 依赖项
mapping:{
"./message.js": "src/message.js"
}
}
则依赖关系图为:
// graph 依赖关系图
let graph = {
// entry 模块
"src/entry.js": {
code: '',
dependencies: ["./src/message.js"],
mapping:{
"./message.js": "src/message.js"
}
},
// message 模块
"src/message.js": {
code: '',
dependencies: [],
mapping:{},
}
}
当项目运行时,通过入口文件成功获取入口文件代码内容,运行其代码,当遇到 import
依赖模块时,通过 mapping
映射其为绝对路径,就可以成功读取模块内容。
并且每个模块的绝对路径 filename 是唯一的,当我们将模块接入到依赖图 graph
时,仅仅需要判断 graph[filename]
是否存在,如果存在就不需要二次加入,剔除掉了模块的重复打包。
3. 使用依赖图,返回一个可以在浏览器运行的 JavaScript 文件
现今,可立即执行的代码形式,最流行的就是 IIFE(立即执行函数),它同时能够解决全局变量污染的问题。
I
所谓 IIFE,就是在声明市被直接调用的匿名函数,由于 JavaScript 变量的作用域仅限于函数内部,所以你不必考虑它会污染全局变量。
(function(man){
function log(name) {
console.log(`hello ${name}`);
}
log(man.name)
})({name: 'bottle'});
// hello bottle
4. 输出到 dist/bundle.js
fs.writeFile
写入 dist/bundle.js
即可。
我们看下最后打包出来的bundle
(function(modules) {
// 模拟 require 语句
function __webpack_require__() {
}
// 执行存放所有模块数组中的第0个模块
__webpack_require__(0);
})([/*存放所有模块的数组 依赖图*/])
(function(modules) { //
// The module cache
var installedModules = {};
function __webpack_require__(moduleId) {
// ...省略细节
}
// 入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
({
"./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {}),
"./src/sayHello.js": (function(module, __webpack_exports__, __webpack_require__) {})
});
- IIFE立即执行函数,保证bundle在浏览器中立即执行
- 入参是依赖图,从入口出发的依赖关系,依赖图的基本结构,moduleID,code
- 将依赖图传入立即执行函数中,通过webpack_require模拟require,抹平不同模块规范差异,同时实现了缓存模块的机制
Loader 就像是一个翻译员,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个翻译员翻译。
- 单一原则: 每个 Loader 只做一件事;
- 链式调用: Webpack 会按顺序链式调用每个 Loader;
- 统一原则: 遵循 Webpack 制定的设计规则和结构,输入与输出均为字符串,各个 Loader 完全独立,即插即用;
为什么链式调用从后往前顺序? compose = (f,g) => (…args) => f(g(…args))
以处理 SCSS 文件为例:
- SCSS 源代码会先交给
sass-loader
把 SCSS 转换成 CSS; - 把
sass-loader
输出的 CSS 交给css-loader
处理,找出 CSS 中依赖的资源、压缩 CSS 等; - 把
css-loader
输出的 CSS 交给style-loader
处理,转换成通过脚本加载的 JavaScript 代码;
一个单纯的loader如下,没有对源码做任何处理
module.exports = function(source) {
// source 为 compiler 传递给 Loader 的一个文件的原内容
// 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
return source;
};
loader中的api
webpack给loader注入this,this上挂载了很多属性和方法 loader 存在运行上下文 context,表示在 loader 内使用 this 可以访问的一些方法或属性
this.callback
是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。
module.exports = function(source) {
// 通过 this.callback 告诉 Webpack 返回的结果
this.callback(null, source, sourceMaps);
// 当你使用 this.callback 返回内容时,该 Loader 必须返回 undefined,
// 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,而不是 return 中
return;
};
this.callback(
// 当无法转换原内容时,给 Webpack 返回一个 Error
err: Error | null,
// 原内容转换后的内容
content: string | Buffer,
// 用于把转换后的内容得出原内容的 Source Map,方便调试
sourceMap?: SourceMap,
// 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
// 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
abstractSyntaxTree?: AST
);
异步
module.exports = function(source) {
// 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
var callback = this.async();
someAsyncOperation(source, function(err, result, sourceMaps, ast) {
// 通过 callback 返回异步执行后的结果
callback(err, result, sourceMaps, ast);
});
};
this.cacheable:是否开启缓存
this.context
:当前处理文件的所在目录,假如当前 Loader 处理的文件是/src/main.js
,则this.context
就等于/src
。this.resource
:当前处理文件的完整请求路径,包括querystring
,例如/src/main.js?name=1
。this.resourcePath
:当前处理文件的路径,例如/src/main.js
。this.resourceQuery
:当前处理文件的querystring
。this.target
:等于 Webpack 配置中的 Target。this.loadModule
:但 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过this.loadModule(request: string, callback: function(err, source, sourceMap, module))
去获得request
对应文件的处理结果。this.resolve
:像require
语句一样获得指定文件的完整路径,使用方法为resolve(context: string, request: string, callback: function(err, result: string))
。this.addDependency
:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为addDependency(file: string)
。this.addContextDependency
:和addDependency
类似,但addContextDependency
是把整个目录加入到当前正在处理文件的依赖中。使用方法为addContextDependency(directory: string)
。this.clearDependencies
:清除当前正在处理文件的所有依赖,使用方法为clearDependencies()
。this.emitFile
:输出一个文件,使用方法为emitFile(name: string, content: Buffer|string, sourceMap: {...})
。
在定义一个 loader 函数时,可以导出一个 pitch 方法,这个方法会在 loader 函数执行前执行。
- remainingRequest: 后面的 loader + 资源路径,loadername! 的语法
- precedingRequest: 资源路径
- metadata: 和普通的 loader 函数的第三个参数一样,辅助对象
手动实现
让我们来手动实现loader吧!
package.json
"loader":"npx webpack --config webpack-loader-test.js"
{
test: /\.js$/,
exclude: /(node_modules)/,
// use: [
// path.resolve(__dirname,'loaders/b-loader.js'),
// path.resolve(__dirname,'loaders/a-loader.js')
// ]
use:[
path.resolve(__dirname,'loaders/upper-loader.js')
]
},
module.exports = function(source){
return source.replace(/const/g,'let')
}
项目中实践的loader
/**
* 这是一个 Webpack 插件,实现了对 async(require('资源'), '分组') 语法的支持
* devtools 目前使用的 Webpack 1.x 动态引入依赖的语法较为繁琐,所以做了一种简写
*/
var template = `(
new Promise(function (resolve) {
require.ensure([], function () {
var module = require($1);
resolve(module.__esModule ? module.default : module);
}, $2);
})
)`.replace(/\s+/g, ' ');
module.exports = function (content) {
this.cacheable && this.cacheable();
return content.replace(/async\s*\(\s*require\s*\((\s*[^)]*)\s*\)\s*,\s*([^)]*)\)/g, template);
};
/**
* 这是一个 Webpack 插件,效果为在资源内容的开头加一行资源路径的注释
* 主要用于在调试时快速定位 Webpack 插入的 <style> 标签来自哪个 CSS 文件
*/
var cwd = process.cwd();
var getRelativePath = function (path) {
return path.replace(cwd, '.').replace(/.*\/node_modules\//, '');
};
module.exports = function(content) {
this.cacheable && this.cacheable();
var path = getRelativePath(this.resourcePath);
return '/* ' + path + ' */\n' + content;
};
/**
* 这是一个 Webpack 插件,用于改变react的引入包
* 通过判断 路径是否含有 'mtd' 跟 是否引入了 @ss/mtd-react 来判断引入react16 还是 react15
* (content.includes('from \'react\'') || content.includes('from \"react\"') || content.includes('require(\'react\')') || content.includes('require(\"react\")')))
*/
module.exports = function (content) {
const entry = this._compilation.options.entry || [];
const reactReg = RegExp(/(from\s+|require\()[\'|\"]react[\'|\"|\/]/, 'g');
const mtdReactReg = RegExp(/(from\s+|require\()[\'|\"](\@ss\/mtd\-react|\@block\/plug)[\'|\"|\/]/, 'g');
// utils需要替换react
if ((this.resourcePath.includes('node_modules\/mtf.utils') || !this.resourcePath.includes('node_modules')) &&
entry[0].split('.js')[0] != this.resource.split('.js')[0] &&
!this.resourcePath.includes('mtd') &&
!content.match(mtdReactReg) &&
content.match(reactReg)) {
this.cacheable && this.cacheable();
return content
.replace(/from\s+[\'|\"]react[\'|\"]/g, 'from \'@mtfe/react\'')
.replace(/require\([\'|\"]react[\'|\"]\)/g, 'require(\'@mtfe/react\')')
.replace(/from\s+\"react\//g, 'from \"@mtfe/react/')
.replace(/from\s+\'react\//g, 'from \'@mtfe/react/')
.replace(/require\(\'react\//g, 'require(\'@mtfe/react\/')
.replace(/require\(\"react\//g, 'require(\"@mtfe/react\/');
}
return content;
};
手动实现plugin
Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
plugin流程
一个最基础的 Plugin 的代码是这样的:
class BasicPlugin{
// 在构造函数中获取用户给该插件传入的配置
constructor(options){
}
// Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
apply(compiler){
compiler.plugin('compilation',function(compilation) {
})
}
}
// 导出 Plugin
module.exports = BasicPlugin;
在使用这个 Plugin 时,相关配置代码如下:
const BasicPlugin = require('./BasicPlugin.js');
module.export = {
plugins:[
new BasicPlugin(options),
]
}
- Webpack 启动后,在读取配置的过程中会先执行
new BasicPlugin(options)
初始化一个BasicPlugin
获得其实例。 - 在初始化
compiler
对象后,再调用basicPlugin.apply(compiler)
给插件实例传入compiler
对象。 - 插件实例在获取到
compiler
对象后,就可以通过compiler.plugin(事件名称, 回调函数)
监听到 Webpack 广播出来的事件。 并且可以通过compiler
对象去操作 Webpack。
Compiler 和 Compilation
在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:
- Compiler 对象包含了 Webpack 环境所有的的配置信息,包含
options
,loaders
,plugins
这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例; - Compilation 代表了一次资源版本构建对象, 包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。
事件流
Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。
插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。
Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。
在 webpack 运行的生命周期中会广播出许多事件,plugin 可以监听这些事件,在合适的时机通过 webpack 提供的 钩子 改变输出结果。在每个生命周期点,webpack 会运行所有注册的插件,并提供当前 webpack 编译状态信息。主要就是挂载在 compiler 和 compilation 对象提供的各种钩子上进行调用。
Compiler 和 Compilation 都继承自 Tapable tap,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:
/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);
/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(params) {
});
同理,compilation.apply
和 compilation.plugin
使用方法和上面一致。
在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。
在开发插件时,还需要注意以下两点:
- 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
- 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
- 有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
compiler.plugin('emit',function(compilation, callback) {
// 支持处理逻辑
// 处理完毕后执行 callback 以通知 Webpack
// 如果不执行 callback,运行流程将会一直卡在这不往下执行
callback();
});
具体参见 compiler钩子api compliation钩子
entryOption/afterPlugins/beforeCompile/emit/compilation
class BasicPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
const options = this.options;
compiler.hooks.emit.tap("BasicPlugin", (complication) => {
let str;
// 在emit钩子上 获取打包后的文件 main.js
for (let file in complication.assets) {
console.log(file, complication.assets[file].size());
str += `文件:${file} 大小${complication.assets[file][
"size"
]()}\n`;
}
// 通过compilation.assets可以获取打包后静态资源信息,同样也可以写入资源
// 在打包输出后dist文件中有fileSize.md文件
complication.assets["fileSize.md"] = {
source: () => str,
size: () => str.length
};
});
}
}
module.exports = BasicPlugin;
项目中实践plugin
const RawSource = require("webpack-sources");
var HtmlLibraryPlugin = function (libraries, libraryManifest) {
this.libraries = libraries;
this.libraryManifest = libraryManifest;
};
HtmlLibraryPlugin.prototype.apply = function (compiler) {
compiler.plugin('emit', function (compilation, callback) {
var publicPath = compiler.options.output.publicPath;
var libraryTags = this.libraries.map(function (library) {
return '<script src="' + publicPath + this.libraryManifest[library] + '"></script>';
}.bind(this)).join('\n');
var html = compilation.assets['index.html'].source();
html = html.replace(/<script /, libraryTags + '\n<script ');
compilation.assets['index.html'] = new RawSource(html);
callback();
}.bind(this));
};
module.exports = HtmlLibraryPlugin;
const LxConfig = require('./template/LxConfig');
const OwlConfig = require('./template/OwlConfig');
/**
* 在html中增加逻辑
* @param {*} LxOptions 灵犀配置信息
* @param {*} OwlOption Owl的配置信息
* @param {*} NODE_ENV 当下环境变量
*/
let BeforeHtmlProcessing = function (LxOptions = null, OwlOption = null, NODE_ENV = 'development') {
this.LxOptions = LxOptions;
this.OwlOption = OwlOption;
// compiler.options.mode也有环境变量信息,但这个量是给webpack编译用的
// 如:在test环境中,我们需要webpack以prod方式编译(近似于线上),但我们加载的代码逻辑需要是dev逻辑,所以需要使用当前 NODE_ENV。
this.NODE_ENV = NODE_ENV;
}
BeforeHtmlProcessing.prototype.apply = function (compiler) {
compiler.hooks.compilation.tap('BeforeHtmlProcessing', (compilation) => {
compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing.tap(
'BeforeHtmlProcessing',
data => {
// head头部引入Owl预采集模块,需要在head所有静态资源之前引入
if (this.OwlOption) data.html = data.html.replace(/\<head\>/, (rs) => rs + OwlConfig.owlPreProccess());
// head尾部引入 灵犀SDK 和 OwlSDK
data.html = data.html.replace(/\<\/head\>/, (result) => {
const lxSdk = this.LxOptions ? LxConfig.LxSDK(this.LxOptions, this.NODE_ENV) : '';
const owlSdk = this.OwlOption ? OwlConfig.owlSDK(this.NODE_ENV) : '';
return lxSdk + owlSdk + result;
});
// body头中 引入OwlStart
if (this.OwlOption) data.html = data.html.replace(/\<body\>/, (rs) => rs += OwlConfig.owlStart(this.OwlOption, this.NODE_ENV));
}
)
})
}
module.exports = BeforeHtmlProcessing;
// 通过script src注入js
// catInit
const catInit = require('./../template/CatInit');
// catSDK TODO crossorigin="anonymous"
const prodOwl = "//www.dpfile.com/app/owl/static/owl_latest.js";
const devOwl = "//s1.51ping.com/app/owl/static/owl_latest.js";
let BeforeHtmlGeneration = function(options) {
options = options || {};
this.options = options;
}
BeforeHtmlGeneration.prototype.apply = function (compiler) {
compiler.hooks.compilation.tap('BeforeHtmlGeneration', (compilation) => {
console.log('compilation', compiler.options.mode);
compilation.hooks.htmlWebpackPluginBeforeHtmlGeneration.tap(
'BeforeHtmlGeneration',
htmlPluginData => {
htmlPluginData.assets.js.unshift(catInit);
compiler.options.mode === 'production' ? htmlPluginData.assets.js.unshift(prodOwl) : htmlPluginData.assets.js.unshift(devOwl)
}
)
})
}
module.exports = BeforeHtmlGeneration;
/**
* 灵犀初始化配置 及 SDK 引入
* @param {*} LxOptions 灵犀配置信息
* @param {*} NODE_ENV 环境变量
*/
const LxSDK = (LxOptions, NODE_ENV = 'development') => {
const { category = 'ead', appnm, cid, bid, autopv = 'off' } = LxOptions;
const isDev = NODE_ENV !== 'production';
if (!cid) throw Error("Lingxi configuration information 'cid' is required, \n please check if the 'lxconfig.cid' configuration is correct.\n(灵犀配置信息 cid 是必须项,请查看 LxConfig.cid 配置是否正确)");
const appnmMeta = appnm ? `<meta name="lx:appnm" content="${appnm}">` : '';
const bidMeta = bid ? `<meta name="lx:bid" content="${bid}">` : '';
// 线上: src="//lx.meituan.net/analytics.js"
// 线下: src="//analytics.fetc.st.sankuai.com/analytics.js"
const LxSdkHost = "lx.meituan.net"; // 灵犀的线下地址只是灵犀sdk做灰度用的,上报上去都是一个仓库,对业务没用
return `
<!---------- 接入灵犀 ---------->
<meta name="lx:category" content="${category}">
${appnmMeta}
<meta name="lx:cid" content="${cid}">
${bidMeta}
<meta name="lx:autopv" content="${autopv}" />
<meta name="lx:mvDelay" content="2" />
<link rel="dns-prefetch" href="//lx.meituan.net" />
<link rel="dns-prefetch" href="//wreport.meituan.net" />
<link rel="dns-prefetch" href="//report.meituan.com" />
<script type="text/javascript">
!(function (win, doc, ns) {
var cacheFunName = '_MeiTuanALogObject';
win[cacheFunName] = ns;
if (!win[ns]) {
var _LX = function () {
_LX.q.push(arguments);
return _LX;
};
_LX.q = _LX.q || [];
_LX.l = +new Date();
win[ns] = _LX;
}
})(window, document, 'LXAnalytics');
LXAnalytics('config','isDev', ${isDev}); // 在初始化时设置isDev为true,以使灵犀数据上报至线下环境,默认为false,即默认上报至线上环境
LXAnalytics("config", "useQR", true); // 开发快速上报功能,满足即时数据分析的需求
LXAnalytics("config", "onWebviewAppearAutoPV", false); // 关闭Webview容器显示/隐藏时的自动PV/PD
</script>
<!---------- 灵犀 SDK ---------->
<script type="text/javascript" src="//${LxSdkHost}/analytics.js" async> </script>
`;
}
module.exports = { LxSDK };
- loader 是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中,运行在打包前。
- plugin 赋予了 webpack 各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是解决 loader 无法实现的其他事,可以作用在 webpack 的整个流程中。
热更新原理
https://mp.weixin.qq.com/s/OQUGvdJi6wJNod0VWTDkEg
https://mp.weixin.qq.com/s/oXzsXIumOmg45SOOCsevQQ
简单来说就是:hot-module-replacement-plugin 包给 webpack-dev-server 提供了热更新的能力,它们两者是结合使用的,单独写两个包也是出于功能的解耦来考虑的。
1)webpack-dev-server(WDS)的功能提供 bundle server的能力,就是生成的 bundle.js 文件可以通过 localhost://xxx 的方式去访问,另外 WDS 也提供 livereload(浏览器的自动刷新)。
2)hot-module-replacement-plugin 的作用是提供 HMR 的 runtime,并且将 runtime 注入到 bundle.js 代码里面去。一旦磁盘里面的文件修改,那么 HMR server 会将有修改的 js module 信息发送给 HMR runtime,然后 HMR runtime 去局部更新页面的代码。因此这种方式可以不用刷新浏览器。
HMR主要实现:
- 如何在服务端文件变化之后通知客户端?使用socket Websocket
- 如何判断文件是否发生变化?客户端和服务端都保存
chunk
,比较是否发生变化
websocket最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种
协议标识符是ws(如果加密,则为wss),服务器网址就是 URL
- HMR Runtime 通过 HotModuleReplacementPlugin 已经注入到我们 chunk 中了
- 除了开启一个 Bundle Server,还开启了 HMR Server,主要用来和 HMR Runtime 中通信
- 在编译结束的时候,通过 compiler.hooks.done,监听并通知客户端
- 客户端接收到之后,就会调用 module.hot.check 等,发起 http 请求去服务器端获取新的模块资源解析并局部刷新页面
webpack —watch启动监听模式之后,webpack第一次编译项目,将结果存储在内存文件系统,相比较磁盘文件读写方式内存文件管理速度更快,webpack服务器通知浏览器加载资源,浏览器获取的静态资源除了JS code内容之外,还有一部分通过webpack-dev-server注入的的 HMR runtime代码,作为浏览器和webpack服务器通信的客户端( webpack-hot-middleware提供类似的功能)
文件系统中一个文件(或者模块)发生变化,dev-server 的中间件 webpack-dev-middleware 和 webpack 交互
- webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中,webpack监听到文件变化对文件重新编译打包,每次编译生成唯一的hash值,根据变化的内容生成两个补丁文件:说明变化内容的manifest(文件格式是hash.hot-update.json,包含了hash和chundId用来说明变化的内容)和chunk js(hash.hot-update.js)模块。
- dev-server 监听配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新
hrm-server通过websocket将manifest推送给浏览器
浏览器端hmr runtime【HotModuleReplacement.runtime 是客户端 HMR 的中枢】根据manifest的hash和chunkId向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过请求,获取到最新的模块代码
HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用
异步加载原理
异步加载的核心其实是使用类jsonp
的方式,通过动态创建script
的方式实现异步加载
将 异步加载的的模块单独导出一个.js 文件,在使用该模块的时候,创建一个 script 对象,加入到 document.head 对象中,浏览器会自动发起请求,请求这个 js 文件,写个回调函数,让请求到的 js 文件做一些业务操作
将 import() 转换成模拟 JSONP 去加载动态加载的 chunk 文件
浏览器不支持import语法和import()语法,那webpack是怎么处理的呢?
output: {
filename: "[name].[hash:8].js",
path: path.resolve(__dirname, "AsyncDist"),
chunkFilename: "bundle-[name]-[chunkhash:8].js",
jsonpFunction: "webpackJsonp_DEMO_ASYNC"
},
// =========入口文件 =============
import INDEX3 from './index3';
function component() {
setTimeout(() => {
import(/* webpackChunkName: 'lodash'*/ "lodash").then(function (lodash) {
console.log("异步加载 lodash");
});
}, 3000);
element.innerHTML = "Hello Webpack,我要好好学习,天天向上啊";
return element;
}
document.body.appendChild(component());
其中,jsonpFunction是定义异步加载的模块数组
/ webpackChunkName: ‘lodash’/ 定义异步加载的bundle名称
打包后生成的文件
bundle-vendors~lodash-22b637c2.js 异步加载的包
index.html
main.7ca2ed1c.js 主入口bundle
先看bundle-vendors~lodash-22b637c2.js,可以看到全局定义了
jsonpFunction:webpackJsonp_DEMO_ASYNC数组,来存放异步加载的包
- 异步加载的文件中存放的需要安装的模块对应的 Chunk Names,vendors~lodash
- 异步加载的文件中存放的需要安装的模块列表
(window["webpackJsonp_DEMO_ASYNC"] = window["webpackJsonp_DEMO_ASYNC"] || []).push([["vendors~lodash"],{
"./node_modules/lodash/lodash.js":
(function(module, exports, __webpack_require__) {...})
})
再看main.7ca2ed1c.js , 主要结构:
- IIFE立即执行函数,保证bundle在浏览器中立即执行
- 入参是依赖图,从入口出发的依赖关系,依赖图的基本结构,moduleID,code
- 将依赖图传入立即执行函数中,通过webpack_require模拟require,将入口文件返回
在依赖图中可以看到,函数入参module对应于当前模块的相关状态(是否加载完成、导出值、id 等)、webpack_exports就是当前模块的导出(就是 export)、webpack_require就是入口 chunk 的webpack_require函数,用于import其它代码
同步import被转化成webpack_require,异步import()被转化成webpack_require.e,返回的是一个promise
(function(modules){
function webpackJsonpCallback(data) {};
// 缓存已经加载过的module。无论是同步还是异步加载的模块都会进入该缓存
var installedModules = {};
// 记录chunk的状态位
// 值:0 表示已加载完成。
var installedChunks = { main: 0};
//获取资源地址
function jsonpScriptSrc(chunkId) {};
//同步import
function __webpack_require__(moduleId) {}
// 用于加载异步import的方法
__webpack_require__.e = function requireEnsure(chunkId) {
//...
}
return __webpack_require__(
(__webpack_require__.s = "./src/async.js")
);
})({
"./src/async.js":
(function(module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _index3__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./index3 */ \"./src/index3.js\");\n\n\nfunction component() {\nsetTimeout(() => {\n __webpack_require__.e(/*! import() | lodash */ \"vendors~lodash\").then(__webpack_require__.t.bind(null, /*! lodash */ \"./node_modules/lodash/lodash.js\", 7)).then(function (lodash) {\n console.log(\"异步加载 lodash\");\n });\n}, 3000);\n element.innerHTML = \"Hello Webpack,我要好好学习,天天向上啊\";\n return element;\n}\n\ndocument.body.appendChild(component());\n\n\n\n//# sourceURL=webpack:///./src/async.js?");
}),
"./src/index3.js":
(function(module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"INDEX3\", function() { return INDEX3; });\nconst INDEX3 = 'index 3';\n\n\n\n//# sourceURL=webpack:///./src/index3.js?");
})
})
下面一个一个来看到底怎么实现的?
jsonpScriptSrc的主要作用是通过publicPath+chunkId的方式获取到异步加载模块的url地址。
function jsonpScriptSrc(chunkId) {
return (
__webpack_require__.p +
"bundle-" +
({ "vendors~lodash": "vendors~lodash" }[chunkId] || chunkId) +
"-" +
{ "vendors~lodash": "22b637c2" }[chunkId] +
".js"
);
webpack_require是webpack的核心,webpack通过webpack_require引入模块。
webpack_require对require包裹了一层,主要功能是加载 js 文件。
//立即执行函数传入参数的的key值
function __webpack_require__(moduleId) {
//如果需要加载的模块已经被加载过,就直接从内存缓存中返回
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
//如果缓存中不存在需要加载的模块,就新建一个模块,并把它存在缓存中
var module = (installedModules[moduleId] = {
i: moduleId, // 模块在数组中的 index
l: false, // 该模块是否已经加载完毕
exports: {} // 该模块的导出值
});
// 从 modules 中获取 index 为 moduleId 的模块对应的函数
// 再调用这个函数,同时把函数需要的参数传入
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);
// 把这个模块标记为已加载
module.l = true;
// Return the exports of the module
return module.exports;
}
这个函数接收一个moduleId
,对应于立即执行函数传入参数的key
值。若一个模块之前已经加载过,直接返回这个模块的导出值;若这个模块还没加载过,就执行这个模块,将它缓存到installedModules
相应的moduleId
为 key 的位置上,然后返回模块的导出值。所以在 webpack 打包代码中,import
一个模块多次,这个模块只会被执行一次。还有一个地方就是,在 webpack 打包模块中,默认import
和require
是一样的,最终都是转化成__webpack_require__
。
webpack_require.e 异步加载核心。异步加载的核心其实是使用类jsonp
的方式,通过动态创建script
的方式实现异步加载
// 记录chunk的状态位
// 值:0 表示已加载完成。
// undefined : chunk 还没加载
// null :chunk preloaded/prefetched
// Promise : chunk正在加载
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
// 判断当前chunk是否已经安装,如果已经使用
var installedChunkData = installedChunks[chunkId];
// installedChunkData没有加载
if (installedChunkData !== 0) {
//installedChunkData正在加载
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
//installedChunkData 为空,表示该 Chunk 还没有加载过,去加载该 Chunk 对应的文件
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push((installedChunkData[2] = promise));
// 通过 DOM 操作,往 HTML head 中插入一个 script 标签去异步加载 Chunk 对应的 JavaScript 文件
var script = document.createElement("script");
var onScriptComplete;
script.charset = "utf-8";
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
// 文件的路径为配置的 publicPath、chunkId 拼接而成
script.src = jsonpScriptSrc(chunkId);
// create error before stack unwound to get useful stacktrace later
var error = new Error();
// 当脚本加载完成,执行对应回调
onScriptComplete = function(event) {
// 避免IE的内存泄漏
script.onerror = script.onload = null;
clearTimeout(timeout);
// 去检查 chunkId 对应的 Chunk 是否安装成功,安装成功时才会存在于 installedChunks 中
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
var errorType =
event && (event.type === "load" ? "missing" : event.type);
var realSrc = event && event.target && event.target.src;
error.message =
"Loading chunk " +
chunkId +
" failed.\n(" +
errorType +
": " +
realSrc +
")";
error.name = "ChunkLoadError";
error.type = errorType;
error.request = realSrc;
chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
// 设置异步加载的最长超时时间
var timeout = setTimeout(function() {
onScriptComplete({ type: "timeout", target: script });
}, 120000);
// 在 script 加载和执行完成时回调
script.onerror = script.onload = onScriptComplete;
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
webpackJsonpCallback 实现异步回调,主要作用是每个异步模块加载并安装。 webpack 会安装对应的 webpackJsonp 文件。
var jsonpArray = (window["webpackJsonp_DEMO_ASYNC"] = window["webpackJsonp_DEMO_ASYNC"] || []);
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 重写数组 push 方法,重写之后,每当webpackJsonp.push的时候,就会执行webpackJsonpCallback代码
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
function webpackJsonpCallback(data) {
//chunkIds 异步加载的文件中存放的需要安装的模块对应的 Chunk ID
// moreModules 异步加载的文件中存放的需要安装的模块列表
var chunkIds = data[0];
var moreModules = data[1];
//循环去判断对应的chunk是否已经被安装,如果,没有被安装就吧对应的chunk标记为安装。
var moduleId,
chunkId,
i = 0,
resolves = [];
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (
Object.prototype.hasOwnProperty.call(installedChunks, chunkId) &&
installedChunks[chunkId]
) {
// 此处的resolves push的是在__webpack_require__.e 异步加载中的 installedChunks[chunkId] = [resolve, reject];的resolve
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
if (parentJsonpFunction) parentJsonpFunction(data);
while (resolves.length) {
// 执行异步加载的所有 promise 的 resolve 函数
resolves.shift()();
}
}
模块循环加载
https://mp.weixin.qq.com/s/gW_2sDfX5o4wamoiZMsxCw
References:
异步加载原理:https://juejin.cn/post/6844904033681948686#heading-2
https://github.com/sisterAn/minipack/blob/master/index.js
https://juejin.cn/post/6844904038543130637#heading-11
WDS:https://github.com/liangklfangl/webpack-dev-server
热更新源码解读 https://juejin.cn/post/6844904008432222215#heading-14
https://mp.weixin.qq.com/s/PeSbdScrvsO0ctECGGY1bQ
https://mp.weixin.qq.com/s/2-zNlGrKUngWdQNvlcgESw
https://mp.weixin.qq.com/s/oXzsXIumOmg45SOOCsevQQ 热更新原理
[
](https://mp.weixin.qq.com/s/PeSbdScrvsO0ctECGGY1bQ)
附
1.miniWebpack
const fs = require('fs')
const path = require('path')
// babel解析器(JavaScript 解析器)
const babelParser = require('@babel/parser')
// 和 babel 解析器配合使用,来遍历及更新每一个子节点
const traverse = require('@babel/traverse').default
const {
transformFromAst
} = require('@babel/core');
// 获取配置文件
const config = require('./minipack.config')
// 入口
const entry = config.entry
// 出口
const output = config.output
/**
* 解析文件内容及其依赖,
* 期望返回:
* dependencies: 文件依赖模块
* code: 文件解析内容
* @param {string} filename 文件路径
*/
function createAsset(filename) {
// 读取文件内容
const content = fs.readFileSync(filename, 'utf-8')
// 使用 @babel/parser(JavaScript解析器)解析代码,生成 ast(抽象语法树)
const ast = babelParser.parse(content, {
sourceType: "module"
})
// 从 ast 中获取所有依赖模块(import),并放入 dependencies 中
const dependencies = []
traverse(ast, {
// 遍历所有的 import 模块,并将相对路径放入 dependencies
ImportDeclaration: ({
node
}) => {
dependencies.push(node.source.value)
}
})
// 获取文件内容
const {
code
} = transformFromAst(ast, null, {
presets: ['@babel/preset-env'],
})
// 返回结果
return {
dependencies,
code,
}
}
/**
* 从入口文件开始,获取整个依赖图
* @param {string} entry 入口文件
*/
function createGraph(entry) {
// 从入口文件开始,解析每一个依赖资源,并将其一次放入队列中
const mainAssert = createAsset(entry)
const queue = {
[entry]: mainAssert
}
/**
* 递归遍历,获取所有的依赖
* @param {*} assert 入口文件
*/
function recursionDep(filename, assert) {
// 跟踪所有依赖文件(模块唯一标识符)
assert.mapping = {}
// 由于所有依赖模块的 import 路径为相对路径,所以获取当前绝对路径
const dirname = path.dirname(filename)
assert.dependencies.forEach(relativePath => {
// 获取绝对路径,以便于 createAsset 读取文件
const absolutePath = path.join(dirname, relativePath)
// 与当前 assert 关联
assert.mapping[relativePath] = absolutePath
// 依赖文件没有加入到依赖图中,才让其加入,避免模块重复打包
if (!queue[absolutePath]) {
// 获取依赖模块内容
const child = createAsset(absolutePath)
// 将依赖放入 queue,以便于 for 继续解析依赖资源的依赖,直到所有依赖解析完成,这就构成了一个从入口文件开始的依赖图
queue[absolutePath] = child
if (child.dependencies.length > 0) {
// 继续递归
recursionDep(absolutePath, child)
}
}
})
}
// 遍历 queue,获取每一个 asset 及其所以依赖模块并将其加入到队列中,直至所有依赖模块遍历完成
for (let filename in queue) {
let assert = queue[filename]
recursionDep(filename, assert)
}
// 返回依赖图
return queue
}
/**
* 打包(使用依赖图,返回一个可以在浏览器运行的包)
* 所以返回一个立即执行函数 (function() {})()
* 这个函数只接收一个参数,包含依赖图中所有信息
*
* 遍历 graph,将每个 mod 以 `key: value,` 的方式加入到 modules,
* 其中key 为 filename, 模块的唯一标识符,value 为一个数组, 它包含:
* function(require, module, exports){${mod.code}}
* ${JSON.stringify(mod.mapping)}
*
* 其中:function(require, module, exports){${mod.code}}
* 使用函数包装每一个模块的代码 mode.code,防止 mode.code 污染全局变量或其它模块
* 并且模块转化后运行在 common.js 系统,它们期望有 require, module, exports 可用
*
* 其中:${JSON.stringify(mod.mapping)} 是模块间的依赖关系,当依赖被 require 时调用
* 例如:{ './message.js': 1 }
*
* @param {array} graph 依赖图
*/
function bundle(graph) {
let modules = ''
for (let filename in graph) {
let mod = graph[filename]
modules += `'${filename}': [
function(require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`
}
// 注意:modules 是一组 `key: value,`,所以我们将它放入 {} 中
// 实现 立即执行函数
// 首先实现一个 require 函数,require('${entry}') 执行入口文件,entry 为入口文件绝对路径,也为模块唯一标识符
// require 函数接受一个 id(filename 绝对路径) 并在其中查找它模块我们之前构建的对象.
// 通过解构 const [fn, mapping] = modules[id] 来获得我们的函数包装器和 mappings 对象.
// 由于一般情况下 require 都是 require 相对路径,而不是id(filename 绝对路径),所以 fn 函数需要将 require 相对路径转换成 require 绝对路径,即 localRequire
// 注意:不同的模块 id(filename 绝对路径)时唯一的,但相对路径可能存在相同的情况
//
// 将 module.exports 传入到 fn 中,将依赖模块内容暴露处理,当 require 某一依赖模块时,就可以直接通过 module.exports 将结果返回
const result = `
(function(modules) {
function require(moduleId) {
const [fn, mapping] = modules[moduleId]
function localRequire(name) {
return require(mapping[name])
}
const module = {exports: {}}
fn(localRequire, module, module.exports)
return module.exports
}
require('${entry}')
})({${modules}})
`
return result
}
/**
* 输出打包
* @param {string} path 路径
* @param {string} result 内容
*/
function writeFile(path, result) {
// 写入 ./dist/bundle.js
fs.writeFile(path, result, (err) => {
if (err) throw err;
console.log('文件已被保存');
})
}
// 获取依赖图
const graph = createGraph(entry)
// 打包
const result = bundle(graph)
// 输出
fs.access(`${output.path}/${output.filename}`, (err) => {
if(!err) {
writeFile(`${output.path}/${output.filename}`, result)
} else {
fs.mkdir(output.path, { recursive: true }, (err) => {
if (err) throw err;
writeFile(`${output.path}/${output.filename}`, result)
});
}
})