概述

之前呢我们已经简单了解了webpack-dev-server的一些基本用法和特性,那他主要就是为我们使用webpack构建的项目提供了一个比较友好的开发环境,和一个可以用来调试的开发服务器。那使用webpack-dev-server就可以让我们的开发过程更加专注于编码。

因为他可以监视到我们代码变化,然后自动进行打包,最后再通过自动刷新的方式,同步到浏览器,以便于我们可以及时的预览。

但是当你实际去使用这样的特性去弯沉具体的开发任务时那你会发现,这里还是会有一些不舒服的地方。

那例如这里是一个编辑器的应用,我想要在这里能够及时去调试编辑器中文本内容的样式,正常的操作肯定是先尝试在编辑器中添加一些文本作为展示样例。

然后我们回到开发工具当中找到控制这个编辑器样式的css文件。在这个编辑器样式文件中我们简单添加一些样式。那这个时候我们就能发现问题了,当我们修改完样式过后呢,原本想着可以及时看到最新的界面效果,但是这个时候我们编辑器中给的内容确没有了,那这里我们不得不再来编辑器中再去添加一些文本,如果说此时你对我们的样式还是不满意的话,那我们还需要继续来去调整样式,而且调整完了过后又会面临刚刚文本内容丢失这样一个问题。

那久而久之的话你就会发现,自动刷新这样一个功能还是很鸡肋,他并没有我们想象中那么好用。那这是因为我们每次修改完代码,webpack监视到文件变化就会自动打包,然后自动刷新到浏览器。

那一旦页面整体刷新,那页面中之前的任何操作状态都会丢失,所以说就会出现,刚刚我们所看到的这样一个情况。但是呢,聪明的人一般都会有一些小办法,例如我们可以在代码当中先去写死一个文本到我们的编辑器当中。那这样的话,即便是我们页面刷新也不会有丢失的这种情况出现。

那又或是我们通过一些额外的代码,把我们的内容先保存到临时存储中,然后刷新过后我们再去取回来。总之就是你有问题,我有办法。

那确实这些都是好办法,但是又都不是特别的好,因为这些都是典型的有洞补洞的操作,并不能根治我们页面刷新过后导致的页面数据丢失的这样一个问题。

而且这些方法都需要我们去编写一些跟我们业务本身无关的一些代码,那更好的办法自然是能够在页面不刷新的这种情况下我们代码也可以及时的更新进去。

那针对于这样的需求,webpack同样也可以满足,那接下来我们就一起去了解一下webpack当中如果去在页面不刷新的情况下及时的去更新我们的代码模块。

HMR介绍

HRM全程是Hot Module Replacement那翻译过来叫做,模块热替换或者叫做模块热更新。

那计算机行业我们经常听到一个叫做热拔插的名词,指的就是我们可以在一个正在运行的机器上随时去插拔设备,而我们机器的运行状态,是不会受插拔设备的影响。而且我们插上的设备可以立即开始工作,例如我们在电脑设备上的USB端口就是可以热拔插的。

那模块热替换的这个热,和我们刚刚提到的热拔插实际上是一个道理,他们都是在运行过程中的即时变化,那webpack中的模块热替换指的就是可以在应用程序运行的过程中实时的去替换掉我们应用中的某个模块。而我们应用的运行状态不会因此而改变。

例如我们在应用程序的运行过程中,我们修改了某个模块,那通过自动刷新就会导致我们应用整体的刷新,那应用中的状态信息呢,都会丢失掉。

而如果我们这个地方使用的是热替换的话,我们就可以实现,只将刚刚修改的这个模块,实时的去替换到应用当中,不必去完全刷新应用。

那这里我们可以先来对比一下使用热更新和使用自动刷新这两种方式的体验差异。

那屏幕显示的这个项目我们已经开启了HMR这个特性,那这里我们同样先在我们的页面当中随意去添加一些内容,也就是为我们的页面去制造一些运行的状态。

