Webpack5详细教程-导读篇

1,382 阅读13分钟

1.导读

Webpack 是一款在前端领域十分出色的模块化打包工具,它的生态发展至今已经十分繁荣了,也促进了前端工程化发展。所以掌握这个工具的使用对于我们在项目开发中是很有用处的,因为它解决了很多问题,例如兼容各种模块化规范、自动化工作流等。虽然随着 ViteSnowpackNo-Bundler 类型打包工具的兴起,大家可能更加追求更好的方案,但是就个人而言,任何工具都是为了解决某一类问题而出现的,所以技术没有银弹。比如已经成为过去时的 jQueryGrunt 等技术,它们可能对于我们而言现在没有价值了,因为我们现在对于碰到要使用它们的场景比较罕见了,但是我们可以大概地去了解一下它们产生的背景,解决了什么问题(可以不必深挖技术细节),这对于我们以后发展还是很有帮助的。所以接下来笔者会先从模块化与自动化发展历程开始讲解,慢慢把 Webpack “引” 出来。

2.模块化与自动化构建工具演进

2.1 模块化发展历程

模块化是一种项目组织方式,可以提高开发效率,降低成本。

2.1.1 stage1:文件划分

很早之前,大家写代码都是直接在 script 标签里面一写到底,当然对于一些很简单的功能是完全可以用这种方式的,但是随着代码越写越长之后,于是就出现了以下问题:

  • 功能划分模糊
  • 作用域污染(命名冲突)
  • 代码不可维护

于是大家就把不同功能的模块单独拆成文件进行划分:

// js/module-a.js
const sum = (a, b) => a + b


// js/module-b.js
const multi = (a, b) => a * b
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./js/module-a.js"></script>
    <script src="./js/module-b.js"></script>
    <script>
      console.log(sum(1, 2))
      console.log(multi(1, 2))
    </script>
  </body>
</html>

虽然看似模块被单独拆分了,它还是没有解决上面的问题,假如 module-b 出现了与 module-a 中一样的同名变量依旧会出现成员冲突的情况,而且模块与模块的依赖关系不清晰。

2.1.2 state 2:命名空间

为了解决上面的命名冲突的问题,于是就只让文件导出一个全局对象,把所有模块成员都挂载到这个对象上面,这种方式被称为 “命名空间”。

// js/module-a.js
window.a = {
  data: 'hello a',
  sum(a, b) {
    return a + b
  },
}

// js/module-b.js
window.b = {
  data: 'hello b',
  multi(a, b) {
    return a * b
  },
}

这种方法很好地解决了命名冲突问题(保证全局变量唯一),但是其他问题还是存在,于是在此基础之上就衍生了 IIFE

2.1.3 stage 3:IIFE:立即调用函数表达式

IIFE 并未采取什么新的技术,它充分利用了 JavaScript 中的闭包特性为模块提供了私有空间。

// js/module-a.js
;(function () {
  const data = 'hello a'

  window.a = {
    data,
    sum(a, b) {
      return a + b
    },
  }
})()

// js/module-b.js
// 传入参数,声明其他模块依赖
;(function (moduleA) {
  const data = 'hello b'
  // 其他模块
  console.log(moduleA.sum(1, 2))

  window.b = {
    data,
    multi(a, b) {
      return a * b
    },
  }
})(window.a)

它很好地解决了作用域污染问题,也可以通过传递参数声明其他模块依赖,但是每次还是需要通过注入 script 标签来引入模块,这就带来了维护问题,因为每次新添加一个模块就要认为地添加一个标签,除此之外你还要保证脚本的书写顺序。这给维护带来了困难,如果可以只维护一个入口文件就好了,于是慢慢就演变到了现代化模块化解决方案。

2.1.4 stage 4:现代化模块化解决方案

(1)Commonjs 模块化规范

随着 Node.js 在 2009 年诞生,Commonjs 模块化规范慢慢出现在人们视野,它主要有以下内容:

  • require 引入模块
  • exports/modules.exports 导出模块
  • 一个文件就是一个模块
  • 模块加载是同步的

注意:Commonjs 规范并不等于模块化规范,它包含了很多内容如:Buffer、二进制、I/O流等内容,而模块化只是其中的一个规范内容。

Commonjs 模块化规范的实现和 IIFE 并非没有关系,上面提到的 requireexports/module.exports包括我们熟知的 __filename 等变量它们其实是通过一个 IIFE 传递的参数:

(function (exports, require, module, __filename, __dirname) {})()

(2)AMD/CMD 规范

Commonjs 模块化规范并不适合在浏览器使用,因为浏览器如果使用以同步的方式加载模块会带来很严重的性能问题,于是社区就出现了 AMD/CMD 模块化规范,对应实现的库为 require.jssea.js。下面只介绍 require.js 简单使用方法:

先引入 require.js,然后添加主入口 :

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./js/require.js" data-main="js/main"></script>
  </body>
</html>

定义模块 A:

// js/module-a.js
// 不依赖其他模块
define(function () {
  const data = 'hello a'

  return {
    data,
    sum(a, b) {
      return a + b
    },
  }
})

定义模块 B:

// js/module-b.js
// 依赖模块 A

define(['./module-a'], function (moduleA) {
  const data = moduleA.sum(1, 2)

  // 使用其他模块代码
  return {
    data,
    multi(a, b) {
      return a * b
    },
  }
})

主入口:

// js/main.js
// 主入口

require(['module-a', 'module-b'], function (moduleA, moduleB) {
  console.log(moduleA.sum(1, 2))
  console.log(moduleB.data)
})

CMD 和 AMD 是类似的,只不过写法有差异,而 CMD 模块化规范后来也被 AMD 兼容了,所以这里就不过多介绍了。

我们可以看到为了书写模块化代码要去引入额外的库,这带来了一定的性能开销,于是 ES 规范里加入了原生的模块化解决方案。

(3)ES Module

ES Module 是在 ES6 规范引入的,具有以下特点:

  • 具有私有作用域
  • 严格模式
  • 延迟执行脚本(默认 defer)
  • 通过 CORS 方式请求外部 JS 模块

这种方式我们算是比较熟悉了,使用方式也很简单。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <!-- 延迟执行脚本,相当于加了 defer -->
    <script src="./js/main.js" type="module"></script>
  </head>
</html>

导出模块:

// js/module-a.js
const data = 'hello a'

export default {
  data,
  sum(a, b) {
    return a + b
  },
}

// js/module-b.js
import moduleA from './module-a.js'

const data = moduleA.sum(1, 2)

export default {
  data,
  multi(a, b) {
    return a * b
  },
}

主入口:

// 主入口

import moduleA from './module-a.js'
import moduleB from './module-b.js'

console.log(moduleA.sum(1, 2))
console.log(moduleB.data)

导出导入的成员

// a.js
const b = 2
export const a = 1
export default b

// b.js
export const b = 2

// index.js
export { a, default as c } from 'a.js'
export { b } from 'a.js'

对于不兼容ESM的浏览器

<script nomodule src="https://unpkg.com/promise-polyfill@8.2.0/dist/polyfill.min.js"></scripts>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></scripts>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></scripts>
<script type=module>
//...
</script>

Node.js处理ES6模块:

  • 使用.mjs结尾的文件来标识ESM,或者在package.json里面type字段设置为module。(type默认是commonjs,如果改成了module,需要使用.cjs结尾来标识commonjs模块)
  • require不能加载ES6模块。
  • .mjs文件不能使用require,通过import导入的commonjs成员是一个默认成员,不能通过import {} from './demo.js'来获取成员,会报错。

注意事项

  • export {} 导出的不是一个对象它只是一个语法,export default {} 才是导出一个对象。

  • 同样的import {} 不是解构对象,它也只是一个语法。

  • import 引入的成员变量是只读的,并且它与 export 导出的成员变量是同一个引用关系。

  • 原生 ESM 中的 import 必须导出的是一个完整的文件名称。

  • import 必须放在顶层作用域,如果需要异步导入模块需要通过 import() 函数来导入。

现如今 ESM 已经被主流浏览器实现了,而 Commonjs 规范内部对 ESM (Node v13.2.0+)也支持了,所以更加推荐大家使用这种方式。

