一直对webpack热更新非常好奇,在看了一些资料之后。尝试写一个简单的demo模仿。
step1
第一步要实现一个跟webpack类似的模块处理工具。这里直接用极客时间上的一个课程的demo. 简单介绍一下这几个文件:
simple-webpack
├─package-lock.json
├─package.json
├─simplepack.config.js
├─src
| ├─greeting.js
| ├─index.html
| └index.js
├─lib
| ├─compiler.js
| ├─index.js
| └parser.js
- simplepack.config.js:类似webpack.config.js
- src: 源码
- lib:类似webpack。里面的parser.js主要使用babylon这个库根据ast树分析依赖
step2
1.过程
第二步,开一个websocket服务,并且在html插入通讯脚本。webpack中,客户端会收到hot、liveReload、invalid、hash、still-ok等15种类型的通知,其中,wanring类型和ok类型会触发reloadApp函数,开了热更新后,这个函数里面会触发webpackHotUpdate
事件,这个事件的处理事件如下:
hotEmitter.on("webpackHotUpdate", function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
log("info", "[HMR] Checking for updates on the server...");
check();
}
});
log("info", "[HMR] Waiting for update signal from WDS...");
function check() {
module.hot
.check(true)
.then(function (updatedModules) {
if (!updatedModules) {
log("warning", "[HMR] Cannot find update. Need to do a full reload!");
log(
"warning",
"[HMR] (Probably because of restarting the webpack-dev-server)"
);
window.location.reload();
return;
}
if (!upToDate()) {
check();
}
__webpack_require__(374)(updatedModules, updatedModules);
if (upToDate()) {
log("info", "[HMR] App is up to date.");
}
})
.catch(function (err) {
// 略
});
};
经过打点可以知道更新的执行部分就在module.hot.check这个方法里面,如果找不到更新的模块就刷新页面。继续找hot.check方法
function hotCheck(applyOnUpdate) {
if (currentStatus !== "idle") {
throw new Error("check() is only allowed in idle status");
}
setStatus("check");
return __webpack_require__.hmrM().then(function (update) {
if (!update) {
setStatus(applyInvalidatedModules() ? "ready" : "idle");
return null;
}
setStatus("prepare");
var updatedModules = [];
blockingPromises = [];
currentUpdateApplyHandlers = [];
return Promise.all(
Object.keys(__webpack_require__.hmrC).reduce(function (promises,key) {
__webpack_require__.hmrC[key](
update.c,
update.r,
update.m,
promises,
currentUpdateApplyHandlers,
updatedModules
);
return promises;
},[])
).then(function () {
return waitForBlockingPromises(function () {
if (applyOnUpdate) {
return internalApply(applyOnUpdate);
} else {
setStatus("ready");
return updatedModules;
}
});
});
});
}
setStatus就是更改currentStatus,这些状态的判断跳过。__webpack_require__.hmrM()
方法是用fetch发起请求。后面执行hmrC上的方法,根据我的搜索只有一个叫jsonp的方法,这里面根据chunkId执行了一些加载,还有一些找不到出处和用处代码,比如__webpack_require__.f
。然后真正触发更改的,是internalApply
,这个函数主要是把currentUpdateApplyHandlers
里面的每个对象的dispose方法和apply方法各自跑一轮。对象是从applyHandler
这个函数里面产生的,这个函数有点长。主要是定义了两个方法:
- dispose: 更改不需要的模块的各种状态,调用
module.hot._disposeHandlers
方法 - Apply: 更新模块,重新加载模块,调用
module.hot._acceptedDependencies[dependency]
然后,这些带下划线的方法是从哪里来的呢? __webpack_require__
里面会给每个模块初始化parent、children、hot等对象属性,这些方法在hot对象里面。以_acceptedDependencies
为例,当你在模块中调用accept
方法的时候,就会加进去。没见过这个方法?我也没用过,仔细看了一下webpack官网才发现
+ if (module.hot) {
+ module.hot.accept('./print.js', function() {
+ console.log('Accepting the updated printMe module!');
+ printMe();
+ })
+ }
像vue、react这种框架,本身在对应的loader里面就实现了该方法,所以我们不用自己写。
2.demo
上面的过程是在是太复杂,搞不定。我要简化一下,首先,修改后的代码直接在socket中返回;其次,也不分析模块之间的父子关系。我就写死一个模块名,模块更新之后的处理也写死统一调用accept函数,也只监听改这个文件的变动。在step1的demo的基础上修改,修改如下
2.1 配置部分改动
'use strict';
const path = require('path');
module.exports = {
entry: path.join(__dirname, './src/index.js'),
output: {
path: path.join(__dirname, './dist'),
filename: 'main.js'
},
template: {
src: './src/index.html',
replaceTag: '<!-- [123] -->'
},
mode: 'server'
};
加了template配置用来指定html便于插入js,加了mode配置用来区分是打包还是本地运行
2.2 lib/index.js 部分变动
const Compiler = require('./compiler');
const options = require('../simplepack.config');
const path = require('path');
const fs = require('fs');
const WebSocket = require('ws');
const socketScript = `
<script>
var ws = new WebSocket('ws://localhost:8090');
ws.onmessage = function (evt)
{
var receivedMsg = evt.data;
receivedMsg = JSON.parse(receivedMsg);
receivedMsg.forEach(item => {
modules[item.filename] = new Function('require', 'module', 'exports', item.transformCode)
__require__(item.filename)
if (item.filename === './greeting.js') {
__require__(item.filename).accept();
}
})
};
</script>
`;
const compiler = new Compiler(options)
if (options.mode === 'server') {
const Koa = require('koa');
const app = new Koa();
// server部分, 提供资源
compiler.hooks.afterEmitFiles.tapPromise('afterEmitFiles', res => {
let html = fs.readFileSync(path.resolve(__dirname, '../', options.template.src)).toString();
html = html.replace(options.template.replaceTag, `<script>${res}</script>`)
html = html.replace('</head>', socketScript + '</head>')
app.use(async ctx => {
ctx.header['content-type'] = 'text/html';
ctx.body = html;
});
app.listen(8010);
return Promise.resolve(console.log("Koa运行在:http://127.0.0.1:8010"));
})
// socket部分, 提供更新
let wss = new WebSocket.Server({ port: 8090 });
let wsList = [];
let greetingFile = path.resolve(__dirname, '../src/greeting.js');
fs.watch(greetingFile, () => {
compiler.run('./greeting.js');
compiler.hooks.build.tapPromise('rebuild', res => {
wsList.forEach(item => item.ws.send(JSON.stringify(res)));
return Promise.resolve(console.log('rebuild'));
});
});
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
wsList.push({ ip, ws })
ws.on('message', function incoming(message) {
console.log('message')
console.log('received: %s', message);
});
ws.on('close', function () {
console.log('close')
wsList = wsList.filter(item => item.ip !== ip);
})
});
}
compiler.run();
这部分主要增加了一个websocket通讯,客户端接受的代码用Function重新替换了一下。监听到文件变动,重新编译一次。run结束后会触发build.tapPromise,随后通知客户端更新
2.3 lib/compiler.js 部分改动
const fs = require('fs');
const path = require('path');
const { getAST, getDependencis, transform } = require('./parser');
const { AsyncSeriesWaterfallHook } = require('tapable');
module.exports = class Compiler {
constructor(options) {
const { entry, output } = options;
this.entry = entry;
this.output = output;
this.modules = [];
this.mode = options.mode;
this.hooks = {
afterEmitFiles: new AsyncSeriesWaterfallHook(['arg1']),
build: new AsyncSeriesWaterfallHook(['arg1'])
}
}
run(filename = null) {
if (filename) {
var newModulesList = [];
const entryModule = this.buildModule(filename);
newModulesList.push(entryModule);
newModulesList.map((_module) => {
_module.dependencies.map((dependency) => {
newModulesList.push(this.buildModule(dependency));
});
});
// 对比
const diff = [];
newModulesList.forEach(newModule => {
const target = this.modules.find(item => item.filename === newModule.filename);
if (!target || target.transformCode !== newModule.transformCode) {
diff.push({
filename,
content: newModule.transformCode
});
}
})
this.hooks.build.promise(newModulesList);
} else {
const entryModule = this.buildModule(this.entry, true);
this.modules.push(entryModule);
this.modules.map((_module) => {
_module.dependencies.map((dependency) => {
this.modules.push(this.buildModule(dependency));
});
});
this.emitFiles();
}
}
buildModule(filename, isEntry) {
let ast;
if (isEntry) {
ast = getAST(filename);
} else {
let absolutePath = path.join(process.cwd(), './src', filename);
ast = getAST(absolutePath);
}
return {
filename,
dependencies: getDependencis(ast),
transformCode: transform(ast)
};
}
emitFiles() {
const outputPath = path.join(this.output.path, this.output.filename);
let modules = '';
this.modules.map((_module) => {
modules += `'${ _module.filename }': function (require, module, exports) { ${ _module.transformCode } },`
});
const bundle = `
var modules = {${modules}};
function __require__(fileName) {
const fn = modules[fileName];
const module = { exports : {
accept () {}
} };
fn(__require__, module, module.exports);
return module.exports;
}
__require__('${this.entry}');
`;
if (this.mode !== 'server') {
fs.writeFileSync(outputPath, bundle, 'utf-8');
}
this.hooks.afterEmitFiles.promise(bundle);
}
};
最后是compile.js。增加了两个hook用来通知外部编译结束。run函数增加了针对某个模块编译的处理。因为我其实只监听了一个依赖,所以map和foreach其实可以省一下。emitFiles函数里面对bundle做一些更改,方便替换和更新。
效果
src下html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p id="p">123</p>
</body>
<!-- [123] -->
</html>
src下index.js
import { greeting } from './greeting.js';
greeting();
src下greeting.js
var count = 0;
export function greeting() {
document.querySelector('#p').innerHTML = count;
}
export function accept () {
count++;
document.querySelector('#p').innerHTML = count;
}
效果如下: