热更新

218 阅读5分钟

使用

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
module.exports = {
    mode:'development',
    devtool:false,
    entry:'./src/index.js',
    output:{
        filename:'[name].[hash].js',
        path:path.resolve(__dirname,'dist'),
        hotUpdateGlobal:'webpackHotUpdate'
    },
    devServer:{
        hot:true,//支持热更新
        port:8080,
        contentBase:path.resolve(__dirname,'static')
    },
    plugins:[
        new HtmlWebpackPlugin({
            template:'./public/index.html'
        }),
        new HotModuleReplacementPlugin()//此处可写可不写,因为如果devServer.hot==true的话,webpack会自动帮你添加此插件
    ]

}

index.js:

let render = ()=>{
    let title = require('./title.js');
    document.getElementById('root').innerText = title;
}
render();
if(module.hot){
    //当title.js模块发生修改的时候,执行render方法这个回调函数
    module.hot.accept(["./title.js"],render);
}

title.js:

module.exports="title8";

注:index.js中的module.hot的module就是commonjs规范里定义的module,webpack会在这个对象上面挂hot这个属性

监听文件变化实际是调用了node的一个原生api:fs.watch

node会在内部不停的询问文件的变化

打包后的dist目录,是静态文件服务器

实现

服务端

测试脚本:node startDevServer.js

startDevServer.js:

//1.准备创建开发服务器
const webpack = require('webpack');
const config = require('./webpack.config');
const Server = require('./webpack-dev-server/lib/Server');
function startDevServer(compiler, config) {
  const devServerArgs = config.devServer ||{};
  //3.启动HTTP服务器,里面还会负责打包我们的项目并提供预览服务,通过它访问打包后的文件
  const server = new Server(compiler,devServerArgs);
  const {port=8080,host="localhost"}= devServerArgs;
  server.listen(port,host,(err)=>{
      console.log(`Project is running at http://${host}:${port}/`);
  });
}
//2.创建complier实例
const compiler = webpack(config);
//3.启动服务HTTP服务器
startDevServer(compiler,config);


module.exports = startDevServer;

Server:

const express = require('express');
const http = require('http');
const updateCompiler = require('./utils/updateCompiler');
const webpackDevMiddleware = require('../../webpack-dev-middleware');
const io = require('socket.io');
class Server{
    constructor(compiler,devServerArgs){
        this.sockets = [];
        this.compiler = compiler;
        this.devServerArgs = devServerArgs;
        updateCompiler(compiler);
        this.setupHooks();//开始启动webpack的编译
        this.setupApp();
        this.routes();
        this.setupDevMiddleware();
        this.createServer();
        this.createSocketServer();
    }

    setupDevMiddleware(){
        this.middleware = webpackDevMiddleware(this.compiler);
        this.app.use(this.middleware);
    }
    setupHooks(){
        //当webpack完成一次完整的编译之后,会触发的done这个钩子的回调函数执行
        //编译成功后的成果描述(modules,chunks,files,assets,entries)会放在stats里
        this.compiler.hooks.done.tap('webpack-dev-server',(stats)=>{
            console.log('新的一编译已经完成,新的hash值为',stats.hash);
            //以后每一次新的编译成功后,都要向客户端发送最新的hash值和ok
            this.sockets.forEach(socket=>{
                socket.emit('hash',stats.hash);
                socket.emit('ok');
            });
            this._stats=stats;//保存一次的stats信息
        });
    }
    routes(){
        if(this.devServerArgs.contentBase){
            //此目录将会成为静态文件根目录
           this.app.use(express.static(this.devServerArgs.contentBase));
        }
    }
    setupApp(){
        //this.app并不是一个http服务,它本身其实只是一个路由中间件
        this.app = express();
    }
    createServer(){
        this.server = http.createServer(this.app);
    }
    createSocketServer(){
        //websocket通信之前要握手,握手的时候用的HTTP协议
        const websocketServer = io(this.server);
        //监听客户端的连接
        websocketServer.on('connection',(socket)=>{
            console.log('一个新的websocket客户端已经连接上来了');
            //把新的客户端添加到数组里,为了以后编译成功之后广播做准备
            this.sockets.push(socket);
            //监控客户端断开事件
            socket.on('disconnect',()=>{
                let index = this.sockets.indexOf(socket);
                this.sockets.splice(index,1);
            });
            //如果以前已经编译过了,就把上一次的hash值和ok发给客户端
            if(this._stats){
                socket.emit('hash',this._stats.hash);
                socket.emit('ok');
            }
        });

    }
    listen(port,host,callback){
        this.server.listen(port,host,callback);
    }
}
module.exports = Server;

