本科毕业设计的前端技术总结(三):这可能是最详尽的Webpack基础总结

本科毕业设计的前端技术总结(三):这可能是最详尽的Webpack基础总结

前情提要:

HowardZ:本科毕业设计的前端技术总结(一):响应式设计与浏览器兼容zhuanlan.zhihu.com图标HowardZ:本科毕业设计的前端技术总结(二):Video、SVG、Canvas与Fontzhuanlan.zhihu.com图标


前两篇文章提到的大部分是视觉效果的设计和实现,这次来一篇更硬核的长文,详细总结一下这个页面是怎么用npm工具构建,又是怎么用webpack打包的。这些工具在前两年被称作是“前端工程化”的潮流,但如今看来,如果想要做前后端分离,它们更像是空气和水。主要的目的是优化前端性能,减少请求数和压缩资源大小,并方便调试与测试,提升开发体验。


这篇文章涉及的内容比较基础。但是实在有点长,我之前的文章和它比就像普通电影和《美国往事》的差别,阅读时请注意劳逸结合。


页面成品链接如下(放在了GitHub Pages,国内访问速度可能较慢):

深度映像工作室zamhown.github.io图标


GitHub地址如下(我把这三篇文章并起来写进了README,这是我写过的最长的README了!如果有帮助欢迎Star):

zamhown/divs-homepagegithub.com图标

总目录


第一部分 响应式设计与浏览器兼容

一、概览

二、响应式设计

三、浏览器兼容


第二部分 Video、SVG、Canvas与Font

四、视频部分

五、Lottie动画部分

六、Canvas动画部分

七、自定义字体与拆分字体

八、其他效果与彩蛋


第三部分 Webpack配置、打包与懒加载(本文内容)

九、Webpack与各loader配置

9.1 打包js文件
9.2 将ES6以上的语法转译为ES5
9.3 打包HTML文件(一)
9.4 打包CSS文件
9.5 优化JS和CSS文件
9.6 打包图片和其他资源文件
9.7 打包HTML文件(二)
9.8 其他配置

十、开发环境配置与拆分

10.1 配置拆分
10.2 开发服务器配置
10.3 模块热替换配置

十一、模块懒加载

十二、其他减少请求数和压缩资源大小的方法


九、Webpack与各loader配置


之前我使用Angular、React和Vue都只用现成的脚手架,这次第一次从零配置webpack,所以关于webpack部分会比较详细,是以初学者的视角去总结的。但是,下文假设读者有使用Angular、React或Vue自带脚手架的经验,知道ES6 module、npm、webpack大概是怎么回事,否则先去看一下webpack官方文档会比较好。


首先明确一下这一部分的目的:


假设这个官网主页已经写好了,有一个手撸的index.html文件,引用了一些css文件,html和css都牵扯了一堆的资源文件,如图片、视频、字体等。js部分引用了一些库,还引用了一个手撸的index.js文件,index.js文件内部还引用了其他数个js文件。


现在把它用webpack打包,生成一套压缩和优化过的,可以直接用于生产环境的前端页面、代码文件和资源文件。

已经被压缩混淆过的生成代码


构建这个项目的命令行脚本我都记录在根目录下的build_steps.txt文件里。首先是项目初始化和webpack安装:

npm init
npm i -D webpack webpack-cli


webpack作为一个模块打包器,原本是用来打包互相依赖的js文件的。但是通过各种loader和插件的加成,才变得“一切”皆可打包。就像人类的胎儿在子宫的发育过程和人类进化过程意外地重合一样,我们逐步配置webpack的过程似乎也模拟了webpack的发展过程。


9.1 打包js文件


首先在根目录下建一个webpack.config.js文件,写入以下配置:

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'docs'),
        filename: 'js/[name].[hash].bundle.js'
    }
};


