0%

基本概念

在研究浏览器的事件循环机制之前,先了解几个关键词。

执行栈

同步任务都在主线程上执行,形成一个执行栈,可以认为是一个存储函数调用的栈结构,遵循后进先出的原则。

任务队列

只要异步任务有了运行结果,就在任务队列中放置一个事件。异步任务分为宏任务macro-task和微任务micro-task,在es6中宏任务被称为task,微任务被称为jobs。宏任务会放置在宏任务队列,微任务会放置在微任务队列。

宏任务

浏览器常见的宏任务:script(整体代码)、setTimeout、setInterval、setImmediate、UI render。

微任务

浏览器常见的微任务:Promise.then、Async/Await、MutationObserver(h5新特性)。

事件循环过程

  1. 开始的时候执行栈和微任务队列为空,宏任务队列有且只有一个script脚本(整体代码);
  2. 执行栈中同步任务执行完毕后,系统会读取任务队列。只有宏任务队列有一个script脚本(整体代码)的异步任务,将该宏任务推入执行栈;
  3. 执行过程中,遇到同步代码直接执行,生成宏任务添加到宏任务队列,生成微任务添加到微任务队列。等同步代码执行玩后,script脚本被移除宏任务队列,这个就是宏任务的执行和出队列的过程;
  4. 执行完一个宏任务之后,接下来就是处理上一个宏任务执行过程中产生的微任务队列,逐个执行微任务并将任务出队,直到队列被清空。需要注意的是宏任务的执行和出队是一个一个执行的,而微任务的执行和出队是一队一队的;
  5. 执行渲染操作,更新界面;
  6. 上面过程循环往复,知道宏任务队列和微任务队列都清空。

举几个🌰

宏任务和微任务执行顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
})
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0)

// 执行顺序:Promise1 -> setTimeout1 -> Promise2 -> setTimeout2

执行流程分析:

整体代码是一个宏任务,将宏任务压入执行栈。执行过程中先生成了一个微任务简称为Promise1添加到微任务队列,后面又生成一个宏任务简称setTimeout1添加到宏任务队列。执行完这个整体代码的宏任务之后,将这个宏任务出队列。

接下来就是执行上一个宏任务(整体代码)生成的微任务队列,开始执行Promise1。执行微任务p1的时候生成一个宏任务setTimeout2,将宏任务setTimeout2添加到宏任务队列,此时宏任务队列有setTimeout1和setTimeout2。

微任务队列执行完成,执行宏任务队列最前面的宏任务setTimeout1,因为队列的原则是先进先出。在执行宏任务setTimeout1的时候,生成了微任务Promise2。执行完宏任务setTimeout1,将这个宏任务出队列。

接下来就是执行上一个宏任务(setTimeout1)生成的微任务队列,开始执行Promise2。执行完微任务列队,再来执行宏任务里的唯一的一个宏任务setTimeout2。执行完宏任务setTimeout2,将这个宏任务出队列。此时宏任务和微任务队列都已清空,结束当前事件循环。

⚠️微任务执行完也是会出列的。

Async/Await执行顺序

科普知识:await下面的代码怎么执行?

如果await后面跟同步函数的调用。相当于直接将await下面的代码注册为一个微任务,可简单理解为promise.then(await下面的代码)。然后跳出async函数,执行其他代码。

如果await后面跟一个异步函数的调用,当await之后的函数中同步任务被执行,异步任务被添加到任务队列之后,直接跳出async函数,执行剩下代码,等剩下代码同步任务被执行,异步代码被添加到任务队列之后,再来将await下面的代码注册为一个微任务。

await后面跟同步函数的调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
console.log('script start')

async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()

setTimeout(function() {
console.log('setTimeout')
}, 0)

new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})

console.log('script end')

// 执行结果: script start -> async2 end -> Promise -> script end
// -> async1 end -> promise1 -> promise2 -> setTimeout

执行流程分析:

  1. 整体代码是个宏任务压入执行栈执行,执行同步任务打印’script start’;
  2. 执行async1时,await后面的函数可以当作同步任务执行,打印’async2 end’;
  3. 因为async1中的await后面的函数并不是异步函数,所以可以直接将await下面的代码生成为一个微任务,并添加到微任务队列;
  4. 代码往下执行生成一个setTimeout宏任务,将宏任务添加到宏任务队列;
  5. 代码往下执行创建一个Promise实例。注意创建Promise实例时,参数是一个是以同步的方式执行的函数,直接打印’Promise’;
  6. 代码往下执行生成promise1和promise2两个微任务,并按顺序加入微任务队列;
  7. 执行同步任务打印’script end’,将全部代码这个宏任务出列;
  8. 开始执行上一个宏任务生成的微任务队列,此时微任务队列里是三个微任务,分别是await下面的代码生成的微任务、promise1和promise2,依次执行出队列。结果是按顺序打印’async1 end’、’promise1’、 ‘promise2’;
  9. 执行完微任务队列后,此时宏任务队列只有一个setTimeout宏任务;
  10. 执行setTimeout宏任务,打印’setTimeout’,结束宏任务并将宏任务出列。此时宏任务和微任务队列都已清空,结束当前事件循环。