添加的中间件webpackDevMiddleware的作用就是拦截静态请求,然后去memory-fs里找对应的文件,如果找到了就返回

../../webpack-dev-middleware:

/**
 * webpack开发中间件
 * 1.真正的以监听模式启动webpack的编译
 * 2.返回一个express中间件,用来预览我们产出的资源文件
 * @param {*} compiler 
 */
const MemoryFileSystem = require('memory-fs');
const fs = require('fs');
const memoryFileSystem = new MemoryFileSystem();
const middleware = require('./middleware');
function webpackDevMiddleware(compiler){
    //1.真正的以监听模式启动webpack的编译
    compiler.watch({},()=>{
        console.log('监听到文件变化,webpack重新开始编译');
    });
    //产出的文件并不是写在硬盘上了,为提供性能,产出的文件是放在内存里,所以你在硬盘上看不见
    //当webpack准备写入文件的时候,是用的compiler.outputFileSystem来写入
    //let fs = compiler.outputFileSystem = memoryFileSystem;
    return middleware({
        fs,
        outputPath:compiler.options.output.path//写入到哪个目录里去
    });

}
module.exports = webpackDevMiddleware;

./middleware:

/**
 * 这个express中间件负责提供产出文件的预览
 * 拦截HTTP请求,看看请求的文件是不是webpack打包出来的文件。
 * 如果是的话,从硬盘上读出来,返回给客户端
 */
let mime = require('mime')
let path = require('path')
function wrapper({fs,outputPath}){
   return (req,res,next)=>{
        let url = req.url;//http://localhost:9000/main.js
        if(req.url === '/favicon.ico') return res.sendStatus(404);
        if(url === '/') url = "/index.html";
        //outputPath = path.resolve(__dirname,'dist')
        //url =/main.js
        //filename = C:\aproject\zhufengwebpack202011\10.hmr9000\dist/main.js
        let filename = path.join(outputPath,url);
        console.log(filename);
        try{
            let stat = fs.statSync(filename);
            if(stat.isFile()){
                let content = fs.readFileSync(filename);
                //main.js=>application/javascript main.jpe=>image/jpeg
                res.setHeader('Content-Type',mime.getType(filename));
                return res.send(content);
            }else{
               return  res.sendStatus(404);
            }
        }catch(error){
            console.log(error);
            return next(error);
        }
   }
}

module.exports = wrapper;

开启热更新的情况下,服务端还会给webpack.config文件中的entry里面添加两个入口:

const path = require('path');
function updateCompiler(compiler){
    const options = compiler.options;
    //1来自于webpack-dev-server/client/index.js 它就是浏览里的websocket客户端
    options.entry.main.import.unshift(
        require.resolve('../../client/index.js')
    );
    //2.webpack/hot/dev-server.js 它用来在浏览器里监听发身的事件,进行后续热更新逻辑
    options.entry.main.import.unshift(
        require.resolve('../../../webpack/hot/dev-server.js')
    );
    console.log(compiler.entry);
    //把入口变更之后,你得通知webpack按新的入口进行编译
    compiler.hooks.entryOption.call(options.context,options.entry);
}
module.exports = updateCompiler;
/**
 * webpack4 
 * entry:{
 *   main:['./src/index.js']
 * }
 * webpack5
 * entry:{
 *   main:{
 *      import:['webpack/hot/dev-server.js','webpack-dev-server/client/index.js','./src/index.js']
 *   }
 * }
 * 
 */

客户端

HotModuleReplacementPlugin为每个模块添加了hot属性

hotCreateRequire为每个模块添加了parents和children属性、check和accept方法

客户端打包时有以下几个入口:

webpack-dev-server/client/index.js

webpack/hot/dev-server.js

./src/index.js

这几个入口最终都会打包到main.js中去

我们以hmr.html和hmr.js两个文件为例,来说明客户端的运行原理

