Vue 设计与实现

Vue 设计与实现

一、平衡的艺术

1、命令式与声明式

命令式与声明式的定义

  • 命令式编程:关注过程,开发者手动控制每一步操作。
  • 声明式编程:关注结果,开发者只需声明期望的结果,框架(如 Vue)负责过程。

命令式和声明式的对比

示例:如何实现以下需求?

  • 获取 id 为 app 的 div 标签
  • 将文本内容设为 hello world
  • 绑定点击事件,点击时弹出提示框

命令式实现

1
2
3
const div = document.querySelector('#app');
div.innerText = 'hello world';
div.addEventListener('click', () => alert('ok') );

声明式实现

1
<div id="app" @click="() => alert('ok')">hello world</div>

在声明式编程中,我们只关心结果,如何实现由 Vue 来处理。

2、性能与可维护性的权衡

性能对比

命令式和声明式各有优缺点,尤其在性能方面,声明式代码通常会稍微比命令式差,原因如下:

示例:更新 div 内容为 hello vue3

  • 命令式代码:直接修改 DOM 元素内容。
1
div.innerText = 'hello vue3';
  • 声明式代码:Vue 会计算差异并更新 DOM。
1
2
3
<div @click="() => alert('ok')">hello world</div>

<div @click="() => alert('ok')">hello vue3</div>

命令式:直接操作 DOM,性能开销较小。
声明式:需要计算差异(diff)并更新,性能开销较大。

可维护性对比

  • 声明式代码:关注“做什么”,框架处理底层细节,代码简洁且易于理解与扩展。
  • 命令式代码:需要手动控制每一个操作,随着项目复杂度增加,维护难度也增加。

声明式更适合团队协作和大型项目开发,提升可维护性。

开发效率对比

声明式编程相对命令式编程,在开发效率上具有明显优势,因为开发者不需要处理大量细节,框架自动完成工作。

示例:创建多个 div 元素,内容为 1, 2, 3, 4

命令式实现

1
2
3
4
5
6
7
const container = document.getElementById('app');
const values = [1, 2, 3, 4];
for (let i = 0; i < values.length; i++) {
const div = document.createElement('div');
div.textContent = values[i];
container.appendChild(div);
}

声明式实现

1
2
3
<div v-for="i in [1, 2, 3, 4]" :key="i">
{{ i }}
</div>

在声明式实现中,我们关注的是数据和模板,而 Vue 会自动帮我们创建 div 元素。

3、虚拟 DOM 的性能

虚拟 DOM 的主要作用是优化“查找差异”的性能消耗。与直接操作 DOM 或使用 innerHTML 比较,虚拟 DOM 能够大幅减少重绘和重排的性能开销。

innerHTML 的性能问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
let html = '';
for (let i = 0; i < 10000; i++) {
html += `<div>${i}</div>`;
}
document.body.innerHTML = html;
</script>
</body>
</html>

使用 innerHTML 的性能开销

  • 步骤
    • 局部 HTML 重新解析
    • 重新生成 DOM 节点
    • 重新生成 Layout 树(Reflow)
    • 分层(Layer Tree)
    • 分块(Tiling)
    • 光栅化(Rasterization)
    • 绘制(Paint)

直接使用 JavaScript 操作 DOM

  • 步骤
    • 直接操作已有的 DOM
    • 重新生成 Layout 树(Reflow)
    • 分层
    • 分块
    • 光栅化
    • 绘制

使用虚拟 DOM

  • 步骤
    • 更改更新的 DOM
    • 查找需要更新的 DOM(diff)
    • 更新真实 DOM
    • 重新生成 Layout 树(Reflow)
    • 分层
    • 分块
    • 光栅化
    • 绘制

innerHTML需要解析HTMl, 并且它会摧毁之前的dom, 构建新的dom。

Javascript只需要更新指定的元素。

虚拟Dom通过diff查找需要更新的元素指定更新。

性能排序
innerHTML < 虚拟 DOM < 原生 JavaScript

运行时与编译时

编译时运行时 的区别

运行时