await后面跟异步函数的调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
console.log('script start')

async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
return Promise.resolve().then(()=>{
console.log('async2 end1')
})
}
async1()

setTimeout(function() {
console.log('setTimeout')
}, 0)

new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})

console.log('script end')

// 执行结果: script start -> async2 end -> Promise -> script end
// -> async2 end1 -> promise1 -> promise2 -> async1 end -> setTimeout

执行流程分析(跟上个🌰的流程差别,主要是await后面的代码执行时机,具体是步骤2、3、8):

  1. 整体代码是个宏任务压入执行栈执行,执行同步任务打印’script start’;
  2. 执行async1时,await后面的函数可以当作同步任务执行。async2函数执行打印’async2 end’,生成一个微任务简称为Promise async2;
  3. 因为async1中的await后面的函数是异步函数,所以直接跳出了async1函数;
  4. 代码往下执行生成一个setTimeout宏任务,将宏任务添加到宏任务队列;
  5. 代码往下执行创建一个Promise实例。注意创建Promise实例时,参数是一个是以同步的方式执行的函数,直接打印’Promise’;
  6. 代码往下执行生成promise1和promise2两个微任务,并按顺序加入微任务队列;
  7. 执行同步任务打印’script end’,再回到async1函数中将await下面的代码生成为一个微任务,并添加到微任务队列。此时本轮宏任务执行就执行完了,将全部代码这个宏任务出列;
  8. 开始执行上一个宏任务生成的微任务队列,此时微任务队列里是三个微任务,分别是Promise async2、promise1、promise2和await下面的代码生成的微任务,依次执行出队列。结果是按顺序打印’async2 end1’、’promise1’、 ‘promise2’、’async1 end’;
  9. 执行完微任务队列后,此时宏任务队列只有一个setTimeout宏任务;
  10. 执行setTimeout宏任务,打印’setTimeout’,结束宏任务并将宏任务出列。此时宏任务和微任务队列都已清空,结束当前事件循环。

基本概念

进程是CPU资源分配的最小单位,线程是CPU调度的最小单位。

进程有单独的属于自己的内存空间,一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线。一个进程的内存空间是共享的,每个线程都可用这些共享内存。

翻译翻译:

如果把进程比作工厂的话,线程就是工厂的工人,工厂有单独的专属于自己的工厂资源,工人可以共享工厂资源。

多进程的好处

在同一个时间内,同一个计算机系统中允许两个或两个以上的进行处于运行状态,进程之间丝毫不会互相干扰,可以同时做多个事情。

多线程的好处

程序中包含多个执行流,即在同一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。

常见进程

以chrome浏览器为例,来分析一下浏览器的进程。

img

上图是浏览器只打开一个百度的标签页时,任务管理器的状态。主要可以关注以下几个进程。

  1. 浏览器进程:负责协调、主控其他进程。
    • 负责各个页面的管理,创建和销毁其他进程;
    • 负责浏览器界面显示与用户交互,如前进、后退等;
  2. GPU进程:使用初衷是为了实现3D CSS的效果,后面随着网页、Chrome的UI界面都选择用GPU来绘制,使得GPU成为了浏览器普遍的需求。
  3. Network Service:网络进程,主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面。
  4. Storage Service:控制文件读写的存储线程;
  5. Audio Service:音频进程;
  6. 渲染进程:核心任务是将HTML、CSS和javascript转换为用户可以与之交互的网页,排版引擎Blink和javascript引擎V8都是运行在该进程中。默认情况下,Chrome会为每个Tab标签创建一个渲染进程,当进程数达到一定的界限后,Chrome会将访问同一个网站的tab都放在一个进程里面跑。处于安全考虑,渲染进程都是运行在沙箱模式下。渲染进程中主要包含以下线程:
    • 主线程 Main thread:对几个常驻线程的调用,执行大多数的代码。
    • 工作线程 Worker thread:web worker和service worker相关的代码由该进程处理。
    • 光栅线程 Raster thread:将文档结构、元素的样式、元素的几何位置以及绘画顺序这些信息转化为显示器的像素的过程叫做光栅化。
    • 合成线程 Compositor thread:将页面分成若干层,分别进行光栅化,光栅化之前合成线程需要将页面的一层切分成一块又一块小图块,光栅线程会栅格化每个图块并将它们储存在GPU的内存中。最后在合成线程中合并成一个页面。当页面的层超过一定的数量后,层的合成操作要比每个帧中光栅化页面的一小部分还要慢。
  7. 插件进程:虽然图上没有,也可以了解一下。主要负责插件的运行,用来保证插件进程崩溃不会对浏览器和页面造成影响。