然后我们回到开发工具当中,这里我们尝试去修改我们文本的样式,我们先将它的颜色修改为红色,那保存过后呢,我们就可以立即看到新的样式结果。而我们的页面并没有整体的刷新。

那这种体验是非常友好的。对于项目中其他代码文件的修改,也可以有相同的热替换的这样一种体验。那我们这里再来尝试修改一下js文件。我们随意去修改一行js代码,然后保存。那此时呢我们浏览器当中,也没有刷新页面,而是直接执行了刚刚我们修改的这个模块。

那不仅如此,对于项目当中那些非文本文件,同样也可以使用热更新,那例如我们所显示的背景图片,我们通过简单的画图板来去修改一下这个图片。保存过后呢,我们浏览器当中同样也可以及时更新过来我们最新的这张图片,而我们整个应用的运行状态呢,也没有因此而发生变化。

那这就是HMR的作用和他的一个体验。

HMR可以算是webpack中最强大的特性之一,同时他也是最受欢迎的特性。因为他确实极大程度的提高了开发者的工作效率,所以说我们接下来要重点来去看,如何去使用HMR

使用HMR

热更新这么强大的功能而言,他的使用并不算特别的复杂,接下来我们就一起来了解下具体如何去使用HMR。

HMR已经集成在了webpack-dev-server工具当中,所以说我们就不需要再去单独安装什么模块了。

那使用这个特性呢,我们需要去运行webpack-dev-server这个命令时通过--hot这样一个参数去开启这样一个特性,或者也可以在配置文件当中去添加对应的配置来去开启这样一个功能。

我们打开配置文件。这里我们需要配置的地方有两个,第一个我们要将dev-server中的hot属性设置为true。

然后我们需要载入一个插件,那这个插件是webpack内置的一个插件,所以我们这里先去导入webpack模块,那有了这个模块过后呢,我们这里使用的是这个模块当中的hot-module-replacement-plugin这样一个插件。

我们把这个插件配置进来。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development'
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    devtool: 'source-map',
    devServer: {
        hot: true
    }
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            template: './src/index.html'
        }),
        new webpack.HotModuleReplacementPlugin()
    ]
}

配置完成过后我们打开命令行终端,因为我们已经开启了HMR,所以我们这里直接去运行webpack-dev-server 然后去启动weboack开发的服务器。

yarn webpack-dev-server

接下来我们就可以在浏览器去体验HMR带来的优势了,我们回到开发工具当中,这里我们先尝试修改一下样式文件,那保存过后这个样式模块就可以以热更新的方式直接去作用到我们的页面当中了。

然后呢我们再来尝试修改一下js模块文件,我们在js中随便console.log一句话,此时我们就会发现,这里的页面却自动刷新了。并没有刚刚我们所看到的那种热更新的体验。

我们再在页面的编辑器当中去添加一些文字,这样的话我们可以更容易去确认是否有刷新,完成过后我们再回到开发工具当中。修改文件,我们发现页面真的刷新了。

我们可以发现样式文件有变动可以实现热更新,但是js文件的修改就失去了作用,那具体我们该如何去实现所有模块资源的热替换,我们接着往下看。

我们发现了模块热替换确实提供了非常友好的开发体验,但是当我们自己尝试开启HMR过后呢,我们发现效果确不尽如人意,那这里是因为HMR他并不像webpack其他的特性一样可以开箱即用。

也就是说我们的HMR还需要我们做一些额外的操作才能够可以正常工作,那webpack中的HMR需要我们手动通过代码去处理当模块更新过后我们需要如何把更新过后的模块去替换到我们运行的页面当中。

那可能会有人问,为什么当我开启了HMR过后我们的样式文件就可以直接去热更新,我们好像也没有手动的去处理样式模块的更新。

这是因为样式文件是经过loader处理的,在style-loader里面就已经自动处理了样式文件的热更新,所以说我们就不需要我们自己去额外做手动的操作。