运行时的代码在浏览器中直接运行,框架本身在代码执行时提供方法、生命周期、响应式等功能。

  • 通过对象描述 DOM 树,动态渲染页面。
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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body id="app">
<script>
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
};

function Render(obj, root) {
const el = document.createElement(obj.tag);
if (typeof obj.children === 'string') {
const text = document.createTextNode(obj.children);
el.appendChild(text);
} else if (obj.children) {
obj.children.forEach((child) => Render(child, el));
}
root.appendChild(el);
}

const root = document.getElementById('app');
Render(obj, root);
</script>
</body>

</html>

编译时

编译时框架会在代码执行前,将模板字符串转换成原生 JavaScript 代码,框架本身不参与执行。

  • 编译过程:模板 → JavaScript 渲染函数。
1
2
3
<div>
<span>hello world</span>
</div>

编译后

1
2
3
4
5
6
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
};

在之前的运行时的基础上加上编译时

编译一个函数Compiler来实现将字符串转为需要的对象

1
2
3
4
5
6
7
8
const html = `
<div>
<span>hello world</span>
</div>`
const root = document.getElementById('app');
// Compiler是一个方法, 返回Render需要的对象
const obj = Compiler(html);
Render(obj, root);

这就是 Vue 的编译时 + 运行时框架。

纯编译时框架

  • 直接将 HTML 转换成原生 JavaScript:
1
2
3
<div>
<span>hello world</span>
</div>

编译后

1
2
3
4
5
const div = document.createElement('div');
const span = document.createElement('span');
span.innerText = 'hello world';
div.appendChild(span);
document.body.appendChild(div);

二、框架设计的核心要素

1、提升用户的开发体验

1
create(App).mount('not-exist');

在vue中挂载一个不存在的元素的时候会收到一条

[Vue warn]: Failed to mount app: mount target selector “#not-exist” returned null.

用于告诉我们挂载失败了, 并说明了失败的原因, 这是Vue内部经过处理后返回的失败, 如果是原生的js的报错对于定位问题是很不友好的, 所以框架设计和开发的过程中, 提供友好的警告信息至关重要。

2、控制框架代码的体积

体积越小,最后浏览器加载资源的时间也就越少。

提供友好的提示就意味着需要编写更多的代码,体积也就会越大。这个问题的解决办法就是,区分开发和生产环境。
源代码路径

1
2
3
4
5
if (__DEV__ && !res) {
warn(
'Failed to mount app: mount target selector ${container} returnedd null.'
)
}

Vue.js源代码使用 roolup.js 对项目进行构建, __DEV__常量是由它来配置的, 当它为false 的时候这段代码就为

1
2
3
4
5
if (false && !res) {
warn(
'Failed to mount app: mount target selector ${container} returnedd null.'
)
}

这段代码就永远不会执行, 所以输出就不会包含这段代码, 这叫做 dead code 如果为true就会包含这段代码, 在构建生产环境的时候__DEV__会被设置为false, 开发环境会被设置为true, 用于提供开发环境的提示。

3、框架要做到良好的Tree-Shaking

副作用 = 代码执行后,影响了外部环境的状态,或者产生了可观察的结果。

Tree-shaking 用于消除那些永远不会执行的代码也就是dead code, 或者是不会产生副作用的代码。

示例: 没有使用的代码会如何处理?

1
2
3
4
// input.js
import { foo } from './utils.js'
const obj = {};
foo(obj);
1
2
3
4
5
6
7
// utils.js
export function foo(obj) {
obj && obj.foo;
}
export function bar(obj) {
obj && obj.bar;
}

使用npx rollup input.js -f esm -o bundle.js打包。

bar没有被调用所以是没有副作用的方法会被去除。

foo方法虽然被调用了但是其中只进行访问了obj没有任何操作也是没有副作用, 所以这里输出的bundle.js为空。

示例: 如果foo不传入参数但是调用了

1
2
3
4
// input.js
import { foo } from './utils.js'
const obj = {};
foo();
1
2
3
4
5
6
7
// utils.js
export function foo(obj) {
console.log(1);
}
export function bar(obj) {
obj && obj.bar;
}