⚠️Network Service、Storage Service和Audio Service这些服务本来是在浏览器进程里面的,后来将这些模块拆分为了一个个不同的服务,这个过程也叫做Chrome服务化。服务化之后,这些功能既可以放在不同的进程里运行,也可以合并为一个单独的进程运行。

这样做主要是为了让Chrome在不同性能的硬件上有不同的表现。当Chrome运行在一些性能比较好的硬件时,浏览器进程香港的服务会放在不同的进程中运行来提高系统的稳定性。如果硬件性能不好,这些服务就被放在同一个进程里面执行来减少内存的占用。

渲染进程中的线程

渲染进程也称为浏览器内核,浏览器内核通过取得页面内容,整理信息、计算组合最终输出可视化的图像结果,通常也被称为渲染引擎。

浏览器内核是多线程,在内核控制下各个线程相互配合,一个浏览器通常由以下常驻线程组成:

  • GUI渲染线程(有且只有一个)
  • JavaScript引擎线程(有且只有一个)
  • 定时触发器线程(多个)
  • 事件触发线程
  • 异步http请求线程(多个)
GUI渲染线程

主要负责页面的渲染,解析HTML、CSS,构建DOM树,布局和绘制等。当界面需要重绘或重排时,会执行该线程。

⚠️渲染线程跟JS引擎线程互斥,当执行JS引擎线程时,GUI渲染会被挂起。当任务队列空闲时,才会执行GUI渲染。互斥的原因是因为,JS是可以操作DOM的,边操作边渲染会出现问题,js被设计成单线程也是这个原因。

JS引擎线程

同步任务和异步任务都由该线程执行。如果JS引擎执行脚本的时间过长,将导致页面渲染阻塞。

定时器触发线程

主要负责执行异步定时器一类的函数,如setTimeOut、setInterval。主线程按顺序执行代码时,遇见定时器,会将定时器交给该线程处理。当计时完毕后,通过事件触发线程将技术完毕后的事件加入到任务队列的尾部,等待JS引擎线程执行。

事件触发线程

主要负责将准备好的事件交给JS引擎线程执行。也就是将已经准备好的异步回调函数添加到任务队列的队尾。

异步http请求线程

负责执行异步请求一类的函数,如: Promise、axios、ajax等。主线程按顺序执行代码时,遇到异步请求,会将函数交给该线程处理。当监听到状态码变更,如果有回调函数,事件触发线程将回调函数加入到任务队列的尾部,等待JS引擎线程执行。

实现目标

将一个入口文件及其依赖js打包到一个出口文件。

注意:只是简单实现webpack的部分功能,目的是帮助理解webpack的打包流程。

webpack打包流程

  1. 初始化Compiler: new Webpack(config)得到Compiler对象;
  2. 开始编译:调用Compiler对象run方法开始执行编译;
  3. 确定入口:根据配置中的entry找到所有入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的Loader对模块进行编译,再找出该模块依赖的模块,递归直到所有的模块都被加载;
  5. 完成模块编译:使用Loader编译完所有模块后,得到了每个模块被编译后的最终内容以及模块的依赖关系图;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,再把每个Chunk转换成单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。

具体实现

整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|-- ToysWebpack
|-- config
|-- index.js 执行这个文件,开始打包
|-- webpack.prod.config.js 生产环境webpack打包配置
|-- webpack.test.config.js 测试环境webpack打包配置
|-- dist
|-- index.bundle.js 生产环境包
|-- index.test.js 测试环境包
|-- node_modules
|-- src 待打包的原始文件夹,出了两个环境的打包入口文件,其他都是依赖文件
|-- add.js
|-- error.js
|-- index.js 生产环境打包的入口文件
|-- log.js
|-- minus.js
|-- test.js 测试环境打包的入口文件
|-- webpack
|-- Compiler.js 打包主文件
|-- index.js webpack打包的开始执行文件
|-- parse.js 编译工具
|-- index.html 供打包后测试使用
|-- package-lock.json
|-- package.json 打包脚本写里面

