写在前面
事情是这样的,之前做毕设的时候做了个项目。这个项目是用React的做的,但没有使用官方的cli工具,经过自己的一顿折腾搭建起来的。项目上线后,体积太大,导致首次加载需要8s之久!!成了我一块心病,于是准备重新学习一下webpack,找个机会把这个项目的打包做一个优化。
之前也对webpack有过一些片面的了解,根据大佬写的一些博客,查阅官方文档,搭建了项目环境,并且打包部署,但是总感觉对webpack的了解很浅显,于是最近找了个视频学习了下,下面做一些简单的分享(我觉得视频比官方文档写的要好,官方文档貌似被吐槽的不轻)。
Webpack与模块化开发
随着前段技术的深入发展,传统的html+js+css一把嗦的方式如今已经不太适用了。首先在html文件中引入大量的js文件我们很难去管理,可能会造成变量污染的问题。其次,我们引入一个文件可能并不需要文件中的所有内容。
webpack最初的目标就是实现前端项目的模块化,也就是说它所解决的问题是如何在前端项目中更高效地管理和维护项目中的每一个资源
模块化开发的历程:
Stage-1 文件划分
这也是最早期的的web模块化的方式,讲不同的文件单独放入js文件中,然后使用script标签引入
└─ stage-1
├── module-a.js
├── module-b.js
└── index.html
缺点:
- 模块直接在全局工作,大量模块成员污染全局作用域;
- 没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改;
- 一旦模块增多,容易产生命名冲突;
- 无法管理模块与模块之间的依赖关系;
- 在维护的过程中也很难分辨每个成员所属的模块
;
Stage-2 命名空间方式
后来,我们约定每个模块只暴露一个全局对象,所有该模块的成员都挂在到这个全局对象中。相当于给每个模块的内部成员添加了命名空间
// module-a.js
window.moduleA = {
method1: function () {
console.log('moduleA#method1')
}
}
缺点:
除了命名的问题能解决,其他问题都不能解决
Stage-3 IIFE立即执行函数
使用立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)为模块提供私有空间。具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现
// module-a.js
;(function () {
var name = 'module-a'
function method1 (param) {
console.log(name + '#method1' + param)
}
window.moduleA = {
// 引入这个文件 就能通过 moduleA.method1('ddddd') 来调用了
method1: method1
}
})()
Stage 4 - IIFE 依赖参数
在 IIFE 的基础之上,我们还可以利用 IIFE 参数作为依赖声明使用,这使得每一个模块之间的依赖关系变得更加明显。
// module-a.js
;(function ($) { // 通过参数明显表明这个模块的依赖
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
window.moduleA = {
method1: method1
}
})(jQuery)
缺点:
最明显的问题就是:模块的加载。在这几种方式中虽然都解决了模块代码的组织问题,但模块加载的问题却被忽略了,我们都是通过 script 标签的方式直接在页面中引入的这些模块,这意味着模块的加载并不受代码的控制。
模块化规范的出现 CommonJS —> AMD —> ES Module
CommonJS:
CommonJS规范是Node.js中所遵循的模块规范,该规范约定,一个文件就是一个模块。每个模块都有单独的作用域,通过module.exports导出成员。但是,Node.js实在启动时加载模块,执行过程中只是使用模块,在Node中使用不会有问题。但是在浏览器端使用同步的方式加载会导致应用运行效率低下。
AMD:
正是因为CommonJS的同步加载机制,所以早期制定模块化标准时,并没有直接选择CommonJS规范,而是专门为浏览器设计了一个规范,叫做 AMD ( Asynchronous Module Definition) 规范,即异步模块定义规范。同期还推出了一个非常出名的库,叫做 require.js,它除了实现了 AMD 模块化规范,本身也是一个非常强大的模块加载器。
ES Modules:
ES Modules 是 ECMAScript 2015(ES6)中定义的模块系统。 已发展成为现今最主流的前端模块化标准。相比于 AMD 这种社区提出的开发规范,ES Modules 是在语言层面实现的模块化,因此它的标准更为完善也更为合理。而且目前绝大多数浏览器都已经开始能够原生支持 ES Modules 这个特性了。
模块化中需要解决的问题
- ES Modules本身是ES6才支持的,本身就存在环境兼容性问题。尽管如今主流浏览器都能正常使用,但是不能保证用户的浏览器能够支持。
- 模块化划分出来的模块文件过多,而前端应用又运行在浏览器中,每个文件都需要单独从服务器请求回来。零散的模块文件必然会导致浏览器频繁发送网络请求,影响应用的工作效率。
- 在前端应用开发中,不仅仅JS代码需要模块化,HTML和CSS这些资源文件也面临被模块化的问题。而且从宏观角度来说,这些文件也是前端应用中的一部分。
Webpack能帮我们解决的问题
1. 和babel插件组合使用,帮助我们编译代码,也就是将我们开发阶段编写的那些包含新特性的代码转换为能够兼容大多数环境的代码,解决我们所面临的环境兼容问题。
2. 能够将散落的模块再打包到一起,这样就解决了浏览器频繁请求模块文件的问题。这里需要注意,只是在开发阶段才需要模块化的文件划分,因为它能够帮我们更好地组织代码,到了实际运行阶段,这种划分就没有必要了。
3. 能支持不通种类的前端模块类型,也就是说可以将开发过程中涉及的样式、图片、字体等所有资源文件都作为模块使用,这样我们就拥有了一个同的模块化方案,所有资源文件的加载都可以通过代码控制,与业务代码统一维护,更为合理。
普通模块化开发 vs Webpack模块化开发
普通模块化开发
首先我们尝试着使用传统的开发方式进行模块化开发看看会遇到什么问题。
└─ 01-es-modules
├── src
│ ├── index.js
│ └── main.js
└── index.html
// ./src/index.js
import main from './main.js';
const testES_Module = main();
document.body.append(testES_Module);
// ./src/main.js
export default () => {
const ele = document.createElement('h2');
ele.textContent = 'Hello World';
ele.addEventListener('click', () => {console.log('clicked');})
return ele;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
<script src="src/index.js"> </script>
</html>
我们不妨直接打开index.html的文件预览一下效果
查阅es-modules mdn文档发现要在html使用模块化的开发方式,需要在使用<script>
标签时加上type='module’属性
- <script src="src/index.js"> </script>
+ <script type="module" src="src/index.js"> </script>
重新打开,发现竟然跨域了!!
我们再查阅mdn文档时发现,有这样一句话,所以我们需要启动一个服务。这里以http-server为例。
cnpm install -g http-server
// 在项目更目录运行
http-server -p 8888
可以看到,我们没有使用webpack的情况下,正常使用了ES Module的规范进行开发。但是目前还有很多浏览器是不支持ES Modules的,直接使用会报错,所以我们需要webpack这样的工具来帮助我们解决问题。
Webpack模块化开发
初始化项目
npm init --yes
为了版本保持一致,建议直接在package.json中固定webpack和webpack-cli的版本
{
"name": "webpack",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "^4.40.2",
"webpack-cli": "^3.3.9"
}
}
cnpm install
在项目的根目录创建webpack.config.js文件来提供webpack的配置,告诉webpack需要如何打包我们的文件
// Node的内置模块
const path = require('path');
module.exports = {
mode: 'none', // mode分为production development node 设置成node方便我们阅读打包后的文件
entry: './src/index.js', // 入口
output: {
// 输出文件名
filename: 'bundle.js',
// 输出路径
path: path.join(__dirname, 'dist')
},
}
注意: 由于我们的webpack.config.js是一个运行在Node.js环境中的JS文件,也就是说我们需要按照 CommonJS 的方式编写代码,这个文件可以导出一个对象,我们可以通过所导出对象的属性完成相应的配置选项
使用npx运行当前目录下的webpack帮助我们打包
npx webpack
在dist目录下查看打包结果
当然,我们可以以预览html的文件直接查看结果(不过还是建议大家以后在本地测试过程中都启一个服务来测试,一来能解决跨域问题,二来可以通过自动刷新甚至HMR来提升我们的开发体验)
Webpack中的Loader机制
之前在学习webpack的时候,对于loader的认知只是单纯的在module中配置一大堆的loader。也一直有过疑问,css不是浏览器支持的吗?为什么还需要css-loader,甚至style-loader?
之前说过,webpack不仅仅是JavaScript模块打包工具,还是整个前端项目的模块打包工具。但是,webpack内部默认只能够处理JS模块代码,默认情况下,他默认的把所有遇到的文档都当做JavaScript模块来处理而webpack中的各种loader才是支持webpack能够支持不同种类资源模块加载的功臣
。
假设我们目前没有配置任何的loader,然后在项目中导入css文件
└─ 01-es-modules
├── src
│ ├── index.js
│ └── css.js
└── index.html
body {
background-color: red;
}
import './index.css'
console.log('success');
运行 npx webpack
报错了,webpack内部默认只能够处理js模块代码,而对于除了js之外的模块,我们需要额外的loader帮助我们去处理(.css文件本身并没有模块化),于是我们自然就想到了css-loader。
cnpm install -D css-loader
在webpack.config.js中告诉webpack对于.css我们应该如何去处理:
const path = require('path');
/*** @type { import('webpack').Configuration }*/
module.exports = {
mode: 'none',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.css$/,
use: 'css-loader'
}
]
}
}
在配置对象的 module 属性中添加一个 rules 数组。这个数组就是我们针对资源模块的加载规则配置,其中的每个规则对象都需要设置两个属性:
首先是 test 属性,它是一个正则表达式,用来匹配打包过程中所遇到文件路径,这里我们是以 .css 结尾;
然后是 use 属性,它用来指定匹配到的文件需要使用的 loader,这里用到的是 css-loader。
重新运行打包 npx webpack, 成功!
不要高兴得太早,我们在dist目录下新建一个index.html文件去引用我们的打包后的bundle.js,发现我们设置的css属性并没有生效(配置过webpack的同学可以知道,我们需要有再有一个style-loader)。我们先来看看现在的打包结果。
仔细阅读这个文件,你会发现 css-loader 的作用是将 CSS 模块转换为一个 JS 模块,具体的实现方法是将我们的 CSS 代码 push 到一个数组中,这个数组是由 css-loader 内部的一个模块提供的,但是整个过程并没有任何地方使用到了这个数组。
因此这里样式没有生效的原因是: css-loader 只会把 CSS 模块加载到 JS 代码中,而并不会使用这个模块。
安装style-loader,并且把use修改为一个数组
cnpm install -D style-loader
// webpack.config.js
const path = require('path');
/*** @type { import('webpack').Configuration }*/
module.exports = {
mode: 'none',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
}
}
查看打包后的结果,我们发现比之前多两个模块,页面中的css样式也能正常显示了。我们可以在代码中看到style标签的创建。
style-loader 的作用总结一句话就是,将 css-loader 中所加载到的所有样式模块,通过创建 style 标签的方式添加到页面上。
开发一个Loader
假设我们需要开发一个可以加载markdown文件的加载器,以便可以在代码中直接导入md文件。在此之前,我们需要先安装一个能把md文件转化成html的包–marked
cnpm install -D marked
└─ 04-webpack-loader
├── src
│ ├── about.md
│ └── main.js
├── package.json
├── markdown-loader.js
└── webpack.config.js
// about.md
### Webpack笔记
1. 这是一个webpack笔记1
2. 这是一个webpack笔记2
3. 这是一个webpack笔记3
// ./markdown-loader.js
const marked = require('marked')
module.exports = source => {
// 1. 将 markdown 转换为 html 字符串
const html = marked(source)
// 2. 将 html 字符串拼接为一段导出字符串的 JS 代码
const code = `module.exports = ${JSON.stringify(html)}`
return code
}
// index.js
import about from './about.md'
console.log(about);
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.md$/,
// 使用模块文件路径
use: './markdown-loader.js'
}
]
}
}
对于loader,在这里要注意几个地方:
- webpack.config.js中的use中不仅可以使用模块名称,还可以使用模块文件路径,这点与 Node 中的 require 函数是一样的
- 每个 Webpack 的 Loader 都需要导出一个函数,这个函数就是我们这个 Loader 对资源的处理过程,它的输入就是加载到的资源文件内容,输出就是我们加工后的结果(return)。
- Webpack 加载资源文件的过程类似于一个工作管道,你可以在这个过程中依次使用多个 Loader,但是最终这个管道结束过后的结果必须是一段标准的 JS 代码字符串。
Webpack中的Plugin机制
Loader负责完成项目中各种各样资源模块的加载,从而实现整体项目的模块化,而Plugin则是用来解决项目中除了资源模块打包以外的其他自动化工作,可以说Plugin的能力范围更广,用途更大。
体验插件机制
- clean-webpack-plugin: 自动在打包之前清除 dist 目录
- html-webpack-plugin: 自动生成应用所需要的 HTML 文件
- copy-webpack-plugin: 指定一些不需要参与构建的静态文件,我们希望Webpack 在打包时一并将这个目录下所有的文件复制到输出目录。
安装依赖
cnpm install -D clean-webpack-plugin html-webpack-plugin copy-webpack-plugin
然后在webpack.config.js中的plugins属性下使用
// ./webpack.config.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new CopyWebpackPlugin({
patterns: ['public'] // 需要拷贝的目录或者路径通配符
}),
]
}
在根目录下新建public文件夹,新增text.txt文件。在src目录下新建html模板
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
<script></script>
</html>
运行打包命令 npx webpack 打包成功!!可以看到 我们不需要自己创建html文件去测试我们打包后的js文件了,静态文件也没有被编译,直接拷贝到了dist目录
开发一个插件
前面我们提到,相比于Loader,插件的能力更为宽泛,因为Loader只是在模块的加载环节工作,而插件的作用范围几乎可以触及Webpack工作的每一个环节。
在Webpack整个工作过程中会有很多环节,类似于Web应用中的事件(框架中的生命周期函数),Webpack几乎在每一个环节都埋下了一个钩子。这样我们在开发插件的生活,通过往这些不同节点上挂载不同的任务,就可以达到拓展Webpack的目的。
现在,我们的需求是能够清楚Webpack打包结果中的注释,这样一来我们的bundle.js将更容易阅读。
在项目根目录下创建 remove-comments-plugin.js
Webpack 要求我们的插件必须是一个函数或者是一个包含 apply 方法的对象,一般我们都会定义一个类型,在这个类型中定义 apply 方法。然后在使用时,再通过这个类型来创建一个实例对象去使用这个插件。
所以我们这里定义一个RemoveCommentsPlugin类型,然后在这个类型中定义一个apply方法,这个方法会在Webpack启动时被调用,它接受一个compiler对象参数,这个对象是Webpack工作过程中最核心的对象,里面包含了我们这次构建的所有配置信息,我们就是通过这个对象去注册钩子函数,具体代码如下
class RemoveCommentsPlugin {
apply(compiler) {
console.log('RemoveCommentsPlugin 启动')
}
}
知道这些过后,还需要明确我们这个任务的执行时机,也就是到底应该把这个任务挂载到哪个钩子上。
我们的需求是删除 bundle.js 中的注释,也就是说只有当 Webpack 需要生成的 bundle.js 文件内容明确过后才可能实施。 然后我们在webpack官方的钩子函数里面找到了emit
这个生命周期函数,比价符合我们的需求。
// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
apply(compiler) {
compiler.hooks.emit.tap("RemoveCommentsPlugin", (compilation) => {
// compilation => 可以理解为此次打包的上下文
for (const name in compilation.assets) {
console.log(name); // 输出文件名称
}
});
}
}
module.exports = RemoveCommentsPlugin;
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const RemoveCommentsPlugin = require('./remove-comments-plugin.js');
module.exports = {
mode: 'none',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'My App',
template: './src/index.html'
}),
new RemoveCommentsPlugin(),
]
}
运行 npx webpack 看到我们的文件名确实被打印出来了
接下来,我们要做的事情是,首先并且判断是否是js文件,然后拿到该文件的上下文,再通过正则替换的方式移除掉代码中的注释,最后覆盖掉 compilation.assets 中对应的属性,在覆盖的对象中,我们任然按照原来的格式返回。
class RemoveCommentsPlugin {
apply(compiler) {
compiler.hooks.emit.tap("RemoveCommentsPlugin", (compilation) => {
// compilation => 可以理解为此次打包的上下文
for (const name in compilation.assets) {
if (name.endsWith('.js')) {
const contents = compilation.assets[name].source();
const noComments = contents.replace(/\/\*{2,}\/\s?/g, '');
compilation.assets[name] = {
source: () => noComments,
size: () => noComments.length,
}
}
}
});
}
}
module.exports = RemoveCommentsPlugin;
以上就是我们实现一个移除注释插件的过程,通过这个过程我们了解了:插件都是通过往Webpack生命周期的钩子中挂载任务函数实现的。