foo内打印了1所以是有副作用的, 所以会被保留

obj对象没有被使用是没有副作用的会被删除。

所以再次打包后bundle.js的结果是

1
2
3
4
5
6
7
// utils.js
function foo(obj) {
console.log(1);
}

// input.js
foo();

示例: 如果使用Object.defineProperty会如何处理?

1
2
3
4
5
6
7
8
9
// input.js
import { foo } from './utils.js'
const obj = {};
Object.defineProperty(obj, "foo", {
get() {
console.log(1);
}
});
foo(obj);
1
2
3
4
5
6
7
// utils.js
export function foo(obj){
obj && obj.foo;
}
export function bar(obj){
obj && obj.bar;
}

这个时候访问foo方法就有副作用了, 因为访问obj.foo会触发get方法输出1。

所以打包后的结果是

1
2
3
4
5
6
7
8
9
10
11
12
13
// utils.js
function foo(obj) {
obj && obj.foo;
}

// input.js
const obj = {};
Object.defineProperty(obj, "foo", {
get() {
console.log(1);
}
});
foo(obj);

4、框架应该输出怎样的构建产物

IIFE(Immediately Invoked Function Expression):定义后立即执行

1
2
3
(function () {
console.log(1);
})()

可以使用rollup.js指定输出为life来指定输出这种形式的资源。

1
npx rollup input.js -f iife -o bundle.js

输出的结果是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(function () {
'use strict';

// utils.js
function foo(obj){
obj && obj.foo;
}

// input.js
const obj = {};
Object.defineProperty(obj, "foo", {
get() {
console.log(1);
}
});
foo(obj);

})();

vue.global.js是IIFE格式形式的资源, 是根据vue源代码编译的。在引入vue后它会自己立刻调用自己, 然后会挂载全局变量Vue。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">{{ message }}</div>

<script>
const { createApp, ref } = Vue

createApp({
setup() {
const message = ref('Hello vue!')
return {
message
}
}
}).mount('#app')
</script>

也可以通过ESM来引入Vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">{{ message }}</div>

<script type="module">
import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'

createApp({
setup() {
const message = ref('Hello Vue!')
return {
message
}
}
}).mount('#app')
</script>

带-browser的资源是给游览器使用的, 而带-bundler的资源是给rollup.js或webpack等打包工具使用的, 它们的区别是比如__DEV__会使用

!!(process.env.NODE_ENV !== 'production')来代替

vue源代码

1
2
3
4
5
6
if (isBundlerESMBuild) {
Object.assign(replacements, {
// preserve to be handled by bundlers
__DEV__: `!!(process.env.NODE_ENV !== 'production')`,
})
}

这样的好处是可以通过webpck配置自行决定构建资源的目标环境。

5、特殊开关

vue提供了很多的可以开启特性, 对于关闭的特性可以使用Tree-Shaking机制让其不包含在打包后的源代码。

比如__FEATURE_OPTIONS_API__实现类似与__DEV__

vue源代码

1
2
3
__FEATURE_OPTIONS_API__: isBundlerESMBuild
? `__VUE_OPTIONS_API__`
: `true`,

如果是打包工具的话会被替代为__VUE_OPTIONS_API__

这个选项的作用是用于控制是否启用 Vue 2 的 Options API 支持。

6、错误处理

框架的错误处理机制好坏直接决定了用户应用的健壮性, 还决定了用户处理错误的心智负担

例子: 工具模块的错误处理

1
2
3
4
5
6
// utils.js
export default {
foo(fn){
fn && fn();
}
}
1
2
3
4
5
// input.js
import utils from 'utils.js'
utils.foo(() => {
console.log(bar);
})

在工具模块执行的出错了, 这个时候有两个办法, 一个是用户进行try…catch,会增加用户的负担, 如果有上百个类似的函数就需要逐一添加错误处理程序, 还有就是我们来代替用户统一处理错误。

为utils.js添加错误处理

1
2
3
4
5
6
7
8
9
10
// utils.js
export default {
foo(fn) {
try {
fn && fn();
} catch (e) {
console.log('你遇到了错误', e)
}
}
}

