前端工程化实战-模块化开发与规范化标准--总结(Day15)
Part2 · 前端工程化实战 · 模块化开发与规范化标准
一、模块化演变过程
模块化概述:
模块化开发为当前最重要的前端开发范式之一。随着前端代码的日益复杂,的前端项目代码出现了不得不花费大量时间去整理。而模块化就是最主流的代码组织方式。它通过把复杂的代码通过功能不同划分为不同的模块,以单独维护的方式,提高开发效率,降低维护成本。【模块化】仅仅为一个思想,并没有提供具体的实现。
1.stage1 基于文件划分
将每一个模块独立为一个文件,在页面中引入这些文件(web中最原始的模块化系统)。
具体做法就是将每个功能及其相关状态数据各自单独放到不同的文件中,约定每个文件就是一个独立的模块,使用某个模块就是将这个模块引入页面,然后直接调用模块的中的成员(成员/函数)
特点:
- 所有的模块都直接在全局工作,并没有私有空间,所有的成员都可以在模块外部被访问或者修改;
- 文件模块过多时,容易产生命名冲突;
- 无法管理模块与模块之间的依赖关系
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Modular evolution stage 1</title>
</head>
<body>
<h1>模块化演变(第一阶段)</h1>
<h2>基于文件的划分模块的方式</h2>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
// 命名冲突,此处命名冲突问题由于引入模块顺序问题而不同,name值最后指向module-b.js中的name值
method1()
// 模块成员可以被修改,外部可以任意修改模块内部成员
name = 'foo'
</script>
</body>
</html>
module-a.js
// module a 相关状态数据和功能函数
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
module-b.js
// module b 相关状态数据和功能函数
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
2.stage2 命名空间方式
命名空间方式每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象中。具体做法就是在第一阶段的额基础上,通过将每个模块【包裹】为一个全局对象的形式实现,有点类似于为模块内的成员添加了【命名空间】的感觉。
特点:
- 通过【命名空间】的方式减少了命名冲的可能
- 同样没有私有空间,所有模块成员也可以在模块外部被访问或者修改
- 同样无法管理模块之间的依赖关系
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Modular evolution stage 2</title>
</head>
<body>
<h1>模块化演变(第二阶段)</h1>
<h2>每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象中</h2>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块成员可以被修改
moduleA.name = 'foo'
</script>
</body>
</html>
module-a.js
// module a 相关状态数据和功能函数
var moduleA = {
name: 'module-a',
method1: function () {
console.log(this.name + '#method1')
},
method2: function () {
console.log(this.name + '#method2')
}
}
module-b.js
// module b 相关状态数据和功能函数
var moduleB = {
name: 'module-b',
method1: function () {
console.log(this.name + '#method1')
},
method2: function () {
console.log(this.name + '#method2')
}
}
3.stage3 IIFE
使用立即执行函数表达式IIFE(Immediately-Invoked Function Expression)为模块提供私有空间。具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,对于需要宝库给外部的成员,通过挂载全局对象的方式实现。
特点:
有了私有成员的概念,私有成员只能在模块成员内通过必报的形式访问。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Modular evolution stage 3</title>
</head>
<body>
<h1>模块化演变(第三阶段)</h1>
<h2>使用立即执行函数表达式(IIFE:Immediately-Invoked Function Expression)为模块提供私有空间</h2>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块私有成员无法访问
console.log(moduleA.name) // => undefined
</script>
</body>
</html>
module-a.js
// module a 相关状态数据和功能函数
;(function () {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
window.moduleA = {
method1: method1,
method2: method2
}
})()
module-b.js
// module b 相关状态数据和功能函数
;(function () {
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
window.moduleB = {
method1: method1,
method2: method2
}
})()
4.stage4 IIFE 参数
利用IIFE参数作为依赖声明使用,具体做法是在第三阶段的基础上,利用立即执行函数的参数传递模块依赖项
特点:
每个模块之间的关系变得更加明显
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Modular evolution stage 4</title>
</head>
<body>
<h1>模块化演变(第四阶段)</h1>
<h2>利用 IIFE 参数作为依赖声明使用</h2>
<p>
具体做法就是在第三阶段的基础上,利用立即执行函数的参数传递模块依赖项。
</p>
<p>
这使得每一个模块之间的关系变得更加明显。
</p>
<script src="https://unpkg.com/jquery"></script>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
</script>
</body>
</html>
module-a.js
// module a 相关状态数据和功能函数
;(function ($) {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
function method2 () {
console.log(name + '#method2')
}
window.moduleA = {
method1: method1,
method2: method2
}
})(jQuery)
module-b.js
// module b 相关状态数据和功能函数
;(function () {
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
window.moduleB = {
method1: method1,
method2: method2
}
})()
5.stage5 模块化规范的出现
require.js提供AMD模块化规范以及一个自动模块化加载器
目录结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Stce4OGs-1610550317435)(https://i.loli.net/2021/01/05/6SVzEnaC8mi2f4Q.png)]
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Modular evolution stage 5</title>
</head>
<body>
<h1>模块化规范的出现</h1>
<h2>Require.js 提供了 AMD 模块化规范,以及一个自动化模块加载器</h2>
<script src="lib/require.js" data-main="main"></script>
</body>
</html>
module1.js
// 因为 jQuery 中定义的是一个名为 jquery 的 AMD 模块
// 所以使用时必须通过 'jquery' 这个名称获取这个模块
// 但是 jQuery.js 并不在同级目录下,所以需要指定路径
define('module1', ['jquery', './module2'], function ($, module2) {
return {
start: function () {
$('body').animate({ margin: '200px' })
module2()
}
}
})
module2.js
// 兼容 CMD 规范(类似 CommonJS 规范)
define(function (require, exports, module) {
// 通过 require 引入依赖
var $ = require('jquery')
// 通过 exports 或者 module.exports 对外暴露成员
module.exports = function () {
console.log('module 2~')
$('body').append('<p>module2</p>')
}
})
main.js
require.config({
paths: {
// 因为 jQuery 中定义的是一个名为 jquery 的 AMD 模块
// 所以使用时必须通过 'jquery' 这个名称获取这个模块
// 但是 jQuery.js 并不一定在同级目录下,所以需要指定路径
jquery: './lib/jquery'
}
})
require(['./modules/module1'], function (module1) {
module1.start()
})
二、模块化规范
1.CommonJS
在之前的每个阶段中,模块加载的方式都是通过script标签手动引入。也就是说,之前的方法中,模块的加载并不受代码的控制。一旦模块过多,会出现各种问题,比如HTML忘记引入模块等。
基于node.js的commonJS规范
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过module.exports导出成员
- 通过require函数载入模块
commonJS是以同步模式加载模块,node执行机制是在启动时加载模块,在执行过程中不需要加载,只会使用。在浏览器端使用commonJS规范的话,必然导致效率低下。每次页面加载都会导致大量的同步模式请求出现。
2.AMD
所以在早期并没有选择commonJS规范,而是专门为浏览器端且结合浏览器特点,重新设计了一个模块加载规范:AMD(Asynchronous Module Definition)异步模块定义规范。同时也除了一个require.js库,其实现了AMD规范,同时本身又是一个强大的模块加载器。
AMD规范中,require.js库规定每个模块使用define关键字去定义。可以传递2-3个参数,传递三个参数的话,第一个参数为该模块的名字;第二个参数为数组,声明该模块的依赖项,数组中每个数组的元素为具体依赖的其它模块;第三个模块为一个函数,该函数的参数与第二个参数中的依赖项一一对应,每一项分别为依赖项导出的成员,函数的作用是为当前的模块提供一个私有的空间。如果需要在本模块中向外部模块导出一些成员,通过return的方式去实现。
define('module1', ['jquery', './module2'], function () {
return {
start: function () {
$('body').animate({margin: '200px'})
module2()
}
}
})
除此之外,require.js还提供了一个require()函数,该函数用来自动加载模块。用法与define类似,区别是require只是用来加载模块,而define是用来定义模块。require函数去加载一个模块时,其内部会自动创建一个script标签,发送对应脚本文件的请求,并且执行相应的模块代码。
require(['module1'], function(module1) {
module1.start()
})
目前绝大多数第三方库都支持AMD规范,其生态相对较好,但是AMD使用起来比较复杂,除了业务代码,需要使用define定义模块以及require()函数去加载模块,导致代码复杂程度较高。如果项目中模块的划分较为细致时,模块JS文件请求频繁,从而呆滞页面效率比较低下。所以AMD也只能为前端模块化规范前进的一步,只是一种妥协的手段,并不是最终的解决方案。
同期淘宝出现了Sea.js + CMD标准,类似commonJS且用法上与require.js大致相同,这种方式在后来也被Require.js兼容。
// CMD规范(类似CommonJS)
define(function (require, exports, module) {
// 通过require引入依赖
var $ = require('jquery');
// 通过exports或者module.exports对外暴露成员
module.exports = function () {
console.log('module-2');
$('body').append('<p>module2</p>')
}
})
3.模块化标准规范
随着技术的发展,模块化 技术实现方式相对以往有了很大的变化。大家在前端模块化的方式也基本统一。在node.js中遵循CommonJS,在Browser中采用ES Module。
在node.js中,CommonJS为其内置的模块,正常使用require去导入模块,module.exports去导出模块。但是在ES Module在browser中就比较复杂一些。ES Module时ECMAScript2015(ES6)中定义的一个最新的模块系统,它是最近几年才定义的标准。定义初期,几乎所有主流浏览器都不支持这个特性,随着webpack等一系列打包工具的流行,这一规范才逐渐开始普及。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IkHynp7m-1610550317441)(https://i.loli.net/2021/01/05/kDBAe8RYy2lWS9C.png)]
4.ES Module
4.1基本特性
- 自动采用严格模式,忽略’use strict’
- 每个ESM模块都是单独的私有作用域
- ESM是通过CORS去请求外部JS模块的
- ESM的script标签会延迟执行脚本
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ES Module - 模块的特性</title>
</head>
<body>
<!-- 通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 JS 代码了 -->
<script type="module">
console.log('this is es module')
</script>
<!-- 1. ESM 自动采用严格模式,忽略 'use strict' -->
<script type="module">
console.log(this) // undifined
</script>
<!-- 2. 每个 ES Module 都是运行在单独的私有作用域中 -->
<script type="module">
var foo = 100
console.log(foo)
</script>
<script type="module">
console.log(foo) // foo is not defined
</script>
<!-- 3. ESM 是通过 CORS 的方式请求外部 JS 模块的 -->
<!-- 所以应用外部js问价需要其CDn支持CORS -->
<!-- <script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script> -->
<!-- CORS不支持文件的方式去访问,只能通过http的方式去访问 -->
<!-- 4. ESM 的 script 标签会延迟执行脚本 defer延迟执行 type="module"与defer属性功能相同 -->
<!-- <script defer src="demo.js"></script> -->
<script type="module" src="demo.js"></script>
<p>需要显示的内容</p>
</body>
</html>
4.2导入和导出
-
export用法
1.单独导出每个成员export var name
module.js
export var name = 'foo module' export function hello () { console.log('hello') } export class Person {}
app.js
import { name, hello, Person } from ‘./module.js’
console.log(name) // foo module
hello() // hello
2.模块尾部统一导出
module.js
```js
var name = 'foo module'
function hello () {
console.log('hello')
}
class Person {}
export { name, hello, Person }
app.js
import { name, hello, Person } from './module.js'
console.log(name) // foo module
hello() // hello
3.导出别名
module.js
var name = 'foo module'
function hello () {
console.log('hello')
}
export {
name as fooName,
hello as fooHello
}
app.js
```js
import { fooName, fooHello } from ‘./module.js’
console.log(fooName) // foo module
fooHello() // hello
```
4.导出default
module.js
var name = 'foo module'
export {
name as default
}
app.js
// 由于default为关键字,所以引入该变量时需要重命名
import { default as fooName } from './module.js'
console.log(fooName) // foo module
5.模块默认导出,引入时可取任意变量名
module.js
var name = 'foo module'
export default name
app.js
import abc from './module.js'
console.log(abc) // foo module
-
注意事项
- export固定写法 { }
- import导入成员并不是复制一个副本,而是直接导入模块成员的引用地址。也就是说import得到的变量与export导出的变量在内存中是同一块空间。一旦模块中的成员被修改,引入的变量也会同时修改。
- import导入的变量是只读变量,但对象的读写属性不受影响
module.js
var name = 'jack' var age = 18 // var obj = { name, age } // export default { name, age } // 这里的 `{ name, hello }` 不是一个对象字面量, // 它只是语法上的规则而已 export { name, age } // export name // 错误的用法 // export 'foo' // 同样错误的用法 setTimeout(function () { name = 'ben' }, 1000)
app.js
// CommonJS 中是先将模块整体导入为一个对象,然后从对象中结构出需要的成员
// const { name, age } = require('./module.js')
// ES Module 中 { } 是固定语法,就是直接提取模块导出成员
import { name, age } from './module.js'
console.log(name, age)
// 导入成员并不是复制一个副本,
// 而是直接导入模块成员的引用地址,
// 也就是说 import 得到的变量与 export 导入的变量在内存中是同一块空间。
// 一旦模块中成员修改了,这里也会同时修改,
setTimeout(function () {
console.log(name, age)
}, 1500)
// 导入模块成员变量是只读的
// name = 'tom' // 报错
// 但是需要注意如果导入的是一个对象,对象的属性读写不受影响
// name.xxx = 'xxx' // 正常
-
import用法
app.js
// 1.导入规则 // import { name } from './module' // 不可以省略.js扩展名以及./,在commonJS中可以省略扩展名及./ import { name } from './module.js' console.log(name) // 'jack' // commonJS中可以直接导入模块,例如:import { lowercase } from './utils',但是在原生ESM中需要填写完整路径 // 后期使用打包工具后,可以省略扩展名以及省略index.js默认文件 import { lowercase } from './utils/index.js' console.log(lowercase("HHH")) // 导入模块时必须使用/开头,否则ESM认为是需要加载一个第三方模块 // import { name } from './module.js' // 或者从网站根目录开始 // import { name } from '/04-import/module.js' // 或者使用完整的url加载模块 import { name } from 'http://localhost:3000/04-import/module.js' // 意味着可以直接饮用CDN的模块文件 console.log(name) // 2.只是需要执行某个模块,并不需要提取模块中的成员 import {} from './module.js' // 或者import './module.js' ,在并不需要外界控制的子功能模块式使用此种导入方式 // 3.导入多个模块 import * as mod from './module.js' // 将所有的导出的成员全部导入并使用as重命名,全部放入一个对象中,每个成员都会作为对象的属性 console.log(mod) // 4.动态导入模块 // var modulePath = './module.js' // import { name } from modulePath // console.log(name) // 报错 // if (true) { // import { name } from './module.js' // } // 报错 // ESM提供全局函数import(),专门用于动态导入模块,该函数返回一个promise对象,当模块的异步加载完成后,会自动执行then中的回调函数,模块的对象可以通过参数获取 import('./module.js').then(function (module) { console.log(module) }) // 5.导入命名成员以及默认成员 import { name, age, default as title } from './module.js' // 或者 console.log(name, age, title) // 导入命名成员以及默认成员简写 import abc, {name, age} from './module.js' // abc可以使用任意变量名 console.log(abc, name, age)
module.js
var name = 'jack' var age = 18 export { name, age } console.log('module action') export default 'default export'
utils/index.js
export function lowercase (input) { return input.toLowerCase() }
-
直接导出所导入的成员
除了导入模块,import还可以配合export使用,效果是将导入的结果直接作为当前模块的导出成员。导出后,当前作用域不再可以访问导入的成员了。一般在index.js中使用,在index.js中把某些目录中散落的一些模块通过export组织到一起再进行导出
app.js
// export {name, age} from './module.js' // console.log(name) // name is not defined // 繁琐的方法 import {Button} from './components/button.js' import {Avatar} from './components/avatar.js' console.log(Button, Avatar) // 简单的方法,compotents中新增index.js,导入再导出组件 import { Button, Avatar } from './components/index.js' console.log(Button) console.log(Avatar)
components/button.js
var Button = 'Button Components' export default Button
components/avatar.js
export var Avatar = 'Avatar Components'
components/index.js
// import {Button} from './components/button.js' // import {Avatar} from './components/avatar.js' // export {Button, Avatar} export { default as Button } from './button.js' export { Avatar } from './avatar.js'
-
polyfill兼容方案
ESM2014年提出,早期的浏览器它不可能支持这个特性,另外,在IE还有一些国产的浏览器上,截止到目前为止都还没有支持,所以说在使用的时候还是需要去考虑将信所带来的一个问题。
可以借助一些编译工具在开发阶段将这些ES6的代码,编译成ES5的方式,然后,再到浏览器当中去执行。这里介绍一个模块browser-es-module-loader,将文件引入到网页中,网页就可以运行ESM了。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>ES Module 浏览器环境 Polyfill</title> </head> <body> <script nomodule src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script> <script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script> <script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script> <script type="module"> import { foo } from './module.js' console.log(foo) </script> <!-- 以上代码在支持ESm中的浏览器会运行两次,所以使用nomoudle确保在不支持ESM的浏览器工作 --> <script nomodule> alert('hello') </script> </body> </html>
这种兼容ESm的方式,它只适合于本地区测试,也就是开发阶段去玩一玩,但是,在生产阶段千万不要去用它,因为它的原理是在运行阶段动态的去解析脚本,效率非常的差。在生产阶段,还是应该预先去把这些代码编译出来,让它可以直接在浏览器当中去工作。
4.3.ESM in Node.js
-
与CommonJS交互
ESM作为JavaScript的语言层面的一个模块化标准,逐渐的会去统一所有JS应用领域的模块化需求,Node.js作为JavaScript的一个非常重要的一个应用领域,目前,已经开始逐步支持这样一个特性,从Node.js的8.5版本过后,内部就已经以实验特性的方式去支持ESM了,也就是说在Node.js当中可以直接原生的去使用ESM去编代码了。但是,考虑到原来的这个comment规范与现在的ESM它们之间的差距还是比较大的,所以说目前,这样一个特性一直还是处于一个过渡的状态,那接下来,就一起来尝试一下,直接在Node环境当中使用ESM编写代码。
需要在Node.js中使用ESM:
- 首先将文件扩展名改为.mjs
- 然后在命令行使用–experimental-modules参数,这个参数代表去启用ESM的实验特性。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RurBpEdi-1610550317443)(https://i.loli.net/2021/01/05/8eO1cBri7sbhIGw.png)]
提取第三方模块
import _ from 'lodash' console.log(_.camelCase('ES Module')) // esModule
提取node内置模块成员
// 也可以直接提取模块内的成员,内置模块兼容了 ESM 的提取成员方式 import {writeFileSync} from 'fs' writeFileSync('./bar.txt', 'es module working~') // 不支持,因为第三方模块都是导出默认成员 // import { camelCase } from 'lodash' // console.log(camelCase('ES Module'))
-
与CommonJS的差异
1.在ESM中使用CommonJS模块
es-module.mjs
// ES Module 中可以导入 CommonJS 模块 // 只能使用import载入默认成员的方式去使用commonJS模块 import mod from './commonjs.js' console.log(mod)
common.js
// CommonJS 模块始终只会导出一个默认成员 // module.exports = { // foo: 'commonjs exports value' // } // 使用commonJS的导出的别名exports exports.foo = 'commonjs exports value'
在命令行运行
D:\DeskTop\02-interoperability>node --experimental-modules es-module.mjs { foo: 'commonjs exports value' }
2.通过commonJS载入ESM(Node原生的环境中不能在 CommonJS 模块中通过 require 载入 ES Module)
es-module.js
export const foo = 'es module export value'
common.js
const mod = require('./es-module.js') console.log(mod) // !!!报错
总结:
- ESM中可以导入CommonJS模块
- CommonJS中不能导入ESM模块
- CommonJS始终只会导出一个默认成员
- 注意import不是解构导出对象
-
ES Modules in Node.js - 与 CommonJS 的差异
esm.mjs
// ESM 中没有模块全局成员了 // // 加载模块函数 // console.log(require) // // 模块对象 // console.log(module) // // 导出对象别名 // console.log(exports) // // 当前文件的绝对路径 // console.log(__filename) // // 当前文件所在目录 // console.log(__dirname) // -------------以上成员无法打印 // require, module, exports 自然是通过 import 和 export 代替 // __filename 和 __dirname 通过 import 对象的 meta 属性获取 // const currentUrl = import.meta.url // console.log(currentUrl) // 通过 url 模块的 fileURLToPath 方法转换为路径 import { fileURLToPath } from 'url' import { dirname } from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) console.log(__filename) console.log(__dirname)
cjs.js
// 加载模块函数 console.log(require) // 模块对象 console.log(module) // 导出对象别名 console.log(exports) // 当前文件的绝对路径 console.log(__filename) // 当前文件所在目录 console.log(__dirname)
-
新版本进一步支持ESM
在Node.js的最新版本当中,它进一步的支持了ESM,这里可以来尝试一下,可以通过Node刚刚experimental去执行一这个js文件,那此时执行的效果,跟之前所看到的也是一样的,不过,在这个新版本当中,可以通过给项目的package点当中去添加一个type字段断,将这个type字段的设置为module,这个时候这个项目向所有的js文件默认就会以ESM去工作了,也就是说不用再将扩展名改成mjs了,直接让它们改回来为js,再回到文件当中,将文件当中的路径也给它修改回来。
此时,就可以回到命令行当中,再次重新运行一下index.js,这个js文件就会按照ESM的形式去工作了,如果这个时候你还想去使用commonJS的话,例如再去新建一个common.js这样一个文件,这个时候需要单独对于commonJS这种方式,做一个偶尔的处理,那就是将这个common的文件修改为点cjs的这样一个扩展名,那此时再次去执行的话,就可以正常的去使用common js规范了。
index.js
// Node v12 之后的版本,可以通过 package.json 中添加 type 字段为 module, // 将默认模块系统修改为 ES Module // 此时就不需要修改文件扩展名为 .mjs 了 import { foo, bar } from './module.js' console.log(foo, bar)
module.js
export const foo = 'hello' export const bar = 'world'
common.cjs
// 如果需要在 type=module 的情况下继续使用 CommonJS, // 需要将文件扩展名修改为 .cjs const path = require('path') console.log(path.join(__dirname, 'foo'))
package.json
{ "type": "module" }
-
Babel兼容方案
如果你使用的是早期的Node,可以使用babel去实现ESM的兼容问题。babel是目前最主流的一块JavaScript的编译器,它可以用来帮助将一些使用了新特性的代码,编译成当前环境的代码。之后可以放心的去使用新特性。下面使用babel去实现低版本Node(这里使用8.0.0)。
index.js
// 对于早期的 Node.js 版本,可以使用 Babel 实现 ES Module 的兼容 import { foo, bar } from './module.js' console.log(foo, bar)
module.js
export const foo = 'hello' export const bar = 'world'
-
安装babel及其它插件
yarn add @babel/node @babel/core @babel/preset-env --dev
-
直接运行yarn babel-node index.js时会报错,不支持import。原因非常简单,因为babel,它是基于插件机制去实现的,它的核心模块,并不会去转换我们的代码,那具体要去转换代码当中的每一个特性,它是通过插件来去实现的,也就是说需要一个插件去转换代码当中的一个特性,那之前所安装的这个preset-env,它实际上是一个插件的集合,在这个插件集合当中去包含了最新的JS标准当中的所有的新特性,可以借助于这个preset直接去把我们当前这个代码当中所使用到的ESM就给它转换过来。
-
使用新命令
yarn babel-node index.js --presets=@babel/preset-env # hello world
-
如果说觉得每次手动的去传输这样一个参数会比较麻烦的话,那你也可以选择把它放到配置文件当中。项目中新建.babelrc文件,该文件为json格式的文件
{ "presets": ["@babel/preset-env"] }
此时可以直接使用yarn babel-node index.js命令直接运行,不需要额外加参数。
-
preset是一个插件集合,我们移除preset,直接使用插件
yarn remove @babel/preset-env yarn add @babel/plugin-transform-modules-commonjs --dev
这时修改配置文件
{ "plugins": [ "@babel/plugin-transform-modules-commonjs" ] }
继续运行命令yarn babel-node index.js,这样也是可以的。
-
三、Webpack打包
1. 模块打包工具的由来及概要
模块化确实很好的解决了在复杂应用开发过程当中的代码组织问题,但是随着引入模块化,Web应用又会产生一些新的问题:
第一个,ESM模块系统,它本身就存在环境兼容问题,尽管现如今主流浏览器的最新版本都已经支持这样一个特性,但是目前还没有办法做到统一所有用户浏览器的使用情况,所以还需要去解决兼容问题;
第二个,通过模块化的方式划分出来的模块文件会比较多,而前端应用又是运行在浏览器当中的,因此每一个在应用当中所需要的文件,都需要从服务器当中请求回来,这些零散的模块文件必将会导致浏览器频繁发出请求,从而影响应用的工作效率;
第三个,在前端应用开发过程当中,不仅仅只有JavaScript的代码需要模块化,随着应用的日益复杂,HTML、CSS等资源文件同样也会面临相同的问题。而且从宏观角度来看的话,这些文件也都可以看作为前端应用当中的一个模块,只不过这些模块的种类和用途跟JavaScript是不同的。
对于整个过程而言模块化肯定是有必要的,不过需要在原有的基础之上去引入更好的方案或者工具去解决上面这样几个问题或者是需求,让开发者在应用的开发阶段,可以继续享受模块化带来的优势,又不必担心模块化对生产环境所产生的一些影响。我们就先对这个所谓的更好的方案或者工具去提出一些设想,我们希望它们能够满足我们的这些设想:
第一点,需要这样一个工具能够编译代码,**开发阶段编写的包含新特性的代码,直接去转换为能够兼容绝大多数环境的代码。**这样一来面临的环境问题也就不存在了;
第二点,**能够将散落的模块文件再次打包到一起,解决了浏览器当中频繁对模块文件发出请求的问题。**至于模块化文件划分,只是在开发阶段需要它,因为它能够更好的代码,但是对于运行环境实际上是没有必要的。所以说可以选择在开发阶段通过模块化的方式去编写,生产阶段还是把它们打包到同一个文件当中。
第三点,需要去支持不同种类的前端资源类型,可以把前端开发过程当中所涉及到的样式,图片,字体等等所有资源文件都当作模块去使用。对于整个前端应用来讲,就有了一个统一的模块化方案。之前介绍的那些模块化方案,实际上只是针对现有JavaScript的模块化方案。现在强调,对于整个前端应用来讲,它的一个模块化的方案。这些资源,有了模块化方案后就可以通过代码去控制,那它就可以与业务代码统一去维护。这样对于整个来讲的话会更加合理一些。
针对前两个需求,完全可以借助于之前所了解过的一些构建系统,去配合一些编译工具就可以实现。但是,对于最后一个需求,很难通过这种方式去解决了,所以说就有了接下来所介绍的一个主题,也就是前端模块打包工具。
前端领域目前有一些工具,就很好的解决了以上这几个问题,其中最为主流的就是Webpack、Parcel、Rollup。以Webpack为例,它的一些核心特性,就很好的满足了上面的那些需求。
-
首先Webpack作为一个模块打包工具(Module Bundler),它本身就可以解决模块化JavaScript代码打包的问题。通过Webpack就可以将零散的模块代码打包到同一个JS文件当中。对于代码中那些有环境兼容问题的代码,可以在打包的过程当中,通过模块加载器(Loader)对其进行编译转换。
-
其次,Webpack还具备代码拆分(Code splitting)的能力,能够将应用当中所有的代码,都按照需要去打包。这样就不会产生把所有的代码全部打包到一起,产生的这个文件会比较大的这样一个问题。可以把应用加载过程当中初次运行的时候所必须的那些模块打包到一起,那对于其它的那些模块再单独存放。等到应用工作过程当中实际需要的某个模块,再去加载这个模块,从而实现增量加载或者叫渐进式加载,这样就不用担心文件太碎或者是文件太大这两个极端的问题。
-
最后对于前端模块类型的问题,**Webpack支持在JavaScript当中以模块化的方式去载入任意类型的资源文件。**例如在Webpack中就可以通过JavaScript去直接import一个css的文件,最终会通过style标签的形式去工作。其它类型的文件,也可以有类似的这种方式去实现。
总之来说,所有的打包工具它们都是以模块化为目标,这里所说的模块化是对整个前端项目的模块化,也就是比之前所说的JavaScript模块化要更为宏观一些。它可以让我们在开发阶段更好的去享受模块化所带来的优势,同时,又不必担心模块化对生产环境所产生的一些影响,那这就是模块化工具的一个作用。
2. Webpack快速上手、配置文件
-
Webpack快速上手
Webpack作为目前最主流的前端模块打包器,提供了一整套的前端项目模块化方案,而不仅仅是局限于只对JavaScript的模块化。通过提供的前端模块化方案,我们就可以很轻松的对前端项目开发过程当中,涉及到的所有的资源进行模块化。因为Webpack的想法比较先进,而且它的文档也比较晦涩难懂,所以说在最开始的时候,它显得对开发者不是十分友好。但是随着它版本的迭代,官方的文档也在不断的更新。目前Webpack已经非常受欢迎了,基本上可以说是覆盖了绝大多数现代化的外部应用项目开发过程。
使用***yarn init***初始化项目目录,安装webpack以及webpack-cli,使用yarn webpack,webpack会自动从src中的index.js开始打包(寻找import)
yarn init --yes yarn add webpack webpack-cli --dev yarn webpack
运行yarn webpack后,webpack自动将index.js、heading.js打包在dist目录下的main.js中,将index.html中的资源引用变为dist/main.js,并将script中的type="module"取消。再次使用serve .命令运行,发现项目依然可以运行。
如果每次都需要yarn去运行webpack命令,会比较麻烦。可以在package.json中添加scripts字段,并将其设置为“build”:“webpack”,这样就可以直接运行
***yarn build***命令。
-
Webpack配置文件
Webpack4.0以后支持零配置文件打包。也就是说不需要配置文件,直接按照约定的内容区打包,它的约定是默认入口文件为src下的index.js,默认输出为dist下的main.js。我们如果需要按照自定约定去打包,Webpack也支持配置文件打包。
在项目根目录下添加webpack.config.js文件,该文件在node环境下运行,其内容如下:
const path = require('path') module.exports = { entry: './src/main.js', // 打包文件的默认入口,不能省略./ output: { filename: 'bundle.js', // 输出打包文件的文件名称 path: path.join(__dirname, 'output') // 必须为绝对路径,所以需要path模块配合 } }
3. Webpack工作模式、打包结果运行原理
Webpack新增了一个工作模式的用法,这种用法大大简化了Webpack配置的复杂程度,可以把它理解成针对于不同环境的基础预设的配置。我们使用yarn webpack打包项目,命令行会出现一个配置警告,大致意思是我们没有设置一个mode的属性,可能会使用默认的production模式去工作,在这个模式下,webpack内部会自动启动一些优化插件,比如自动压缩代码。这对实际生产环境非常友好,但是对于开发环境中,我们无法阅读这些打包结果。
Webpack工作模式:
- production(默认)
- development(开发模式)
- none
可以通过cli参数去指定打包的模式,具体用法就是给webpack传递一个参数:–mode,参数有三种取值,默认就是production,production模式它会自动启动优化,去优化打包结果。第二种参数是development,开发模式会自动优化打包的速度,它会添加一些开发过程中需要的辅助到代码中(后面介绍调试的会详细介绍)。第三种参数是none模式,none模式下,webpack运行最原始状态的打包,不会做任何额外的处理。
yarn webpack --mode production # production为默认的工作模式,可以不用去显式指定
yarn webpack --mode development
yarn webpack --mode none
目前工作模式只有这三种,具体的三种模式的差异可以从官网(https://webpack.js.org/configuration/mode)中找到。当然除了cli参数指定模式,还可以通过配置文件方式指定工作模式。
在webpack.config.js中添加node字段,并指定模式。这样就可以通过yarn webpack直接以配置的方式去打包。
webpack.config.js
const path = require('path')
module.exports = {
// 这个属性有三种取值,分别是 production、development 和 none。
// 1. 生产模式下,Webpack 会自动优化打包结果;
// 2. 开发模式下,Webpack 会自动优化打包速度,添加一些调试过程中的辅助;
// 3. None 模式下,Webpack 就是运行最原始的打包,不做任何额外处理;
mode: 'development',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
}
}
我们解读一下webpack打包过后的结果,为了可以更好的理解打包过后的代码,这里先将webpack的工作模式是为none,这样是以最原始的状态去打包代码。
ps:此处使用的版本:
"webpack": "4.40.2",
"webpack-cli": "3.3.9"
webpack.config.js
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
}
}
yarn webpack
生成的bundle.js文件
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ }
/******/ };
/******/
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/
/******/ // create a fake namespace object
/******/ // mode & 1: value is a module id, require it
/******/ // mode & 2: merge all properties of value into the ns
/******/ // mode & 4: return value when already ns object
/******/ // mode & 8|1: behave like require
/******/ __webpack_require__.t = function(value, mode) {
/******/ if(mode & 1) value = __webpack_require__(value);
/******/ if(mode & 8) return value;
/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/ var ns = Object.create(null);
/******/ __webpack_require__.r(ns);
/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/ return ns;
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])()
document.body.append(heading)
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (() => {
const element = document.createElement('h2')
element.textContent = 'Hello world'
element.addEventListener('click', () => {
alert('Hello webpack')
})
return element
});
/***/ })
/******/ ]);
整个函数是一个立即执行函数,参数为modules。函数调用时传入一个数组,数组中的每个元素是参数列表相同的函数。这里的函数对应的就是源代码中的模块,也就是说每一个模块最终都会被包裹到这样一个函数当中,从而去实现模块的私有作用域。
进入webpack工作入口函数,这个函数内部并不复杂,而且注释也非常清晰。最开始先定义了一个对象,用于存放或者叫缓存加载过的模块。紧接着,定义了一个__webpack_require函数,该函数专门用来加载模块。再往后就是在require函数上面挂载了一些其它的数据和一些工具函数。入口函数执行到最后,调用了require函数,参数传入0,来加载模块。这个地方的模块ID,实际上就是上面的模块数当中的元素下标,也就是说这里才开始去加载在源代码当中所谓的入口模块。
4. Webpack资源模块加载
正如一开始提到,webpack并不只是JavaScript的模块化打包工具。它应该是整个前端项目或前端工程的模块打包工具,也就是说可以通过webpack引入任意类型的静态资源文件。接下来通过webpack引入css文件。
首先在项目目录中添加一个main.css文件,内容如下:
body {
margin: 0 auto;
padding: 0 20px;
max-width: 800px;
background: #186fb1;
}
然后回到webpack.config.js下,将入口文件的路径指向新创建的css文件。随后配置loader组件,test值为正则表达式/.css$/,use配置一个数组,分别为style-loader以及style-loader
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.css',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
// rules数组针对于其它资源模块的加载规则,每个规则对象的都需要设置两个属性。
rules: [
{
test: /.css$/, // test用来去匹配在打包过程当中所遇到的文件路径
// use用来去指定我们匹配到的文件,需要去使用的loader
use: [
'style-loader',
'css-loader'
]
}
]
}
}
命令行启动,yarn webpack,通过serve . 运行,在浏览器中访问就可以看到我们的css生效了。
ps:use中,如果配置了多个loader,其执行顺序是从数组最后一个元素往前执行。所以这里一定要把css-loader放到最后,因为我们必须要先通过css-loader把css代码转换为模块才可以正常打包。
style-loader工作代码在bundle.js中,部分代码如下:
loader是webpack实现整个前端模块化的核心,通过不同的loader就可以实现加载任何类型的资源。
5. Webpack导入资源模块
通过上面的探索,webpack确实可以把css文件作为打包的入口文件。不过webpack的打包入口文件一般是JavaScript。因为打包入口文件从某种程度来说,算是应用的运行入口。前端应用当中的业务是由JavaScript去驱动的。上面只是尝试一下,正确的做法还是将JavaScript文件作为打包的入口文件。
webpack.config.js
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
}
}
分别新建main.css以及heading.css文件,代码如图所示。分别在heading.js和main.js中通过import引入css文件。因为heading.css使用了类选择器,所以需要在黄色框中为element添加类名。
构建完成后,命令行运行yarn webpack,随后运行serve .命令,在浏览器打开,发现css文件生效。
传统的做法当中是将样式和行为分离开,单独去维护,单独去引入。而webpack中建议我们在JavaScript中引入css。原因是webpack不仅仅建议我们在JavaScript中引入css,而是建议我们编写代码过程中,去引入任何当前模块所需要的资源文件。因为真正需要资源的不是应用,而是此刻正在编写的代码,它想要工作,就必须加载对应的资源。
通过JavaScript代码去引入资源文件,或者叫建立我们JavaScript和资源文件之间的依赖关系。它有一个很明显的优势,JavaScript代码本身是负责完成整个应用的业务功能,那放大来看,它就是驱动了我们整个前端应用,而在实现业务功能的过程当中,可能需要用到样式、图片等等一系列的资源文件,如果建立了这种依赖关系,一来逻辑上比较合理,因为我们的JavaScript确实需要这些资源文件的配合,才能去实现对应的功能,二来保证上线时资源文件不会缺失,而且每一个上线的文件都是必要的。
6. Webpack文件资源加载器
目前webpack社区提供了非常多的资源加载器,基本上能想到的所有合理的需求都会有对应的loader,接下来尝试一个非常有代表性的资源加载器。
大多数的加载器都类似于css-loader,都是将资源模块转换为js代码的实现方式去工作。但是,还有一些经常用到的资源文件,例如项目当中的图片或者字体,这些文件是没有办法通过js的方式去表示的,对于这一类的资源文件,需要用到文件资源加载器,也就是file-loader。
项目src目录中添加一张图片icon.png,在main.js中通过import的方式导入图片,并且创建图片标签,将其src设置为导入的接收值。
初始目录:
main.js
import createHeading from './heading.js'
import './main.css'
import icon from './icon.png'
const heading = createHeading()
document.body.append(heading)
const img = new Image()
img.src = icon
document.body.append(img)
随后在webpack.config.js中设置一个新的规则,当遇到.png结尾的文件时,使用file-loader加载器。
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/' // 网站的根目录
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
}
}
安装file-loader,命令行使用yarn webpack打包资源。serve .运行,通过浏览器可以观察到,img资源被加载成功。
打包后的目录:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4kTdX5HR-1610550317461)(https://i.loli.net/2021/01/05/FtikhS9xqPB5I8a.png)]
总结文件加载器的工作过程,webpack打包过程中遇到图片文件,根据webpack.config.js中的配置规则对应到file-loader加载器。file-loader首先将导入的(图片)文件复制到输出目录dist,然后将文件拷贝到输出目录的路径作为这个模块的返回值,对于这个应用来说,这个资源就被发布了。同时,也可以通过模块的导出成员拿到这个资源的访问路径。
7. Webpack URL 加载器
除了file-loader这种通过拷贝物理文件的形式去处理文件资源以外,还有一种通过data-url去表示文件,这种方式也非常常见。Data URL是一种非常特殊的协议,它可以直接用来表示一个文件。传统URL一般要求服务器上有一个对应的文件,然后通过请求这个地址,得到服务器上对应的文件。
Data URL是一种当前URL就可以直接去表示这种文件内容的方式,也就是说这种Data URL中的文本就已经包含了文件的内容。在使用这种URl时,我们就不会再发送任何的HTTP请求。例如下面的URL:
data:text/html;charset=UTF-8,<h1>html content!</h1>
浏览器就能根据url解析出来这是一个HTML类型的文件内容,它的编码是UTF-8,内容是h1标签。复制该url到浏览器地址栏,可以看到浏览器将它正常渲染出来了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UJAsm8G4-1610550317466)(https://i.loli.net/2021/01/05/y8fJzoeGNAvkwTj.png)]
但是如果是图片或者字体这一类的,这些无法通过文本表示的二进制文件。我们可以将这些文件内容编译为base64编码,以base64编码也就是字符串表示文件的内容。
webpack中有一种加载器专门处理这种文件:url-loader,安装该加载器并在webpack.config.js中配置如下:
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'url-loader'
}
]
}
}
在遇到png文件时,通过url-loader编译后打包。运行yarn webpack,查看bundle.js文件,发现它将该图片转为base64编码,并导出。
通过serve .启动服务器,发现该图片被正常显示在页面上。通过F12开发者工具打开发现该图片的src为上面base64的编码字符串。并且复制该url到地址栏,浏览器也可以正常渲染出该图片。
这种方式其实非常适合项目当中体积比较小的资源,因为体积过大的话,就会造成打包结果非常大,从而影响运行速度。最佳的实践方式,应该是对于项目当中的小文件,通过url-loader的去转换为URL代码,从而减少应用发送信息。对于较大的文件,应该传统的方式,单个文件方式去存放,从而提高我们应用的加载速度,它支持通过配置选项的方式来去实现刚刚所说的这种最佳实践方式。回到配置文件当中,具体做法就是将url-loader的这样一个字符串,这种简化的配置方式修改为一个对象,那对象当中的loader的属性,还是这个字符串url-loader,为它添加一些其它配置选项。option选项中可以配置其它参数。具体参数如下:
{
test: /.png$/,
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
}
- 小文件使用Data URL,减少请求次数
- 大文件单独提取存放,提高加载速度
8. Webpack 常用加载器分类
webpack中的资源加载器有点像是生活中工厂里的生产车间,它是用来去处理和加工打包过程当中所遇到的资源文件,设计当中还有很多其它的加载器,。
目前个人分为三类:
-
编译转换类型加载器
它会把加载到的资源模块转换为JavaScript的代码,例如之前所用的css-loader,它就是将css代码转换为bundle当中的一个JavaScript的模块,从而去实现通过JavaScript去运行css
-
文件操作类型加载器
文件操作类型加载器都会把加载到的资源模块拷贝输出的目录,同时将文件的访问路径向外导出。例如之前用到的file-loader,它就是一个非常典型的文件操作类型加载器
-
代码检查类
针对于代码质量检查的加载器,就是对所加的资源文件,一般是代码,去进行校验的一种加载器。这种加载器,它的目的是为了统一代码的风格,从而去提高代码质量,这种类型加载器一般不会去修改生产环境的代码。
9. Webpack 与ES2015
由于webpack默认就能处理代码当中的import和export,所以很自然都会有人认为webpack会自动编译的ES6代码。实则不然,那是webpack的仅仅是对模块去完成打包工作,所以说它才会对代码当中的import和export做一些相应的转换,它并不能去转换我们代码当中其它的es6特性。
如果需要将ES6的代码打包并编译为ES5的代码,需要一些其它的编译形加载器。这里安装一些额外的插件。
yarn add babel-loader @babel/core @babel/preset-env --dev
安装完成后,编写webpack.config.js配置文件,针对js代码指定babel-loader
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
},
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
}
]
}
}
再次重新打包,代码中的ES2015中的新特性都被转换。
总结:
- webpack只是打包工具,它不会处理一些ES6或者更高版本的新特性
- 如果需要处理新特性,可以通过为js代码单独配置加载器来实现
10. Webpack 加载资源的方式
除了代码中的import能够触发模块的加载,webpack还提供几种方式,具体如下:
-
遵循ESM标准的import声明
-
遵循commonJS标准的require函数
如果通过commonJS标准的require函数载入一个ESM的话,需要require一个函数的default属性去获取
const heading = require('./heading.js').default
-
遵循AMD标准的define函数和require函数
webpack遵循多种模块化标准,不过除非必要,建议不要在一个项目中混用多种标准,这样会造成项目可维护性差。每个项目使用一种标准就行。
除了JavaScript代码中的这三种方式之外,还有一些独立的加载器,它在工作时也会去处理所加载到的资源当中的一些导入的模块,例如:
-
css-loader加载的css文件,import指令以及部分属性当中的URL函数,也会去触发相应的资源模块加载
样式代码中的@import指令和url函数
-
html-loader加载html文件中的一些src属性也会触发
webpack.config.js
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
},
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
},
{
test: /.html$/,
use: {
loader: 'html-loader',
options: {
attrs: ['img:src', 'a:href'] // HTML中针对不同的属性采用不同的加载器
}
}
}
]
}
}
main.js入口文件
import './main.css'
import footerHtml from './footer.html'
document.write(footerHtml)
总结webpack资源加载方式:
- 遵循ESM标准的import声明
- 遵循CommonJS标准的require函数
- 遵循AMD标准的define函数和require函数
- 样式代码中的@import指令和url函数
- HTML代码中的img标签的src属性以及a标签的href属性
11. Webpack 核心工作原理
其实webpack官网首屏的内容就已经很清楚的描述了它的工作原理。
这里以一个普通的前端项目为例,在项目中一般会散落着各种各样的代码及资源文件,webpack会根据webpack.config.js配置,找到其中一个文件作为打包入口,一般情况下入口文件是一个JavaScript文件。
然后,它会顺着入口文件中的代码,根据代码中出现的import或者require之类的语句,解析推断出来这个文件所依赖的资源,其次就形成了整个项目中所有用到文件之间的一个依赖关系的一个依赖树,有了这个依赖树之后,webpack会遍历或者叫递归这个依赖树,然后找到每个节点所对应的资源文件。
其次根据配置文件中rules属性去找到这个模块所对应的加载器,然后交给对应的loader加载器去加载这个模块。最后会将加载到的结果放到bundle.js也就是打包结果中,从而实现整个项目的打包。
整个过程中loader的机制气到了一个很重要的作用,因为如果没有loader的话,webpack就没办法实现各种资源文件的加载,那对于webpack来说,它也只能算是一个用来打包或者合并js模块代码的一个工具了。
12. webpack 开发一个Loader
loader作为webpack的核心机制,内部的工作原理非常简单,接下来一起开一发一个自己的loader。需求是一个markdown文件的加载器,希望有了一个加载器之后,可以直接在代码中直接导入这个markdown文件。
webpack内部的一个工作原理其实非常简单,就是一个从输入到输出之间的一个转换,除此之外,还了解了loader,它实际上是一种管道的概念,可以将此次的这个loader的结果交给下一个loader去处理,然后通过多个loader去完成一个功能。例如之前所使用的css-loader和style-loader的之间的一个配合,包括后面还会使用到的sass或者less这种loader,它们也需要去配合刚刚所说的这两种loader,这个就是工作管道一个特性。
13. Webpack 插件机制介绍
插件机制是webpack当中另外一个核心特性,它目的是为了增强webpack项目自动化方面的能力,loader就是负责实现各种各样的资源模块的加载,从而实现整体项目打包,plugin则是用来去解决项目中除了资源以外,其它的一些自动化工作,例如:
- plugin可以帮我们去实现自动在打包之前去清除dist目录,也就是上一次打包的结果;
- 又或是它可以用来去帮我们拷贝那些不需要参与打包的资源文件到输出目录;
- 又或是它可以用来去帮我们压缩我们打包结果输出的代码。
总之,有了plugin的webpack,几乎无所不能的实现了前端工程化当中绝大多数经常用到的部分,这也正是很多初学者会有webpack就是前端工程化的这种理解的原因。
14. Webpack 自动清除输出目录插件
了解了插件的基本作用过后,接下来先来体验几个最常见的插件,通过这个过程去了解如何使用插件。
第一个就是用来自动清除输出目录的插件(clean-webpack-plugin),通过之前的演示你可能已经发现,webpack每次打包的结果都是覆盖到dist目录,而在打包之前,dist中就可能已经存在一些之前的遗留文件,再次打包,它只能覆盖掉那些同名的文件,对于其它那些已经移除的资源文件就会一直积累在里面,非常不合理。那更为合理的做法就是在每次打包之前,自动去清理dist目录,这样就只会存在那些需要的文件,clean-webpack-plugin就很好的实现了这样一个需求。
那它是一个第三方的插件,先来通过yarn去安装,安装过后能回到webpack.config.js的配置文件当中,然后去导入这个那这个插件模块。然后,使用插件我们需要去为配置对象添加一个plugins属性,这个属性就是专门用来去配置插件的地方,它是一个数组,添加一个插件就是在这个数组当中去添加一个元素。
绝大多数插件模块导出的都是一个类型,这里的clean-webpack-plugin也不例外,所以使用它就是通过这个类型去创建一个实例,然后将这个实例放到这个数组当中。完成之后再次尝试yarn webpack进行打包,此时,之前的那些打包结果就不会存在了,dist目录中都是本次打包的结果,非常干净。
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
}
]
},
plugins: [
new CleanWebpackPlugin(), // 通过这个类型去创建一个实例
]
}
15. Webpack 自动生成HTML插件
-
生成基本HTML文件
除了清理dist的目录以外,还有一个非常常见的需求就是自动去生成使用打包结果的HTML,在这之前HTML都是通过硬编码的方式单独去存放在项目根目录下的。
但这种方式有两个问题,第一就是在项目发布时,需要同时去发布的HTML文件和所有的打包结果,这样的话会比较麻烦。而且上线过后,还需要去确保HTML的代码路径引用都是正确的。第二个问题是,如果说输出的目录或者是输出的文件名,也就是打包结果的配置发生了变化,HTML代码中script标签所引用的路径就需要手动的去修改。
这是硬编码的方式存在的两个问题,要解决这两个问题,最好的办法就是通过webpack自动去生成HTML文件,也就是让HTML也去参与到webpack构建过程中,构建过程中,webpack知道它生成了多少个bundle,它会自动将这些打包的bundle添加到的页面当中。这样的话,一来HTML它也输出到了dist目录,上线的时候就只需要把dist目录发布出去就可以了,二来,HTML当中对于bundle的引用,它是动态的注入进来的,它不需要手动的去硬编码。所以说它可以确保路径的引用是正常的。
具体的实现方式,需要去借助一个叫html-webpack-plugin的一个插件去实现。这个插件同样也是一个第三方的模块,同样需要去单独安装这个模块。
yarn add html-webpack-plugin --dev
之后回到配置文件当中,载入这个模块。但这里不同于clean-webpack-plugin,html-webpack-plugin默认导出的就是一个插件的类型,我们不需要去解构它内部的成员。有了这个类型过后,回到配置对象的plugins属性当中,去添加一个这个类型的实例对象。这样就完成了这个插件的一个配置。
那最后我们回到命令行终端,再次运行打包命令,index.html出现在了dist目录当中,对于bundle的引用的路径也是正常了。这样就不再去需要根目录下的index.html文件了,之后HTML文件都是通过webpack自动生成出来的。
const path = require('path') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') module.exports = { mode: 'none', entry: './src/main.js', output: { filename: 'bundle.js', path: path.join(__dirname, 'dist'), }, module: { rules: [ { test: /.css$/, use: [ 'style-loader', 'css-loader' ] }, { test: /.png$/, use: { loader: 'url-loader', options: { limit: 10 * 1024 // 10 KB } } } ] }, plugins: [ new CleanWebpackPlugin(), // 用于生成 index.html new HtmlWebpackPlugin(), }) ] }
-
生成HTML基本标签以及使用模板生成HTML
有了html-webpack-plugin之后,就可以动态生成应用所需要的的HTML文件,但是这里仍然存在一些需要改进的地方。
首先是HTML中的标题必须要修改,其次是很多时候需要自定义页面当中的一些元数据标签和一些基本的DOM结构。对于简单的自定义,可以使用修改webpack.config.js文件中的html-webpack-plugin属性,如下:
plugins: [ new CleanWebpackPlugin(), // 用于生成 index.html new HtmlWebpackPlugin({ title: 'Webpack Plugin Sample', // 生成html文件的标题 meta: { viewport: 'width=device-width' // 生成一些自定义的dom元素 }, }), ]
如果需要对HTML文件进行大量的自定义的话,需要在源代码中添加一个用于生成HTML文件的模板文件,让html-webpack-plugin根据模板生成页面。在src目录中添加index.html文件,根据需要在模板中添加一些响应的元素。模板中希望动态输出一些内容,采用lodash模板语法的方式:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Webpack</title> </head> <body> <div class="container"> <!--loadsh模板语法,访问插件中的options属性中的title值--> <h1><%= htmlWebpackPlugin.options.title %></h1> </div> </body> </html>
htmlWebpackPlugin.options实际是html-webpack-plugin内部提供的一个变量,也可以通过另外一个属性去添加一些自定义变量。然后通过template属性去指定模板文件。再次使用yarn webpack指令去打包项目,发现dist中的index.html出现了自定义的内容。
plugins: [ new CleanWebpackPlugin(), // 用于生成 index.html new HtmlWebpackPlugin({ title: 'Webpack Plugin Sample', meta: { viewport: 'width=device-width' }, template: './src/index.html' // 用于指向模板文件的路径 }), ]
-
输出多个页面文件
除了自定义输出文件的内容,同时输出多个页面文件也是一个非常常见的需求。其实配置非常简单,配置文件中添加一个新的HtmlWebpackPlugin对象,配置如下:
plugins: [ new CleanWebpackPlugin(), // 用于生成 index.html new HtmlWebpackPlugin({ title: 'Webpack Plugin Sample', meta: { viewport: 'width=device-width' }, template: './src/index.html' }), // 用于生成 about.html new HtmlWebpackPlugin({ filename: 'about.html', // 用于指定生成的文件名称,默认值是index.html title: 'About html' }) ]
16. Webpack 插件使用总结
在项目中,一般还有一些不需要参与构建的静态文件,它们最终也需要发布到线上,例如我们网站的favicon.icon,一般会把这一类的文件统一放在项目的public目录当中,希望webpack在打包时,可以一并将它们复制到输出目录。
对于这种需求,可以借助于copy_webpack_plugin,先安装一下这个插件,然后再去导入这个插件的类型,最后同样在这个plugin属性当中去添加一个这个类型的实例,这类型的构造函数它要求传入一个数组,用于去指定需要去拷贝的文件路径,它可以是一个通配符,也可以是一个目录或者是文件的相对路径,这里使用plugin,它表示在打包时会将所有的文件全部拷贝到输出目录,再次运行webpack指令,打包完成过后,public目录下所有的文件就会同时拷贝到输出目录。
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'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
}
]
},
plugins: [
new CleanWebpackPlugin(),
// 用于生成 index.html
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
// 用于生成 about.html
new HtmlWebpackPlugin({
filename: 'about.html'
}),
new CopyWebpackPlugin([
'public'
])
]
}
17. Webpack 开发一个插件
通过前面的了解,我们知道相比于loader,plugin的能力范围相对更广一些。loader只是在加载模块的环节去工作,而plugin范围几乎触及到工作的每一个环节。那么plugin的工作机制到底是怎么实现的?其实原理很简单,webpack的插件机制其实就是在软件开发中最常见的钩子机制。
钩子机制也很容易理解,有点类似于web中的事件。在webpack工作过程中有很多环节,为了便于插件的扩展,webpack几乎给每一个环节都埋下了一个钩子。这样的话,在开发插件的过程中,就可以通过钩子在不同节点上挂载不同的任务,这样可以轻松的扩展webpack能力。
接下来自定义一个插件,了解具体如何往钩子上挂在任务。webpack要求插件必须是一个函数或者是一个包含apply方法的对象。一般会把插件定义为一个类型,然后在类型汇总定义一个方法,使用的时候就是通过这个类型构建一个实例。
插件需求:
-
清除webpack打包生成中的bundle.js中的每行首位的注释字符
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')
class MyPlugin {
apply (compiler) {
console.log('MyPlugin 启动')
// 通过compiler的hooks方法访问emit,并通过tap方法注册一个钩子函数。参数一:插件名称;参数二:挂载到钩子上的函数
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation => 可以理解为此次打包的上下文
// compilation.assets打包过程中所有的资源信息
for (const name in compilation.assets) {
// console.log(name) // 每个打包成功后的文件名
// console.log(compilation.assets[name].source()) // 通过键值访问对应文件名的资源
if (name.endsWith('.js')) {
// 获取后缀为.js的文件资源
const contents = compilation.assets[name].source()
// 使用全局正则替换注释
const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
// 将替换后的结果覆盖到原文件中
compilation.assets[name] = {
source: () => withoutComments, // 暴露最新的内容
size: () => withoutComments.length // 暴露最新内容的长度
}
}
}
})
}
}
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
}
]
},
plugins: [
new CleanWebpackPlugin(),
// 用于生成 index.html
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
// 用于生成 about.html
new HtmlWebpackPlugin({
filename: 'about.html'
}),
new CopyWebpackPlugin([
// 'public/**'
'public'
]),
new MyPlugin()
]
}
随后运行yarn webpack重新打包,发现每行前面的注释已经被去除掉了。
18. Webpack 开发体验的问题
在此之前,我们已经了解了一些webpack的相关概念和一些基本的用法,但是如果以目前的状态去应对日常的开发工作还远远不够。那是因为编写源代码再通过webpack打包,然后,运行应用最后刷新浏览器这种周而复始的方式,过于原始。如果说在实际的开发中还按照这种方式去使用,那必然会大大降低我们的开发效率,应该如何去提高我们的开发效率?
在这里对理想的开发环境做一个设想,首先希望这样一个环境,它必须使用HTTP服务区运行而不是以文件的形式去预览。这样的话我们一来,更加接近生产环境的状态,二来可能会需要去使用ajax之类的一些API,这些API使用文件的形式去访问是不被支持的。其次我们希望这样一个环境当中,我们去修改源代码过后,webpack就可以自动帮我们完成构建,然后浏览器可以即时显示最新的结果,这样的话就可以大大减少在开发过程中额外的重复操作,最后,还需要这样一个环境,它能够去提供Source Map支持,这样的话,我们运行过程当中一旦出现错误,就可以根据错误的堆栈信息,快速定位到源代码当中对应的位置,便于调试应用,。那对于以上这些需求,webpack都已经有相对应的功能去实现,接下来让重点了解具体如何增强使用webpack的开发体验。
19. Webpack 自动编译及自动刷新浏览器
自动编译
-
目每次修改完源代码都是通过命令行手动重复运行webpack命令,从而得到最新的打包结果。那这种办法,我们也可以使用webpack cli提供的watch的工作模式。如果之前了解过其它的构建工具,那应该对这种模式并不陌生。
在这种模式下,项目下的源文件会被监视,一旦这些文件发生变化,它就会自动重新去运用打包任务。具体的用法也非常简单,就是在启动webpack命令时添加 –watch命令参数,这样的话,webpack就会以监视模式去运行。在打包完成过后,cli不会立即退出,而是会等待文件的变化,然后再次工作,一直到手动结束这个cli。
这种模式下,我们就只需要专注编码,不必再去手动完成这些重复的工作了。这里,可以再开启一个新的命令行终端,同时以http的形式去运行应用,然后,我们打开浏览器去预览,尝试修改源代码,以观察模式工作的webpack就会自动重新打包,刷新页面,查看最新的页面结果。
自动刷新浏览器
-
使用browser-sync去监听目录,并自动刷新浏览器
browser-sync dist --files "**/*"
20. Webpack Dev Server
Webpack Dev Server是webpack官方推出的一个开发工具,根据名字,就应该知道它提供了一个开发服务器,并且,它将自动编译和自动刷新浏览器等一系列对开发非常友好的功能全部集成在了一起。这个工具可以直接解决我们之前的问题。
因为这是一个高度集成的工具,所以它使用起来也非常的简单。
-
打开命令行,以开发依赖安装
yarn add webpack-dev-server --dev
它提供了一个webpack-dev-server的cli程序,那我们同样可以直接通过yarn去运行这个cli,或者,可以把它定义到npm script中。运行这个命令 yarn webpack-dev-server,它内部会自动去使用webpack去打包应用,并且会启动一个HTTP server去运行打包结果。在运行过后,它还会去监听我们的代码变化,一旦语言文件发生变化,它就会自动立即重新打包,这一点,与watch模式是一样的。不过这里也需要注意webpack-dev-serverr为了提高工作效率,它并没有将打包结果写入到磁盘当中,它是将打包结果,暂时存放在内存当中,而内部的HTTP server从内存当中把这些文件读出来,然后发送给浏览器。这样一来的话它就会减少很多不必要的磁盘读写操作,从而大大提高我们的构建效率。
这里,我们还可以为这个命令传入一个**–open**的参数,它可以用于去自动唤起浏览器,去打开我们的运行地址,打开浏览器过后(如果说你有两块屏幕的话),你就可以把浏览器放到另外一块屏幕当中,然后,我们去体验这种一边编码,一边即时预览的开发环境了。
yarn webpack-dev-server --open
21. Webpack Dev Server静态资源访问
web-dev-server默认会将构建结果输出的文件,全部作为开发服务器的资源文件,也就是说,只要是通过webpack的打包能够输出的文件,都可以正常被访问到。但是如果说还有一些静态资源也需要作为开发服务器的资源被访问的,那就需要额外的去告诉webpack-dev-server。
它具体的方法就是在我们webpack.config.js的配置文件当中去添加一个对应的配置,在配置对象当中去添加一个devServer的属性,这个属性是专门用来为webpack制定相关的配置选项,可以通过这个配置对象的contentBase属性来去制定额外的静态资源路径,这个属性可以是一个字符串或者是一个数组,也就是说可以配置一个或者是多个路径,这里将这个路径设置为项目根目录当中的public目录。
devServer: {
contentBase: './public',
},
那可能有人会有疑问,因为之前(1.16 Webpack 插件使用总结),已经通过插件将这个目录输出了,那按照刚刚的说法,我们所有输出的文件都可以直接被server,也就是直接可以在浏览器端访问到。那按道理来讲的话,这里这些文件就不需要再作为开发服务器的额外的资源路径了。事实情况确实如此,如果说你能这么想的话,那也就证明你确实理解了这样一个点,但是,我们在实际去使用webpack的时候,我们一般都会把copyWebpackPlugin这样的插件留在上线前的那一次打包中使用,那在平时的开发过程当中,我们一般不会去使用它,这是因为在开发过程中我们会频繁、重复执行打包任务,那假设我们需要拷贝的文件比较多或者是比较大,如果说我们每次都去执行这个插件的话,我们打包过程当中的开销就会比较大,速度自然也就会降低了。由于这是额外的话题,所以说具体的操作方式,就是具体怎么样去让我们在开发阶段,不去使用copyWebpackPlugin,然后在上线前那一刻我们再去使用这种插件,那这种操作方式我们在后续再来介绍。
那这里先注释掉copyWebpackPlugin,这样确保在打包过程当中不会再去输出public目录当中的静态资源文件,然后回到命令,再次执行webpack-dev-server,启动过后,此次public目录当中并没有被拷贝到输出目录,如果说webpack只去加载那些打包生成的文件,那public目录文件应该是访问不到的,但是通过刚才的contentBase已经将它指定为了额外的资源路径,所以说应该可以访问到。打开浏览器,去访问页面文件以及bundle.js,都是来源于打包结果当中,然后再去尝试访问一下favicon.ico,这个文件就是来源于contentBase当中所配置的public目录了,除此之外,例如这个other.html文件,它也是这个目录当中所指定的文件。以上,就是contentBase,它可以用来去为webpack额外去指定一个静态资源目录的操作方式。
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')
},
devServer: {
contentBase: './public',
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB
}
}
}
]
},
plugins: [
new CleanWebpackPlugin(),
// 用于生成 index.html
new HtmlWebpackPlugin({
title: 'Webpack Tutorials',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
// // 开发阶段最好不要使用这个插件
// new CopyWebpackPlugin(['public'])
]
}
22. Webpack Dev Server 代理API
由于开发服务器的缘故,这里会将应用运行在localhost:8080,而最终上线过后,应用一般又和API会部署到同源地址下面。这样就会有一个非常常见的问题,那就是在实际生产环境当中,可以直接去访问API,但是回到开发环境当中就会产生跨域请求问题。
可能有人会说可以使用跨域资源共享的方式去解决这个问题,事实确实如此。如果请求的这个API支持CORS,这个问题就不成立了,但是,并不是每种情况下,服务端的API都一定要支持CORS的。如果说前后端同源部署,也就是我们的域名、协议、端口是一致,这种情况下根本没有必要去开启CORS,所以以上这个问题还是经常会出现,那解决这个问题最好的办法就是在开发服务器当中去配置**代理(proxy)**服务,也就是把接口服务代理到本地的这个开发服务地址。webpack-dev-server它支持直接通过配置的方式去添加代理服务。
具体的用法如下,目标就是将github的API代理到本地的开发服务器当中,先在浏览器当中尝试去访问一下其中的一个接口https://api.github.com/users
Github的接口的Endpoint(可以理解为接口端点/入口),它一般都是在根目录下,例如这里所使用的这个users这个Endpoint,知道了接口的地址过后,回到配置文件当中,在devServer当中去添加一个proxy属性,这个属性专门用来去添加代理服务配置的。这个属性是一个对象,其中每一个属性的就是一个代理规则的配置,那属性的名称,就是需要被代理的请求路径前缀,也就是请求以哪个地址开始,它就会走代理请求。但一般为了容易辨别,都会将其设置为"/api",也就是请求开发服务器当中的"/api"开头的这种地址,都会让它代理到接口当中。
它的值是为这个前缀所匹配到的这个代理规则配置,将代理目标设置为"https://api.github.com",也就是说当请求以斜线开头,代理目标就是https://api.github.com。此时如果去请求"http://localhost:8080/api/users",就相当于请求了"https://api.github.com/api/users"。意思是请求的路径是什么,它最终代理的这个地址、路径是会完全一致的。
而实际需要请求的这个接口地址,实际上是在"https://api.github.com/users",也就是跟路径下面的"users",所以说对于代理路径当中的"/api",需要通过重写的方式把它去掉,可以在这儿再去添加一个pathRewrite属性,来去实现代理路径的重写。重写规则就是把路径当中以"/api"开头的这个开头的这段字符串给它替换为空。pathRewrite属性,它最终会以正则的方式来去替换请求的路径,所以在这儿,以**"^"**表示开头。
除此之外,还需要设置changeOrigin属性为true,这是因为默认代理服务器的会以实际在浏览器当中请求的主机名,在这里就是localhost:8080作为代理请求的主机名。也就是在浏览器端对这个代理过后的这个地址发起请求,这个请求背后,它肯定还需要去请求到github服务器,请求的过程当中会带一个主机名,这个主机名默认情况下使用的是用户在浏览器端发起请求的主机名,也就是localhost:8080。而一般情况下服务器需要根据主机名去判断这一台主机名,因为一个请求请到服务器过后,服务器一般会有多个网站,它会根据主机名去判断这个请求是属于哪个网站,然后把这个请求指派到对应的网站。localhost:8080对于github来说肯定是不认识,所以说这里需要去修改,**“changeOrigin=true”**的这种情况下就会以实际代理请求发生的过程当中的主机名去请求。请求github的地址,真正请求的应该是https://api.github.com,所以说主机名就会保持原有状态。这个时候,就不用再关心最终把它代理成什么样,只需要去正常的请求就可以了。
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 = {
...
devServer: {
contentBase: './public',
proxy: {
'/api': {
// http://localhost:8080/api/users -> https://api.github.com/api/users
target: 'https://api.github.com',
// http://localhost:8080/api/users -> https://api.github.com/users
pathRewrite: {
'^/api': ''
},
// 不能使用 localhost:8080 作为请求 GitHub 的主机名
changeOrigin: true
}
}
},
...
}
main.js中添加代理过后的地址:
// 跨域请求,虽然 GitHub 支持 CORS,但是不是每个服务端都应该支持。
// fetch('https://api.github.com/users')
fetch('/api/users') // http://localhost:8080/api/users
.then(res => res.json())
.then(data => {
data.forEach(item => {
const li = document.createElement('li')
li.textContent = item.login
ul.append(li)
})
})
23. Source Map介绍
通过构建编译之类的操作,可以将开发阶段的源代码转换为能够在生产环境当中运行的代码,这是一种进步。但是这种进步的同时,也就意味着在实际生产环境当中运行的代码,与开发阶段所编写的代码之间会有很大的差异,在这种情况下,如果需要去调试应用,又或是运行应用的过程当中出现了意料之外的错误,我们将无从下手,这是因为无论是调试还是报错,它都是基于转换过后的代码来进行的,Source map就是解决这一类问题最好的一个办法。
其名字就已经表述了它的作用,叫做源代码地图,它是用来映射转换过后的代码与原代码之间的一个关系。一段转换过后的代码,通过转换过程当中生成的这个Source map文件,就可以逆向得到源代码。
目前,很多第三方的库在去发布的文件当中,都会有一个**".map"后缀的文件,例如这里,可以打开jquery-3.4.1.min.map**文件看一下,这是一个json格式的文件,这个文件里面记录的就是转换过后的代码与转换之前代码之间的映射关系。
主要有这几个属性,简单来看一下:
-
首先是version,它指的是当前这个文件所使用的source map的标准的版本,
-
然后是**“sources”**属性,这个属性中记录的是转换之前源文件的名称,因为很有可能是多个文件合并转换为了一个文件,所以说这里这个属性是一个数组。
-
再然后是**“name”**属性,这个指的是源代码当中使用的一些成员名称,在压缩代码时,会将开发阶段所编写的那些有意义的变量名替换为一些简短的字符,从而去压缩整体代码的体积,这个属性中记录的是原始对应的那些名称。
-
最后是**"mappings"的属性,这个属性其实是整个source map文件的核心属性,它是一个Base64-VLQ**编码的一个字符串,该字符串记录的信息,就是转换过后代码当中的字符与转换之前所对应的映射关系。
有这样一个文件后,一般会在转换过后的代码当中,通过添加一行注释的方式来引入这个source map文件,不过这个特性它只是用来帮助开发者更容易去调试和定位错误的,所以说它对生产环境其实没有什么太大的意义,在最新版的jquery中,已经去除了引用source map的注释。这里想要去尝试的话,需要手动的添加回来。
这里在jquery.min.js文件当中,最后一行去添加一个注释**"//# sourceMappingURL=jquery-3.4.1.min.map",这样在浏览器当中如果打开了开发人员工具的,开发人员工具加载到的这个js文件最后有这么一行注释,它就会自动去请求这个source map**文件,然后根据这个文件的内容,逆向解析出来对应的源代码,以便于调试,同时因为有了映射的关系,当源代码当中出现了错误,也就很容易能定位到源代码当中对应的位置。
这里简单总结一下,source map的它解决的就是在前端方向引入了构建编译之类的概念过后,导致前端编写的源代码与运行的代码之间,不一样所产生的那些调试的问题。
24. Webpack 配置 Source Map
webpack打包过程同样支持为打包结果生成对应的source map文件。用法上也非常简单,不过它提供了很多不同的模式,这就导致大部分的初学者可能会比较懵。将来一起去研究webpack中如何去配置使用source map以及它几种不同模式之间的一些差异。
回到webpack.config.js配置文件当中,这里需要使用的一个配置属性——devtool,这个属性是用来去配置开发过程中的辅助工具,也就是与source map相关的一些功能配置,这里可以直接将这个属性设置为source map,然后打开命令行终端,运行yarn webpack。打包完成过后,打开所生成的dist的目录,此时在这个目录当中就会生成bundle.js和它对应的map文件。而且打开bundle.js,找到这个文件的最后,这个文件的最后也通过注释的方式去引入了这个soft map文件。
如果只是这么去使用,实际的效果就会差的比较远,为什么这么说,因为截止到目前,webpack对source map的风格支持很多种,也就是说它有很多实现方式,那每种方式所生成的source map的效果,以及生成source map的速度都是不一样的,很简单也很明显的一个道理就是效果最好的,一般它的生成速度也就会最慢,而速度最快的一般生成出来的这个source map文件也就没有什么效果,具体哪种方式才是最好或者说最适合的,后续还需要继续去探索。
25. Websocket eval 模式的 Source Map
webpack.config.js中的devtool,它除了可以使用source-map,还支持很多其它的模式,具体的可以参考文档当中有一个不同模式之间的一个对比表。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ciIONFaG-1610550317517)(https://i.loli.net/2021/01/05/aoX4hPuYTcI1jmS.png)]
这张表中分别从初次构建速度、重新打包速度、是否适合在生产环境中使用以及所生成的方式、source map的质量这四个维度,去对比了这些不同方式之间的一些差异,表格当中对比可能不够明显,所以接下来配合表格中的介绍,通过具体的尝试来去体会这些不同模式之间的差异,从而找到适合自己的最佳实践。
首先来看一个叫做eval的模式,eval是js当中的一个函数,它可以用来去运行我们字符串当中的js代码,这里可以尝试一下,通过一位去执行一个"console.log(123)",默认情况下这段代码会运行在一个临时的虚拟机当中,可以通过source URL来去声明这段代码所属的文件路径,这里再来尝试执行一下,在这段js代码字符串当中去添加一个注释内容,就是**"#sourceURL=’./foo/bar.js’",回车执行,此时这段代码它所运行的这个环境就是"./foo/bar.js",这也就意味着可以通过sourceURL**来去改变我们通过eval执行的这段代码所属的这种环境的一个名称,其实它还是运行在这个虚拟环境当中,只不过它告诉了执行引擎我这段代码所属的这个文件路径,这只是一个标识而已。
了解了这样一个特点过后,回到配置文件中,这里将devtool属性设置为**“eval”**。也就是使用eval模式,然后回到命令行终端,再次运行打包,打包完成过后去运行一下这个应用,然后回到浏览器,刷新一下页面,此时根据控制台的提示,就能找到这个错误所出现的文件,但是当打开这个文件,看到的却是打包过后的模块代码,那这是为什么?因为在这种模式下,它会将每一个模块所转换过后的代码都放在eval函数当中去执行,并且在这个一位函数执行的字符串的最后通过sourceURL的方式去说明所对应的文件路径,这样的话浏览器再通过eval去执行这段代码的时候就知道这段代码所对应的源代码是哪一个文件,从而实现定位错误所出现的文件,但只能去定位文件,这种模式下它不会去生成source map文件,也就是说实际上,跟source map没有什么太大关系,所以说它的构建速度也就是最快的,但是它的效果也很简单,它只能定位源代码文件的名称,而不知道具体的行列信息。
26. Webpack devtool模式对比
为了可以更好的对比不同模式的source map之间的差异,这里使用一个新的项目来同时创建出不同模式下的打包结果,然后通过具体的实验来去横向对比它们之间的差异。
目录结构:
打开webpack.config.js的配置文件,在这个文件当中已经提前定好了一个数组,数组中的每一个成员就是配置取值的一种。
const allModes = [
'eval',
'cheap-eval-source-map',
'cheap-module-eval-source-map',
'eval-source-map',
'cheap-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'inline-cheap-module-source-map',
'source-map',
'inline-source-map',
'hidden-source-map',
'nosources-source-map'
]
循环遍历这个数组,编写webpack.config.js内容,具体内容如下:
const HtmlWebpackPlugin = require('html-webpack-plugin')
const allModes = [
'eval',
'cheap-eval-source-map',
'cheap-module-eval-source-map',
'eval-source-map',
'cheap-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'inline-cheap-module-source-map',
'source-map',
'inline-source-map',
'hidden-source-map',
'nosources-source-map'
]
module.exports = allModes.map(item => {
return {
devtool: item,
mode: 'none',
entry: './src/main.js',
output: {
filename: `js/${item}.js`
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
filename: `${item}.html`
})
]
}
})
配置一个html- webpack-plugin,也就是为每一个打包任务去生成一个HTML文件,通过前面的了解,应该知道,html可以用来生成一个使用打包结果的html,待会儿,就是通过这些HTML在浏览器当中去尝试这些不同的打包结果。
这样的配置可以一次生成多个打包任务,对应js目录中生成以数组allModes中每个元素命名的js文件。命令行通过yarn webpack运行结果如下:
命令行运行serve dist:
有了这些不同模式下的打包结果过后,接下来就可以一个一个仔细去对比了,这里先看几个比较典型的模式,然后找出它们之间的一些关系。
-
eval模式
它就是将模块代码放到eval函数当中去执行,并且通过sourceURL标注这个模块文件的路径,这种模式下它并没有生成对应的source map,它只能定位是哪一个文件出了错误;
-
eval-source-map模式
这个模式同样也是使用eval函数去执行模块代码,不过这里有所不同的是,它除了可以帮找到错误出现的文件,还可以定位到具体的行和列的信息,因为在这种模式下相比于eval,它生成了source map;
-
cheap-eval-source-map模式
这个模式的名字差不多就可以推断出来一些信息,它其实就是在上面的的eval-source-map基础之上加了一个cheap,用我们计算机行业经常说的一个词就是阉割版的source map,为什么这么说,因为它虽然也生成了source map,但是这种模式下的source map,它只能帮我们定位导航而没有列的信息,也就是少了一点效果,它的生成速度自然也就会快很多;
-
cheap-module-eval-source-map模式
根据这个名字慢慢的就发现webpack的这些模式的名字好像不是乱起的,它好像有某种规律,这里其实就是cheap-eval-source-map的这个模式基础之上多了一个module,在这种模式下的特点,可能乍一看不会那么明显,因为它也就只能定位导航,这里再来把刚刚cheap-eval-source-map的这个模式也找出来,然后,仔细做一个对比,通过仔细对比你会发现,cheap-module-eval-source-map定位源代码跟我们编写的源代码是一模一样的,而cheap-eval-source-map它显示的是经过ES6转换过后的结果,这样的话这两者之间的差异也就出来了,这也是为什么之前在配置的时候会给js文件单独配一个loader的原因,因为带有module的这种模式下,它解析出来的源代码是没有经过loader的加工,也就是真正手写的那些源代码,而不带module,它是加工过后的一个结果,如果说想要真正跟手写代码一样的源代码的话,就需要选择cheap module这种模式;
了解了以上这些模式过后,基本上就可以算是通盘了解了所有的模式,因为其它的模式无外乎就是把这几个特点再次排列组合罢了。例如。cheap-source-map,它没有eval,也就意味着它没有用eval的方式去执行模块代码,没有module的话也就意味着它反过来的这个源代码,是处理过后的代码。
-
inline-source-map模式
它跟普通的其实效果上是一样的,只不过source map的这个模式下,它的这个source map文件是以物理文件的方式存在,它使用的是data URL的方式去将我们的source map嵌入到的代码当中,之前遇到的eval,它其实也是使用这种行内的这种方式把source map嵌入进来,那这种方式实际上我个人觉得是最不可能用到的,因为它把source map嵌入到源代码当中过后,这个时候就导致这个代码体积会变大很多。
-
hidden-source-map模式
这个模式下在开发工具当中是看不到效果的,但是回到开发工具当中,去找一下这个文件,会发现它确实生成了source map文件,这就跟jquery是一样的,在构建过程当中生成的文件,但是,它在代码当中并没有通过注册的方式去引入这个文件,所以说在开发工具当中看不到效果,这个模式实际上是在开发一些第三方包的时候会比较有用,我们需要去生成source map,但是不想在的这个代码当中直接去应用它们,一旦当使用这个包的开发者出现了问题,它可以再把这个source map手动引入回来或者通过其它的方式去使用source map。Source map还有很多其它的使用方式,通过http_header也可以去使用,这些就不在这儿扩展了。
-
nosource-source-map模式
这个模式下能看到错误出现的位置,但是点击这个错误信息,点进去过后是看不到源代码的,这个nosource指的就是没有源代码,但是它同样提供了行列信息,这样的话对于我们来讲,还是结合自己编写的源代码找到错误出现的位置,只是在开发工具当中看不到源代码,这是为了在生产环境当中去保护源代码不会被暴露。
以上,介绍了很多种的source map,也做了一些具体的对比,通过这些对比,大家要能总结出来这个source map里面这几个核心关键词,它们的一些特点,然后,对于其它几个模式没有介绍到的,就很容易能知道它们一些特点了。可能了解很多的这些模式过后,对大家来讲的,最痛苦的一件事情就是选择一个合适的source map模式,这个问题,下面接着来看。
27. Webpack 选择Source Map模式
虽然webpack可支持各种各样的source map模式,但是其实掌握它们的特点过后,发现一般在应用开发时,只会用到其中的几种,根本就没必要在选择上纠结。这里介绍一下个人在开发时的一些选择。
首先,在开发模式下会选择cheap-module-eval-source-map,原因有三点:
-
第一是编写代码的风格一般会要求每一行代码不会超过80个字符,source map能够定位到行就够了,因为每一行里面最多也就80字符,很容易找到对应的位置;
-
第二是使用框架的情况会比较多,以react和vue来说,无论是使用jsx还是vue的单文件组件,loader转换过后的代码和转换之前都会有很大的差别,这里需要去调试转换之前的源代码,所以要选择有module的方式;
-
第三是虽然cheap-module-eval-source-map的初次启动就是打包启动速度会慢一些,但是大多数时间都是在使用webpack-dev-server,以监视模式去重新打包,而不是每次都启动打包,所以说这种模式下它重新打包速度比较快。
其次在生产模式下会选择none,原因很简单,因为source map会暴露源代码到生产环境,这样的话,但凡是有一点技术的人,都可以很容易去复原项目当中绝大多数的源代码。这一点,其实被很多开发者可能都忽略掉了,它们就光认为source map能够带来便利,但是带来这个便利的同时也会有一些隐患。其次,个人认为调试和报错找错误这些都应该是开发阶段的事情,应该在开发阶段就尽可能把所有的问题和隐患都找出来,而不是到了生产环境让全民去帮忙公测。所以这种情况就尽量避免不在生产环境区域使用source map,如果说对你的代码实在是没有信心的话,那我建议你选择nosource-map模式,这样当出现错误时,在控台当中就可以找到源代码对应的位置,但是不至于去向外暴露的源代码内容。
当然这个过程当中的选择实际上也没有绝对,去理解这些模式之间的差异的目的,就是为了可以在不同环境当中,快速去选择一个合适的模式,而不是去寻求一个通用的法则,在开发行业没有绝对的通用法则。
28. Webpack 自动刷新问题
在此之前已经简单了解了webpack dev serve的一些基本用法和特性,但它主要就是为使用webpack构建的项目,提供了一个比较友好的开发环境和一个可以用来调试的开发服务器。使用webpack就可以让开发过程更加专注于编码,因为它可以监视到代码的变化,然后自动进行打包,最后再通过自动刷新的方式同步到浏览器,以便于即时预览。但是当实际去使用这样一个特性去完成一些具体的开发任务时,会发现这里还是会有一些不舒服的地方,例如在编辑器的应用,想在编辑其中输入一些文字,然后手动调整css,希望试试更新输入的文字样式,但是这个时候编辑器当中的内容却没有了,这里不得不再来编辑器当中再去添加一些文本,那久而久之的话就会发现,自动刷新这样一个功能还是很鸡肋,它并没有想象的那么好用。这是因为每次修改完代码,webpack监视到文件的变化过后就会自动打包,然后自动刷新到浏览器,一旦页面整体刷新,那页面中之前的任何操作状态都会丢失,所以就会出现刚刚所看到的这样一个情况。但是,聪明的人一般都会有一些小办法,例如可以在代码当中先去写死一个文本到编辑器当中,这样即便页面刷新,也不会有丢失的这种情况出现。这些方法都需要去编写一些跟业务本身无关的一些代码,更好的办法自然是能够在页面不刷新的这种情况下,代码也可以及时的更新进去,针对这样的需求webpack同样也可以满足,接下来了解一下webpack当中如何去在页面不刷新的情况下,及时的去更新代码模块。
29. Webpack HMR 体验
HMR全称是Hot Module Replacement,叫做模块热替换或者叫做模块热更新。计算机行业经常听到一个叫做热拔插的名词,那指的就是可以在一个正在运行的机器上随时去插拔设备,而机器的运行状态是不会受插设备的影响,而且插上的设备可以立即开始工作,例如电脑上的USB端口就是可以热拔插的。
模块热替换当中的这个热,跟刚刚提到的热拔插实际上是一个道理,它们都是在运行过程中的即时变化,那**webpack中的模块热替换指的就是可以在应用程序运行的过程中实时的去替换掉应用中的某个模块,而应用的运行状态不会因此而改变。**
例如在应用程序的运行过程中,修改了某个模块,通过自动刷新就会导致整个应用整体的刷新,页面中的状态信息都会丢失掉,而如果这个地方使用的是热替换的话,就可以实现只将刚刚修改的这个模块实时的去替换到应用当中,不必去完全刷新应用。
30. Webpack 开启 HMR
对于热更新这种强大的功能而言,操作并不算特别复杂,了解一下具体如何去使用。HMR已经集成到webpack-dev-server中,所以就不需要再去单独安装什么模块,使用这个特性需要再去运行参数**–hot**开启这个特性。
目录结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mkWmpMhK-1610550317539)(https://i.loli.net/2021/01/06/2Q8vErk6zp3BZU7.png)]
yarn webpack-dev-server --hot
也可以使用配置的方式去打开HMR热更新
webpack.config.js
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
filename: 'js/bundle.js'
},
devtool: 'source-map',
devServer: {
hot: true
// hotOnly: true // 只使用 HMR,不会 fallback 到 live reloading
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /\.(png|jpe?g|gif)$/,
use: 'file-loader'
}
]
},
plugins: [
new HtmlWebpackPlugin({
title: 'Webpack Tutorial',
template: './src/index.html'
}),
new webpack.HotModuleReplacementPlugin() // 必须开启插件
]
}
修改editor.css文件,发现浏览器并没有刷新页面,而且修改的内容也自动更新到浏览器上了。
31. Webpack 处理JS模块热替换
但是js文件好像有问题,修改js文件后,浏览器依然进行刷新,这是因为webpack-dev-server不知道如何去重新构建js模块。这是需要手动进行配置。
进入webpack打包的主入口文件main.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)
if (module.hot) { // 判断hot是否开启,防止js出现错误后页面刷新后错误信息不被保留
let hotEditor = editor // 预先保存editor用于下次热更新使用
module.hot.accept('./editor.js', () => {
const value = hotEditor.innerHTML // 预先保存页面状态信息(这里为编辑器输入的文本信息)
document.body.removeChild(hotEditor) // 移除原先的editor
hotEditor = createEditor() // 使用createEditor创建新的editor
hotEditor.innerHTML = value // 在新的editor中写入上面保留的页面状态信息
document.body.appendChild(hotEditor) // 将新的editor更新到页面中
})
}
32. Webpack 处理图片模块热替换
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,与业务代码无关 ============
// console.log(createEditor)
if (module.hot) {
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
})
module.hot.accept('./better.png', () => {
img.src = background // 重新赋值图片src路径
})
}
33. Webpack 生产环境优化
前面了解到的一些用法和特性都是为了可以在开发阶段,拥有更好的开发体验,而这些体验提升的同时,webpack打包结果也会随之变得越来越臃肿,这是因为在这个过程中webpack为了实现这些特性,它会自动往打包结果中添加一些额外的内容,例如之前所使用到的source map和HMR,它们都会往输出结果当中去添加额外的代码来去实现各自的功能,但是这些额外的代码对于生产环境来讲是容易的,因为生产环境跟开发环境有了很大的差异,在生产环境中强调的是以更少量,更高效的代码去完成业务功能,也就是说会更注重运行效率。而在开放环境中,只注重开发效率,那针对这个问题,webpack当中就推出了mode的用法,那它提供了不同模式下的一些预设配置,其中生产模式中就已经包括了很多在生产环境当中所需要的优化配置,同时webpack也建议我们为不同的工作环境去创建不同的配置,以便于让打包结果可以适用于不同的环境。接下来一起来去探索一下生产环境中有哪些值得优化的地方以及一些注意事项。
34. Webpack 不同环境下的配置
尝试为不同的工作环境以创建不同的webpack配置。创建不同的环境配置的方式主要有两种:
-
第一种是在配置文件中添加相应的配置判断条件,根据环境的判断条件的不同导出不同的配置。
webpack配置文件支持导出函数,函数中返回所需要的的配置对象,函数接受两个参数,第一个是env(cli传递的环境名参数),第二个是argv(运行cli过程中传递的所有参数)。可以借助这样一个特点来去实现不同的开发环境和生产环境分别返回不同的配置。
const webpack = require('webpack') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') module.exports = (env, argv) => { const config = { mode: 'development', entry: './src/main.js', output: { filename: 'js/bundle.js' }, devtool: 'cheap-eval-module-source-map', devServer: { hot: true, contentBase: 'public' }, module: { rules: [ { test: /\.css$/, use: [ 'style-loader', 'css-loader' ] }, { test: /\.(png|jpe?g|gif)$/, use: { loader: 'file-loader', options: { outputPath: 'img', name: '[name].[ext]' } } } ] }, plugins: [ new HtmlWebpackPlugin({ title: 'Webpack Tutorial', template: './src/index.html' }), new webpack.HotModuleReplacementPlugin() ] } if (env === 'production') { config.mode = 'production' config.devtool = false config.plugins = [ ...config.plugins, // ES6将几个数组组合起来,生产环境下需要clean-webpack-plugin和copy-webpack-plugin new CleanWebpackPlugin(), new CopyWebpackPlugin(['public']) ] } return config }
命令行运行:yarn webpack,当没有传递env参数时,webpack会默认mode为开发阶段(development),对应的public下的文件不会被复制。
命令行运行:yarn webpack --env production,传递env参数后,webpack以生产环境(production)进行打包,额外的插件会工作,public目录下的文件会被复制。
这就是通过在导出函数中对环境进行判断,从而去实现为不同的环境倒出不同的配置,当然也可以直接在全局去判断环境变量,然后直接导出不同的配置,这样也是可以的。
-
第二种是为不同的环境单独添加一个配置文件,确保每一个环境下面都会有一个对应的配置文件。
通过判断环境参数数据返回不同的配对象,这种方式只适用于中小型项目。因为一旦项目变得复杂,配置文件也会一起变得复杂起来,所以说对于大型的项目,还是建议大家使用不同环境去对应不同配置文件的方式来实现。一般在这种方式下面,项目当中至少会有三个webpack配置文件,其中两个(webpack.dev.js/webpack.prod.js)是用来适配不同的环境的,那另外一个是一个公共的配置(webpack.common.js)。因为开发环境和生产环境并不是所有的配置都完全不同,所以说需要一个公共的文件来去抽象两者之间相同的配置。
项目目录:
webpack.common.js
const HtmlWebpackPlugin = require('html-webpack-plugin') module.exports = { entry: './src/main.js', output: { filename: 'js/bundle.js' }, module: { rules: [ { test: /\.css$/, use: [ 'style-loader', 'css-loader' ] }, { test: /\.(png|jpe?g|gif)$/, use: { loader: 'file-loader', options: { outputPath: 'img', name: '[name].[ext]' } } } ] }, plugins: [ new HtmlWebpackPlugin({ title: 'Webpack Tutorial', template: './src/index.html' }) ] }
webpack.dev.js
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() ] })
webpack.prod.js
const merge = require('webpack-merge') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') const common = require('./webpack.common') module.exports = merge(common, { mode: 'production', plugins: [ new CleanWebpackPlugin(), new CopyWebpackPlugin(['public']) ] })
webpack-merge提供了更加智能的配置合并,使用yarn add webpack-merge --dev安装到生产环境中。将common中的配置分别于dev和prod组合,生产新的配置。
命令行运行
yarn webpack --config webpack.prod.js # --config用于指定配置文件 # 或者 yarn webpack --config webpack.dev.js
如果觉得使用命令行太过麻烦,也可以在package.json进行配置
"scripts": { "prod": "webpack --config webpack.prod.js", "dev": "webpack --config webpack.dev.js" },
随后命令行运行
yarn prod # 或者yarn dev
35. Webpack DefinePlugin
webpack4中新增的production模式下,内部新增了很多通用的优化功能。对于使用者而言,这种开箱即用的体验是非常棒的,但是对于学习者而言这种开箱即用会导致学习者忽略很多需要了解的东西,以至于出现问题后无从下手。如果需要深入了解webpack的使用,建议可以单独研究一下每个配置背后的作用。这里先学习几个主要的优化配置,顺便去了解webpack是如何优化打包结果的。
-
DefinePlugin
为代码注入全局成员,production模式下DefinePlugin会被启用,并且往代码中注入了一个常量:process.ev.NODE_ENV。很多第三方模块都是通过这个成员去判断当前的运行环境,从而去决定是否执行例如打印日志的这些操作。下面单独使用这个插件。
webpack.config.js
const webpack = require('webpack') // DefinePlugin为webpack内置插件 module.exports = { mode: 'none', entry: './src/main.js', output: { filename: 'bundle.js' }, plugins: [ new webpack.DefinePlugin({ // 值要求的是一个代码片段,该对象中每一个键值都会被注入到代码中 // API_BASE_URL: 'https://api.example.com' // 错误写法 第一步写法 // API_BASE_URL: '"https://api.example.com"' // 正确写法 API_BASE_URL: JSON.stringify('https://api.example.com') }) ] }
main.js
console.log(API_BASE_URL)
bundle.js
// 错误写法 /***/ (function(module, exports) { console.log(https://api.example.com) // 按照第一步写法,报错,非JavaScript代码段 /***/ }) /******/ ]); // 正确写法 /***/ (function(module, exports) { console.log("https://api.example.com") // 按照第二部或第三部写法,正常 /***/ }) /******/ ]);
36. Webpack Tree Shaking
Tree-shaking字面意义是“摇树”,伴随着摇树,树上的枯叶就会掉落下来。这里的Tree-shaking【摇掉】的是代码中未引用的部分,这部分代码叫做未引用代码(dead code)。Webpack生产模式优化中就有这样一个有用的功能,它可以检测出代码中未引用的代码,然后移除掉它们。
compontents.js
export const Button = () => {
return document.createElement('button')
console.log('dead-code') // 未引用代码
}
// 未引用代码,index.js中没有导入
export const Link = () => {
return document.createElement('a')
}
// 未引用代码,index.js中没有导入
export const Heading = level => {
return document.createElement('h' + level)
}
index.js
import { Button } from './components'
document.body.appendChild(Button())
通过yarn webpack --mode production打包后发现,console.log(‘dead code’)以及其它两个组件压根没有输出到bundle.js,这是因为Tree-shaking在生产模式下自动开启。
37. Webpack 使用Tree Shaking
需要注意的hiTree-shaking并不是webpack中的某一个配置选项,它是一组功能搭配使用过后的使用效果,这种功能会在生产模式下自动启用。但是由于目前官方文档中对Tree-shaking的介绍有点混乱,所以这里再来介绍一下它在其它模式下如何一步一步的手动的开启。顺便通过这个过程,了解Tree-shaking的工作过程以及它的优化功能。
之前在没有启用production工作模式时,生成的bundle.js部分代码如下,其中未引用到的Link及Heading都被输出到bundle.js中。
document.body.appendChild(Object(_components__WEBPACK_IMPORTED_MODULE_0__["Button"])())
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Button", function() { return Button; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Link", function() { return Link; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Heading", function() { return Heading; });
const Button = () => {
return document.createElement('button')
console.log('dead-code')
}
const Link = () => {
return document.createElement('a')
}
const Heading = level => {
return document.createElement('h' + level)
}
/***/ }
然后在webpack.config.js中添加如下配置optimization,该对象集中配置webpack优化功能。
module.exports = {
mode: 'none',
entry: './src/index.js',
output: {
filename: 'bundle.js'
},
optimization: { // 集中配置webpack优化功能
// 模块只导出被使用的成员
usedExports: true,
// 尽可能合并每一个模块到一个函数中
concatenateModules: true,
// 压缩输出结果
// minimize: true
}
}
随后继续运行yarn webpack打包命令,输出bundle.js部分如下:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// CONCATENATED MODULE: ./src/components.js
const Button = () => {
return document.createElement('button')
console.log('dead-code')
}
const Link = () => {
return document.createElement('a')
}
const Heading = level => {
return document.createElement('h' + level)
}
// CONCATENATED MODULE: ./src/index.js
document.body.appendChild(Button())
/***/ }
通过对比代码发现,未开启优化时,bundle.js将三个组件全部导入,然后使用**document.body.appendChild(Object(_componentsWEBPACK_IMPORTED_MODULE_0[“Button”])())创建组件。而开启优化后(为方便观察,先关闭minimize),bundle.js直接使用document.body.appendChild(Button())**创建组件。实际上打开minimize后,在压缩代码中完全找不到Link以及Heading组件。
如果把代码看做【大树】,可以理解为usedExports将枯叶标记起来,而minimize负责把【枯叶】摇下来。
其中concatenateModules负责尽可能合并每一个模块到一个函数中,未开启时一个模块为一个函数。这个作用又被称之为Scope Hoisting(作用域提升),它是webpack3中添加的特性,此时再配合minimize,这样代码体积又会减小很多。
38. Webpack Tree Shaking 于Babel
由于早期webpack发展非常快,变化比较多,所有找资料时得到的结果并不一定适用于当前所使用的版本,对于Tree-shaking的资料更是如此。很多资料中表示如果使用babel-loader就会导致Tree-shaking失效。这里说明一下,首先需要明确的是Tree-shaking的实现,前提是必须使用ES Module组织代码,也就是说,交给webpack打包的代码必须使用ESM的方式来去实现的模块化。原因是webpack在打包所有模块之前,先将模块根据不同的配置交给不同的loader去处理,最后再将所有loader处理过后的结果打包在一起。为了转换ECMAScript新特性,很多时候会选择babel-loader去处理JavaScript,babel-loader转换代码时有可能会将ES Module处理成CommonJS,取决于是否使用转换ES Module的插件。例如之前使用的**"@babel/preset-env"**,其中就有这样一个插件去将ESM转换为CommonJS。这样webpack打包时,拿到的代码就是以commonJS组织的代码,所以说Tree-shaking会失效。
-
实验一:开启bebel-loader,验证Tree-shaking是否会失效
webpack.config.js
module.exports = { mode: 'none', entry: './src/index.js', output: { filename: 'bundle.js' }, module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: [ "@babel/preset-env" // 插件集合 ] } } } ] }, optimization: { // 模块只导出被使用的成员 usedExports: true, // 尽可能合并每一个模块到一个函数中 // concatenateModules: true, // 压缩输出结果 // minimize: true } }
yarn webpack
bundle.js
/***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return Button; }); /* unused harmony export Link */ /* unused harmony export Heading */ var Button = function Button() { return document.createElement('button'); console.log('dead-code'); }; var Link = function Link() { return document.createElement('a'); }; var Heading = function Heading(level) { return document.createElement('h' + level); }; /***/ })
结论:当开启bebel-loader时,Tree-shaking正常工作,当使用minimize后,未引用的代码将被删除掉。与上面的描述不符。这是因为最新版本中babel-loader中自动关闭了ES Module转换插件。
探索源码:
node_modules\babel-loader\lib\injectCaller.js部分代码
module.exports = function injectCaller(opts, target) { if (!supportsCallerOption()) return opts; return Object.assign({}, opts, { caller: Object.assign({ name: "babel-loader", // Provide plugins with insight into webpack target. // https://github.com/babel/babel-loader/issues/787 target, // Webpack >= 2 supports ESM and dynamic import. supportsStaticESM: true, // 当前环境支持ES Module supportsDynamicImport: true, // Webpack 5 supports TLA behind a flag. We enable it by default // for Babel, and then webpack will throw an error if the experimental // flag isn't enabled. supportsTopLevelAwait: true }, opts.caller) }); };
node_modules@babel\preset-env\lib\index.js部分代码
const modulesPluginNames = getModulesPluginNames({ modules, transformations: _moduleTransformations.default, shouldTransformESM: modules !== "auto" || !(api.caller == null ? void 0 : api.caller(supportsStaticESM)), //禁用ESM的转换 shouldTransformDynamicImport: modules !== "auto" || !(api.caller == null ? void 0 : api.caller(supportsDynamicImport)), shouldTransformExportNamespaceFrom: !shouldSkipExportNamespaceFrom, shouldParseTopLevelAwait: !api.caller || api.caller(supportsTopLevelAwait) });
所以webpack最终打包时得到的依然是ES Module的代码,所以Tree-shaking还是工作的。
-
实验二:配置babel-loader,强制开启ES Module转换,验证Tree-shaking是否会失效
webpack.config.js
module.exports = { mode: 'none', entry: './src/index.js', output: { filename: 'bundle.js' }, module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: [ // 如果 Babel 加载模块时已经转换了 ESM,则会导致 Tree Shaking 失效 ['@babel/preset-env', { modules: 'commonjs' }] // 强制使用babel esm插件,将代码中的esm转换为CommonJs // ['@babel/preset-env', { modules: false }] // 也可以使用默认配置,也就是 auto,这样 babel-loader 会自动关闭 ESM 转换 // ['@babel/preset-env', { modules: 'auto' }] // 根据环境判断是否开启ES Module插件 ] } } } ] }, optimization: { // 模块只导出被使用的成员 usedExports: true, // 尽可能合并每一个模块到一个函数中 // concatenateModules: true, // 压缩输出结果 // minimize: true } }
bundle.js
exports.Heading = exports.Link = exports.Button = void 0; var Button = function Button() { return document.createElement('button'); console.log('dead-code'); }; exports.Button = Button; var Link = function Link() { return document.createElement('a'); }; exports.Link = Link; var Heading = function Heading(level) { return document.createElement('h' + level); }; exports.Heading = Heading;
发现usedExport未生效,其导出所有成员,包含未引用的成员。开启压缩袋吗Tree-shaking也没办法工作。
**总结:**最新版本的bebel-loader并不会导致Tree-shaking失效,如果不确定,最简单的办法是将preset-env中的module改为false,确保preset-env不会开启ES Module转换插件,这样也确保了Tree-shaking工作的前提。另外,上述实验过程也值得琢磨,通过这样会的探索可以了解到很多知其所以然的内容。
['@babel/preset-env', { modules: false }]
39. Webpack sideEffects及注意
webpack4中还新增了一个sideEffects的新特性,它允许通过配置的方式去标识代码是否有副作用,从而为Tree-shaking提供更大的压缩空间,副作用是指模块执行的时候,除了导出成员是否还做了一些其它的事情,这个特性一般只有在去开发一个NPM模块时才会用到,但是因为官网当中把sideEffects的介绍跟Tree-shaking混到了一起,所以很多人误认为它俩是因果关系,其实它俩真的没有那么大的关系。
这里把sideEffects弄明白,你就能理解为什么了。这里先设计一个能够让sideEffects发挥效果的一个场景:
目录结构:
基于刚刚的这个案例基础之上,把components拆分出了多个组件文件(button.js/heading.js/link.js),然后在index.js当中集中导出,便于外界导入。这是一种非常常见的同类文件组织方式。回到入口文件当中去导入components中的组件。
index.js打包入口文件
import { Button } from './components'
// 样式文件属于副作用模块
import './global.css'
// 副作用模块
import './extend'
console.log((8).pad(3))
document.body.appendChild(Button())
这样就会出现一个问题,因为在这载入的是components这个目录下的index.js,index.js入口文件中又载入了所有的组件模块,这就会导致只想导入button组件,但是所有的组件模块都会被加载执行。打开命令行终端,然后尝试运行打包,打包完成过后找到打包结果,你会发现所有组件的模块确实都被打包进了bundle.js。
sideEffects特性就可以用来解决此类问题,打开webpack.config.js的配置文件,在optimization属性当中去开启sideEffects属性,注意这个特性在production模式下同样也会自动开启。开启这个特性过后,webpack在打包时就会先检查当前代码,当前项目所属的这个package.json中有没有sideEffects的标识,以此来判断这个模块是否有副作用。如果说这个模块没有副作用,那这些没有用到的模块就不再会打包。可以打开package.json,然后尝试去添加一个sideEffects字段,把它设置false,这样的话就标识当前这个package.json所影响的这个项目,它当中所有的代码都没有副作用,一旦这些没有用的模块它没有副作用了,它就会被移除掉。
完了以后再打开命令行终端,然后再次运行打包,打包过后同样找到打包输出的bundle.js,此时那些没有用到的模块就不再会被打包进来了,那这就是的sideEffects作用。注意这里设置了两个地方,先在webpack.config.js的配置当中去开启的sideEffects,它是用来去开启这个功能,而在package.json们添加的sideEffects它是用来标识项目代码是没有副作用的。它俩不是一个意思,不要弄混了
sideEffects注意事项:
使用sideEffects这个功能的前提就是确定你的代码没有副作用,否则的话,在webpack打包时就会误删掉那些有副作用的代码,例如这里准备了一个extend.js一个文件,在这个当中并没有向外导出任何成员,它仅仅是在number这个对象的原型上挂载了一个方法,用来为数字去添加前面的倒零,这是一种非常常见的基于原型的扩展方法。
// 为 Number 的原型添加一个扩展方法
Number.prototype.pad = function (size) {
// 将数字转为字符串 => '8'
let result = this + ''
// 在数字前补指定个数的 0 => '008'
while (result.length < size) {
result = '0' + result
}
return result
}
回到index.js当中去导入这个extend.js,但因为这个模块确实没有导出任何成员,所以说这里也就不需要去提取任何成员,只不过在导入这个模块过后就可以使用。它为number所提供的扩展方法了,这里为number做扩展的这样一个操作,就属于extend.js这个模块的副作用,因为在导入的这个模块过后,number的原型上就会多一个方法,这就是副作用。
import { Button } from './components'
// 样式文件属于副作用模块
import './global.css'
// 副作用模块
import './extend'
console.log((8).pad(3))
document.body.appendChild(Button())
此时如果说还表示项目当中所有的代码都没有副作用的话,再次回到命令行运行打包,打包过后,找到打包结果,这个时候就会发现刚刚的这个扩展的操作,它是不会被打包进来的,因为它是副作用代码,但是在的配置当中已经声明了没有副作用,所以它们就被移除掉了。除此之外,还有在代码当中载入的css模块,都属于副作用模块,同样会面临刚刚这样一种问题。
解决的办法就是在package.json当中,关掉副作用或者是标识一下当前这个项目当中哪些文件是有副作用的,这样的话webpack就不会去忽略这些有副作用的模块。打开package.json,把false改成一个数组,然后再去添加一下extend.js的路径,还有这个global.css的文件路径,这里也可以使用路径通配符的方式来去配置。
{
"name": "31-side-effects",
"version": "0.1.0",
"main": "index.js",
"author": "leo ",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"css-loader": "^3.2.0",
"style-loader": "^1.0.0",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.9"
},
"sideEffects": [
"./src/extend.js",
"*.css"
]
}
完成以后再次打开命令行终端运行打包,此时在找bundle.js中发现,这个有副作用的两个模块也会被同时打包进来了。以上就是对webpack和内置的一些优化属性的一些介绍,总之,这些特性,它都是为了弥补webpack的早期在设计上的一些遗留问题,这一类的技术的发展确实越来越好。
40. Webpack 代码分割
通过webpack实现前端项目整体模块化的优势很明显,但是它同样存在一些弊端,那就是项目当中所有的代码最终都会被打包到一起,试想一下,如果说应用非常复杂,模块非常多的话,那打包结果就会特别的大,很多时候超过两三兆也是非常常见的事情。而事实情况是,大多数时候在应用开始工作时,并不是所有的模块都是必须要加载进来的,但是,这些模块又被全部打包到一起,需要任何一个模块,都必须得把整体加载下来过后才能使用。而应用一般又是运行在浏览器端,这就意味着会浪费掉很多的流量和带宽。
更为合理的方案就是把的打包结果按照一定的规则去分离到多个bundle.js当中,然后根据应用的运行需要,按需加载这些模块,这样的话就可以大大提高应用的响应速度以及它的运行效率。可能有人会想起来在一开始的时候说过webpack就是把项目中散落的那些模块合并到一起,从而去提高运行效率,那这里又在说它应该把它分离开,这两个说法是不是自相矛盾?其实这并不是矛盾,只是物极必反而已,资源太大了也不行,太碎了更不行,项目中划分的这种模块的颗粒度一般都会非常的细,很多时候一个模块只是提供了一个小小的工具函数,它并不能形成一个完整的功能单元,如果不把这些散落的模块合并到一起,就有可能再去运行一个小小的功能时,会加载非常多的模块。而目前所主流的这种HTTP1.1协议,它本身就有很多缺陷,例如并不能同时对同一个域名下发起很多次的并行请求,而且每一次请求都会有一定的延迟,另外每次请求除了传输具体的内容以外,还会有额外的header请求头和响应头,当大量的这种请求的情况下,这些请求头加在一起,也是很大的浪费。
综上所述,模块打包肯定是有必要的,不过像应用越来越大过后,要开始慢慢的学会变通。为了解决这样的问题,webpack支持一种分包的功能,也可以把这种功能称之为代码分割,它通过把模块,按照所设计的一个规则打包到不同的bundle.js当中,从而去提高应用的响应速度,目前的webpack去实现分包的方式主要有两种:
-
第一种就是根据业务去配置不同的打包入口,也就是会有同时多个打包入口同时打包,这时候就会输出多个打包结果;
-
第二种就是采用ES Module的动态导入的功能,去实现模块的按需加载,这个时候webpack会自动的把动态导入的这个模块单独输出的一个bundle.js当中。
41. Webpack 多入口打包
多入口打包一般适用于传统的“多页”应用程序。最常见的划分规则是一个页面对应一个打包入口,对于不同页面之间公共的部分再去提取到公共的结果中。
目录结构
一般webpack.config.js配置文件中的entry属性只会一个文件路径(打包入口),如果需要配置多个打包入口,则需要将entry属性定义成为一个对象(注意不是数组,如果是数组的话,那就是将多个文件打包到一起,对于整个应用来讲依然是一个入口)。一旦配置为多入口,输出的文件名也需要修改**"[name].bundle.js**",[name]最终会被替换成入口的名称,也就是index和album。
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
index: './src/index.js', // 多入口
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name]占位符,最终被替换为入口名称index和album
},
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all'
}
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
})
]
}
命令行运行yarn webpack命令,打开dist目录发现已经有两个js文件。打开html文件,发现两个html文件都引入了两个js文件,但需求是各自引入各自的js/css文件,所以这里需要进一步处理,在html-webpack-plugin插件中增加chunks属性,其值为对应需要引入的js文件入口名称。
...
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index']
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album']
})
]
42. Webpack 提取公共模块
多入口打包本身非常容易理解,也非常容易使用,但是它也存在一个小小的问题,就是在不同的打包入口当中,它一定会有那么一些公共的部分,按照目前这种多入口的打包方式,不同的打包结果当中就会出现相同的模块,例如在我们这里index入口和album入口当中就共同使用了global.css和fetch.js这两个公共的模块,因为实例比较简单,所以说重复的影响不会有那么大,但是如果共同使用的是jQuery或者Vue这种体积比较大的模块,那影响的话就会特别的大,所以说需要把这些公共的模块去。提取到一个单独的bundle.js当中,webpack中实现公共模块提取的方式也非常简单,只需要在优化配置当中去开启一个叫splitChunks的一个功能就可以了,回到配置文件当中,配置如下:
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all' // 表示会把所有的公共模块都提取到单独的bundle.js当中
}
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index']
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album']
})
]
}
打开命令行运行yarn webpack后发现,公共模块的部分被打包进album~index.bundle.js中去了。
43. Webpack 动态导入
按需加载是开发浏览器应用当中一个非常常见的需求,一般常说的按需加载指的是加载数据,这里所说的按需加载指的是在应用运行过程中需要某个模块时才去加载这个模块,这种方式可以极大的节省带宽和流量。webpack支持使用动态导入的这种方式来去实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的bundle.js当中,从而实现分包,相比于多入口的方式,动态导入更为灵活,因为通过代码的逻辑去控制,需不需要加载某个模块,或者是时候加的某个模块。而分包的目的中就有很重要的一点就是:让模块实现按需加载,从而去提高应用的响应速度。
具体来看如何使用,这里已经提前设计好了一个可以发挥按需加载作用的场景,在这个页面的主体区域,如果访问的是文章页的话,得到的就是一个文章列表,如果访问的是相册页,显示的就是相册列表。
项目目录:
动态导入使用的就是ESM标准当中的动态导入,在需要动态导入组件的地方,通过这个函数导入指定的路径,这个方法返回的就是一个promise,promise的方法当中就可以拿到模块对象,由于网站是使用的默认导出,所以说这里需要去解构模块对象当中的default,然后把它放到post的这个变量当中,拿到这个成员过后,使用mainElement.appendChild(posts())创建页面元素,album组件也是如此。完成以后再次回到浏览器,此时页面仍然可以正常工作的。
// import posts from './posts/posts'
// import album from './album/album'
const render = () => {
const hash = window.location.hash || '#posts'
console.log(hash)
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
// mainElement.appendChild(posts())
// 这个方法返回的就是一个promise,promise的方法当中就可以拿到模块对象,由于网站是使用的默认导出,所以说这里需要去解构模块对象当中的default,然后把它放到post的这个变量当中
import('./posts/posts').then(({ default: posts }) => {
mainElement.appendChild(posts())
})
} else if (hash === '#album') {
// mainElement.appendChild(album())
import('./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
}
}
render()
window.addEventListener('hashchange', render)
这时再回到开发工具当中,然后重新去运行打包,然后去看看此时打包的结果是什么样子的,打包结束,打开dist目录,此时dist目录下就会多出3个js文件,那这三个js文件,实际上就是由动态导入自动分包所产生的。这3个文件的分别是刚刚导入的两个模块index.js/album.js,以及这两个模块当中公共模块fetch.js。
动态导入整个过程无需配置任何一个地方,只需要按照ESM动态导入成员的方式去导入模块就可以,内部会自动处理分包和按需加载,如果说你使用的是单页应用开发框架,比如react或者Vue的话,在你项目当中的路由映射组件,就可以通过这种动态导入的方式实现按需加载。
44. Webpack 魔法注释
默认通过动态导入产生的bundle.js文件,它的名称只是一个序号,但这并没有什么不好的,因为在生产环境当中,大多数时候是根本不用关心资源文件的名称是什么,但是如果说还是需要给这些bundle.js命名的话,可以使用webpack所特有的魔法注释是来去实现。
// import posts from './posts/posts'
// import album from './album/album'
const render = () => {
const hash = window.location.hash || '#posts'
console.log(hash)
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
// mainElement.appendChild(posts())
// /* webpackChunkName: 'components' */'魔法注释,特定格式
import(/* webpackChunkName: 'posts' */'./posts/posts').then(({ default: posts }) => {
mainElement.appendChild(posts())
})
} else if (hash === '#album') {
// mainElement.appendChild(album())
import(/* webpackChunkName: 'album' */'./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
}
}
render()
window.addEventListener('hashchange', render)
特定格式:webpackChunkName:‘components’,这样就可以给分包所产生的帮的起上名字了,再次打开命令行终端运行webpack打包,此时生成的bundle.js文件它的name会使用刚刚注释当中所提供的名称了。
如果webpackChunkName是相同的,最终就会被打包到一起,例如这里可以把这两个webpackChunkName设置为components,然后再次运行打包,此时,这两个模块它都会被打包进components.bundle.js文件,借助于这样一个特点,就可以根据自己的实际情况,灵活组织动态加载的模块所输出的文件了。
45. Webpack MiniCssExtractPlugin
MiniCssExtractPlugin是一个可以将css代码从打包结果当中提取出来的插件,通过这个插件就可以实现css模块的按需加载。它的使用也非常简单,回到项目当中,先执行yarn add mini-css-extract-plugin,打开webpack的配置文件,首先需要先导入这个插件的模块,导入过后就可以将这个插件添加到配置对象的plugins数组当中。这样的话,它在工作时就会自动提取代码当中的css到一个单独的文件当中。除此以外,目前所使用的样式模块,它是先交给css-loader去解析,然后交给style-loader的去处理,它的作用就是将样式代码通过style标签的方式注入到页面当中,从而使样式可以工作。MiniCssExtractPlugin的话,样式就会单独存放到文件当中,也就不需要加style标签,而是直接通过link的方式去引入。所以这里就不再需要style-loader,取而代之使用的是MiniCssExtractPlugin所提供的一个MiniCssExtractPlugin.loader,来实现样式文件,通过link标签的方式去引入。
webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Dynamic import',
template: './src/index.html',
filename: 'index.html'
}),
new MiniCssExtractPlugin()
]
}
完成以后回到命令行终端,再次运行webpack打包过后,就可以在目录当中看到提取出来的文件了,不过这里需要注意一点,如果说样式文件体积不是很大的话,提取到单个文件当中,效果可能适得其反。个人的经验是:如果css超过了150KB左右,才需要考虑是否将它提取到单独文件当中,否则的话其实css嵌入到代码当中,它减少一次请求效果可能会更好。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NpdmczdM-1610550317556)(C:\Users\24561\AppData\Roaming\Typora\typora-user-images\image-20210110001523687.png)]
46. Webpack OptimizeCssAssetsWebpackPlugin
使用了MiniCssExtractPlugin过后,样式文件就可以被提取到单独的css文件当中了,但是这里同样有一个小问题,回到命令行,尝试以生产模式去运行打包(yarn webpack --mode production),照之前的了解,在生产模式下webpack会自动去压缩输出的结果,这里打开输出的样式文件,发现样式文件根本没有任何的变化,这是因为webpack内置的压缩插件仅仅针对于js文件的压缩,对于其它的资源文件压缩都需要额外的插件来去支持。
webpack官方推荐了一个OptimizeCssAssetsWebpackPlugin插件,可以使用这个插件来去压缩样式文件。首先安装一下这个插件,yarn add optimize-css-assets-webpack-plugin,安装完成后回到配置文件当中,先导入这个插件,完成过后去把这个插件添加到配置对象的plugins当中,此时再次回到命令行终端,重新运行打包,这次打包完成过后,样式文件就可以以压缩文件的格式去输出了。
不过这里还有一个额外的小问题,可能大家在官方文档当中会发现,文档当中这个插件它并不是配置在plugins数组当中的,而是添加到了optimization属性当中的minimizer属性当中,这是为什么,其实也非常简单。如果说把这个插件配置到plugin数组当中,这个插件它在任何情况下都会正常工作,而配置到minimizer当中的话,那只会在minimizer特性开启时才会工作,所以说webpack的建议,像这种压缩类的插件,应该配置到minimizer数组当中,以便于可以通过这个选项去统一控制。这里尝试把这个插件移植到的optimization属性的数组当中,然后再次运行打包,此时如果说没有开启压缩这个功能的话,这个插件就不会工作,反之如果说以生产模式打包,minimizer的属性就会自动开启,这个压缩插件就会自动工作,样式文件也就会被压缩。但是这么配置也有一个小小的缺点,可以来看一眼输出的js文件,这时候发现原本可以自动压缩的js,这次却不能自动压缩了,这是因为设置了minimizer这个数组,webpack就认为如果配置了这个数组,就是要去自定义所使用的压缩器插件,内部的js压缩器就会被覆盖掉,所以这里需要手动再去把它添加回来,内置的js压缩插件叫做terser-webpack-plugin,回到命令行,然后来手动安装一下这个模块,安装完成过后这里再来把这个插件手动的去添加到这个数组当中,这样的话,如果再以生产模式运行打包,js文件、css文件都可以正常被压缩了,如果说以普通模式打包也就是不开启压缩(minimizer)的话,它也就不会以压缩的形式输出了。
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin') // css压缩插件
const TerserWebpackPlugin = require('terser-webpack-plugin') // webpack内置的js压缩插件
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Dynamic import',
template: './src/index.html',
filename: 'index.html'
}),
new MiniCssExtractPlugin()
]
}
47. Webpack 输出文件名 Hash
一般部署前端的资源文件时,都会启用服务器的静态资源缓存,这样的话对于用户的浏览器而言,就可以缓存住应用当中的静态资源,后续就不再需要请求服务器得到这些文件,整体应用的响应速度就有一个大幅度的提升。不过开启静态资源的客户端缓存,也会有一些小小的问题,如果说在缓存策略当中的缓存失效时间设置的过短的,效果就不是特别明显,如果说把过期时间设置的比较长,一旦在这个过程当中应用发生了更新,重新部署过后,又没有办法及时更新到客户端。
为了解决这个问题,建议在生产模式下需要给输出的文件名当中加哈希值,这样的话一旦的资源文件发生改变,文件名称也可以跟着一起去变化,对于客户端而言,全新的文件名就是全新的请求,也就没有缓存的问题,这样的话就可以把服务端的缓存策略当中的时间设置得非常长,也就不用担心文件更新过后的问题。
webpack中的filename属性和绝大多数插件中的filename的属性,都支持通过占位符的方式来去为文件名设置hash,不过它们支持三种hash,效果各不相同。
-
hash
const { CleanWebpackPlugin } = require('clean-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin') const TerserWebpackPlugin = require('terser-webpack-plugin') module.exports = { mode: 'none', entry: { main: './src/index.js' }, output: { // 这个hash是整个项目级别的,也就是说一旦项目当中有任何一个地方发生改动,这一次打包过程当中的哈希值全部都会发生变化。 filename: '[name]-[hash].bundle.js' // 最普通的hash,项目级别 }, optimization: { minimizer: [ new TerserWebpackPlugin(), new OptimizeCssAssetsWebpackPlugin() ] }, module: { rules: [ { test: /\.css$/, use: [ // 'style-loader', // 将样式通过 style 标签注入 MiniCssExtractPlugin.loader, 'css-loader' ] } ] }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ title: 'Dynamic import', template: './src/index.html', filename: 'index.html' }), new MiniCssExtractPlugin({ filename: '[name]-[hash].bundle.css' }) ] }
-
chunkhash
const { CleanWebpackPlugin } = require('clean-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin') const TerserWebpackPlugin = require('terser-webpack-plugin') module.exports = { mode: 'none', entry: { main: './src/index.js' }, output: { // 这个哈希chunk级别的,也就是在打包过程当中,只要是同一路的打包,chunkhash都是相同的,一个打包入口算一路 filename: '[name]-[chunkhash].bundle.js' }, optimization: { minimizer: [ new TerserWebpackPlugin(), new OptimizeCssAssetsWebpackPlugin() ] }, module: { rules: [ { test: /\.css$/, use: [ // 'style-loader', // 将样式通过 style 标签注入 MiniCssExtractPlugin.loader, 'css-loader' ] } ] }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ title: 'Dynamic import', template: './src/index.html', filename: 'index.html' }), new MiniCssExtractPlugin({ filename: '[name]-[chunkhash].bundle.css' }) ] }
-
contenthash
const { CleanWebpackPlugin } = require('clean-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin') const TerserWebpackPlugin = require('terser-webpack-plugin') module.exports = { mode: 'none', entry: { main: './src/index.js' }, output: { // 它实际上是文件级别的hash,是根据输出文件的内容生成的哈希值,也就是说只要是不同的文件,它就有不同的哈希值 filename: '[name]-[contenthash].bundle.js' }, optimization: { minimizer: [ new TerserWebpackPlugin(), new OptimizeCssAssetsWebpackPlugin() ] }, module: { rules: [ { test: /\.css$/, use: [ // 'style-loader', // 将样式通过 style 标签注入 MiniCssExtractPlugin.loader, 'css-loader' ] } ] }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ title: 'Dynamic import', template: './src/index.html', filename: 'index.html' }), new MiniCssExtractPlugin({ filename: '[name]-[contenthash].bundle.css' }) ] }
相比于hash和chunkhash,contenthash它应该算是解决缓存问题最好的方式,因为它精确的定位到了文件级别的hash,只有当这个文件发生了变化,才有可能去更新文件名,这个实际上是最适合去解决缓存问题的。另外,如果觉得这个20位长度的hash太长的话,webpack还允许指定hash的长度,可以在占位符里面通过":8"来去指定hash的长度,个人觉得如果说是控制缓存的话,八位的contenthash应该是最好的选择了。
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name]-[contenthash:8].bundle.js' // 控制缓存最好的选择
},
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Dynamic import',
template: './src/index.html',
filename: 'index.html'
}),
new MiniCssExtractPlugin({
filename: '[name]-[contenthash:8].bundle.css' // 控制缓存最好的选择
})
]
}
四、其它打包工具
1. Rollup 概述
Rollup也是一款ESM的打包器,它也可以将项目当中散落的细小模块打包为整块的代码,从而使得划分的模块可以更好的运行在浏览器环境或者是node环境。从作用上来看,与webpack非常类似,不过相比于webpack,Rollup要小巧的多,因为webpack在去配合一些插件的使用下,几乎可以完成开发过程中前端工程化的绝大多数工作,而Rollup仅仅可以说是一个ESM的打包器,并没有任何其它额外的功能,例如webpack中有对开发者十分友好的HMR(模块热替换)。在Rollup当中就没有办法完全支持,Rollup诞生的目的并不是要与webpack之类的一些工具去全面竞争,它的初衷只是希望能够提供一个高效的ESM打包器,充分利用ESM的各项特性,构建出结构比较扁平,性能比较出众的类库。至于它其它的一些特点和优势,需要上手过后才能了解。
2. Rollup 快速上手
这里准备一个简单的实例,示例当中使用ESM的方式组织的代码模块化。
示例如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N4gAM0l1-1610550317560)(https://i.loli.net/2021/01/10/WqgmyYtLz6GuAIR.png)]
尝试使用Rollup完成对这个示例应用的打包,首先安装一下Rollup模块,它同样也应该作为项目的开发依赖全装,所以使用yarn add rollup --dev,安装这个模块它就会在node_module当中的bin目录里面提供一个cli程序,可以通过这个cli去使用Rollup打包。
回到命令行,仍然是通过yarn rollup去运行,因为yarn可以自动找到node_module中的cli程序,避免手动通过路径去查找对应的cli,执行过后会发现,在不传递任何参数的情况下,rollup会自动打印出来它的帮助信息,信息一开始的位置就已经告诉我们的正确用法,我们这儿,应该通过参数去指定一个打包入口文件。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZIRTPFvz-1610550317561)(https://i.loli.net/2021/01/10/ISMHUoymtaAF19G.png)]
回来再次执行这个命令,这里打包入口是"./src/index.js",此时命令行出了一个错误,意思是在说应该去指定一个代码输出的格式,输出格式的概念应该并不陌生意思就是你希望把ESM的代码转换过后,以什么样的格式去输出,这里可以使用–format的参数去指定输出的格式,这里先选择最适合浏览器端的iife(自调用函数)。然后回车,此时打包结果就被打印到控制台当中了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NgVrpu06-1610550317563)(https://i.loli.net/2021/01/10/b5TvdYzSZa8Ft1M.png)]
还可以通过–file函数去指定一个输出文件的路径,打包结果就会输出到文件当中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SNgzE05r-1610550317564)(https://i.loli.net/2021/01/10/7YEBx1hikH8OgAo.png)]
打开bundle.js文件第一印象就是它的打包结果惊人的简洁,基本上就跟以前手写的代码是一样的,相比于webpack当中大量的引导代码还有一堆的模块函数,这里的输出结果几乎没有任何多余的代码,就是把打包过程当中各个模块按照模块的依赖顺序先后的拼接到一起,而且此时仔细去观察打包结果,你会发现在输出结果当中,它只会去保留用到的部分,对于未引用的部分都没有输出,这是因为rollup默认会自动Tree-shaking优化输出的结果,Tree-shaking这个概念最早也就是在Rollup工具当中提出的。
3. Rollup 配置文件
Rollup同样支持以配置文件的方式去配置打包过程中的各项参数,可以在项目中新建一个配置文件(rollup.config.js),这个文件运行在node环境当中,不过,它自身会额外处理这个配置文件,所以说这里可以直接使用ESM,它需要导出一个配置对象,这个对象中可以去通过input属性去指定打包的入口文件路径,然后通过output属性去指定输出的相关配置,output属性要求是一个对象,在output的对象当中,可以使用file属性去指定输出文件名,然后可以用format来去指定输出格式。
rollup.config.js
export.default = {
input: 'src/index.js', // 打包入口
optput: {
file:'dist/bundle.js', // 打包输出文件名
format: 'iife' // 立即执行函数
}
}
完成以后回到命令行再次执行。–config参数来去表明这里使用项目中的配置文件,默认的话rollup是不会去读取配置文件,必须使用这个参数。也可以使用参数指定不同配置文件的名称, 比如可以用rollup.production.js或者是rollup.development.js分别对于开发和生产不同的配置文件。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bakKoxd6-1610550317565)(https://i.loli.net/2021/01/10/MdcuePODJXVkisF.png)]
4. Rollup 使用插件
Rollup自身的功能就只是ESM模块的合并打包,如果项目有更高级的需求,例如想要去加载其它类型的资源文件,或者是要在代码当中去导入CommonJS模块,又或是想要它去编译ECMAscript的新特性。这些额外的需求,Rollup同样支持使用插件的方式去扩展实现,而且插件是Rollup唯一的扩展方式,它不像webpack中划分了loader、 plugin、minimizer等三种扩展方式。
这里先尝试使用一个可以让在代码当中导入json文件的插件,通过这样一个过程去了解,如何在Rollup当中使用插件。这里使用的这个插件的名字叫做rollup-json-plugin。打开命令行终端,然后将rollup-plugin-json作为项目的开发依赖安装进来,安装完成后,打开配置文件,由于Rollup的配置文件可以直接使用ESM,所以这里直接使用import的方式去导入这个插件模块,这个插件模块它默认导出的是一个插件函数,可以将这个函数的调用结果添加到配置对象的plugin数组当中,需要注意的是,这里是将调用的结果放到数组当中,而不是直接将这个函数放进去。
rollup.config.js
import json from 'rollup-plugin-json'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'iife'
},
plugins: [
json() // 直接放入函数的返回结果
]
}
配好这个插件后,就可以在代码当中通过import的方式去导入json文件。回到index.js文件当中,这里尝试通过import导入项目根目录下的这个package.json文件,这个这份文件当中的每一个属性就会作为一个单独的导出成员,这里提取一下json当中的name和version,然后通过log函数把它们打印出来。
index.js
// 导入模块成员
import { log } from './logger'
import messages from './messages'
import { name, version } from '../package.json'
// 使用模块成员
const msg = messages.hi
log(msg)
log(name)
log(version)
完成以后回到命令行终端,再次运行rollup打包,打包完了后找到输出的bundle.js,此时能看到,json中的name和version正常被打包进来了,而json当中那些没有用到的属性也都会Tree-shaking移除掉。这就是在Rollup当中如何去使用插件。
打包结果bundle.js
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8v71lsE1-1610550317567)(https://i.loli.net/2021/01/10/9aUoySAqntiTmO3.png)]
5. Rollup 加载NPM模块
Rollup默认只能够按照文件路径方式去加载本地的文件模块,对于node_modules当中那些第三方的模块,它并不能够像webpack一样直接去通过模块的名称导入对应的模块。为了抹平这样一个差异,Rollup官方给出了一个rollup-plugin-node-resolve插件,通过使用这个插件,就可以在代码当中直接去使用模块名称导入对应的模块。
回到命令行,安装一下这个插件(yarn add rollup-plugin-node-resolve --dev),安装成功后打开rollup.config.js配置文件,同样需要先将这个插件导入进来,然后将这个插件函数的调用结果配置到plugins组当中。
import json from 'rollup-plugin-json'
import resolve from 'rollup-plugin-node-resolve'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'iife'
},
plugins: [ // 切记使用函数调用结果
json(),
resolve()
]
}
回到index.js代码当中,直接node_modules当中的第三方的PM模块,这里导入的是提前安装好的叫做lodash_es的模块,这个模块就是非常常见的lodash模块的ES版本。导入过后可以使用这个模块所提供的一些工具方法了。
index.js
// 导入模块成员
import _ from 'lodash-es'
import { log } from './logger'
import messages from './messages'
import { name, version } from '../package.json'
// 使用模块成员
const msg = messages.hi
log(msg)
log(name)
log(version)
log(_.camelCase('hello world'))
完成以后再次打开命令行终端,然后运行优化打包。此时lodash当中对应的代码就能够被打包到bundle.js当中,这里去使用的lodash ES版本,而不是使用lodash普通版本的原因是rollup默认只能够去处理ES模型模块,如果说需要去使用普通版本,需要做额外的处理。
6. Rollup 加载 CommonJS 模块
正如上面所说,Rollup设计的就是只处理ESM的模块打包,如果在代码当中去导入commonJS模块,默认是不支持的。但是目前还是会有大量的NPM模块使用CommonJS方式去导出成员,为了兼容这些模块,Rollup官方给出了一个插件(rollup-plugin-commonjs)。打开命令行安装这个插件,安装过后同样打开rollup.config.js配置文件,这里导入rollup-plugin-commonjs插件,然后把它配置到plugin数组当中。过后就可以回到index.js代码当中去直接导入commonJS模块。
yarn add rollup-plugin-commonjs --dev
rollup.config.js
import json from 'rollup-plugin-json'
import resolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'iife'
},
plugins: [
json(),
resolve(),
commonjs()
]
}
首先添加一个CommonJS模块的示例文件,新建一个叫做cjs.common.js的文件,通过export也就是commonJS的方式去导出了一个对象,有了这个模块过后,回到index.js当中,尝试通过import直接去导入这个commonJS模块,把它整体提取出来,common模块的导出会作为一个默认导出,这里把这个默认导出对象打印出来。
cjs-module.js
module.exports = {
foo: 'bar'
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lkgGxinA-1610550317568)(https://i.loli.net/2021/01/10/FokrGygc8f9UTAW.png)]
完成以后再次打开命令行终端,然后运行rollup打包。此时的这个commonJS模块也就可以被打包进bundle.js。这里你会发现导入的这个commonJS的默认导出,它以一个对象的形式出现在打包结果当中了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wOgRpEMP-1610550317569)(https://i.loli.net/2021/01/10/RDo5fpjXBwyzLSt.png)]
7. Rollup 代码拆分
在Rollup最新的版本中已经开始支持代码拆分了,同样可以使用符合ESM标准的动态导入的方式,去实现模块的按需加载,Rollup内部也会自动去处理代码的拆分,也就是分包。回到打包入口文件当中,这里一起来尝试一下,先注释掉这些使用静态导入的代码,然后使用动态导入的方式,去导入一下log对应的这个模块,那这里import的方法同样返回的是一个promise对象,在这个对象当中,它的then方法里面可以拿到一个模块导入过后的对象。因为模块导出的成员都会放在这个对象当中,所以这里就可以使用解构的方式去提取出来里面的那个方法。然后使用这个log方法去打印一个日志消息。
index.js
// // 导入模块成员
// import { log } from './logger'
// import messages from './messages'
// // 使用模块成员
// const msg = messages.hi
// log(msg)
import('./logger').then(({ log }) => {
log('code splitting~')
})
完了以后打开命令行终端尝试运行rollup二打包,不过直接去运行rollup打包,它会报出一个错误,说的是使用code-splitting,也就是代码拆分这种方式去打包,它要求的format(输出格式),不能是iife(自执行函数)这种形式。原因也很简单,因为自执行函数会把所有的模块都放到同一个函数当中,它并没有像webpack一样有引导代码,所以说它没有办法实现代码拆分。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mgJSjbtt-1610550317573)(https://i.loli.net/2021/01/10/Kv3OrCIsiP6cTBn.png)]
如果想使用代码拆分的,必须要使用AMD或者是commonjs等其它的标准,在浏览器环境当中只能使用AMD的标准,所以说这里需要使用AMD的格式去输出打包结果,这里再次运行这个打包命令,这一次通过–format这个参数去覆盖的配置文件当中的format设置,把它设置为AMD,再次运行打包。
yarn rollup --config --format amd
这里同样报出了一个错误,说的是code-splitting方式,它需要输出多个文件。因为需要输出多个文件,这里就不能再使用file的这种配置方式,因为file指定一个单个文件输出的文件的文件名,如果需要输出多个文件的话,可以使用dir的参数。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N2HUPYlw-1610550317574)(https://i.loli.net/2021/01/10/CxWS4y89ZPYBKun.png)]
回到配置文件当中,设置format以及dir参数。
rollup.config.js
export default {
input: 'src/index.js',
output: {
dir: 'dist',
format: 'amd'
}
}
完成之后回到命令行终端,再次运行rollup,打包完成过后,回到dist目录下,这里它就会根据刚刚的动态导入,生成一个入口的bundle以及动态导入所对应的那个bundle,它们都是采用AMD的标准去输出的。这就是在Rollup当中如何去实现代码拆分,它使用的是动态导入去实现的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pq84RKwT-1610550317575)(https://i.loli.net/2021/01/10/GAWzf6PeOi4ImaX.png)]
8. Rollup 多入口打包
Rollup同样支持多入口打包,而且对于不同入口当中公共的部分,Rollup也会自动提取到单个文件当中作为独立的版本。具体来看如何去配置,在这里的示例当中有两个入口分别是index和album,它们两个公用了fetch.js和log.js这两个模块。回到rollup.config.js配置文件当中,配置多入口打包的方式非常简单,只需要将input属性修改为一个数组就可以了
input: ['src/index.js', 'src/album.js'],
也可以使用与webpack当中相同的对象的配置形式去配置。
input: {
foo: 'src/index.js',
bar: 'src/album.js'
},
不过这里需要注意,因为多入口打包内部会自动提取公共模块,也就是说内部会使用代码拆分code-splitting,这里就不能再去使用iife(自执行函数),需要将输出格式修改为amd。完成后,打开命令行终端,然后运行rollup打包,打包过后,dist目录下就会多出三个文件,分别是两个不同打包入口的打包结果,与公共提取出来的一个公共模块。另外需要注意一点的是,对于amd这种输出格式的js文件,不能直接去引用到页面上,而必须要去通过实现amd标准的库(require.js)去加载。这里在dist目录下手动去创建一个index.html文件,然后尝试在这个html当中去使用打包生成的结果,这里采用的就是require.js个库来去加载以amd标准输出的版本,使用CDN地址,然后把它引入到页面当中,它可以通过data-main这样一个参数来去指定require加载的这个模块的入口模块路径,这里就是"data-main=‘foo.js’"。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- AMD 标准格式的输出 bundle 不能直接引用 -->
<!-- <script src="foo.js"></script> -->
<!-- 需要 Require.js 这样的库 -->
<script src="https://unpkg.com/requirejs@2.3.6/require.js" data-main="foo.js"></script>
</body>
</html>
完成后,再次回到命令行终端,然后server .当前目录运行到HTTP服务之上,然后打开浏览器,再打开开发人员工具,此时你就可以看到的打包结果正常的加载起来也正常的工作了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zyQeQGgO-1610550317576)(https://i.loli.net/2021/01/10/eWGoiFqp2wzNAlV.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Gvzbh3d3-1610550317577)(https://i.loli.net/2021/01/10/9miV1nbqsMBvOKC.png)]
9. Rollup 选用原则
通过以上探索和尝试,发现Rollup确实有它的优势:
- 输出结果更加扁平
- 首先就是它输出的结果会更加扁平一些,执行效率自然就会更高
- 自动移除未引用代码
- 其次就是它会自动移除,未引用的那些代码也就是Tree-shaking
- 打包结果依然完全可读
- 就是它的打包结果基本上跟手写的代码是一致的,也就是说打包结果对于开发者而言还是可以正常的阅读的
但是它的缺点同样也很明显:
- 加载非ESM的第三方模块比较复杂
- 模块最终都被打包到一个函数中,无法实现HMR
- 浏览器环境中,代码拆分功能依赖AMD库
综合以上的这些特点,发现如果是正在开发一个应用程序,肯定会面临要去大量引入第三方模块这样的需求,同时又需要HMR这样的功能去提升的开发体验,而且应用一旦大了过后,还涉及到必须要去分包,这些需求,Rollup在满足上都会有一些欠缺。而如果正在开发的是一个JavaScript的框架或者是一个类库,这些优点就特别有必要,缺点几乎都可以忽略。就拿加载第三方模块来说,在开发类库的时候,在代码当中会很少依赖一些第三方的模块,所以说很多像React或者Vue之类的一些框架中,都是使用Rollup作为模块打包器,而并非是webpack。
但是到目前为止,开源社区中,大多数人还是希望这两个工具可以共同存在共同发展,并且能够相互支持和借鉴,原因也很简单,就是希望能够让更专业的工具去做更专业的事情。总结一下就是webpack的感觉是大而全而Rollup是小而美,在对它们两者之间的选择上,基本的原则就是如果正在开发应用程序,建议大家使用webpack。如果说你是正在开发类库或者是开发框架的话,建议选择Rollup。不过这也并不是一个绝对的标准,只是一个经验法则,因为Rollup它同样也可以去构建绝大多数的应用程序,webpack也同样可以去构建内库或者框架,只不过相对来讲,术业有专攻。另外一点就是随着近几年webpack的发展,Rollup中的很多优势几乎已经被抹平了,例如像这个Rollup当中的这种扁平化输出,在webpack当中就可以使用concatenateModules的这样一个插件去完成,也可以实现类似的这样一个输出。
10. Parcel
Parcel是一款完全零配置的前端打包器,它提供了近乎傻瓜式的使用体验,只需要去了解它所提供的几个简单的命令,就可以直接使用它去构建前端应用程序了。
下面直接去看具体如何去使用Parcel,首先新建一个空项目,先通过yarn init的方式去初始化一个项目中的package.json文件,完成后就可以去安装parcel所对应的模块,不过这里注意,parcel的NPM模块叫做parcel-bundler,这里同样将它安装到项目的开发依赖当中。安装完成后,parcel-bundler模块它在node_modules当中的bin目录里面,就提供了parcel的cli程序。后续就可以使用这个cli去执行对整个应用的打包,既然是打包应用代码,这里就得先有代码。
回到项目的根目录下去新建一个src目录,用于存放开发阶段所编写的源代码,然后同时去创建一个index.html文件,待会html是parcel打包的入口文件。Parcel跟webpack一样,都支持以任意类型的文件作为打包入口,不过Parcel的官方建议使用HTML文件作为打包入口。官方所给出的理由是因为HTML是应用运行在浏览器端时的入口,所以应该使用HTML文件作为打包入口,在这个入口文件当中可以正常像平时一样去编写,也可以在这里正常的去引用一些资源文件,在这里被引用的资源最终都会被打包到一起,最终输出到输出目录。
这里先来引入一个main.js的脚本文件,然后紧接着就来去新建一个对应的main.js文件,除此以外再去额外新建一个foo.js文件,然后在这个当中以ESM的方式去默认导出一个对象,而且在这个对象当中去订一个bar方法。然后回到main.js当中,这里通过import导入foo模块,并且使用它倒对象中的bar方法。Parcel同样支持对ESM模块的打包,完成后再次回到命令行终端,使用yarn parcel去运行一下node_modules的parcel命令,这个命令它需要传入打包入口文件的路径,在这里就是src下的index.html文件。此时执行这个命令,parcel就会根据所传入的参数,先去找到index.html文件,然后根据index.js当中script标签去找到它所引入的这个main.js文件,再顺着import语句,找到所对应的foo模块,从而去完成整体项目的打包。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jUxuk10S-1610550317579)(https://i.loli.net/2021/01/10/teks9nj34I5L1HY.png)]
命令行执行后发现,parcel不仅仅打包了应用,而且它同时还开启了一个开发服务器,这个开发服务器就跟webpack当中的webpack-dev-server一样,打开这个地址,启动浏览器,然后在浏览器当中打开开发人员工具,此时就可以使用自动刷新这样的功能。这里可以先去尝试修改一下源代码当中的console.log()里面的内容,然后保存过后就会发现屏幕右侧的浏览器会自动刷新一下,从而去执行最新的打包结果。
如果说需要的是HMR模块热替换的体验,parcel当中同样也支持,回到main.js当中,这里同样需要去使用HMR所提供的API,先去判断module.hot对象是否存在,如果存在这个对象就证明当前这个环境可以使用HMR的API。然后就可以使用module.hot.accept方法去处理模块热替换的逻辑了,不过这里的accept跟webpack所提供的API有一点不太一样。webpack中的API支持接收两个参数,用来去处理指定模块更新过后的逻辑,而parcel这里所提供的accept,它只可以接受一个参数,也就是这个回调函数,作用就是当这个模块更新或者是当前模块依赖的模块更新过后,它会自动执行。可以尝试先在这里打印一下HMR。然后main.js当中,尝试再次修改源代码,此时就可以看到,代码就会自动被热替换到浏览器当中自动执行了。
main.js
import foo from './foo'
foo.bar()
if (module.hot) {
module.hot.accept(() => {
console.log('hmr')
})
}
除了热替换,parcel还支持一个非常友好的功能,就是自动安装依赖。试想一下你正在开发一个应用的过程中,突然间你想要去使用某个第三方的模块,你此时就需要去先停止正在运行的dev-server,然后去安装这个模块,安装完成过后再去重新启动dev-server。有了自动安装依赖这样的功能过后,就不必要再这样麻烦了。
回到main.js当中,假设这里想要去使用一下jQuery,虽然之前并没有安装jQuery模块,但是因为有了自动安装依赖这样的功能的缘故,这里只管正常导入就可以了,导入完成过后正常去使用一下jQuery所提供的API。在文件保存过后,它会自动去安装刚刚所导入的这个模块,极大程度的避免了额外的一些手动操作。
import $ from 'jquery'
import foo from './foo'
foo.bar()
$(document.body).append('<h1>Hello Parcel</h1>')
if (module.hot) {
module.hot.accept(() => {
console.log('hmr')
})
}
除此之外,它同样支持加载其他类型的资源模块,而且相比于其他的模块打包器,在parcel当中去加载任意类型的资源模块,同样还是零配置的。例如这里再来添加一个style.css样式文件,然后在这个文件当中去添加一些简单的样式。完成过后,回到main.js当中,通过import导入这个样式文件,这个样式就可以立即生效了。整个过程并没有去安装额外的插件、loader。还可以随意去添加一个图片到项目当中,然后回到代码当中去导入这张图片,最后再通过jQuery将它显示到页面之中,你会发现同样也是可以正常显示出来的,而且整个过程并没有停下来做额外的事情。总之,Parcel希望给开发者的体验就是你想要做什么,你就只管去做,额外的事情就由工具去负责处理。
import $ from 'jquery'
import foo from './foo'
import './style.css'
import logo from './zce.png'
foo.bar()
$(document.body).append('<h1>Hello Parcel</h1>')
$(document.body).append(`<img src="${logo}" />`)
if (module.hot) {
module.hot.accept(() => {
console.log('hmr')
})
}
另外parcel同样支持使用动态导入,内部如果使用了动态导入的话,它也会自动拆分代码。尝试一下,这里先将静态导入的jQuery注释,然后使用动态导入的方式去导入这块的模块。在这个import方法所返回的promise对象的then当中,就可以拿到所导入的jQuery对象了。然后把使用jQuery的代码移到then方法里面,保存过后,回到浏览器,刷新一下页面,然后再找到network面板当中,就能够看到刚刚所拆分出来的jquery所对应的bundle文件请求。
// import $ from 'jquery'
import foo from './foo'
import './style.css'
import logo from './zce.png'
foo.bar()
import('jquery').then($ => {
$(document.body).append('<h1>Hello Parcel</h1>')
$(document.body).append(`<img src="${logo}" />`)
})
if (module.hot) {
module.hot.accept(() => {
console.log('hmr')
})
}
以上基本上就是Parcel当中最常用的一些特性了,在使用上parcel几乎没有任何的难度,从头到尾只是执行了一个parcel命令,所有的事情都是parcel内部自动完成的。
回到命令行结束parcel命令,然后看一下parcel如何以生产模式运行打包。需要去执行parcel-cli所提供的一个build命令,然后跟上打包入口文件的路径,就可以以生产模式运行打包了。
yarn parcel build src/index.html
这里额外补充一点,对于相同体量的项目打包,parcel构建速度会比webpack快很多,因为在parcel的内部使用的是多进程同时去工作,充分发挥了多核CPU的性能。webpack也可以使用一个叫做happypack的一个插件来去实现这一点。看一眼输出的结果,这里所说出的这些文件都会被压缩,而且样式代码也都单独提取到单个文件当中了。这就是parcel的一个体验,整体下来就是一个感觉——舒服,因为它在使用上真的太简单了,试想一下之前用的webpack,需要做很多额外的配置,安装很多的插件。在parcel当中其实也有这样的一些插件,只不过它是自动安装的,我们整体是不需要去关心这些东西的,所以它在使用上就给了一种非常舒服的感觉。
Parcel2017年发布的,出现的原因也就是因为当时webpack在使用上过于繁琐,而且官方的文档也不是很清晰明了,所以说parcel它一经推出,就迅速被推上了风口浪尖,其核心特点就是真正意义上做到了完全零配置,对项目没有任何的侵入,而且整个过程有自动安装依赖的这样一个体验,让开发过程可以更加专注于编码。除此之外,还有一个就是parcel一开始提供的这种构建速度就非常快,因为它内部使用了多进程同时工作,所以相比于webpack的打包速度,parcel要更快一些。但是这个刚刚也说了,webpack也可以借助于插件去解决这样的问题。
parcel的优点固然很明显,但是目前实际上你去观察使用情况,你会发现,绝大多数的项目打包还是会选择使用webpack,个人认为原因可能有两点:
- 第一点就是webpack它的生态会更好一些,扩展更丰富,而且出现问题也很容易去解决;
- 第二点就是随着这两年的发展,webpack也越来越好用。开发者随着不断的去使用也越来越熟悉,所以说个人选择的话,可能也会选择webpack,parcel这样的工具对于开发者而言,去了解它其实也就是为了保持对新鲜技术和工具的敏感度,从而更好的把握技术的趋势和走向。
五、规范化标准
1. 规范化介绍
规范化:规范化是践行前端工程化过程中的重要的组成部分,在这里会通过以下几个方面来进行介绍和说明。
- 为什么要有规范标准
- 软件开发需要多人协同
- 不同开发者具有不同的编码习惯和喜好
- 不同的喜好增加项目维护成本
- 每个项目或者团队需要明确统一的标准
- 哪里需要规范化标准
- 代码、文档、甚至提交日志
- 开发过程中认为编写的成果物
- 代码标准化规范最为重要
- 实施规范化的方法
- 编码前认为的标准约定
- 通过工具实现ESLint
- 常见的规范化实现方式
- ESLint 工具使用
- 定制 ESLint 校验规则
- ESLint 对 TypeScript的支持
- ESLint 结合自动化工具或者 webpack
- 基于 ESLint 的衍生工具
- Stylelint 工具的使用
具体有哪些方法可以去完成这些规范化的操作,最初在落实规范化的操作时,其实非常的简单,只需要提前约定好一个可以执行的标准,然后按照这个标准进行各自的开发工作,最终在code review环节,就会按照之前约定的标准去进行检查相应的代码,但是如果单靠人为约束的方式落实规范化会有很多的问题。首先,人类约束不可靠,其次开发者很难记住每个规则。所以就需要有专门的工具加以保障,相比于人类检查工具的检查更为严谨更为可靠。同时还可以去配合自动化的工具实现自动化检查,这样的规范化就更加容易得到质量上的保证。
一般把通过工具去找到项目中不合规范的地方这样的一个过程称之为Lint。之所以称之为叫Lint的原因,是因为在刚有c语言的时候有一些常见的代码问题是不能被编译器捕获到的,所以有人就开发了一个叫做Lint工具用于在编译之前检查出这些问题,避免编译之后,带来一些不必要的问题,所以后续这种类似功能的工具就都可以称之为Lint,或者说Linter。例如现在前端最常见的也是ESLint、Stylelint等。
2. ESLint 介绍
- 最为主流的JavaScript Lint工具 检测JavaScript代码质量
- ESLint 很容易统一开发者的编码风格
- ESLint 可以帮助开发者提升编码能力
这里看一下关于ESLint的基本介绍,之前已经知道当下采用工具去完成项目代码校验工作是更加高效和合理的。在这里使用的是ESLint,它是目前最为主流的JavaScript Lint工具,专门用于监测JavaScript代码的质量。通过ESLint就可以很容易的去统一不通开发者的编码风格。例如缩进,换行、分号以及空格之类的使用。不仅如此,ESLint还可以找出代码中不合理的地方,比如定义了从未使用的变量,或者一个变量使用之后才去对它进行声明,再或者说去进行比较的时候,往往总是选择"=="的符号等,以及其他的一些不合理的操作。这些不合理的操作一般就是代码中些潜在问题,通过ESLint就能够去有效的避免这些问题,从而提高代码的质量。
另一方面,个人认为ESLint也可以去帮助开发者提升编码能力,为什么这么说。试想一下,如果你编写的代码每次在执行Lint操作的时候都能够去找出一堆的问题,而这些问题,大都是以往编码时候的坏习惯,慢慢的就应该记住了这些问题,正常来说,当下次再次遇到的时候,你自己就会去主动的避免他们,那么久而久之,你的编码能力自然而然的也就得到了一个隐性的提升。
总结一下,就是想去表达:无论出于提升项目代码质量的原因,还是说要去提高自身编码水平的原因,ESLint都有很大的价值。接下来,会去通过一些尝试,具体的去体会ESLint的这些优势,顺便去掌握这一类Lint工具的使用规律,以便后面去接触到其他Lint的工具的时候,可以去做到触类旁通,最后做到以不变应万变,这一块就是关于ESLint简单的介绍。
3. ESLint 安装
这里来看一下使用ESLint之前的一些准备工作,其实,就是ESLint的安装和校验。动手实操之前,先快速的梳理一下具体的操作步骤,首先使用ESLint就是为了校验项目的代码,因此,需要先有一个项目,在这个项目中该如何使用ESLint,它其实就是一款基于node.js开发的NPM模块,所以想要使用ESLint也就需要先通过NPM或者yarn来安装这一模块。最后完成安装之后,就可以通过简单的命令来校验安装操作是否成功。
这里打开了一个空的示例项目,里面没有任何的文件,先打开命令行终端,然后通过npm init --yes来初始化项目的package.json文件,用于管理项目的npm依赖。有了package.json之后,就可以安装ESLint模块。在这里,使用的是npm工具安装,就是npm install eslint --save-dev,把ESLint作为项目的开发依赖安装到项目本地。
npm init --yes
npm install eslint --save-dev
额外补充一个小的话题,就现阶段来说,已经很少需要去全局安装某个模块,因为大多数情况下都是具体项目依赖某个模块。把模块安装到项目本地,让它跟着项目一起管理会更加的合理,而且别人在拿到你的项目过后,不需要单独的去关心这个项目依赖了哪些全局模块,直接通过npm install就可以安装必要的工具模块,这也就从侧面提高项目的可维护性。
由于ESLint模块提供了一个cli程序,所以安装完成过后,在项目的node_modules的.bin目录下,就会多出一个ESLint的可执行文件,后续就可以直接通过这个cli程序去检测产生的问题。这里回到命令行终端,通过路径找到这个ESLint可执行文件,然后添加一个–version参数,表示查看当前所安装的ESLint版本。当然通过前面的介绍,你应该了解过,对于node_modules下的.bin目录里的可执行文件来说,可以去通过npx或者说yarn命令来找到之后快速的去执行它们,不必使用完整的路径去进行访问。如果你使用的是yarn,你就可以直接去执行yarn ESLint,这样的话yarn会自动的找到bin目录下的ESLint可执行文件。这里使用的是npm工具,所以这里使用的是npx,而不是npm,npm是npm最新版本当中集成的一个工具,也就是说你只需要安装最新版本的npm工具,那么就可以直接去使用npx命令,那这里同样跟上一个–version参数。
npx eslint --version
# yarn eslint --version
在这里,最后再补充一句,不需要去纠结到底该选npm还是使yarn,他们两者之间没有绝对的好坏之分,各有各的优势,你就按照你所在团队或者说项目的具体要求,使用其中的任何一款即可。
4. ESLint 快速上手
这里来看一下ESLint快速上手的相关内容,当可以执行ESLint模块的安装操作之后,就在通过一个案例来具体的看一下,ESLint在项目代码检查方面的具体表现。首先,还是先快速的对后续的操作做一个步骤上的说明:
-
**编写“问题代码”。**在最开始的时候需要先去新建一个项目,并且完成相应的初始化操作,同时也安装好对应的ESLint模块;
-
**使用ESLint执行检测。**然后在这个项目当中去新建一个文件,同时,在这个js文件中编写一些所谓的问题代码;
-
**完成eslint使用配置。**在这之后,就可以去执行相关的命令来进行代码的检查,但是,在第1次使用的操作之前,必须要先完成相应的配置,然后才能去进行正常的使用,而这些配置接下来会具体的讲解
项目说明:在这之前,已经完成了NPM相关的初始化操作,同时在项目的目录当中新建了一个文件叫做01-prepare.js。紧接着在这个文件当中去编写一些简单的代码。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F1GUgWPW-1610550317580)(https://i.loli.net/2021/01/10/UtJEQ5KO2kLs3Hl.png)]
接下来就先回到命令行终端这里,通过npx eslint ./01-prepare.js去执行(参数可以是路径通配符,因为这样就可以去实现批量的检查)。回车过后在终端当中却打印出了一串错误的信息,大体意思就是没有找到一个配置文件,同时它也给出了一个解决的办法,就是去执行eslint --init,然后再去初始化一个eslint配置文件。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W3yOgI9j-1610550317582)(https://i.loli.net/2021/01/10/ZAiCHPaJgpLKN8s.png)]
这里执行一下npx eslint --init,eslint会给出一些交互性的问题,如下:
? How would you like to use ESLint? ...
To check syntax only # 只检查语法
> To check syntax and find problems # 检查语法和问题
To check syntax, find problems, and enforce code style # 检查语法、问题以及代码风格
? What type of modules does your project use? ... # 项目中使用哪种模块化类型
> JavaScript modules (import/export) # ESM
CommonJS (require/exports) # CommonJS
None of these
? Which framework does your project use? ... # 项目使用哪种框架
> React
Vue.js
None of these
? Does your project use TypeScript? » No / Yes # 项目是否使用了TypeScript
? Where does your code run? ... (Press <space> to select, <a> to toggle all, <i> to invert selection) # 项目运行在哪里
√ Browser
√ Node
? How would you like to define a style for your project? ... # 使用哪种代码风格
> Use a popular style guide # 目前最受欢迎的
Answer questions about your style # 通过回答问题自定义风格
Inspect your JavaScript file(s) # 检查js文件
? Which style guide do you want to follow? ... # 选择哪种最受欢迎的代码风格
> Airbnb: https://github.com/airbnb/javascript
Standard: https://github.com/standard/standard # 标准
Google: https://github.com/google/eslint-config-google
? What format do you want your config file to be in? ... # 以什么格式定义配置文件
> JavaScript
YAML
JSON
Checking peerDependencies of eslint-config-standard@latest
The config that you've selected requires the following dependencies:
eslint-config-standard@latest eslint@^7.12.1 eslint-plugin-import@^2.22.1 eslint-plugin-node@^11.1.0 eslint-plugin-promise@^4.2.1
? Would you like to install them now with npm? » No / Yes # 是否安装风格化代码依赖项
最终这里选择的答案如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oxY0WB1c-1610550317584)(https://i.loli.net/2021/01/10/VEPuDY29rk5GRtX.png)]
一切OK过后项目的根目录下就会多出一个.eslintrc.js的配置文件,有了这个配置文件过后,再次来执行命令npx eslint 01-prepare去校验文件。根据这次的执行结果,它首先检查到的就是一个语法错误,回到代码当中,先修正这个错误,它是由foo函数错误调用引起的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1MIBjpX0-1610550317585)(https://i.loli.net/2021/01/10/GITfKZX5Yvc93Fn.png)]
再次校验,就看到了更多的错误。可能你会好奇,为什么刚刚没有找出这些错误?其实原因非常简单,因为刚才的代码当中存在着语法错误,eslint是没有办法去检查问题代码和代码风格。这个时候,就可以自己根据提示找到具体的问题代码,然后去进行解决。也可以去通过**–fix**参数来自动的修正绝大多数代码风格上的问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pkp8an9a-1610550317587)(https://i.loli.net/2021/01/10/UaNGuq9oO52cLwd.png)]
在这里,就先通过npx eslint 01-prepare --fix来完成一次修正,当再次去执行的时候,问题的数量一下就少了很多,那些风格上的问题就都已经被自动修正了,非常的方便。不过如果你自己还没有养成良好的代码习惯,我建议在开始的时候还是手动的去修改每一个不好的地方,因为这样就可以去加深的印象,作为一个优秀的开发人员,写出了代码,它本身就应该是格式良好的,而不是后来的去依赖这些工具进行格式化。这些工具,它只是在最后用于确保代码的质量。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uFOmiKak-1610550317588)(https://i.loli.net/2021/01/10/hjVqoY1FB5M6Ox9.png)]
在最后,会看到还有几个没有被修复的问题,需要回到代码当中,自己手动的去进行处理。移除未引用的foo变量,移除未定义的函数。再次保存,然后回到那个命令行,再次去运行检查,此时代码本身的问题就全部解决了。
以上这些,就是eslint的基本作用,简单的总结一下其实就是两点:
- 第一可以去找出代码当中的问题,问题包括语法错误、代码不合理、风格不统一;
- 第二可以去自动修复代码风格上的绝大多数的问题
5.ESLint 配置文件解析
这里深入了解一下ESLint配置文件,之前通过npx eslint --init在项目根目录下创建了一个eslintrc.js配置文件。在这个文件当中写入的配置就会去影响当前目录以及所有子目录文件。正常情况下是不会手动的修改配置,但说如果需要去开启或者关闭某些校验规则的时候,这个配置文件就会非常重要。
下面回到项目当中,具体看一下里面的配置内容。先简单的梳理一下,这里创建02-configuration.js文件用于编写示例代码。然后演示配置文件修改之后的运行结果。同时也提前做好了eslint的初始化,生成配置文件。打开配置文件内容如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2vZKmOA6-1610550317590)(https://i.loli.net/2021/01/11/pnJMq2cwSBvXZjk.png)]
因为这个配置文件最终也是运行在node.js环境当中,可以看到这里以CommonJS的方式导出了一个对象。在这个项目当中,目前是有4个配置选项。
-
env
JavaScript在不同的运行环境中是否不同的API可以被调用,这些API很多时候都是以全局成员的方式去提供出来。例如在浏览器环境中可以直接去使用window和document对象,而在node.js中不存在这些对象。env选项的作用就是标记当前代码最终的运行环境。ESLint根据env判断一些全局成员是否可用,从而从而避免代码中使用到那些不存在的成员。
例如browser:“true”,代表代码运行在浏览器环境中,意味着可以直接在代码当中使用document或window这样的全局对象。换个角度,这里的每一组环境对应的全局变量,一旦开启某个环境,这个环境中的所有的全局成员都可以被使用。
修改env中的browser为false,在02-configuration.js中使用document.getElementById("#abc"),运行npx eslint ./02-configuration.js,发现并没有报document未定义的错误。那是因为在项目初始化的时候,使用了standard风格,最终配置继承了standard配置,而在standard中做了一些具体的配置,所以这时候document和window在任何环节中都可以运行。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4EUqwQ9j-1610550317593)(https://i.loli.net/2021/01/11/qBjhyfN8Lkb7vmc.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KT3HjQH0-1610550317594)(https://i.loli.net/2021/01/11/7ucT4SehUf5AiFm.png)]
换一个browser中的全局成员alert使用,运行后发现提示alert未定义。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3b9VoVud-1610550317595)(https://i.loli.net/2021/01/11/qvl4LX93c1EwAZO.png)]
env选项的作用确实是根据环境来判断全局成员是否可用。env具体可用的环境如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-veDgt3Io-1610550317596)(https://i.loli.net/2021/01/11/Qwjrg3sbq5CefEH.png)]
这张表里面给出了目前所有能用到的环境以及所对应的说明,不过需要注意的是这些环境,它们并不是互斥的,也就是说你可以同时开启多个不同环境。
-
extends
继承一些共享配置,例如这里使用的为standard,这就是设计中常见的配置。很多时候在多个项目之间共享eslint配置,可以定制一个公共的配置文件或模块,然后在这里继承。该属性值为数组,也就是说可以同时继承多个共享配置。
-
parserOptions
这个选项的作用就是用来设置语法解析器的相关配置。ESMAScript近几年发布了很多新的语法,如let const这些关键字,这个配置的主要作用就是控制是否允许使用某个ES版本的语法
-
rules
配置eslint校验规则的开启或者关闭。例如开启no_alert,其属性值可以为off、warn、error。
6.ESLint 配置注释
这里看一下ESLint配置注释相关内容,简单说一下配置注释,其实就可以理解为是将配置直接通过注释的方式写在脚本文件当中,然后再去执行代码的校验。那为什么要这么做的原因也很简单,在实际的开发过程中如果使用eslint,难免就会遇到一两个必须要违反配置规则的情况,这种情况下肯定不能因为这一两个点就去推翻校验规则的配置。所以在这个时候,就可以去使用eslint的配置去解决这个问题。
例如在这里去定义一个普通的字符串,但是在这个基础上当中,因为业务的需求需要去使用一个${}占位符,但是所使用的standard风格不允许这样去使用。可以直接先回到命令行来运行的eslint,发现它报了这样一个错误出来,
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QhOvsVwa-1610550317597)(https://i.loli.net/2021/01/11/TL9l2Pa6iSBjJuH.png)]
这种时候可以去通过注释的方式。去临时禁用一下当前的规则,这种注释的语法有很多种,具体可以去参考官方给出了一个文档。在这里,就可以直接去使用eslint-display-line, 这样在工作的时候就会选择性的去忽略这一行代码,再次回到命令行的终端运行eslint,这个时候就不会再有错误出现了。
这样去使用虽然能够解决所面临的问题,但是同样也会带来一个新问题,这个是一行中如果说有多个问题存在的时候,所有问题就都不会被检测了,因此更好的做法就应该是在注释的后边再去跟上一个具体要禁用的规则名称,可以在这里面去把需要禁用的规则给它写上,这里需要禁用的就是"no-template-curly-in-string", 这个时候eslint在工作的时候就会忽略掉当前制定的这个规则,而其他的问题,仍然是可以被正常的发现。
当然注释的方式不仅可以去禁用某个规则,还能够去声明全局变量修改某个规则的配置,临时开启某个环境等。这些功能,如果有需要的话,可以访问地址:
http://eslint.cn/docs/user-guide/configuring#using-configuration-comments
7.ESLint 结合自动化工具
这里看一下,eslint结合自动化工具的使用。eslint本身是一个独立的工具,但如果现在是在一个有自动化构建工作流的项目中,还是建议去把eslint集成到自动化构建的工作流当中。这样去做有两个优点,首先肯定是需要执行项目构建的,而把eslint集成在构建的过程当中,就可以去确保它一定会去工作。其次整合在一起去管理也会更加的方便,与项目相关的命令也会更加的统一。不需要一会执行gulp,一会去执行eslint。
提前准备一个gulp构建任务的项目,项目目录如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gGIyvqda-1610550317599)(https://i.loli.net/2021/01/11/wCItkGda2pZ3FrA.png)]
首先安装依赖,在执行之前,通过插件先去检查代码。简单的说一下,由于这里使用的是gulp-load-plugins自动加载插件的,所以就不需要再手动载入的模块,可以直接找到这个插件去进行使用。这里找到script构建任务来,具体的去完成一下这个操作。当前的这个任务就会执行代码的eslint的操作,然后再去执行,同时导出script任务,就直接去执行这个任务。完成之后再次回到命令行终端,然后使用npx script找到eslint命令执行任务。发现这里报出了一个错误,分析一下可以发现它说的就是没有找到eslint的配置文件,这就跟最开始的时候直接使用遇到的问题是一样的,那么就应该先去创建一个配置文件。快速初始化eslint,重新的去运行任务,这次可以正常的执行。
完成这些以后,接下来就可以去找到项目当中的main.js入口文件,在文件中加入一些明显的问题代码,例如:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rM0oArnZ-1610550317601)(https://i.loli.net/2021/01/11/MRl742UqxCwchY6.png)]
再次回到命令行终端,重新运行任务,你会发现这个任务仍然是可以成功执行的,这就显得有些不合理了,因为在最初的设想就是当eslint发现问题过后,就可以直接的去体现出来,同时,也能够去终止后续的编译任务,这里为什么可以依然去成功的执行?其实这个问题的原因也很简单,eslint只会去检查代码当中的问题,他并不会去根据检查的结果来作出任何的反馈。
所以正确的做法就应该是在eslint插件处理完成之后,先去使用format方法,然后在动态当中去打印出具体错误信息,之后再去使用eslint当中的failAfterError方法,错误之后可以直接去终止任务管道。这里使用相应的方法来完成这样的两个操作步骤,完成之后,再次回到命令行终端,重新的去运行script个任务,这个时候在控台当中就说出了一些错误信息,这时候就算是集成到编译js的任务当中了,也就是融入到了所设计的工作流当中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2ddc9mtf-1610550317603)(https://i.loli.net/2021/01/11/xIzdapRNEmJCK2Y.png)]
之后根据报错信息手动修改问题代码。
8.ESLint 结合 Webpack
如果你现在正在开发是一个使用webpack打包的项目,ESLint也同样可以集成进去,只不过webpack集成ESlint并不是以插件(plugins)的方式完成,而是通过loader机制。webpack在打包之前会将遇到的模块交给对应的loader进行处理,所以ESLint就可以通过loader的形式集成到webpack中。这样就可以实现在打包JavaScript代码之前,先通过eslint校验JavaScript代码。
前置工作:
-
克隆一下地址项目
git clone https://github.com/zce/zce-react-app.git
-
安装对应模块
yarn
-
安装eslint模块及eslint-loader
yarn add eslint eslint-loader --dev
-
初始化.eslintrc.js配置文件
yarn eslint --init
webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'production',
entry: './src/main.js',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
'babel-loader',
// 'eslint-loader' // 首先执行
]
},
{
test: /\.js$/,
exclude: /node_modules/,
use: 'eslint-loader',
enforce: 'pre' // 该条loader优先执行
},
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html'
})
]
}
如果想使用eslint校验代码,需要在babel-loader之前使用eslint处理(注意:loader的使用顺序是从数组中的最后一位开始),或者使用常见的配置方法,对js新增一个loader配置,添加属性enforce,将其值赋为pre,代表该条配置执行顺序优先于其他loader。
最后命令行执行yarn webpack后发现控制台报出很多错误,这也就意味着eslint-loader已经开始生效了。关于Webpack后续配置在下面讲到。
9.ESLint 结合 Webpack 后续配置
这里看一下eslint结合到webpack的后续配置相关内容,接续上一节内容。
前置工作中初始化好的.eslintrc.js
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"standard"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
}
};
在命令行运行yarn webpack后发现,eslint报出了很多错误,其原因是react框架特殊的语法,其从jsx编译为js代码,其中react定义却未被使用。eslint官方社区提供了一个插件用于解决此问题,安装eslint-plugin-react。
yarn add eslint-plugin-react --dev
在.eslintrc.js中配置插件
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"standard"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
'react/jsx-uses-react': 2, // rules中使用数字2代替前面用到的error,二者效果相同
'react/jsx-uses-vars': 2
},
plugins: [
'react' // 使用安装的eslint-plugin-react插件
]
};
修改过后,再次运行命令yarn webpack,可以发现已经没有任何报错了。这就是eslint-plugin-react的作用以及基本使用,不过对于大多数的eslint插件来说,一般都会提供一个共享的配置,从而降低使用的成本。这里使用的eslint-plugin-react中就导出了两个共享的配置,分别是:recommended和all。上面需要使用的就是recommended,插件提供共享配置,可以通过继承extends进行使用。继承的语法规则是:‘plugin:[要继承的插件名称]/[具体的配置名字]’,具体修改如下:
.eslintrc.js
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"standard",
"plugin:react/recommended"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
// 'react/jsx-uses-react': 2, // rules中使用数字2代替前面用到的error,二者效果相同
// 'react/jsx-uses-vars': 2
},
// plugins: [
// 'react' // 使用安装的eslint-plugin-react插件
// ]
};
再次进入命令行运行webpack打包工具,发现使用该共享配置于单独配置效果是一样的,不过第二种共享配置更加的方便。
10.现代化项目集成 ESLint
这里看一下现代化项目对ESLint支持,随着react、vue等框架的逐渐普及,这些框架的周边生态也都相当完善了,最明显的感觉就是现阶段再开发一个react或者vue.js的项目,基本上不需要自己配置webpack或者eslint等这些工程化的工具了,而在官方的cli程序中直接集成了这些工具。这里使用vue-cli创建vue项目作为演示。
前置工作:
-
准备一个空项目
-
安装vue
yarn global add @vue/cli
-
通过vue命令创建vue.js项目
vue create 5coder-vue-app
交互方式询问一些特性:
? Please pick a preset: Default ([Vue 2] babel, eslint) Default (Vue 3 Preview) ([Vue 3] babel, eslint) > Manually select features # 手动选择 ? Check the features needed for your project: ( ) Choose Vue version >(*) Babel # 选择babel运行环境 ( ) TypeScript ( ) Progressive Web App (PWA) Support ( ) Router ( ) Vuex ( ) CSS Pre-processors (*) Linter / Formatter ( ) Unit Testing ( ) E2E Testing ? Pick a linter / formatter config: ESLint with error prevention only ESLint + Airbnb config > ESLint + Standard config # 代码风格选择standard于eslint配合 ESLint + Prettier ? Pick additional lint features: ( ) Lint on save # webpack构建时自动校验 >(*) Lint and fix on commit # 利用git钩子,在git commit之前自动校验代码,确保提交到仓库中的代码是被校验过的 ? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys) > In dedicated config files # 独立的配置文件 In package.json ? Save this as a preset for future projects? (y/N) n # 是否在后续的项目保存该配置
根据提示运行当前项目:
cd 5coder-vue-app npm run serve
同时也可以在编译器中修改代码示例,通过这种方式就不需要在工具的配置和使用上花费太多的时间,开发者可以更加专注于业务功能的开发。
11.ESLint 检查 TypeScript
现阶段前端项目中使用TypeScript开发的情况越来越多,所以这里看一下eslint如何校验TypeScript代码的。对于TypeScript代码的lint来说,以前使用tslint工具,但是后面tslint官方放弃维护,推荐使用eslint配合TypeScript插件进行代码校验。
初始化eslint配置文件,本次初始化时需要注意项目中需要使用到TypeScript(√ Does your project use TypeScript? · No / Yes)
yarn eslint --init
index.ts
function foo(ms: string): void{
console.log(msg);
}
foo('hello typescript~')
.eslintrc.js
module.exports = {
env: {
browser: true,
es2021: true
},
extends: [
'standard'
],
parser: '@typescript-eslint/parser', // 指定语法解析器TypeScript
parserOptions: {
ecmaVersion: 11,
},
plugins: [
'@typescript-eslint'
],
rules: {
}
}
命令行执行yarn eslint index.ts进行代码校验,结果如下:
=============
WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.
You may find that it works just fine, or you may not.
SUPPORTED TYPESCRIPT VERSIONS: >=3.3.1 <4.1.0
YOUR TYPESCRIPT VERSION: 4.1.3
Please only submit bug reports when using the officially supported version.
=============
E:\2021\lagou\02-02-study-materials\codes\02-02-04-01-eslint\eslint-typescript\index.ts
1:13 error Missing space before function parentheses space-before-function-paren
1:31 error Missing space before opening brace space-before-blocks
2:15 error 'msg' is not defined no-undef
2:19 error Extra semicolon semi
2:20 error Block must not be padded by blank lines padded-blocks
6:25 error Newline required at end of file but not found eol-last
✖ 6 problems (6 errors, 0 warnings)
5 errors and 0 warnings potentially fixable with the `--fix` option.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
12.Stylelint 认识
众所周知,前端项目中出了JavaScript代码需要被lint之外,css代码也同样需要被lint。对于css代码的lint操作,一般会使用stylelint的工具进行完成,Stylelint的使用与ESLint基本一致。
Stylelint使用介绍
-
提供默认的代码检查规则
可以在配置文件中选择性的开启或者关闭某一些规则
-
提供CLI工具,快速调用
-
通过插件支持Sass Less PostCSS
-
支持Gulp或Webpack集成
关于css代码的检查
安装stylelint,“yarn add stylelint -D”,在命令行使用如下命令,发现提示没有配置文件(Error: No configuration provided for E:\2021\lagou\02-02-study-materials\codes\02-02-04-01-eslint\11-stylelint\index.css),所以需要先在项目中添加配置文件.stylelintrc.js
yarn stylelint index.css # 或者使用yarn stylelint *.css通配符的方式
由于stylelint中并未提供共享配置,所以需要先安装stylelint-config-standard
yarn add stylelint-config-standard
.stylelintrc.js
module.exports = {
extends: "stylelint-config-standard"
}
通过以上配置,stylelint可以正常检查css代码。
关于Sass代码的检查
如果需要使用stylelint校验项目中的Sass代码,需要安装另外的模块"stylelint-config-sass-guidelines"。
yarn add stylelint-config-sass-guidelines -D
安装完成后,再次回到配置文件中,将extends设置为数组,并将stylelint-config-sass-guidelines添加进去
.stylelintrc.js
module.exports = {
extends: [
"stylelint-config-standard",
"stylelint-config-sass-guidelines"
]
}
再次执行yarn stylelint index.sass,可以正常进行sass代码的校验。
其余样式代码的校验也是相同的,如果想讲stylelint集成到gulp或者webpack中,参考eslint配置即可。
13.Prettier 的使用
Prettier是近两年使用频率较高的一款通用的前端代码格式化工具,几乎能完成所有类型代码文件的格式化工作。在日常使用中也可以使用它完成代码格式化,或者说完成markdown文档格式化工作。通过prettier,很容易落实前端项目中的规范化标准,而且它的使用也是非常简单的。
目录结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Me81NyD-1610550317604)(https://i.loli.net/2021/01/13/L2bBE7ONUDrcSd9.png)]
需要首先安装prettier工具,命令行执行以下命令:
yarn add prettier --dev
安装完成后,在命令行使用如下命令后,prettier默认将格式化后的代码打印输出到控制台中,如果需要将格式化后的代码直接覆盖进源文件中,需要添加参数:
–write
yarn prettier style.css
yarn prettier style.css --write
yarn prettier . --write # 执行后将当前目录下所有文件进行格式化并写入原文件中
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GGIheuox-1610550317606)(https://i.loli.net/2021/01/13/8z9hfYJ7cwmpLBt.png)]
14.Git Hooks 工作机制
这里来看一下关于git hooks的一些使用内容的介绍,因为在后期当中需要去使用到eslint和git hooks的一个结合,目前来说,已经了解了代码规范化的重要性,同时也知道了如何去通过使用一些工具来确保的代码规范是如何落地的。但是,在这个过程当中还是有一些遗漏的问题,比如说团队中如果某一个成员没有按照要求去使用lint工具,或者说压根儿就忘记了去使用lint工具,最后直接把有问题的代码去提交到了远程仓库,这种情况下在后期去进行项目集成时候导致整个项目的代码就有可能不被检测通过。这个时候lint工具就丧失了它的意义,而本身来说使用lint目的就是为了去确保提交到仓库中的代码都是没有问题的,而且格式也是良好的,那么该怎样去解决这个问题,如果说只是单纯的靠口头的约束去要求团队当中的成员,在提交代码之前都必须要去执行lint,这样的结果必然是流于形式,所以更好的办法就应该是通过某种方式强制的要求代码在提交之前,必须先要去通过lint检查。
Git Hooks介绍
- Git Hooks也称之为git钩子,每个钩子都对应一个任务
- 通过shell脚本可以编写钩子任务触发是要具体执行的操作
下面演示一下git hooks的使用。首先创建一个空的git项目
git init
其次打开项目目录找到隐藏文件.git,打开后找到pre_commit.sample文件,复制并重命名为pre_commit(无后缀)。通过编译器打开该文件并清除所有内容(第一行的#!/bin/sh不能被删除)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xZ13fu3M-1610550317608)(https://i.loli.net/2021/01/13/TvCJ3kdz9iuXDpm.png)]
在文件pre_commit文件简单编写如下脚本:
#!/bin/sh
echo "before commit"
随后在命令行运行一下git add/git commit等命令,测试编写的pre_commit脚本是否运行。
touch demo.txt
vim demo.txt
git add .
git commit -m "test"
运行后发现before commit被打印在控制台上。
具体的概念操作是这个操作发生的时候,就可以去自动的执行钩子里边所定义的一些任务,那明白了这个hooks之后,就可以在将来去想办法,如何在commit之前去强制执行lint的操作,不过这里面会有一些配置上的东西,需要单独去进行讲解。
15.ESLint 结合 Git Hooks
这里来看一下,关于ESlint结合Git Hooks的一个具体的使用,之前已经知道了Git Hooks是如何来完成工作的,而现在,想要的就是希望去通过Git的钩子,可以在代码提交之前强制的去实现对代码的一个lint操作,但是,这里就遇到了一个很现实的问题,比如说当下的很多的开发者,其实并不是很擅长的去使用shell脚本来编写一些功能,而当前的这个功能又是必须要去使用的。
基于以上问题,所以就有人开发了一个npm的工具模块,直接将Git Hooks操作进行一个简单化的实现,这个模块就是Husky。有了这个模块就可以去实现在不编写shell脚本的情况下,也能够去直接使用的钩子所带来的一些功能。不过这里有一件事不能忘记了,因为在上一节中做测试的时候,手动去修改了一下.git/hooks目录下的一些内容pre-commit,所以在这里进行删除,否则的话就会去影响模块的工作。
项目目录
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iOpSRtFg-1610550317611)(https://i.loli.net/2021/01/13/HxtAsuBY9oc2gyz.png)]
-
首先需要安装husky模块
yarn add husky --dev
-
安装完成后去package.json文件中,编写如下内容:
{ "name": "git_hooks", "version": "1.0.0", "main": "index.js", "author": "Leo <19924519007@163.com>", "license": "MIT", "scripts": { "test": "eslint" }, "devDependencies": { "eslint": "^7.17.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1" }, "husky": { "hooks": { "pre-commit": "npm run test" } } }
-
修改index.js文件内容
const a=1; 222
-
命令行执行add操作及commit操作
git add . git commit -m "3333"
出现如下提示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9NJEhrM0-1610550317612)(https://i.loli.net/2021/01/13/Aotdfvl6cgiZPRs.png)]
这样git commit就不会执行了,如果需要在eslint后继续执行其他操作,就需要一个新的工具lint-staged模块。
-
yarn add lint-staged --dev
-
修改package.json
{ "name": "git_hooks", "version": "1.0.0", "main": "index.js", "author": "Leo <19924519007@163.com>", "license": "MIT", "scripts": { "test": "eslint index.js", "precommit": "lint-staged" }, "devDependencies": { "eslint": "^7.17.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1" }, "husky": { "hooks": { "pre-commit": "npm run precommit" } }, "lint-staged": { "*.js": [ "eslint", "git add" ] } }
-
再次进行git add以及git commit操作
提示如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SIIpR9Vx-1610550317613)(C:\Users\24561\AppData\Roaming\Typora\typora-user-images\image-20210113224920187.png)]
nt基本一致。
Stylelint使用介绍
-
提供默认的代码检查规则
可以在配置文件中选择性的开启或者关闭某一些规则
-
提供CLI工具,快速调用
-
通过插件支持Sass Less PostCSS
-
支持Gulp或Webpack集成
关于css代码的检查
安装stylelint,“yarn add stylelint -D”,在命令行使用如下命令,发现提示没有配置文件(Error: No configuration provided for E:\2021\lagou\02-02-study-materials\codes\02-02-04-01-eslint\11-stylelint\index.css),所以需要先在项目中添加配置文件.stylelintrc.js
yarn stylelint index.css # 或者使用yarn stylelint *.css通配符的方式
由于stylelint中并未提供共享配置,所以需要先安装stylelint-config-standard
yarn add stylelint-config-standard
.stylelintrc.js
module.exports = {
extends: "stylelint-config-standard"
}
通过以上配置,stylelint可以正常检查css代码。
关于Sass代码的检查
如果需要使用stylelint校验项目中的Sass代码,需要安装另外的模块"stylelint-config-sass-guidelines"。
yarn add stylelint-config-sass-guidelines -D
安装完成后,再次回到配置文件中,将extends设置为数组,并将stylelint-config-sass-guidelines添加进去
.stylelintrc.js
module.exports = {
extends: [
"stylelint-config-standard",
"stylelint-config-sass-guidelines"
]
}
再次执行yarn stylelint index.sass,可以正常进行sass代码的校验。
其余样式代码的校验也是相同的,如果想讲stylelint集成到gulp或者webpack中,参考eslint配置即可。
13.Prettier 的使用
Prettier是近两年使用频率较高的一款通用的前端代码格式化工具,几乎能完成所有类型代码文件的格式化工作。在日常使用中也可以使用它完成代码格式化,或者说完成markdown文档格式化工作。通过prettier,很容易落实前端项目中的规范化标准,而且它的使用也是非常简单的。
目录结构
[外链图片转存中…(img-2Me81NyD-1610550317604)]
需要首先安装prettier工具,命令行执行以下命令:
yarn add prettier --dev
安装完成后,在命令行使用如下命令后,prettier默认将格式化后的代码打印输出到控制台中,如果需要将格式化后的代码直接覆盖进源文件中,需要添加参数:
–write
yarn prettier style.css
yarn prettier style.css --write
yarn prettier . --write # 执行后将当前目录下所有文件进行格式化并写入原文件中
[外链图片转存中…(img-GGIheuox-1610550317606)]
14.Git Hooks 工作机制
这里来看一下关于git hooks的一些使用内容的介绍,因为在后期当中需要去使用到eslint和git hooks的一个结合,目前来说,已经了解了代码规范化的重要性,同时也知道了如何去通过使用一些工具来确保的代码规范是如何落地的。但是,在这个过程当中还是有一些遗漏的问题,比如说团队中如果某一个成员没有按照要求去使用lint工具,或者说压根儿就忘记了去使用lint工具,最后直接把有问题的代码去提交到了远程仓库,这种情况下在后期去进行项目集成时候导致整个项目的代码就有可能不被检测通过。这个时候lint工具就丧失了它的意义,而本身来说使用lint目的就是为了去确保提交到仓库中的代码都是没有问题的,而且格式也是良好的,那么该怎样去解决这个问题,如果说只是单纯的靠口头的约束去要求团队当中的成员,在提交代码之前都必须要去执行lint,这样的结果必然是流于形式,所以更好的办法就应该是通过某种方式强制的要求代码在提交之前,必须先要去通过lint检查。
Git Hooks介绍
- Git Hooks也称之为git钩子,每个钩子都对应一个任务
- 通过shell脚本可以编写钩子任务触发是要具体执行的操作
下面演示一下git hooks的使用。首先创建一个空的git项目
git init
其次打开项目目录找到隐藏文件.git,打开后找到pre_commit.sample文件,复制并重命名为pre_commit(无后缀)。通过编译器打开该文件并清除所有内容(第一行的#!/bin/sh不能被删除)。
[外链图片转存中…(img-xZ13fu3M-1610550317608)]
在文件pre_commit文件简单编写如下脚本:
#!/bin/sh
echo "before commit"
随后在命令行运行一下git add/git commit等命令,测试编写的pre_commit脚本是否运行。
touch demo.txt
vim demo.txt
git add .
git commit -m "test"
运行后发现before commit被打印在控制台上。
[外链图片转存中…(img-AyIw3OCE-1610550317609)]
具体的概念操作是这个操作发生的时候,就可以去自动的执行钩子里边所定义的一些任务,那明白了这个hooks之后,就可以在将来去想办法,如何在commit之前去强制执行lint的操作,不过这里面会有一些配置上的东西,需要单独去进行讲解。
15.ESLint 结合 Git Hooks
这里来看一下,关于ESlint结合Git Hooks的一个具体的使用,之前已经知道了Git Hooks是如何来完成工作的,而现在,想要的就是希望去通过Git的钩子,可以在代码提交之前强制的去实现对代码的一个lint操作,但是,这里就遇到了一个很现实的问题,比如说当下的很多的开发者,其实并不是很擅长的去使用shell脚本来编写一些功能,而当前的这个功能又是必须要去使用的。
基于以上问题,所以就有人开发了一个npm的工具模块,直接将Git Hooks操作进行一个简单化的实现,这个模块就是Husky。有了这个模块就可以去实现在不编写shell脚本的情况下,也能够去直接使用的钩子所带来的一些功能。不过这里有一件事不能忘记了,因为在上一节中做测试的时候,手动去修改了一下.git/hooks目录下的一些内容pre-commit,所以在这里进行删除,否则的话就会去影响模块的工作。
项目目录
[外链图片转存中…(img-iOpSRtFg-1610550317611)]
-
首先需要安装husky模块
yarn add husky --dev
-
安装完成后去package.json文件中,编写如下内容:
{ "name": "git_hooks", "version": "1.0.0", "main": "index.js", "author": "Leo <19924519007@163.com>", "license": "MIT", "scripts": { "test": "eslint" }, "devDependencies": { "eslint": "^7.17.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1" }, "husky": { "hooks": { "pre-commit": "npm run test" } } }
-
修改index.js文件内容
const a=1; 222
-
命令行执行add操作及commit操作
git add . git commit -m "3333"
出现如下提示:
[外链图片转存中…(img-9NJEhrM0-1610550317612)]
这样git commit就不会执行了,如果需要在eslint后继续执行其他操作,就需要一个新的工具lint-staged模块。
-
yarn add lint-staged --dev
-
修改package.json
{ "name": "git_hooks", "version": "1.0.0", "main": "index.js", "author": "Leo <19924519007@163.com>", "license": "MIT", "scripts": { "test": "eslint index.js", "precommit": "lint-staged" }, "devDependencies": { "eslint": "^7.17.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1" }, "husky": { "hooks": { "pre-commit": "npm run precommit" } }, "lint-staged": { "*.js": [ "eslint", "git add" ] } }
-
再次进行git add以及git commit操作
提示如图:
[外链图片转存中…(img-SIIpR9Vx-1610550317613)]
本文地址:https://blog.csdn.net/weixin_42122355/article/details/112596877