这个配置设定了如下信息:

  • 打包js的入口文件是src/index.js;
  • 打包好的js文件存放在项目根目录下的docs文件夹中(一般这个文件夹都起名为dist,但是在这个项目中,GitHub Pages直接从docs文件夹加载网页,因此这里就是docs了);
  • 打包好的js文件起名规则是“js/[name].[hash].bundle.js”,也就是把打包好的文件存入./docs/js文件夹,文件名为[name].[hash].bundle.js。其中[name]代表原js文件名,[hash]代表本次生成的哈希值(用于避免上线之后浏览器缓存不及时更新的问题)。


接下来这个配置就可以直接使用了。开始打包之前还需要编辑一下package.json文件,移除main属性并添加一行"private": true(为了防止npm意外发布你的代码):


然后在script属性里添加一个build属性:

"scripts": {
    "build": "node_modules/.bin/webpack"
},


build属性的值“node_modules/.bin/webpack”是一条命令。如果我在命令行中运行npm run build,那么等同于运行webpack命令。而webpack命令默认的配置文件是webpack.config.js,因此这个文件名不用写进命令里去。


这时我们在命令行中运行一下npm run build,如果js文件的解析没什么错误,你会发现docs文件夹被自动生成,结构是类似于这样的:

 docs
  |- /js
    |- index.dd32afaad6ab68153238.bundle.js


9.2 将ES6以上的语法转译为ES5


ES6语法固然很爽,但并不是所有浏览器都支持,出于浏览器兼容的目的我们应该在打包js文件时把ES6及以上语法转译为ES5。这个领域最有名的工具就是babel


babel的官网有一个在线转换器,可以把下一代js语法转为ES5语法。我觉得是个很好的“实验室”,可以研究下新语法的本质。

小例子:箭头函数与标准匿名函数对于this指向的差别


为了使用这个神器,我们还需要了解loader的概念。loader是webpack用来预处理文件的插件,当webpack从入口开始解析文件时,遇到的所有被引用的文件,只要符合某个loader的标准就调用这个loader预处理该文件。换句话说,这允许你打包几乎任何静态资源。


在webpack里,我们需要用到的是babel-loader,可以用来预处理js文件。先安装babel-loader及相关库:

npm i -D babel-loader @babel/core @babel/preset-env


接下来配置webpack.config.js:

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'docs'),
        filename: 'js/[name].[hash].bundle.js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env'],
                        babelrc: false,  // 不采用.babelrc的配置
                    }
                }
            }
        ]
    }
};


配置文件里加了一个新属性module,在rules里增加了一个规则。test表示该loader适用的文件的正则表达式,exclude表示排除掉的文件的正则表达式,这些表达式用来匹配文件的路径。use里是对loader的设置。


我们完成了这样一个工作:src/index.js文件与它依赖的所有js文件都被打包进一个文件中,并实现了浏览器兼容,仅此而已,然并卵。我要生成的是整个页面和它的所有资源,只打包js有什么用呢?


9.3 打包HTML文件(一)


我希望每次运行npm run build的时候,页面文件index.html中对该js文件的引用也要自动改变,并且也要放到docs文件夹下。其实只靠webpack本身已经完不成这个功能了,我们需要一个插件:html-webpack-plugin,用于打包html文件。首先是插件的安装:

npm i -D html-webpack-plugin


接下来继续配置webpack.config.js,更改三处地方,变成这样:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');  // 更改1

module.exports = {
    entry: {
        index: './src/index.js'  // 更改2
    },
    output: {
        path: path.resolve(__dirname, 'docs'),
        filename: 'js/[name].[hash].bundle.js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env'],
                        babelrc: false,  // 不采用.babelrc的配置
                    }
                }
            }
        ]
    },
    plugins: [  // 更改3
        new HtmlWebpackPlugin({
            minify:{
                collapseWhitespace: true
            },
            chunks: ['index'],
            filename: 'index.html',
            template: './src/layout/index.html'  
        })
    ]
};


更改1:引入html-webpack-plugin。