这个时候js就不会因为错误而结束执行了, 但是如果有多个方法都需要处理过于麻烦, 所以可以添加一个添加错误处理的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// utils.js
export default {
foo(fn) {
callWithErrorHandlling(fn);
},
bar(fn) {
callWithErrorHandlling(fn);
}
}
function callWithErrorHandlling(fn) {
try {
fn && fn();
} catch (e) {
console.log('你遇到了错误', e)
}
}

虽然现在有统一的错误处理方法了, 但是用户不可以自己处理错误, 需要可以让用户注册统一处理错误的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// utils.js
let HandleError = null;
export default {
foo(fn) {
callWithErrorHandlling(fn);
},
registerErrorHandler(fn) {
handleError = fn;
}
}
function callWithErrorHandlling(fn) {
try {
fn && fn();
} catch (e) {
handleError(e);
}
}
1
2
3
4
5
6
7
8
// input.js
import utils from './utils.js'
utils.registerErrorHandler((e) => {
console.log('用户处理错误', e);
})
utils.foo(() => {
console.log(bar);
})

这个时候错误处理的能力就由用户控制, 用户想忽略就忽略, 想要打印就打印, 这其实就是Vue的错误处理的原理, 也可以在Vue中注册统一的错误处理函数。

7、良好的TypeScript类型支持

TypeScript是由微软开源的编程语言, 简称TS, 它是JavaScript的超集, 能够为JavaScript提供类型支持, 使用TS的好处有很多如代码即文档, 编辑器自动提示, 能够一定程度上避免低级bug, 代码的可维护性更强等, 因此对TS类型支持是否完成也成为评价一个框架的好坏。

例子: 返回值类型丢失

1
2
3
function foo(val: any){
return val;
}

根据ts自动类型推导, 这个函数接受的参数是val, 并且参数类型为any, 该函数直接将参数作为返回值, 该函数的返回值类型是由参数决定的, 如果参数是number类型, 那么返回值应该也是number类型。

1
2
3
4
5
// input.ts
function foo(val: any){
return val;
}
let result = foo('test');

直接上result类型为any, 因为any类型不会因为传入的参数而改变, 为了达到理想的状态, 需要使用泛型

例子: 使用泛型来自动推导类型

1
2
3
4
5
// input.ts
function foo<T>(val: T): T {
return val;
}
let result = foo('e');

这个时候result的类型就是想要的String, 如果传入number就是number。

三、Vue.js的设计思路

1、声明式地描述UI

vue.js 3是一个声明式的UI框架, 它使用了声明式来描述内容。

vue的声明式描述

1
2
3
<h1 onclick="handler">
<span>标题内容</span>
</h1>

在vue的解决方案是(使用模板来描述):

1
2
3
4
5
<template>
<h1 @click="handler">
<span>标题内容</span>
</h1>
</template>

还可以在Vue使用虚拟DOM来描述组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup>
import { h } from 'vue';

function handler() {
console.log('点击了标题');
}

const TitleComponent = {
render() {
return h('h1', { onClick: handler }, [
h('span', '标题内容')
]);
}
};
</script>

<template>
<TitleComponent />
</template>

vue.js会通过render函数的返回值拿到虚拟DOM, h函数就是用于创建虚拟dom对象的, 然后vue.js就可以把组件的内容渲染出来。

使用对象来描述的好处就是可以动态更改标签, 相对模板来说更加的灵活

动态描述标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup>
import { h, ref } from 'vue';

function handler() {
console.log('点击了标题');
}
function levelIncrease() {
level.value = level.value < 6 ? level.value + 1 : 1;
}
let level = ref(1);
const TitleComponent = {
render() {
return h(`h${level.value}`, { onClick: handler }, [
h('span', '标题内容')
]);
}
};
</script>

<template>
<button @click="levelIncrease">点击level+1</button>
<TitleComponent />
</template>

这样可以动态的描述标签, 比模板更加灵活, 如果使用模板的话远没有使用js对象灵活, 下面是使用模板实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup>
import { ref } from 'vue';