到这为止,模块化演进历程部分讲解告一段落了。所以,Webpack 跟这有何关系?我们再回头看看 Webpack 的介绍:模块化打包工具。这个模块化可不是上面介绍的模块化,而是 Webpack 为了兼容上面主流模块化规范而自己实现了一套模块化机制,它能处理让多种模块化同时使用,这就是它的强大之处,也就是说我们可以在同一个文件里面使用requireimport。不仅如此,对于 Webpack 来说,一切皆模块,可不只是 JS 文件。

下面我们将对自动化构建工具进行一个介绍,我们可以不必对其中细节过于深究,只需要看看它们的共性是什么。

2.2 自动化构建工具发展历程

自动化构建就是把一些源代码手动转为生产环境代码的过程交给程序去做。

2.2.1 人工时期

很早以前,那时候自动化工具并未兴起,大家开发完项目之后就先要对脚本、样式、图片等静态资源进行压缩,这时候会找一些三方工具帮忙压缩,然后再上传到服务器进行部署。之后开发新功能又是重复工作,随着 Node.js 诞生,事情出现好转,因为这个时候带来了 Npm

2.2.2 Npm Script

有了 Npm Script 之后我们可以配置一些自动化脚本(在 package.json 中的 scripts 字段进行配置 ),配合一些脚手架工具来帮助我们做一些重复的事情。而这种方式至今都保持着不败的地位,它通常配合着一些自动化构建工具使用,给开发体验带来了质的飞升。它的缺点就是过于简单,很多功能都没有,所以单独使用 Npm Scripts 的场景很有限。

2.2.3 Grunt

Grunt 带来了“任务”的概念,我们可以把我们想做的事情都当成任务,比如:

  • 编译 sass
  • 使用 babel 处理 js 兼容
  • 热更新
  • 单元测试

在项目中只需要配置一下 gruntfile.js 就可以实现自动化执行任务。

const loadGruntTasks = require('load-grunt-tasks')

module.exports = (grunt) => {
  grunt.initConfig({
    // 清除文件的插件
    clean: {
      temp: 'temp/**',
    },
    // 编译Sass
    sass: {
      options: {
        sourceMap: true,
        implementation: require('sass'),
      },
      main: {
        files: {
          'dist/css/main.css': 'src/scss/main.scss',
        },
      },
    },
    babel: {
      options: {
        sourceMap: true,
        presets: ['@babel/preset-env'],
      },
      main: {
        files: {
          'dist/js/app.js': 'src/js/app.js',
        },
      },
    },
    watch: {
      js: {
        files: ['src/js/*.js'],
        tasks: ['babel'],
      },
      css: {
        files: ['src/scss/*.scss'],
        tasks: ['sass'],
      },
    },
  })

  // 自动加载插件
  loadGruntTasks(grunt)

  grunt.registerTask('default', ['sass', 'babel', 'watch'])
}

它的插件系统也是很丰富的,可以按照各类插件来实现我们的需求,也支持异步任务。但是也有一些缺点:

  • 很多插件没有集成,不能做到开箱即用。
  • 基于磁盘读写,效率不高。

它的出现弥补了 Npm Scripts 的问题,但是正如上面提到的一些缺点,于是社区又出现了新的自动化构建工具。

2.2.4 Gulp

Gulp 也是一款很出色的自动化构建工具,它带来了“流”的概念(基于Node.js中的stream),它所有的操作都是在内存中完成,之后再把资源输出到磁盘,很好地解决了 Grunt 的不足。“流”你可以理解为将源文件通过一个个的管道处理,最终得到目标文件的一个过程。这些管道通常是各种插件来做的。它提供的 parallel(并行执行任务) 和 series(同步执行任务) API 可以帮我们组合任意任务。

我这里基于 Gulp 封装了一个 CLI 工具 pages-cli(未发布),可以帮我们自动执行相关构建流程,由于篇幅关系这里就不过多介绍 Gulp 使用了,相关源代码以及上传到 Github,大家感兴趣可以查阅(gulp-case)