实际环境中,hmr.js就相当于打包出来的合并了3个入口的main.js

hmr.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HMR</title>
</head>
<body>
    <input/>
    <div id="root"></div>
    <script src="/socket.io/socket.io.js"></script>
    <script src="hmr.js"></script></body>
</html>

引入/socket.io/socket.io.js之后,就会在window上添加了io方法,在js中就可以使用了

webpack-dev-server/client/index.js:

var hotEmitter = require('../../webpack/hot/emitter');
//通过websocket客户端连接服务器端
var socket = io();
//当前最新的hash值
var currentHash;
socket.on('hash',(hash)=>{
    console.log('客户端据此到hash消息');
    currentHash = hash;
});
socket.on('ok',()=>{
    console.log('客户端据此到ok消息');
    reloadApp();
});
function reloadApp(){
    hotEmitter.emit('webpackHotUpdate',currentHash);
}

webpackHotUpdate事件在webpack/hot/dev-server.js文件中监听:

var hotEmitter = require('../../webpack/hot/emitter');
hotEmitter.on('webpackHotUpdate',(currentHash)=>{
  console.log('dev-server收到了最新的hash值',currentHash);
  //进行真正的热更新检查
  hotCheck();
});

HotModuleReplacementPlugin插件给每个模块添加hot属性、

hotCreateRequire为每个模块添加了parents和children属性、check和accept方法,都体现在打包后的main.js里面,我们此处用hmr.js来模拟一下打包后的main.js

hotCheck是HotModuleReplacementPlugin里定义的吗?(待确认)

  function hotCheck(){
      console.log('开始进行热更新的检查!');
      hotDownloadManifest().then(update=>{
        update.c.forEach(chunkId=>{
          hotDownloadUpdateChunk(chunkId);
        });
        lastHash = currentHash;
      }).catch(()=>{
        window.location.reload();
      });
  }
  function hotDownloadManifest(){
    //webpack4 ajax webpack5 fetch
    return fetch(`main.${lastHash}.hot-update.json`).then(res=>res.json())
  }
  function hotDownloadUpdateChunk(chunkId){
    let script = document.createElement('script');
    script.src = `${chunkId}.${lastHash}.hot-update.js`;
    document.head.appendChild(script);
  }

下载下来的.hot-update.js文件大概是这样:

self["webpackHotUpdate"]("main",{

/***/ "./src/title.js":
/*!**********************!*\
  !*** ./src/title.js ***!
  **********************/
/***/ ((module) => {

module.exports="title5";

/***/ })

},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ 	"use strict";
/******/ 
/******/ 	/* webpack/runtime/getFullHash */
/******/ 	(() => {
/******/ 		__webpack_require__.h = () => "0805f33576e78cb08802"
/******/ 	})();
/******/ 	
/******/ }
);

self["webpackHotUpdate"]方法定义如下:

  self["webpackHotUpdate"]= function(chunkId,moreModules){
    hotAddUpdateChunk(chunkId,moreModules);
  }
  let hotUpdate = {};
  function hotAddUpdateChunk(chunkId,moreModules){
    for(var moduleId in moreModules){
      //合并到模块定义对象里
      hotUpdate[moduleId]= modules[moduleId]=moreModules[moduleId];
    }
    hotApply();
  }
  function hotApply(){
    for(let moduleId in hotUpdate){
        let oldModule = cache[moduleId];//获取到老的模块 module parents children
        delete cache[moduleId]//得把老的缓存删除,不然再加载还会读到老模块
        //会不会有模块没有parents,入口模块就没有父亲. webpack5模块联邦
        if(oldModule.parents && oldModule.parents.size>0){
           let parents = oldModule.parents;
           parents.forEach(father=>{
            father.hot.check(moduleId);
           });
        }
    }
  }
  function hotCreateModule(){
    let hot = {
      _acceptedDependencies:{},//接收的依赖对象
      accept(deps,callback){//接收依赖的变化 注册各模块的回调函数
        for(let i=0;i<deps.length;i++){
          hot._acceptedDependencies[deps[i]] = callback;
        }
      },
      check(moduleId){
        let callback = hot._acceptedDependencies[moduleId];
        callback&&callback();
      }
    }
    return hot;
  }

\