function handler() {
console.log('点击了标题');
}
function levelIncrease() {
level.value = level.value < 6 ? level.value + 1 : 1;
}
let level = ref(1);
</script>

<template>
<button @click="levelIncrease">点击level+1</button>
<h1 v-if="level == 1" @click="handler">标题内容</h1>
<h2 v-else-if="level == 2" @click="handler">标题内容</h2>
<h3 v-else-if="level == 3" @click="handler">标题内容</h3>
<h4 v-else-if="level == 4" @click="handler">标题内容</h4>
<h5 v-else-if="level == 5" @click="handler">标题内容</h5>
<h6 v-else-if="level == 6" @click="handler">标题内容</h6>
</template>

2、初识渲染器

上面使用了虚拟dom来构建真实的dom, 其实是利用到了渲染器把js对象变为真实的dom。

例子: 自制渲染器

假如有以下虚拟dom, 这不是vue的虚拟dom

1
2
3
4
5
6
7
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}

编写一个渲染器用于渲染上面的虚拟dom为真实dom, 第一个参数是虚拟dom对象, 第二个参数是真实dom对象作为挂载点, 会把虚拟dom渲染到该对象下。

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
function renderer(wnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(wnode.tag);

// 遍历 vnode.props, 将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick --> click
vnode.props[key] // 事件处理函数
);
}
}

// 处理 children
if (typeof vnode.children == 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(wnode.children));
} else if (Array.isArray(wnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el));
}

// 将元素添加到挂载点下
container.appendChild(el);
}

使用这个渲染器把上面的虚拟dom渲染出来

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<div id="app"></div>
<script src="renderer.js"></script>
<script>
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
const app = document.getElementById('app');
renderer(vnode, app)
</script>

</body>

</html>

实际上vue的渲染器还会响应式更新, 通过diff找出修改了的地方, 然后更新元素。

3、组件的本质

组件的本质就是一组DOM元素的封装, 这组DOM元素就是组件要渲染的内容, 可以使用一个函数来代表组件, 返回值就代表组件要渲染的内容。

添加组件的处理

1
2
3
4
5
6
7
8
9
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}

使用虚拟DOM标识上面的组件

1
2
3
const vnode = {
tag: MyComponent
}

但是前面的render函数没有处理tag为组件的情况, 需要为render函数添加处理tag为组件的处理方法。

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
function renderer(vnode, container) {
if (typeof vnode.tag == 'string') {
mountElement(vnode, container);
} else if (typeof vnode.tag == 'function') {
mountComponent(vnode, container);
}
}

function mountElement(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag);
// 遍历 vnode.props, 将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick --> click
vnode.props[key] // 事件处理函数
);
}
}

// 处理 children
if (typeof vnode.children == 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children));
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el));
}

// 将元素添加到挂载点下
container.appendChild(el);
}

function mountComponent(vnode, container) {
const subtree = vnode.tag();
renderer(subtree, container)
}

使用对象来表示组件

渲染组件就是先调用tag里面的组件方法拿到返回值, 再进行渲染, 组件不一定是函数, vue的渲染组件不是用的方法而是用的对象。

1
2
3
4
5
6
7
8
9
10
11
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}

需要修改一下渲染函数, 调用组件对象内的render()函数

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
function renderer(vnode, container) {
if (typeof vnode.tag == 'string') {
mountElement(vnode, container);
} else if (typeof vnode.tag == 'object') { // ⬅️
mountComponent(vnode, container);
}
}

function mountElement(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag);
// 遍历 vnode.props, 将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick --> click
vnode.props[key] // 事件处理函数
);
}
}

// 处理 children
if (typeof vnode.children == 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children));
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el));
}

// 将元素添加到挂载点下
container.appendChild(el);
}

function mountComponent(vnode, container) {
const subtree = vnode.tag.render(); // ⬅️
renderer(subtree, container)
}

4、模板的工作原理

无法是虚拟DOM(渲染函数), 还是使用模板都是声明式地描述UI, vue.js同时支持这两种方式描述UI, 上面主要是讲述虚拟DOM, 模板的工作主要是依靠编译器

编译器的主要是工作是把模板编译为渲染函数