package.json

debug - 调试脚本, test - 打测试环境包, prod - 打生产环境包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"name": "toys_webpack",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"debug": "cross-env NODE_ENV=prod node --inspect-brk ./config/index.js ",
"test": "cross-env NODE_ENV=test node ./config/index.js ",
"prod": "cross-env NODE_ENV=prod node ./config/index.js "
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.15.0",
"@babel/parser": "^7.15.3",
"@babel/preset-env": "^7.15.0",
"@babel/traverse": "^7.15.0",
"cross-env": "^7.0.3",
"mkdirp": "^1.0.4"
}
}

config文件夹

index.js:执行package.json里的脚本后会到这个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const webpack = require('../webpack/index.js')
const env = process.env.NODE_ENV

// 懒加载当前运行环境的打包配置
const optionsMap = {
prod: () => require('./webpack.prod.config.js'),
test: () => require('./webpack.test.config.js')
}
const options = optionsMap[env]()

// 给webpack函数传入打包配置,返回一个Compiler实例
const compiler = webpack(options)
// 执行run方法开始编译打包
compiler.run()

webpack.prod.config.js:生产环境打包配置

1
2
3
4
5
6
7
8
9
const path = require('path')

module.exports = {
entry: '../src/index.js',
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'index.bundle.js',
}
}

webpack.test.config.js: 测试环境打包配置

1
2
3
4
5
6
7
8
9
const path = require('path')

module.exports = {
entry: '../src/test.js',
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'index.test.js',
}
}

src文件夹

生产环境相关文件

index.js: 入口文件

1
2
3
4
import { add } from './add.js'
import { log } from './log.js'

log(add(1,2))

add.js

1
export const add = (a, b) => a + b

log.js

1
export const log =  console.log
测试环境相关代码

test.js: 入口文件

1
2
3
4
import { minus } from './minus.js'
import { error } from './error.js'

error(minus(1,2))

minus.js

1
export const minus = (a, b) => a - b

error.js

1
export const error =  console.error

webpack文件夹

index.js

webpack打包的开始执行文件

1
2
3
4
5
6
7
8
9
10
const Compiler = require('./Compiler.js')

/**
* ToysWebpack只做一件事:将入口文件及其依赖打包到出口文件
*/
const webpack = (options) => {
return new Compiler(options)
}

module.exports = webpack
Compiler.js

打包主文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
const path = require('path')
const fs = require('fs')
const mkdirp = require('mkdirp')
const { getAst, getDeps, getCode } = require('./parse.js')
const { entry } = require('../config/webpack.prod.config.js')

class Compiler {
constructor (options) {
// 配置信息
this.options = options
// 所有依赖的容器
this.modules = []
// 入口文件绝对路径
this.entryFilePath = path.resolve('config', options.entry)
}

// 编译打包
run () {
// 开始第一次构建,得到入口文件的信息
const fileInfo = this.build(this.entryFilePath)
// 将入口文件的信息添加到容器
this.modules.push(fileInfo)
// 遍历fileInfo的deps, 递归得到依赖文件的信息
this.modules.forEach(item => {
// 取出当前文件的所有依赖
const deps = item.deps
for (const dep in deps) {
/**
* dep: 依赖相对路径
* deps[dep]: 依赖绝对路径
* 得到依赖文件的信息
*/
const depFileInfo = this.build(deps[dep])
// 将处理过后的依赖信息添加到容器中,后面会遍历到完成下一层的递归
this.modules.push(depFileInfo)
}
})
// 整理依赖关系图
const depsGraph = this.modules.reduce((graph, module) => {
return {
...graph,
[module.filePath]: {
deps: module.deps,
code: module.code
}
}
}, {})
// 生成输出文件
const generate = (depsGraph) => {
/**
* 定义一个匿名立即执行函数,确保里面的代码只能自己操作
*/
const bundle = `
(function(depsGraph){
// require目的:加载入口文件
function require(module){
// 函数内部的require其实执行的是localRequire
function localRequire(relativePath){
// 找到要引入模块的绝对路径,通过require加载
return require(depsGraph[module].deps[relativePath])
}
// 定义暴露的对象,将来模块要暴露的内容都放在这里
let exports = {};
(function(require, exports, code){
eval(code)
})(localRequire, exports, depsGraph[module].code);
// 作为require的返回值返回出去
return exports
}
require('${this.entryFilePath}')
})(${JSON.stringify(depsGraph)})
`
// 生成输出文件的绝对路径
const { path: outputPath, filename } = this.options.output
const filePath = path.resolve(outputPath, filename)
// 写入文件
const made = mkdirp.sync(path.dirname(filePath))
fs.writeFileSync(filePath, bundle)
}
generate(depsGraph)
}

// 开始构建
build (filePath) {
// 将入口文件解析ast
const ast = getAst(filePath)
// 获取ast中所有的依赖
const deps = getDeps(filePath, ast)
// 将ast解析成code
const code = getCode(ast)
return {
// 文件路径
filePath,
// 当前文件的所有依赖
deps,
// 当前文件解析后的代码
code
}
}
}

module.exports = Compiler
parse.js

编译工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const fs = require('fs')
const path = require('path')
const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAstSync } = require('@babel/core')

/**
* tips:下面三步也是将ES6转化成ES5的核心步骤,
* 当然在项目实践中不需要去这么转化,有封装得更易于使用的轮子。
*/

//1. @babel/parser将es6的代码解析为AST
const getAst = (filePath) => {
// 同步读取文件
const file = fs.readFileSync(filePath, 'utf-8')
/**
* 调用parse方法将文件解析成AST
* sourceType: 解析代码的模式,为module时可以解析ES6导入或导出语句
*/
const ast = parse(file, { sourceType: "module" })
return ast
}

// 2.@babel/traverse遍历AST收集依赖
const getDeps = (filePath, ast) => {
const deps = {}
// 获取文件所在文件夹的路径
const dirname = path.dirname(filePath)
// 内部会遍历ast中的program.body,判断里面语句类型
traverse(ast, {
/**
* 当type为ImportDeclaration时,会执行下面的回调
* 参数NodePath里存储了当前执行的语句的详细信息
*/
ImportDeclaration: (NodePath) => {
// 依赖的文件的相对路径
const relativePath = NodePath.node.source.value
// 生成基于入口文件的绝对路径
const absolutePath = path.resolve(dirname, relativePath)
// 添加依赖
deps[relativePath] = absolutePath
}
})
return deps
}

// 3.@bebel/core将AST解析为对应的es5代码
const getCode = (ast) => {
const { code } = transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
})
return code
}

module.exports = {
getAst,
getDeps,
getCode
}

index.html

供打包后测试使用

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="./dist/index.bundle.js"></script>
<script src="./dist/index.test.js"></script>
</body>
</html>

开发环境优化

优化打包速度

HMR

模块热替换,一个模块发生变化,只会重新打包这一个文件,而不是打包所有文件,提升构建速度。

  1. HTML文件:默认不能使用HMR功能,开启HMR功能之后会导致HTML文件不能热更新了。解决方法是修改entry入口,将HTML文件引入。

  2. CSS文件:可以使用HMR功能,因为style-loader内部实现了。

  3. JS文件:默认不能使用,得手动处理热替换。

    1
    2
    3
    4
    5
    6
    7
    if (module.hot) {
    // 一旦module.hot为true,说明开启了HMR功能
    module.hot.accept('./XXX.js', () => {
    // 监听XXX.js文件的变化,执行下面逻辑代码
    ...
    })
    }

优化代码调试

source-map

一种提供构建后代码到源代码映射的技术,如果构建后代码出错了,通过映射可以追踪源代码错误。

