前端工程化:Webpack打包-笔记

292 阅读9分钟

模块打包工具的由来是什么呢,先简单总结下,有以下几点:

  1. 首先,我们前面学的ES Modules存在环境兼容问题,我们也不能去统一用户使用环境。
  2. 而且,模块文件过多会导致网络请求频繁。影响工作效率。
  3. 所有前端资源都应该模块化,不仅仅是JS文件,还有css、image、font等。

所以我们需要这样一款打包工具:

  • 代码编译降级成兼容性好的代码。ES6 -> ES5。
  • 将散落文件打包到一起。解决频繁请求问题。
  • 支持不同种类资源类型。

现在讨论的是对于整个前端应用的模块化方案,而不是前面的仅仅针对JS的模块化。

开始使用

安装webpack及其cli

"devDependencies": {
  "webpack": "^5.26.3",
  "webpack-cli": "^4.5.0"
}

打包原理

去代码调试一下看看,基本原理比较简单。loader是webpack实现整个前端模块化的核心特性,借助于loader就可以加载任何类型的资源。loader是从后往前执行的,要注意顺序。

在webpack打包,我们会将css、image这种资源在JS中引入,这样对于webpack来说是合理且必要的:

  1. webpack是根据依赖关系去寻找模块,在js中引入,说明确实是需要这些资源。
  2. 确保了线上资源不丢失。

file-loader源码探析

查看源码,是直接将图片文件路径作为模块导出出去,并且会拼接上公共路径__webpack_require__.p同时,如果未指定pulicPath,webpack会根据打包文件位置去自动计算一个默认的公共路径。

// 导出的图片路径
const __WEBPACK_DEFAULT_EXPORT__ = (__webpack_require__.p + "06c26fe7f9477c6d495ecb2b5331da7b.png");

没指定pulicPath的情况下,会根据以下代码去获取script标签src指定的链接路径,将其作为publicPath:

(() => {
	var scriptUrl;
	if (__webpack_require__.g.importScripts) scriptUrl = __webpack_require__.g.location + "";
	var document = __webpack_require__.g.document;
	if (!scriptUrl && document) {
		if (document.currentScript)
			scriptUrl = document.currentScript.src
		if (!scriptUrl) {
			var scripts = document.getElementsByTagName("script");
			if(scripts.length) scriptUrl = scripts[scripts.length - 1].src
		}
	}
	// When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration
	// or pass an empty string ("") and set the __webpack_public_path__ variable from your code to use your own logic.
	if (!scriptUrl) throw new Error("Automatic publicPath is not supported in this browser");
	scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/?.*$/, "").replace(//[^/]+$/, "/");
	__webpack_require__.p = scriptUrl;
})();


// scriptUrl示例 http://127.0.0.1:5500/myFile/webpack-demo/output/

file-loader工作示意图:

它是将导入的结果变成一个url地址,并生成一个文件到输出目录。它的原理是拿到source,也就是文件内容,图片内容buffer,然后生成一个具有相同文件内容的文件到输出目录,返回一段代码export default '文件路径'

Data Urls 和 url-loader

它可以直接表示一个文件,其文本就包含了整个文本内容,使用如下语法:

data:[<mediatype>][;base64],<data>
[协议][-----媒体类型和编码----][-编码-]
                       
// html示例
data:text/html;charset=UTF-8,<h1>html content</h1>

对于图片或字体可以转为base64文件编码,依此可以使用url-loader。最佳实践方式是将小文件用该loader进行打包,来减少网络请求的次数。大文件依然使用file-loader打包,单独存放。

loader可分为:

  • 编译转换类:css-loader、babel-loader
  • 文件操作类:file-loader
  • 代码检查类:eslint-loader

webpack只是一个打包工具,和gulp的插件机制类似,webpack具体处理文件方式也是由特定loader加载器去进行编译转换的。

webpack加载资源的方式可分为这几种:

  • ES Modules的import、
  • CommonJS的require、
  • AMD标准的define和require函数、
  • 所有样式代码的@import指令和url函数、
  • HTML代码中的src、href属性。

打包核心原理

会从entry入口文件开始,根据其内部的importrequire等等导入语法,构建出依赖关系树,然后去递归遍历每个资源模块,在这个过程中使用对应的loader去加载编译转换每个模块,将最终结果放入bundle中。

在这个过程中,loader扮演了一个很重要的角色。

属性解析

  • mode属性:有product、development、none三种属性。none属性下,webpack不回去做额外的处理。

开发自己的loader

loader也是一个模块,它需要导出一个函数,该函数的参数是被加载文件的字符串,我们可以将它经过我们的处理后再返回,需要注意返回的内容必须是一个JavaScript格式的代码,不然会引起语法报错。

const marked = require('marked')

module.exports = source => {
  // console.log(source)
  // return 'console.log("hello ~")'
  const html = marked(source)
  // 第一种方式:直接自己拼接为JS代码
  // return html
  // return `module.exports = "${html}"`
  // return `export default ${JSON.stringify(html)}`

  // 第二种方式:
  // 返回 html 字符串交给下一个 loader 处理
  return html
}