更改2:我们在配置文件中把entry改为了键值对的形式,相当于显式声明了一个块(Chunk)。chunk表示一个文件,默认情况下webpack的输入是一个入口文件,输出也是一个文件,这个文件就是一个chunk。如果后期增加一个入口文件,就会打包成两个bundle文件,就是两个chunk。这里的块名就是键值对的key值“index”。


更改3:新增plugins属性,在plugins里new一个HtmlWebpackPlugin的实例。在传入的对象里,minify表示压缩html的相关设置(这里设置为消去html里标签之间的空白字符),chunks表示要在html中引入的js文件的块名,filename表示最后打包生成的html的文件名(也可以是相对路径,以output.path为根目录),template设置了html页面的模板地址,就是你要解析的原html文件。


将docs文件夹手动删除,再运行npm run build之后,文件结构是类似于这样的:

 docs
  |- index.html
  |- /js
    |- index.dd32afaad6ab68153238.bundle.js


打开index.html文件,会发现源代码都缩到了一行,而且在<body>的末尾自动添加了一个标签(js文件的hash值可能不同):

<script type="text/javascript" src="js/index.dd32afaad6ab68153238.bundle.js"></script>


如果你之前手动引入过这个js文件,这个标签还是会加上去。因此在原html文件里没有必要手动引入任何需要打包的js文件。


9.4 打包CSS文件


在webpack中,js是一等公民,webpack默认不打包css文件,打包css文件同样需要loader与其他插件。


在原始网页中css文件是直接link在<head>标签里的。但是用了webpack就不必在html中引用了,而是引用在js里:


为了能解析这些css文件,先安装css-loader

npm i -D css-loader


然后配置webpack.config.js文件,在modules.rules中添加以下规则:

{
    test: /\.css$/,
    use: [
        {
            loader: 'css-loader',
            options: {
                url: true,
                modules: 'global'
            }
        }
      ]
}


css-loader的配置中,url表示是否解析css中url()语句引用的资源文件,modules表示是否启用局部作用域,默认为“scope”即启用,css中的类名等会被替换为哈希值。由于我只有一个html文件,所以选择了全局作用域。


如果只用css-loader,打包后你会发现相关css直接放在了页面<head>里的<style>标签里,并没有输出单独的css文件。输出css文件需要使用一个插件:mini-css-extract-plugin。首先安装插件:

npm i -D mini-css-extract-plugin


接着修改webpack.config.js文件,首先在文件开头引用mini-css-extract-plugin:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');


接下来修改引用css-loader的那条规则,变成以下这样:

{
    test: /\.css$/,
    use: [
        {
            loader: MiniCssExtractPlugin.loader,
            options: {
                publicPath: '../'
            }
        },
        {
            loader: 'css-loader',
            options: {
                url: true,
                modules: 'global'
            }
        }
    ]
}


我们在同一个规则下设置了两个loader,实际处理顺序是先用css-loader再用MiniCssExtractPlugin.loader(从下到上应用loader,与直觉相反)。


在MiniCssExtractPlugin.loader里设置了publicPath,在打包时publicPath会加到css引用的所有资源文件路径的头部,如果使用了静态文件CDN,这个属性也可以填CDN的地址。publicPath其实是一个重要属性,很多地方都有,具体可参考这篇文章


然后,我们在plugins属性里增加一项(和HtmlWebpackPlugin并列):

new MiniCssExtractPlugin({
    filename: 'css/[name].[hash].css'
})


这里才算正式声明了这个插件,并设置了css文件的保存路径,与output.filename相似。


接下来运行npm run build,如果不报错(事实上会报错,因为我们设置了解析css里的url,但是又没有相应loader),输出的docs文件夹结构是类似这样的:

 docs
  |- index.html
  |- /js
    |- index.dd32afaad6ab68153238.bundle.js
  |- /css
    |- index.dd32afaad6ab68153238.css


9.5 优化JS和CSS文件


虽然以上工作成功打包了js和css,但还没有经过进一步的压缩和优化。在这里我们停顿一下,通过引用一些插件来优化js和css的输出效果。需要安装的插件分别是uglifyjs-webpack-pluginoptimize-css-assets-webpack-plugin