1
组合方式:[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

开发环境推荐使用:eval-source-map / eval-cheap-module-source-map

生产环境优化

优化打包速度

oneof

找到了匹配的loader之后,后面的loader就不去匹配了,一个文件在oneOf里只会匹配一个loader。

babel缓存

开启缓存后,babel运行的结果会保存起来。如果js文件没有变化,就可以直接使用babel的缓存,不需要重新去编译。

多进程打包

可以使用thread-loader对后面的loader开启多进程打包。

需要注意的是进程的启动大概600ms,通信也需要时间,只有消耗时间长的工作使用多进程打包才有优化效果,如babel-loader。

externals

配置忽略打包的库,在入口文件中以CDN的方式引入,来优化打包速度。

dll

将一些不常更新的第三方库单独打包,在构建的时候将第三方库打包后的文件引入,每次打包只打包项目自身的代码。

优化代码调试

source-map

生产环境隐藏源代码推荐使用:

  1. nosources-source-map 全部隐藏
  2. hidden-source-map 只隐藏源代码,会提示构建后代码错误信息

生产环境不隐藏源代码推荐使用:source-map / cheap-module-source-map

缓存

在服务端将静态资源设置缓存时间,当用户在缓存时间内请求相同的静态文件的时候,会直接从cookie里去获取。这里的缓存方案,处理场景是在缓存时间内对静态资源有改动,使用户只从服务器获取改动的静态文件,没改动的文件还是从cookie里获取。

打包文件名添加使用hash值,类似于打包文件版本号。文件hash值一旦改变,说明改文件有变动。

  1. hash:每次打包都会生成一个唯一hash值;
  2. chunkhash:打包来自于同一个入口,属于同一个chunk,就公用一个hash值;
  3. contenthash:根据文件内容生成hash值;
tree shaking

去除应用程序中没有用到的代码和库,让打包后的代码体积更小,从而提升程序执行速度。

SideEffect: 让 webpack 去除 tree shaking 带来副作用的代码。

使用方式:

  1. 使用ES6 module(webpack4.X需要使用ES6 module,webpack5也支持CommonJS)
  2. 开启生产模式
code split
optimization
  1. 可以将node-modules中代码单独打包成一个chunk最终输出;
  2. 自动分析多入口chunk中,有没有公共文件,如果有会打包成单独的一个chunk。
import

import动态导入语法,能将某个文件单独打包成一个chunk。

1
2
3
4
5
6
7
// 给打包文件重命名
import(/* webpackChunkName: 'XXX' */'./XXX.js')
.then(() => {
...
}).catch(() => {
...
})
懒加载/预加载

懒加载:使用的时候才去加载,优点是不白加载,缺点是如果加载文件体积过大会卡顿;

预加载:当其他资源加载完了再去加载,优点是使用时流畅,缺点是兼容性不好。

PWA

一种理念,使用多种技术来增强webapp的功能,是网站的体验变得更好。能够模拟一些原生功能,如离线也可以访问。

基本概念

响应式数据改变之后,执行对数据有依赖的watcher,这个过程称为派发更新。

更新过程

响应式数据改变的时候,会执行数据的set方法。在set方法里面主要做了3件事:

  1. 更新数据的值;
  2. 调用observe方法处理新值,如果是对象或者VNode类型,则进行数据劫持;
  3. 调用dep.notify方法,开始派发更新。

dep.notify方法中将数据依赖的watcher按创建顺序排序,遍历执行watcher实例的update方法。这里有3种情况(注意是if - else if - else):

  1. computed watcher,只需要把watcher实例的dirty属性值设置为true。

  2. 同步的watcher,就直接执行watcher实例的run方法;

  3. 执行queueWatcher方法。

第一种情况computed watcher在派发更新里做的事情比较简单,因为只是一个取值操作,并没有复杂回调。

第二种情况也比较简单,后面会具体聊到run方法。

这里重点了解一下第三种情况,因为一般数据更新都是这个情况。在queueWatcher方法中,主要做的事情有两个:

  1. 根据watcher实例的id从小到大顺序,往待执行的watcher队列里插入当前watcher,使用has对象保证同一个watcher只添加一次;
  2. 使用nextTick异步执行flushSchedulerQueue方法,通过waiting保证在一次派发更新过程中对nextTick方法只执行一次。

flushSchedulerQueue主要做的3个事情:

  1. 对队列做了从⼩到⼤的排序,确保组件的更新由父到子,user watcher要优先于render watcher,computed watcher不会到这个队列来。

  2. 遍历队列依次执行watcher的run方法;

  3. 执行resetSchedulerState方法,将控制流程状态的变量恢复到初始状态,清空队列,这就完成了一次派发更新。

run方法里先执行了watcher实例的get方法,得到当前的值,并重新进行依赖收集。如果满足新值和旧值不等、新值是对象类型、deep模式任何一个条件,执行watcher的回调。

这里需要注意的是,render watcher 和 computed watcher都执行不到这个回调。render watcher 执行完get方法,重新收集依赖并更新视图之后,因为没有返回值满足不了判断条件,所以就结束了。computed watcher只是在需要获取计算属性的时候会执行一下getter方法,获取到最新值并收集依赖就结束了,都不会执行到判断条件这里来。

总结

响应式数据变化的时候,并不会马上就去执行对数据有依赖的watcher,而是会将user watcher和render watcher放入到一个watcher队列里面,computed watcher会在程序需要读取计算属性的时候执行。当同步任务执行完,按照浏览器的事件循环机制,执行到这个nextTick里的回调函数的时候,就开始执行watcher队列。

new Vue 之后执行一系列的初始化操作,如合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化inject、props、method、data、computed、watch、privide等等。

初始化完成之后,对Vue实例进行挂载。挂载时会判断是否有render函数,如果是el和template转换成render方法,执行render方法会生成VNode tree,也就是VDOM。

最后再调用patch方法,将VNode渲染成DOM并完成挂载,实际上整个渲染过程调用的就是原生的DOM API。

父->子

方式

父组件通过属性的方式给子组件传值,子组件通过props来接受父组件的值。

注意
  1. 如果传递的是个对象的话,只改变下面的某个属性子组件中是不会响应式更新的,如果子组件需要在数据变化时响应式更新,可以用watch深拷贝deep:true监听到变化;

  2. 如果你传进来的是个对象,同时你又需要在子组件中操作传进来的这个数据,那么在父组件中的这个数据也会改变,因为传递的只是个引用,可通过将对象做深拷贝创建一个副本解决这个问题。

可以但不建议的操作
  1. 直接用ref调用子组件函数直接把数据以参数的形式传给子组件;

  2. $children是一个数组,是直接儿子的集合,不保证顺序,也不是响应式。

子->父

方式

子组件通过$emit的操作触发父组件的自定义事件,父组件中监听这个事件并在回调中写相关逻辑。

可以但不建议的操作
  1. 可以在子组件中定义一种专供父组件调用的函数,当父组件想要获取子组件数据就直接主动调用ref执行这个函数获取数据;

  2. $parent是当前组件树的根 Vue 实例,如果当前实例没有父实例,此实例将会是其自己。

兄->弟

方式
  1. 路由URL参数:把需要跨页面传递的数据放到url后面,跳转到另外页面时获取url字符串获取想要的参数即可。局限性:只适合传递比较小的数据。

  2. EventBus:在组件之外定义一个event-bus.js作为组件间通信的桥梁,也可称之为事件总线。EventBus实现了发布订阅模式,往事件总线里订阅事件用的是on,发布事件用的是emit。

1
2
3
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()

祖先->后代

方式
  1. provide/inject,允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。

  2. $attrs:在子组件中添加v-bind=’$​attrs’,就把父组件传来的子组件没props接收的数据全部传到孙组件,子组件和孙组件都可以用this.$attrs获取。

注意

provide 和 inject 绑定并不是可响应的。如果数据类型是对象Object,其对象的属性还是可响应的,因为对象格式数据存储的是指针而不是数据,操作的是同一个对象。

通用

方式

1.Vuex集中状态管理:类似与一个共享数据仓库,所有的组件都可以去操作;

2.localStorage,sessionStorage,cooikes之类的存在本地也能做到组件间的通信。

编译过程

主要三个步骤:parse -> optimize -> codegen。

将模版template经过上面三个步骤,编译成render函数。

parse

parse方法对模版做解析生成AST抽象语法树,AST是源代码抽象语法结构的树状表示,可以简单理解为是将源代码按照一定规则转换成一种树状结构。

解析HTML模版的时候,使用正则做各种匹配,如注释节点、文档类型节点、开始标签、结束标签等,对于不同情况进行不同的处理,直到整个template被解析完成。

在处理标签的时候为每一个标签创建了一个AST元素,建立起父子关系生成AST树。AST元素节点一共有3种类型,type为1表示是普通类型,为2表示表达式,为3表示纯文本。

⚠️ 在parse过程中,还完成了对指令的解析。

optimize

optimize做的事情就是优化AST树。

由于Vue是响应式的,但是模板中并不是所有的数据都是响应式的,很多数据是首次渲染后就永远不会变化,可以在编译阶段将一些AST节点优化成静态节点,可以在数据更新patch的时候跳过这些节点的比对,提高渲染速度。

整个optimize过程中就干了两件事情:

  1. 标记静态节点
  2. 标记静态根

codegen

优化后的AST抽象语法树通过generate函数生成render函数字符串。在compileToFunctions中,会将这个render函数字符串转换成函数。

概念

首先需要明确的一个概念,数据响应式不等于双向绑定。

数据响应式:通过数据的改变去驱动视图的变化。

双向绑定:除了数据的改变去改变视图的变化外,视图的变化反过来也影响数据。

在Vue中,使用v-model这个语法糖来实现双向绑定。

应用场景

v-model的应用场景有两个:

  1. 表单元素:填写表单时,视图变化更新数据;

  2. 组件:父组件通过v-model给子组件传值,子组件的触发父组件的自定义方法,更新传给子组件的值。

    ⚠️ 这里子组件的触发父组件的自定义方法,父组件是不需要做额外操作的,开发者不需要在父组件去硬编码监听这个子组件的事件,更不需要为这个事件添加回调函数,因为这些都是v-model内部做的事情。

表单元素

可以将表单元素看作一个子组件,父组件将响应式数据的值传给表单元素。

根据表单元素的类型,编译过程中会给父组件添加自定义事件,当表单元素值变化的时候,会触发这个编译过程中生成的自定义事件。

这个自定义事件的参数值就是表单元素变化后的值,开发者不需要额外处理,它会自己完成对父组件传给子组件值的更新。

组件

父组件将响应式数据的值传给子组件。

要实现双向绑定,子组件要做的两件事情:

  1. 接收props;
  2. 触发父组件的自定义方法。

这里是有一个默认值的。如果父组件使用v-model来实现跟子组件的双向绑定,子组件默认可以接收到key为value的一个从父组件传过来的props属性,而且子组件可以通过触发父组件的input事件,并将value需要更新的值做为参数,是可以完成一个更新数据闭环的。

子组件接收到的key值和触发父组件自定义事件的名称都是可以配置的,如果不配置就是用的value和input。配置方法:在子组件的model属性中给prop和event属性赋值就行。

在这个场景下,父组件更新方法做的事情非常简单,数据是子组件调用方法的时候传递过来的,调用时机是子组件通知的,需要更新的就是v-model后面的响应式数据。因为这个方法做的事情太简单,所以就在编译v-model的时候内部完成了。

差异

表单元素和组件的双向绑定最大的差异,就在子组件的双向绑定,需要子组件去触发父组件的自定义事件更新。

其实也是根据应用场景不同来做的这个差异化,组件更新的时机本身就比较灵活,这个交予组件自己控制会更自由。而表单元素的应用场景比较简单,视图数据变化的时候自动触发父组件更新,这样用起来更简单。

语法糖

语法糖主要做的事情有3个:

  1. 给子组件传值;

  2. 内部给父组件生成一个事件回调函数,用来更新传给子组件的值;

    ⚠️ 这里稍微有点绕,子组件通知父组件更新父组件传给子组件的值。因为Vue是单向数据流,子组件是不能自己更新从父组件传过来的数据的。

  3. 如果是表单元素,数据变化会自动触发父组件事件回调函数。

Vue中通过EventBus事件中心进行兄弟节点之间的通信,是一个非常经典的发布订阅模式实现。

前置准备

往Vue实例上添加一个_events属性,属性值是一个对象,用于以键值对的方式,分别存储事件名称,以及存储事件回调函数的数组。

$on、$once、$emit、$off方法是定义在Vue原型上的,所有的Vue实例都可以调用这些方法。

事件中心要运转起来,必须要基于同一个实例来调用这些方法,因为只有这样事件的存储空间才会被共用,事件的发布和订阅才能被串起来。在Vue中使用事件中心的时候,可以全局定义一个Vue实例,专门用于组件通信。

$on

$on方法接收两个参数,第一个参数可能是字符串类型的事件名和数组类型事件名数组,第二个参数是回调函数。

这里用到了递归,如果参数的第一项是数组,就遍历数组,将数组项作为第一个参数,第二个参数不变,递归执行$on方法。直到第一个参数是一个字符串了,就可以接着执行订阅事件的逻辑。

接下来判断当前_events对象里,$on方法的第一个参数作为key,所对应的值是否存在。

  1. 如果不存在,则先创建一个空数组,然后再往数组中添加回调函数;
  2. 如果存在,则说明值已经是一个数组类型了,直接往数组中添加回调函数。

$once

$once方法接收两个参数,第一个参数是字符串类型的事件名,第二个参数是回调函数。

内部执行的就是on方法,并且当回调函数执行一次后,再通过off方法移除事件的回调,确保回调函数只执行一次。

在once方法中新创建一个回调函数,作为on方法事件的回调,在新回调函数中执行off方法移除事件的回调,那么新回调函数就是只会被执行一次,在里面执行once的第二个参数就可以了。

$emit

$emit方法接收一个参数,是一个字符串类型的事件名。

根据事件名找到事件名对应的所有的回调函数,然后执行所有的回调函数。

$off

$off方法接收参数是不固定个数的(0、1或2),用于移除事件回调。

  1. $off():将_events对象赋值为空对象;
  2. $off([event, …], fn):这里也用到了递归,最后执行off(event, fn),一个一个执行移除指定事件名event和指定fn;
  3. $off(event, fn):用于移除指定事件名event和指定fn;
  4. $off(event):用于移除指定事件名event对应所有的fn。