这里有一个小技巧,使用JSON.stringify可以转义html中的空格、换号、引号等。从这个例子可以知道loader负责文件从输入到输出的转换。和gulp的文件流工作机制很像。对于一个文件可以使用多个loader,就像工作管道。

插件机制

todo: 补全webpack核心原理、loader机制、plugins机制、结合以前总结的工作原理

DevServer

Webpack中的DevServer,为了提高工作效率,它并没有把打包结果写入到磁盘中,而是暂时放在了内存中,http-server也是将文件从内存中读了出来,然后发送给浏览器,这样就减少了大量磁盘读写操作。

在 Webpack4.0 中可以使用 webpack dev server,但是在5.0版本中已经不再适用,我们可以直接使用webpack server 来取代使用。

contentPath

额外为开发服务器指定查找资源目录。

在使用loader的过程中出现了问题,在使用babel-loader时会报错,查看了文档发现是因为使用方式不对,应该按如下方式来进行配置:

{
  test: /.m?js$/,
    exclude: /node_modules/,
      use: {
        loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { targets: "defaults" }]
            ]
          }
      }
}

SourceMap

SourceMap解决了源代码与运行代码不一致产生的问题,能帮助开发人员在调试工具中直接定位到源代码。它有几个关键属性:

  • version:sourceMap的版本
  • source:一个数组,搜集源代码的路径
  • names:一个数组,搜集源代码中使用的成员名称,因为源代码中会替换有意义的变量名,而这个数组会将其记录下来,方便还原。
  • mappings:核心属性,它是一个base64-vlq编码的字符串,它记录的是源代码和转换代码的映射关系
// csdn上更详细的解释
{
  version : 3,        //Source map的版本
  file: "out.js",      //转换后的文件名
  sourceRoot : "",   //转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空
  sources: ["foo.js", "bar.js"],   //转换前的文件。该项是一个数组,表示可能存在多个文件合并
  names: ["src", "maps", "are", "fun"],   //转换前的所有变量名和属性名
  mappings: "AAgBC,SAAQ,CAAEA"  //记录位置信息的字符串
}

我们可以在转换代码中加入一行注释来引入SourceMap文件:

//# sourceMappingURL=sourceMap文件路径   // 可以是网络链接

webpack支持12种不同的方式实现sourceMap,每种的效率、效果和速度是不同的。不同模式之间的对比表如下所示:

它们这些模式起的名字是有含义的,就是如下属性的排列组合:

  • eval:是否使用eval执行模块代码
  • cheap:Source Map是否能定位到行信息,带cheap则不显示行了。
  • moduel:是否能够得到未被Loader处理的源代码,比如ES6代码转化

如何选择

开发环境一般选择cheap-module-eval-source-map,原因有如下几点:

  1. 代码一行一般不会超过80个字符。
  2. 代码经过loader转换后的差异较大。
  3. 首次打包速度慢无所谓,重新打包相对较快。

而生产环境则不会使用sourceMap,这也是为了安全。

模块热替换(更新)

HMR(hot module replacement)是webpack中最强大的功能之一。在刚开始使用时,我们发现style文件没有去配置HMR方案,它却有热更新功能,这是因为在我们使用的loader中进行了处理。并且一般我们通过脚手架创建的项目也都给我们集成好了HMR方案。

开启热替换

在devServer中设定hot: true,同时引入webpack内置插件HotModuleReplacementPlugin,在Plugins中定义好。

如果想自己对JS文件使用HMR,就需要我们去文件中配置了,通过module.hot方法注册对应依赖模块修改时,所走的回调函数。并且这些代码被webpack打包到生产环境时,都会被移除掉,不会产生代码冗余。

对于JS文件,webpack无法提供一个通用的热替换方案,因为每一个JS模块的业务逻辑是不一样的,所以需要使用者去自己定义处理逻辑。详细使用可以看如下示例代码:

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)

// ================================================================
// HMR 手动处理模块热更新
// 不用担心这些代码在生产环境冗余的问题,因为通过 webpack 打包后,
// 这些代码全部会被移除,这些只是开发阶段用到
if (module.hot) {
  let hotEditor = editor
  module.hot.accept('./editor.js', () => {
    // 当 editor.js 更新,自动执行此函数
    // 临时记录编辑器内容
    const value = hotEditor.innerHTML
    // 移除更新前的元素
    document.body.removeChild(hotEditor)
    // 创建新的编辑器
    // 此时 createEditor 已经是更新过后的函数了
    hotEditor = createEditor()
    // 还原编辑器内容
    hotEditor.innerHTML = value
    // 追加到页面
    document.body.appendChild(hotEditor)
  })

  // 图片热替换逻辑
  module.hot.accept('./better.png', () => {
    // 当 better.png 更新后执行
    // 重写设置 src 会触发图片元素重新加载,从而局部更新图片
    img.src = background
  })

  // style-loader 内部自动处理更新样式,所以不需要手动处理样式模块
}

accept函数中接收了一个依赖模块路径,在第二个回调函数中会使用其返回的最新数据

我觉得热替换这个功能对于日常开发来说,还是很鸡肋的,因为对于JS不同的模块都要去编写不同的热替换逻辑,个人觉得没什么太多使用场景。可能css样式和图片的热替换有些应用场景。但官方说法是利大于弊,就好像单元测试,对于长期维护的项目来说是有好处的。如果日常开发中能遵循一些开发规范,使用HMR也会容易些。

对于目前的大部分前端框架,都给我们集成好了HMR方案,可以直接使用。

注意事项

  1. 处理HMR的代码报错会导致页面自动刷新,并且会清除报错信息。解决办法:使用hotOnly。
  2. 在项目中可能没有开启HMR但是在代码使用了,解决办法:进行module.hot判断。
  3. 关于HMR的额外代码并不会打包到生产文件中去,它编译过来就是一个if(false) {}而这种判断会被压缩掉。

生产环境优化

生产环境注重运行效率,开发环境注重开发效率。在webpack4推出了mode配置,不同模式提供了不同预设配置。生产环境默认提供了很多生产环境中的优化配置。官方也建议为不同的环境,配置不同的打包配置。

在webpack中,支持两种方式去区分不同环境使用不同的打包配置,第一种是通过module.exports导出一个函数:

module.exports = (env, argv) => {
  // 在这函数中可以return一个config对象,通过判断env来选择不同环境的打包配置
  // argv函数可以接受运行cli 进行打包时输入的全部参数
  if(env === "production){
     return prodConfig;
  }
  if (env === "development") {
     return devConfig;
  }
  return baseConfig;
}

第二种方法是使用webpack官方提供的webpack-merge库进行配置合并:

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

module.exports = merge(common, {
  mode: 'development',
  devtool: 'cheap-eval-module-source-map',
  devServer: {
    hot: true,
    contentBase: 'public'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
})

如果使用普通的Object.assign方法,会覆盖同名属性。而使用这个webpack-merge方法,在对于类似plugins这种数组配置他不会覆盖,而是合并,在这个函数内部会处理好合并逻辑。

使用这个方式时,因为没默认打包文件,所以需要我们在cli命令中指定配置文件yarn webpack --config xxx.js,我们也可以把命令定义在NPM 命令中,方便使用。

内置插件

前面已经学习了HotModuleReplacementPlugin这个内置插件,这里再讲几个。

DefinePlugin

它是一个全局常量定义插件,使用该插件通常定义一些常量值,使用方式如下:

new webpack.DefinePlugin({
  	// 值要求是一个代码片段
    API_BASE_URL: JSON.stringify('https://api.example.com'),
    PI: `Math.PI`, // PI = Math.PI
    VERSION: `"1.0.0"`, // VERSION = "1.0.0"
    DOMAIN: JSON.stringify("duyi.com")
})

这样一来,在源码中,我们可以直接使用插件中提供的常量,当webpack编译完成后,会自动替换为常量的值。

BannerPlugin

它可以为每个chunk生成的文件头部添加一行注释,一般用于添加作者、公司、版权等信息。

new webpack.BannerPlugin({
  banner: `
  hash:[hash]
  chunkhash:[chunkhash]
  name:[name]
  author:yuanjin
  corporation:duyi
  `
})

ProvidePlugin

自动加载我们需要的重复大量使用的公共模块,这样就不用到处去 import 或 require。

new webpack.ProvidePlugin({
  $: 'jquery',
  _: 'lodash'
})

// 然后在我们任意源码中:
$('#item'); // <= 起作用
_.drop([1, 2, 3], 2); // <= 起作用

TreeShaking

“树抖动”,它的实现必须是基于ES Module(补充原因)。在使用babel-loader时,可能会导致TreeShaking不生效,在新版中已经关闭了转为CommonJs的插件。

sideEffects

标识模块是否有副作用:模块执行时除了导出成员之外,所做了的事情。它和Tree-Shaking配合使用,作用是告诉webpack,该模块是否有副作用,没有的话,该模块如未被引用,则会被移除。

// 开启方式,在webpack.config.js中加入配置
module.exports = {
  optimization: {
    sideEffects: true
    }
  }
}

// 在模块的package.json中加入配置
{
  "sideEffects": false,
  // 或
  "sideEffects": [
    "./src/extend.js",
    "*.css"
  ]
}

使用这个功能前提是我们要确定代码真的没有副作用。比如:在模块中给一些原生方法做方法扩展;导入的css模块。解决方式是如上所示,标识出有副作用的模块。

webpack代码分割

多入口打包

通过entry属性配置一个对象。这里不是一个数组,因为数组是将多个入口打包成一个bundle。

提取公共模块

使用splitChunks这个属性,可以让webpack自动进行公共模块的打包,都打包至一个bundle中,减少代码体积。

module.exports = {
  optimization: {
    splitChunks: {
      // 分包策略
    }
  }
}

实践一下

动态导入

对于通过import()方法导入的模块,webpack会自动对其处理分包,我们还可以通过注释,给打包出来的bundle起名字。

import(/* webpackChunkName: 'posts' */'./posts/posts')

并且相同name的动态导入,会被打包到一起。