Gulp 距离上次版本更新已经三年了,但这并不代表着它已经“死亡”了,我们可以看到 Npm 的周下载量就可以看出(100万+),可见还是有很多项目使用着它。

Gulp 的优点就是很灵活,但同时也带来了缺点,就是不能开箱即用,集成度不高。

2.2.5 FIS 3

FIS 3 是百度推出的一款自动化工具,它开箱即用,内置了很多插件,它算是 Gulp 与 Grunt 的一个“加强版”。我们只需要写个配置文件配置一下:

// 指定输出目录
fis.match('*.{js,scss,png}', {
  release: '/assets/$0'
})

// 编译sass
fis.match('**/*.scss', {
  rExt: '.css',
  parser: fis.plugin('node-sass'),
  optimizer: fis.plugin('clean-css')
})

// 编译 js
fis.match('**/*.js', {
  parser: fis.plugin('babel-6.x'),
  optimizer: fis.plugin('uglify-js')
})

不过很可惜的是,FIS 3 已经不再维护了,而且 Npm 周下载量也少得可怜,但是这并不代表它不是一款出色的工具。

在这里总结一下,这些工具看似五花八门,但都是为了解决某些问题而出现的,有时候我们并不需要完全去掌握所有工具使用,但对于它们的应用场景与产生的背景都需要去了解一下,在将来某一天我们可能也会碰到市面上还未解决的痛点问题,这时候我们的技术积累就能够派上用场了,说不定我们也能造出一款很好用的工具。另外,我们不难看出 Node.js 是多么强大,这些工具能够产生很大程度上得益于 Node.js 的发展,所以大家知道要学啥了吧😏。

2.3 现代化构建工具

上面的自动化构建工具基本覆盖了我们的业务场景,但是我们还有一个需求就是要是能对模块化进行统一管理就更好了,于是就出现了现代化构建工具,它们大都支持模块化打包。下面就简单对各个工具介绍,后面会对 Webpack 进行深入的讲解。

  • Webpack:模块化打包工具,所以资源都被当初模块处理,有十分庞大的生态。
  • Parcel:模块化打包工具,零配置,且对大部分静态资源提供开箱支持,解决 Webpack 中配置繁琐问题,Parcel 使用 worker 进程去启用多核编译。
  • Rollup:模块化打包工具,主要针对 ES Module 进行打包,最早提出 Tree-shaking,可以减少输出文件的大小并提升运行性能,通常用于某些库的打包。
  • Snowpack:它的理念在于利用浏览器原生 ES Module 的能力来减少或避免整个bundle的打包,开发时提供 No-Bundler 服务器,文件被构建一次就会被缓存。
  • Vite:也是一款 No-Bundler构建工具(仅限开发时),它受到 Snowpack 的一些启发,开发时冷启动,极快的热模块替换体验,但在生产环境还是使用了 Rollup 进行了打包。

3. 为什么还要学 Webpack

我们往往会在一个技术兴起的时候就去追求它,当然这并没有错。而是我们要明白为什么要学习它,这也是本文一直强调的观点。诚然,Vite 这类工具将来一定是主流,它当然值得我们学习,可我们也不能完全抛弃 Webpack,因为它的生态已经十分繁荣了,而且有很多项目都在使用 Webpack,所以掌握这项技能对我们解决问题也有很大帮助。后续的文章我会从以下几个方向进行讲解:

  • 入门阶段,这里讲解如何从 0 到 1 搭建一个 Vue/React 项目。
  • 熟练阶段,这里讲解 loader/plugin 机制,以及如何自己写一个 loader/plugin。
  • 进阶阶段,这里讲解 Webpack核心之 Tapable 库源码、打包后结果分析、核心构建过程源码分析、实现一个简易打包器。

4. 总结

笔者从模块化与自动化构建工具发展历程讲解,讲述了它们在发展过程中为了解决一些问题而产生了各种各样的工具,而最终本文旨在让大家去了解这类工具带给我们的价值是什么,从而讲来学习 Webpack 的时候能有个准备,希望给大家带来些许收获。

5. 参考

- 阮一峰:Javascript模块化编程(三):require.js的用法

  • 拉钩教育 -《Webpack原理与实践》