可能你会想,凭什么样式文件就可以自动处理,而我的脚本文件就需要我们自己手动处理呢,这个原因也很简单,因为样式模块更新过后呢,他只需要把更新过后的css及时的替换到页面当中,他就可以覆盖掉之前的样式,从而实现样式文件的更新。

而我们所编写的javaSciript的模块他是没有任何规律的,因为你可能在一个模块中导出的是一个对象,也有可能是一个字符串,还有可能导出的是一个函数,那我们对导出的这个成员我们的使用也是各不相同的。

所以说webpack面对这些毫无规律的js模块,他就根本不知道,如何去处理更新过后的模块,那也就没有办法帮你实现一个通用所有模块的替换方案。

这就是为什么样式文件可以直接热更新,而js文件更新过后我们页面还是自动刷新的原因。

那可能有一些使用过vue-cli或者是create-react-app的一些脚手架工具的人来说,他会觉得,我的项目当中并没有手动的去处理我们js模块的更新,我的代码照样可以做热替换,没有我们刚刚说的这么麻烦。

那这是因为你使用的是框架,那我使用框架开发时,我们项目中每个文件他自然就有了规律,因为框架他提供的就是一些规则,例如我们再react模块中要求每一个模块必须要去导出一个函数,或者是导出一个类。

那有了这样一个规律那就可能会有通用的替换办法,例如每一个文件导出的都是一个函数的话那他就自动的把这个函数再拿回来再去执行一下。

而且这些工具内部都已经帮你提供好了这种通用的HMR替换模块,所以说我们就不需要自己手动处理了,如果你之前没有接触过这样的工具,也无所谓,你可以忽略掉这一条。这并不影响我们后面的理解。

综上所述,我们还需要自己手动处理当JS模块更新过后我们需要去做的事情。

HMR APIs

Hot-module-replacement-plugin为我们js提供了一套用于去处理HMR的api,我们需要在自己的代码中去使用这套api来去处理当某一个模块更新过后应该如何替换掉当前正在运行的页面当中。

接下来我们一起回到代码当中,尝试通过HMR的api来去手动处理模块更新过后的热替换,我们打开js。

这是我们打包的入口文件。也就是在这个文件当中才开始去加载其他的模块,那就是因为这个模块当中使用了这些导入的模块,那一但当这些模块更新了过后,我们就必须要去重新使用这些模块。

所以说我们要在这个模块中去处理他所依赖的这些模块更新过后的热替换。

在这套API当中,他为我们的module对象提供了一个hot属性,那这个属性也是一个对象,他就是我们HMR API的核心对象,那他提供了一个accept方法,用于去注册,当我们某一个模块更新过后的处理函数。

那这个方法的第一个参数接收的就是我们依赖模块的路径,第二个参数就是依赖路径更新过后的处理函数。

我们这里先来尝试注册一下当我们的editor模块更新过后的处理函数。那这里第一个参数就是editor模块的路径,然后第二个参数呢,我们传入一个函数。然后在这个函数中我们去打印一个消息,表示一个editor模块更新了。

import createEditor from './editor';
import background from './better.png';
import './global.css';

const editor = createEditor();
document.body.appendChild(editor);

const img = new Image();
img.src = background;
document.body.appendChild(img);

module.hot.accept('./editor', () => {
    console.log('editor 模块更新了,需要这里手动处理');
});

完成过后我们打开命令行启动webpak-dev-server回到浏览器打开开发人员工具。

那这个时候我们就可以尝试回到开发工具中去修改editor这个模块中的代码。那此时浏览器的控制台中就会打印我们刚刚所打印的那个消息,而且也就不会再去触发自动刷新了。

那也就是说一旦这个模块的更新被我们这样手动的处理了,那他就不会去触发自动刷新,反之如果我们没有手动处理这个模块的热替换。那HMR就会自动forback到自动刷新,从而导致我们页面刷新。

JS模块热替换

那了解了这个API的作用过后我们就需要去考虑,具体该怎样实现editor模块这个热替换的逻辑,那这个模块导出的是一个函数,我们这里先直接打印到控制台当中,然后在模块更新过后我们再去打印一次。

import createEditor from './editor';
import background from './better.png';
import './global.css';

const editor = createEditor();
document.body.appendChild(editor);

const img = new Image();
img.src = background;
document.body.appendChild(img);

console.log(createEditor);
module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理');
    console.log(createEditor);
});

那这个时候我们回到我们的editor.js当中,我们尝试去修改这个模块,然后保存,此时你会发现,当我们的模块更新过后我们这里拿到的函数也就更新为了最新的结果。

那知道这样一个特点就好办了,因为我们这里是使用了这个函数去创建一个界面的元素,那一但当这个函数更新了,我们界面上这个元素也应该被重新创建。所以说我们这里先直接去移除原来的元素,然后我们再去调用createEditor这个函数去创建一个新的元素,然后追加到页面中。

那这样的话就相当我们的界面重新工作了。

并且我们这里还需要去记录下来我们新创建的这个元素,把他放在变量当中,否则的话我们下一次热替换的时候就找不到这一次所创建的这个元素了。

import createEditor from './editor';
import background from './better.png';
import './global.css';

const editor = createEditor();
document.body.appendChild(editor);

const img = new Image();
img.src = background;
document.body.appendChild(img);

let lastEditor = editor;
module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理');
    // console.log(createEditor);
    document.body.removeChild(lastEditor);
    const newEditor = createEditor();
    document.body.appendChild(newEditor);
    lastEditor = newEditor;
});

完成以后我们再来尝试修改editor模块。保存过后我们回到浏览器当中来查看一下这里呢,界面当中的元素确实立即更新为了我们最新的结果。

我们再尝试在界面上输入一些内容,再修改editor模块,保存,此时你就会发现和我们之前同样的问题。

那由于热替换时我们把界面上之前的编辑器元素已经移除掉了,那我们之前所输入的状态自然也就丢失掉了,然后替换为了一个新的元素,所以说我们界面上的这些状态都会丢失。

这也就证明我们的热替换操作还需要去改进,那我们必须在要替换原来的元素之前先把他的状态保留下来。

那想要保留我们编辑器中的状态也非常简单,我们就是把编辑器当中之前的内容给他存下来,然后在替换过后我们再把它放回去就好了。

因为这里我们用的是一个可编辑元素,并不是一个文本框,所以我需要通过innerHTML拿到我们之前所添加的内容,然后我们在创建新元素过后再把它设置到新元素当中,那这样的话就可以解决我们这个文本框状态的丢失问题。

let lastEditor = editor;
module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理');
    // console.log(createEditor);
    const value = lastEditor.innerHTML;
    document.body.removeChild(lastEditor);
    const newEditor = createEditor();
    newEditor.innerHTML = value;
    document.body.appendChild(newEditor);
    lastEditor = newEditor;
});

我们再次回到浏览器输入一些内容,再来修改editor模块,此时这个模块就可以以热替换的方式工作,而不用担心我们状态丢失问题,因为我们模块重新工作之后我们已经把上一次的状态记录下来。

那这就是我们针对于js模块热替换的一个处理过程,注意这不是一个通用的方式,这只适用于我们当前的editor.js模块。

那通过这样一个过程我们就应该能够发现为什么webpack的HMR需要我们自己去处理js模块的热更新,因为不同的模块有不同的逻辑,不同的业务逻辑又导致我们在这他的处理过程肯定也是不同的。

那我们这里是一个文本编辑器,所以我们需要去保留状态,那如果说这里不是这种类型的,那就不需要这么做。

所以说webpack根本没有办法去提供一个通用的替换方案。

图片模块热替换