1
2
3
<div @click="handler">
click me
</div>

经过编译器编译后的结果:

1
2
3
render() {
return ..............
}

vue文件就是一个组件, template标签内的内容就是模板内容

1
2
3
4
5
<template>
<div @click="handler">
click me
</div>
</template>

编译器会把模板内容编译成渲染函数并且添加到组件对象上。

无论使用模板还是手写渲染函数, 对于一个组件来说, 它要渲染的内容都是通过渲染函数产生的, 然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM。

5、Vue.js是各个模块组成的有机整体

组件的实现依赖于渲染器, 模块的编译依赖于编译器, 并且编译后生成的代码是根据渲染器和虚拟DOM的设计决定的。因此Vue.js的各个模块之间是互相关联、互相制约的, 共同构成一个有机整体。

编译器和渲染器是如何配合工作, 并且实现性能提升的?

有以下模板:

1
<div id="foo" :class="cls"></div>

编译成渲染函数(实际上对象不会这么简单):

1
2
3
4
5
6
7
8
9
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
}
}
}

cls是一个变量, 如果更改了后, 渲染器需要找到它, 并且重新渲染, 这个寻找需要消耗一定的时间成本, vue.js在编译阶段会识别出来可能变化的内容, id不会变化, 但是class

可能会发生变化, 所以会在渲染函数添加一个标记(PatchFlag), 用来标记这个虚拟DOM对象可能发生变化, 这样只需要去关注class的变化。

四、响应系统的作用与实现

1、响应式数据与副作用函数

副作用函数是指会产生副作用的函数:

1
2
3
4
function effect() {
const ageSpan = document.getElementById('age');
ageSpan.innerText = 18;
}

当effect执行的时候会设置ageSpan的文本内容, 这个时候就产生了副作用, 影响了外部的内容, 副作用很容易产生, 比如修改了全局变量, 打印内容, 甚至读取变量。

在一个副作用函数中读取了某个对象的属性:

1
2
3
4
5
const obj = { age: 18 };
function effect() {
const ageSpan = document.getElementById('age');
ageSpan.innerText = obj.age;
}

上面的函数会设置ageSpan的innerText, 当obj.age的值改变后, 应该ageSpan的值也跟着变化, 也就是重新执行effect, 如果能够做到这点那么obj就是响应式数据。

2、响应式数据的基本实现

如何实现响应式数据?

  • 读取obj.age会触发get()
  • 设置obj.age会发出set()

可以设置一个拦截对象的读取和写入, 当读取obj.age的时候, 把副作用函数effect存储到一个内, 这就是依赖收集, 然后在set()后重新执行收集的依赖。

可以通过Object.definePropectyProxy对象来实现, 对象属性的读取和设置操作

使用definePropecty设计

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
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8" />
<title></title>
</head>

<body>
<button onclick="changeText()">修改内容</button>
<span>
我今年<span id="age"></span>
</span>
<script>
// 创建存储依赖的桶
let bucket = new Set();
const obj = { age: 18 };
// 用于存储年龄数据
let ageValue = obj.age;
Object.defineProperty(obj, 'age', {
set(newValue) {
console.log('触发设置');
// 不能再源对象上设置, 这样会无限触发set
// obj.age = newValue;

// 使用一个中间变量存储值
ageValue = newValue;
// 重新调用收集的内容
bucket.forEach(fn => fn())

},
get() {
console.log('触发读取');
// 添加副作用方法effect
bucket.add(effect);
// 返回中间变量
return ageValue;
}
})
function effect() {
const ageSpan = document.getElementById('age');
ageSpan.innerText = obj.age;
}
function changeText() {
obj.age++;
}
effect();

</script>
</body>
</html>

使用Proxy设计

使用Proxy对象来更方便的实现, 可以不使用中间变量来存储数据。

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
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8" />
<title></title>
</head>