npm i -D uglifyjs-webpack-plugin
npm i -D optimize-css-assets-webpack-plugin


若要在webpack.config.js文件里配置,两个插件作为优化器,不应放在plugins属性里,而是放在一个新的属性:optimization。我们在配置文件中加入这个属性:

optimization: {
    minimizer: [
        new UglifyJsPlugin({
            cache: true,
            parallel: true,
            sourceMap: true // set to true if you want JS source maps
        }),
        new OptimizeCSSAssetsPlugin({})
    ]
}


UglifyJsPlugin的配置中,前两个都是关乎构建性能的,分别是启用文件缓存和并行化处理。最后一个sourceMap设置了是否输出对应的map文件(输出map会减慢编译速度)。和压缩相关的参数还有很多,具体文档可见:UglifyjsWebpackPlugin · webpack 中文文档(2.2)


OptimizeCSSAssetsPlugin的配置可参考:NMFR/optimize-css-assets-webpack-plugin。需要注意的是,对css的压缩没有js压缩那么“无损”,据我所知有一些压缩规则可能会造成问题,比如:



当然一般情况下是不会有影响的,但如果压缩以后样式改变,可以想想是不是因为这些原因。


除此之外,我们还要设置一下devtool。在配置中增加如下属性,与optimization同级:

devtool: 'source-map'


当js异常抛出时,你常会想知道这个错误发生在哪个文件的哪一行。然而因为webpack将文件输出为一个或多个bundle,而且又被混淆压缩过,所以追踪这一错误会很不方便。devtool可以设置各种source map,具体可见这篇文章


9.6 打包图片和其他资源文件


html、css、js都有了,但是和它们相关的资源文件还没被放进输出目录里,接下来就涉及到对图片、视频、图标、字体文件等资源的打包了。我们需要安装一个神器:url-loader

npm i -D url-loader


这个loader有什么用呢?它几乎支持所有资源的预处理,而且可以把比较小的资源文件转为base64编码嵌入html、css或js文件里,从而节省资源请求数。我们在modules.rules里面添加以下规则:

{
    test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2|mp4|ico)$/,
    use: [
        {
            loader: 'url-loader',
            options: {
                limit: 8192,
                name (file) {
                    return '[path][name].[ext]?[hash]';
                }
            }
        }
    ]
}


test属性可以根据实际情况自己删改,但是应该包括所有可能引用到的资源文件的后缀名。


loader配置里的limit是文件大小阈值,这里设置为8k,8k以下的文件就会被转换为base64编码,否则原样复制到输出目录。以下是base64版svg图片嵌入html的例子:


回到配置中来,name同样是指输出的文件名格式,这里用了函数类型作为属性值,函数返回“[path][name].[ext]?[hash]”,其实和直接写字符串效果一样。[path]是指文件名以外的相对路径(相对于项目根目录),这样打包以后输出目录中资源文件的相对路径会被完整保留下来。[name]指原文件名,[ext]指原扩展名。至于哈希值我把它放在了问号的后面,既能避免缓存问题,又能保持文件名的清爽。


一般而言我们对于资源文件的打包工作就结束了。但是在这个项目里,经测试发现有一张小图片被转化为base64以后不能被成功识别,其实就是第三节提到过的Safari图标文件。可能Safari在提取图标的时候不支持base64只认文件路径。这里又牵扯出一个问题:如何处理例外?


解决这个问题还需要一个file-loader。file-loader是url-loader的弱化版,换句话说url-loader只是在file-loader的基础上多了base64功能,恰好可以用file-loader处理那些不需要base64编码的文件。首先进行安装:

npm i -D file-loader


我们只需要对一个名为icon256.png的图片应用file-loader。在url-loader的规则上方插入一条新规则,再在url-loader的规则里添加exclude。修改如下:

{
    test: /icon256\.png$/,
    use: [
        {
            loader: 'file-loader',
            options: {
                name (file) {
                    return '[path][name].[ext]?[hash]';
                }
            }
        }
    ],
},
{
    test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2|mp4|ico)$/,
    exclude: /icon256\.png$/,  // 添加例外
    use: [
        {
            loader: 'url-loader',
            options: {
                limit: 8192,
                name (file) {
                    return '[path][name].[ext]?[hash]';
                }
            }
        }
    ]
}


关于这两个loader和base64编码的原理,这篇文章讲得不错。


9.7 打包HTML文件(二)


终于到最后一步了。我们之前使用html-webpack-plugin的时候,功能只是压缩、输出html文件,并在里面加入打包后的js和css引用。但是html中引用的资源文件却没有解析(如<img>的src),也没有复制到输出目录。如果没有接下来的设置,上文的url-loader只会处理css和js里引用的资源文件。


为此我们需要安装html-loader

npm i -D html-loader


添加的规则如下:

{
    test: /\.html$/,
    use:[
        {
            loader: 'html-loader',
            options: {
                attrs: [
                    'img:src',
                    'link:href',
                    'video:src',
                    'video:poster'
                ]
            }
        }
    ]
}


配置里的attrs属性列出了四个字符串,以“img:src”为例,img是指html中的<img>标签,src是指<img>的src属性,表示html-loader作用于<img>标签中的src的属性。这里一定要把引用本地资源文件的属性名都列出来。


接下来心满意足地运行一下npm run build,一个生产环境下可用的页面包终于被生成出来了!


9.8 其他配置


这里介绍两个实用工具:eslint和clean-webpack-plugin。


一般的代码编辑器都不会对你的js代码风格指指点点,就算你全写在一行也照样能跑。但是为了保持良好的代码风格,我们可能需要一个额外的工具监督自己,这种工具在团队项目中更为重要。达成这个目的需要安装eslint,名字里带lint的工具基本都有类似的监督功能。

npm i -D eslint eslint-loader
npm i -g eslint


第一行是在本项目的开发依赖中安装eslint和eslint-loader,第二行是全局安装eslint。安装好以后就可以在项目目录下初始化eslint:

eslint --init


与npm --init相似,它会以问答形式来确定你想维持的代码风格,最终生成一个.eslintrc.js配置文件。如果你用的编辑器是vscode(强行安利!),它会自动读取.eslintrc.js文件,对不符合规则的代码进行提示。


我们还需要用eslint-loader对js代码进行预处理,在webpack.config.js文件的modules.rules里添加以下规则(放在babel-loader规则的上方):

{
    enforce: 'pre',
    test: /\.js$/,
    exclude: /node_modules/,
    loader: 'eslint-loader'
}


配置好这些以后,在build的时候如果它发现有地方不符合eslint规则,会报错提醒你修改。


但是我们有时也需要灵活一些。比如eslint默认规则是不应该在代码中出现console对象,但在第八节讲过,我们有输出彩蛋的刚性需求(并不),这时候就需要绕过规则。控制console对象的规则名为no-console,我们只需要在相关代码文件第一行加入如下注释就好:

/* eslint-disable no-console */


现在这个构建方法还有一个小瑕疵:由于输出的js和css文件名里都有哈希值,每次构建都无法覆盖先前的原文件,多次构建以后不同版本的代码混在一起极为不便。那么需要在每次运行npm run build之前手动删除输出文件夹docs。但是这一步也是可以自动的,只需要安装一个叫clean-webpack-plugin的工具就好,它能帮你在打包前自动删除输出目录:

npm i -D clean-webpack-plugin


在webpack.config.js的开头引用:

const CleanWebpackPlugin = require('clean-webpack-plugin');


在plugins属性里面添加这个插件:

new CleanWebpackPlugin(path.resolve(__dirname, 'docs'), {
    root: __dirname,
    verbose: true
})


构造CleanWebpackPlugin实例的参数有两个:你要删除的文件夹路径和相关配置。在配置里,root表示项目根目录,verbose表示是否将日志打印在控制台上。


再build一次试一下,完美!


十、开发环境配置与拆分


10.1 配置拆分


我们在上一节主要维护了一个webpack.config.js文件,似乎已经够用了。但是我们还面临一个问题:如何更方便地调试页面?难道每改一处代码就要运行一遍npm run build吗?


如果用过ARV三大框架,肯定会了解开发服务器(dev server)、实时重新加载(live reloading)和模块热替换(Hot Module Replacement, HMR)的概念。我们可以借助webpack搭一个专用于前端页面调试的本地服务器,在具有http协议的同时,还能实现一更改代码文件,浏览器里的预览页面就会自动刷新,甚至不用刷新就能自动更新的功能。


在这个时候,我们明显感觉到了开发环境和生产环境的不同。通常这两个环境的webpack配置文件是独立的,但是最好还是遵循不重复原则(Don't repeat yourself, DRY),保留一个公共配置。为了将这些配置合并在一起,我们需要使用一个叫webpack-merge的工具。有了公共配置,就不必在环境特定(environment-specific)的配置中重复代码。


接下来我们开始拆分配置。首先安装webpack-merge:

npm i -D webpack-merge


然后在项目根目录建立一个文件夹config,新建三个js文件:

 config
  |- webpack.common.js
  |- webpack.dev.js
  |- webpack.prod.js


接下来要把webpack.config.js拆分到webpack.common.js和webpack.prod.js两个文件中,webpack.config.js就不复存在了。那么哪些配置是生产环境独有的呢?我选择了以下三个部分:


  • devtool
  • 所有优化器
  • clean-webpack-plugin插件


把这四部分抽到webpack.prod.js以后,剩余设置都放在webpack.common.js。webpack.prod.js需要引用webpack.common.js,再和自己的设置merge在一起。


最后webpack.prod.js是这样的:

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

const CleanWebpackPlugin = require('clean-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = merge(common, {
    mode: 'production',
    devtool: 'source-map',
    optimization: {
        minimizer: [
            new UglifyJsPlugin({
                cache: true,
                parallel: true,
                sourceMap: true
            }),
            new OptimizeCSSAssetsPlugin({})
        ]
    },
    plugins: [
        new CleanWebpackPlugin(common.output.path, {
            root: common.output.path + '/../',
            verbose: true
        })
    ]
});


注意,由于我把配置文件移到了config文件夹,当前目录不再是项目根目录,所有和路径有关的属性都要检查一下。比如clean-webpack-plugin配置里的路径,又比如在webpack.common.js中,output.pash属性应改为“path.resolve(__dirname, '../docs')”。


关于mode属性:mode有三个参数production、development、none,默认为production。在mode为production或development的状态下,为了兼顾两个状态下的程序运行,webpack创建了一个全局变量process.env.NODE_ENV,等同于在插件plugins中加入了如下语句:

new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("development|production") })


process.env.NODE_ENV可以在程序中区分当前是生产环境还是开发环境,是个很重要的量。


10.2 开发服务器配置


接下来可以配置开发环境了。首先安装webpack-dev-server,这个是本地开发服务器工具:

npm i -D webpack-dev-server


将开发环境设置写入webpack.dev.js中:

const path = require('path');
const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
    mode: 'development',
    devtool: 'inline-source-map',
    devServer: {
        contentBase: path.join(__dirname, 'docs')
    }
});


我们可以发现主要是增加了三个属性:mode、devtool和devServer。其中devServer是webpack-dev-server的配置。contentBase指定了服务器资源的根目录,如果不写入contentBase的值,那么contentBase默认是项目的目录。更多设置可以参考这篇文章


devtool和生产环境不同,变成了inline-source-map,将source map嵌入到源文件中。


这里需要强调一下webpack打包和webpack-dev-server开启服务的区别:webpack输出真实的文件,而webpack-dev-server输出的文件只存在于内存中,开启的时候在输出目录是找不到相关文件的。


我们添加一个script脚本,可以直接运行开发服务器。再次修改package.json文件,scripts属性如下:

"scripts": {
    "dev": "node_modules/.bin/webpack-dev-server --open --config config/webpack.dev.js",
    "start": "npm run dev",
    "build": "node_modules/.bin/webpack --config config/webpack.prod.js"
}


我们修改了build(用--config指定了配置文件),增加了dev和start。dev的命令是webpack-dev-server,用--open设置自动弹出浏览器窗口,用--config指定配置文件。而start语句指向dev。如此一来,我们用命令行运行npm run dev或npm start就可以运行开发服务器了。


10.3 模块热替换配置


模块热替换(Hot Module Replacement, HMR)是webpack提供的最有用的功能之一。它允许在运行时更新各种模块,而无需进行刷新。和之前webpack-dev-server自带的live reload功能不同,模块热替换由于避免了整页更新,从而在调试时可以保持网页的使用状态(如填写字段、弹出弹窗等等)。关于HMR的内部原理,这篇文章讲得很好。


启用这个功能实际上很简单。只需要更新webpack-dev-server的配置,和使用webpack内置的HMR插件。


首先在webpack.dev.js文件开头引用webpack:

const webpack = require('webpack');


然后在配置里增加plugins属性,引入两个插件:

plugins: [
    new webpack.HotModuleReplacementPlugin(),  // 开启全局的模块热替换
    new webpack.NamedModulesPlugin()  // 当模块热替换时在浏览器控制台输出对用户更友好的模块名信息
]


然后devServer属性里加一行设置即可。

devServer: {
    contentBase: path.join(__dirname, 'docs'),
    hot: true  // 开启热替换
}


现在我们可以试一下了:在开发服务器运行中,把html文件上的一段文案改一下,保存,观察浏览器的console。


Nothing hot updated?怎么和说好的不一样?


查阅了很多资料,发现原因是热更新的检查是从入口文件开始的,入口文件并没有引用html文件。有一个解决方案是在js入口文件(index.js)里添加引用:

import './layout/index.html';


但是这个方法无疑会增大js文件大小,我不是很喜欢。而且经过测试,html改动后仍然会调用live reloading而非HMR,所以这里留一个疑问。


做到这里不禁感叹,以前使用Angular、React和Vue的时候体验都很顺滑,从没觉得从零搭建webpack脚手架难度这么大。毕竟这是一个边练边学的过程,真要做时间比较赶的项目,还是成熟脚手架更稳定。


十一、模块懒加载


webpack支持模块懒加载。在这个项目中,模块懒加载的需求其实来自于第二节提到的移动端canvas聚光灯动画,第六节介绍了它的实现。由于这个动画是用在移动端代替背景视频的,因此我需要判断一下当前设备是否是移动端,只有在移动端导入和它相关的所有代码。


在动画实现上,我分离了设置动画的代码并放在一个单独的scrawl_canvas_animate.js文件里。同时我又将Scrawl-canvas库魔改一番,合并缩减了代码放在了scrawlCanvas.js文件中,被scrawl_canvas_animate.js文件引用。


那么如何在代码中动态加载这个模块?使用Dynamic Import比较简单直接一点:

$(function() {
    // 移动端canvas聚光灯动画(懒加载)
    // 是否是移动端且支持canvas
    if (device.mobile() && Modernizr.canvas()) {
        import(
            /* webpackChunkName: "scrawl_canvas_animate" */
            './scrawl_canvas_animate')  // 模块路径
            .then(scrawl_canvas_animate => {
                // 使用这个模块的相关代码
            });
    }
});


在这里,import成为了一个函数,接受的参数与import语句无异,并提供基于Promise的API。在路径参数之前有一行webpackChunkName注释,用来告诉webpack在打包时这个文件的块名应该起什么。


为了接应这行注释,我们更改一下webpack.common.js文件,在output属性中添加一行设置:

chunkFilename: 'js/[name].[hash].bundle.js',