图片模块的热替换逻辑就会简单的多,我们快速来看一下。

我们同样通过module.hot.accept这个方法去注册一下这个图片模块的热替换处理函数,那在这个函数当中我们只需要将图片元素的src设置为新的图片路径就可以了。

因为在图片修改过后我们的图片文件名呢是会发生变化的,而我们这里拿到的就是更新之后的文件名。所以说我们直接重新设置图片元素的src就可以实现图片的热替换。

那以上就是我们针对两种不同类型资源的热替换的处理过程,可能你会觉得比较麻烦因为我们需要去写一些额外的代码,甚至有人觉得我们不如不用,那我个人的想法是利大于弊。

这个道理就像是为什么现在的开发者都愿意去写一些单元测试一样,对于一个长期开发的项目,这一点额外的工作并不算什么,而且如果说你能为自己的代码设计一些规律的话,那你也可以去实现一些通用的替换方案。

那当然如果说你使用的是框架去开发的话,那使用HMR将十分简单,因为大部分框架当中都有成熟的HMR方案,那你只需要去使用就可以了,但是我们这里使用的是纯原声的方式去做的开发。所以说HMR使用起来相对会麻烦一点,那这也正是为什么大部分人都喜欢选择集成式框架的原因,因为足够简单。

注意事项

刚开始去使用HMR肯定会遇到一些问题,下面我们来看一下最有可能发生的问题和大家容易产生疑惑的地方。

首先大家容易出问题的地方就是,如果说我们处理热替换的代码有错误,那就不容易发现,结果会导致页面自动刷新,而自动刷新过后,页面中的错误信息已经被清除了,这样一来我们就不容易发现到底是哪里出错了。

这种情况推荐大家使用hot only的方式来去解决,因为我们默认使用的hot方式如果说热替换失败,那他就自动会回退去使用自动刷新这样一个功能,而hot only他就不会去使用自动刷新。

配置文件当中,这里我们将dev-server当中的hot: true修改为hotOnly: true。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development'
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    devtool: 'source-map',
    devServer: {
        hotOnly: true
    }
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            template: './src/index.html'
        }),
        new webpack.HotModuleReplacementPlugin()
    ]
}

重启服务,此时再去修改代码,无论代码是否被处理了模块的热替换浏览器都不会去自动刷新了。

那这样的话我们这些错误信息就可以很容易的看到了。

第二个问题就是如果我们在代码中使用了HMR提供的API,但是我们在启动dev-server的时候没有开启HMR的选项,那此时我们再运行环境中就会报出一个accept undefined的错误。

原因是因为module.hot对象是HMR插件所提供的,我们没有开启这个插件,所以也就没有这个对象。

解决的办法也非常简单,就跟我们在业务代码中去判断API兼容一样,我们应该先去判断是否存在module.hot这个对象然后再去使用它。这样的话就可以解决我们这样的一个问题。

可能还会有一个疑问,那就是我们再我们的代码当中写了很多与业务功能本身无关的代码,那这会不会有影响。

那这个答案也很简单,我们通过一个简单的尝试来验证下,我们回到配置文件当中,那这里我们确保我们已经将热替换功能关闭了。并且我们已经移除了热替换的插件(plugin)。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development'
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    devtool: 'source-map',
    devServer: {
        // hotOnly: true
    }
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            template: './src/index.html'
        }),
        // new webpack.HotModuleReplacementPlugin()
    ]
}

然后我们打开命令行终端,运行一下webpack打包。

yarn webpack

打包完成过后我们找到生成的bundle.js文件,然后我们找到对应的那个模块,找到过后你就会发现其实我们代码当中编辑的那些处理热替换的代码都已经被移除掉了,只剩下一个if(false) {} 的空判断。

这种没有意义的判断在我们代码压缩过后也会自动去掉,所以说他根本不会影响我们生产环境当中的运行状态。

至此我们对HMR的一个使用基本上就完全了解了。