<body>
<button onclick="changeText()">修改内容</button>
<span>
我今年<span id="age"></span>
</span>
<script>
// 创建存储依赖的桶
let bucket = new Set();
// 原始数据
const data = { age: 18 };
const obj = new Proxy(data, {
get(target, key){
bucket.add(effect);
return target[key];
},
set(target, key, newValue){
target[key] = newValue;
// 调用收集的依赖
bucket.forEach(fn => fn())
// 表示操作成功
return true;
}
})
function effect() {
const ageSpan = document.getElementById('age');
ageSpan.innerText = obj.age;
}
function changeText() {
obj.age++;
}
effect();

</script>
</body>

</html>

上面的代码可以实现简单的响应式系统, 但是不够灵活 bucket.add(effect);

是硬编码, 应该更加灵活的添加副作用方法, 并且如果对象有多个属性, 每个属性都应该有一个自己的桶。

3、设计一个完善的响应系统

响应式系统的优化

响应式系统的工作流程:

  • 当读取操作发生时, 将副作用函数收集到
  • 当设置操作发生时, 从中取出副作用函数并执行

上面添加到桶内的函数是固定的, 硬编码了副作用函数effect, 如果副函数不叫effect就无法使用了。

可以提供一个注册副作用函数的机制

1
2
3
4
5
6
7
8
9
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn){
// 设置当前副作用函数为fn
activeEffect = fn;
// 调用副作用函数
fn();
}