现在还有一个很重要的问题,Dynamic Import还处于草案阶段,浏览器并不能直接支持,甚至连babel-loader和eslint都通不过。


面对这种情况,我们首先解决babel-loader,需要新的工具:babel-plugin-syntax-dynamic-import

npm i -D babel-plugin-syntax-dynamic-import


这个工具能帮助babel-loader解析Dynamic Import语法。安装以后将webpack.common.js里babel-loader所在的规则修改一下:

{
    test: /\.js$/,
    exclude: /(node_modules|bower_components)/,
    use: {
        loader: 'babel-loader',
        options: {
            presets: ['@babel/preset-env'],
            babelrc: false,  // 不采用.babelrc的配置
            plugins: [
                'syntax-dynamic-import'
            ]
        }
    }
}


接下来解决eslint,这里需要安装babel-eslint

npm i -D babel-eslint


然后修改.eslintrc.js文件,添加以下配置:

"parser": "babel-eslint",
"parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module",
    "allowImportExportEverywhere": true
}


修改好后,运行一下npm run build,我们发现在docs文件夹的js子文件夹赫然多了一个文件:

 js
  |- index.dd32afaad6ab68153238.bundle.js
  |- scrawl_canvas_animate.dd32afaad6ab68153238.bundle.js


成功了!scrawl_canvas_animate虽然没有写在配置文件的入口列表里,webpack还是把它挖了出来,并单独打包成一个文件。


如果懒加载的模块与主模块有共同的依赖,为了节省请求资源大小,需要把公用的模块单独打包。这个涉及到Code Splitting,具体可参考这篇文章


除了canvas动画部分,lottie动画也适用于懒加载,因为如果浏览器不支持lottie-web(如第三节提到的IE11/Edge),也不必加载相关代码和整个lottie-web库。lottie动画部分处理与上文相似,就不赘述了。


测试一下,在PC端Chrome浏览器打开页面,同域js请求是这样的:


用设备模拟器模拟移动端环境,同域js请求是这样的:

前三个js存入缓存,最后一个是懒加载的js


懒加载的js在加载时,<script>引用全部放在了<head>标签里。


十二、其他减少请求数和压缩资源大小的方法


终于把最重要的部分总结完了,现在是饭后甜点时间。


当页面用Webpack进行优化以后,总结一些尚余的可优化空间:


1. 静态文件CDN:用CDN加载静态文件可以减少对页面服务器的请求。如果想通过CDN引用js库,推荐七牛云的Staticfile CDN。前些年还可以通过fork它的GitHub仓库再pr来自行增加js库,但现在好像不太维护了,只有一些最知名的库还在更新。即使如此,它的速度和易用性都不错,关键是免费。


2. spirit图:spirit图算是经典的减少图片资源请求的压缩方式,主要是把小尺寸图片拼在一张大图里,然后通过css的background-position来定位。在本项目中,“团队”部分的照片就用spirit图优化过,六个照片其实是一张图片:


当然spirit图也有着维护较麻烦的缺点。一般只有不怎么变化的小尺寸图片会用这种方法。


3. 尽量使用svg图片。作为纯矢量格式的svg往往比同样内容的png小太多,也清晰太多。压缩了资源大小不说,如果用了url-loader也更容易被转换为base64,这下连请求都省了。


在不断优化的过程中,多多检查开发者工具的Network标签页,看着请求慢慢变少,资源慢慢变小,就会有一种“盘它”的感觉。


Hoo wee……终于总结完了。这次介绍的作品是一张简简单单的主页,但牵扯到的东西贯穿了我三年来的学习成果(这次没有总结Vue在其中的应用,因为没啥讲头),我相信做精一样东西比千百次地重复收获得更多。在开发的时候,我碰过不少壁,做过不少妥协,也遗留了不少问题,希望可以得到大佬和未来实践的指引,就当是为以后寻找方向吧。


三篇总结的全文已更到GitHub仓库,欢迎来踩~

zamhown/divs-homepagegithub.com图标

编辑于 2019-03-01

文章被以下专栏收录