通过调用effect可以注册副作用函数, 然后在Proxy对象的get内部只需要获取activeEffect的值就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = new Proxy(data, {
get(target, key){
if(activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
set(target, key, newValue){
target[key] = newValue;
// 调用收集的依赖
bucket.forEach(fn => fn())
// 表示操作成功
return true;
}
})

使用effect收集依赖后, 由于所有属性都是使用的一个bucket, 所有属性的set都会导致依赖的重新调用, 应该把副作用函数和操作的字段建立联系。

创建一个新的桶WeakMap代替Set作为桶的数据结构:

1
const bucket = new WeakMap();

修改get/set拦截器

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
const obj = new Proxy(data, {
get(target, key) {
// 没有 activeEffect, 直接return
if (!activeEffect) return target[key];
// 获取desMap,它是一个Map类型
let depsMap = bucket.get(target);
// 不存在创建
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 获取deps, 它是一个Set, 里面存储相关键的依赖
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
// 返回属性值
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
// 获取对应对象的Map
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据key获取依赖
const effects = depsMap.get(key);
// 调用收集的依赖
effects && effects.forEach(fn => fn())
}
})

上面的bucket的结构是

  • WeakMap: target --> Map
  • Map: Key --> Set
  • Set: effect-1,effect-2,effect-3.....

为什么使用weakmap而不是map

1
2
3
4
5
6
7
8
const map = new Map();
const weakmap = new WeakMap();
(function() {
const foo = {foo: 1};
const bar = {bar: 2};
map.set(foo, 1);
weakmap.set(bar, 2);
})()

WeakMap 的设计初衷是让键(key)可以被“弱引用”,这样当键对象不再被引用时,它可以被垃圾回收器自动清理。

foo 对象

  • Map 中,foo 是作为强引用存在的。即使 IIFE 执行完毕后,foo 仍然会存在,因为 Map 保留了对它的引用。

bar 对象

  • WeakMap 中,bar 是作为弱引用存在的。由于 WeakMap 不阻止对象的垃圾回收,IIFE 执行完后,bar 对象不再被其他引用所指向,所以它会被垃圾回收器清理掉。

上面的场景中用户代码如果代理的对象已经不存在了, 这个key已经没有存在的意义了, 所以需要使用WeakMap。

封装函数track和trigger

把get和set的逻辑封装成函数track和trigger

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
const obj = new Proxy(data, {
get(target, key) {
// 添加副作用函数到bucket
track(target, key);
// 返回属性值
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
// 触发对应key的副作用函数
trigger(target, key);
}
})
function track(target, key) {
// 没有 activeEffect, 直接return
if (!activeEffect) return target[key];
// 获取desMap,它是一个Map类型
let depsMap = bucket.get(target);
// 不存在创建
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 获取deps, 它是一个Set, 里面存储相关键的依赖
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 调用收集的依赖
effects && effects.forEach(fn => fn())
}

把trigger和track封装成函数后, 增加了灵活性复用性

分支切换与cleanup

分支切换导致的副作用函数遗留问题

1
2
3
4
const data = { ok: true, text: 'hello' };
effect(() => {
document.getElementById('text').innerHTML = obj.ok == true ? obj.text : 'no';
})

effect内部传入了一个函数内部是一个三元表达式, 当obj.ok改变后, 这个副作用函数重新执行了, 执行的分支改变了, 这就是分支切换。

1
2
3
4
5
6
7
8
9
10
dqstIFLX4614
└── text: effects
└── effect....
└── ok: effects
└── effect....

遗留的副作用函数会导致不必要的更新, 比如上面的obj.okfalse的时候, obj.text更新后, effect副作用函数会重新执行

```js
document.getElementById('text').innerHTML = obj.ok == true ? obj.text : 'no';

但是obj.ok为false时, 这个表达式的结果永远为no, obj.text导致了不必要的更新。

解决副作用函数遗留问题

要解决这个问题, 需要每次副作用函数执行时, 可以把副作用函数从关联的依赖集合中移除, 重新收集依赖, 要实现这个就需要知道哪些依赖集合中包含它, 需要重新设计effect函数。

1
2
3
4
5
6
7
8
9
10
11
12
function effect(fn) {
const effectFn = () => {
// 设置当前副作用函数为effectFn
activeEffect = effectFn;
// 调用副作用函数
fn();
}
// 添加数组存储副作用函数关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}

添加一个effectFn作为执行的副作用函数, 并且添加原型deps用于存储依赖集合。

然后就需要修改track函数, 添加依赖到列表。

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
function track(target, key) {
// 没有 activeEffect, 直接return
if (!activeEffect) return target[key];
// 获取desMap,它是一个Map类型
let depsMap = bucket.get(target);
// 不存在创建
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 获取deps, 它是一个Set, 里面存储相关键的依赖
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
// 添加依赖集合到deps列表
activeEffect.deps.push(deps);
}
function effect(fn) {
const effectFn = () => {
// 调用cleanup完成清除工作
cleanup(effectFn);
// 设置当前副作用函数为fn
activeEffect = effectFn;
// 调用副作用函数
fn();
}
// 添加数组存储副作用函数关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
function cleanup(effectFn) {
// 遍历effectFn.deps数组
for(deps of effectFn.deps) {
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}

测试后发现上面代码出现了死循环, 问题出现在trigger函数

1
2
3
4
5
6
7
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 调用收集的依赖
effects && effects.forEach(fn => fn()) // 这一句代码
}

effects.forEach(fn => fn())执行方法的时候, 调用cleanup, 然后会移除effects中的相关副作用函数, 然后又会执行副作用函数, 添加到effects。

用下面的代码来说明:

1
2
3
4
5
6
7
const set = new Set([1]);

set.forEach((item) => {
set.delete(1);
set.add(1);
console.log('遍历中');
});

如果一个值已经被访问过了, 但是该值被删除并重新添加到集合, 如果此时forEach遍历没有结束, 那么该值会重新被访问。

解决set无限循环

使用另一个set集合并遍历它就能解决这个问题, 就不会导致无限循环。

1
2
3
4
5
6
7
8
const set = new Set([1]);

const newSet = new Set(set);
newSet.forEach(() => {
set.delete(1);
set.add(1);
console.log('遍历中');
});

解决之前trigger的无限循环:

1
2
3
4
5
6
7
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectToRun = new Set(effects);
effectToRun.forEach(fn => fn());
}

构建effectToRun集合并遍历它, 替代直接遍历effects集合, 从而避免了无限执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function effect(fn) {
const effectFn = () => {
// 调用cleanup完成清除工作
cleanup(effectFn);
// 设置当前副作用函数为fn
activeEffect = effectFn;
// 调用副作用函数
fn();
console.log(effectFn.deps); // 打印deps
}
// 添加数组存储副作用函数关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}

查看后发现已经成功的解决了副作用函数的遗留问题。

嵌套的effect与effect栈

effect是可以发生嵌套的: