Vue原理

一、Vue原理

  • 响应式系统
    • 学习Vue中如何实现数据的响应式系统,从而达到数据驱动视图。
  • vue中选项方法
    • 学习watch选项 $watch方法 computed选项 $set方法 $nextTick $mount方法的封装
  • template 编译过程
    • 学习Vue内部是怎么把template模板编译成虚拟DOM,从而渲染出真实DOM
  • 虚拟 dom 生成与更新
    • 学习什么是虚拟 DOM,以及Vue中的DOM-Diff原理

二、Vue2 学习路线图

下面这张流程图中表示了vue的关键部分的执行过程,和核心函数。我们可以根据这样一个过程来自己实现一个vue框架。

image-20230704110034877-8439636.png

通过梳理Vue初始化的过程,我们发现实现一个类似于Vue的框架主要需要实现这几部分 响应式系统框架、虚拟dom编译渲染机制 MVVM更新机制,接下来我们先从最基本的响应式系统开始,自己动手写一个Vue的简单框架

【思考】Vue在初始化的过程中主要经历的哪些步骤

【回答】

1、初始化Vue构造函数,挂载属性 方法

2、模板编译成render函数

3、通过Watcher收集依赖

4、diff更新dom

5、渲染dom

【补充】vue是一个标准的MVVM框架么?

Vue 并不完全是一个MVVM框架MVVM只能数据驱动视图,视图更改数据,而不能通过其他方式操作数据。在vue中我们也可以自己手动修改数据,所以vue并不是一个完全意义上的MVVM框架。

三、Vue2 响应式原理

从这一小节开始我们带着大家实现一个Vue框架

我们先来看看面试宝典中的关于Vue响应式的八股文 (P143-4)

image-20230712205031984.png

相信绝大多数的同学看到这个八股文都会感觉头大。学完今天的内容,我们都会对怎么回答vue的响应式原理有了自己的理解。

下面我们一起来揭秘vue的响应式原理到底是怎么实现的!

1、章节概述

我们首先实现学习路线中第一条分支,从状态初始化到数据响应式的过程

image-20230712205309319.png

所谓数据响应式就是能够使数据变化可以被检测并对这种变化做出响应的机制。MVVM框架中要解决的一个核心问题是连接数据层和视图层,通过数据驱动应用,数据变化,视图更新,要做到这点的就需要对数据做响应式处理,这样一旦数据发生变化就可以立即做出更新处理。

data.png

Vue 的响应式原理依赖于Object.defineProperty,Vue通过设定对象属性的 setter/getter 方法来监听数据的变化,通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。

所以在vue中的数据响应式原理主要是给data绑定一个观察着 observe 让数据变成可观察的,我们首先来看源码然后自己尝试手写一个observe

2、环境准备

在这一小节中我们开始自己实现一个Vue框架,通过Vue源码我们了解到Vue使用的rollup构建工具进行打包

/package.json

image-20230419133024189.png

这里我们也使用Rollup实现项目打包,我们之前有学习过脚手架工具webpack,Rollup和webpack的区别在于项目类代码中有大量的代码拆分,构建项目类型的应用显然webpack更为合适,如果想要构建js类库将多个模块打包成一个大的文件rollpu更加合适,同时rollup中提供的tree-shake可以帮助我们自动删除冗余代码

Webpack Rollup
vue-cli, create-react-app 各类应用脚手架 react,vue,three.js,D3,moment

1、源码工程的初始化

1、新建项目文件夹,在文件夹下初始化工程
1
npm init -y

image-20230419134428522.png

获得package.json

image-20230419134457299.png

2、安装Rollup打包依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1,安装 rollup:用于 Vue 源码的打包构建
npm install rollup

// 2,使用 babel:需要安装核心模块 @babel/core;
npm install @babel/core

// 3,rollup 与 babel 关联
npm install rollup-plugin-babel

// 4,浏览器兼容:将 ES6 语法转译为 ES5
npm install @babel/preset-env


// ==> 合并写法:一次性安装开发环境所需的全部依赖
npm install rollup @babel/core rollup-plugin-babel @babel/preset-env -D
3、创建Vue.js文件

创建打包入口:src/index.js

1
2
3
4
5
// src/index.js Vue 构造函数
function Vue(){}

// 导出 Vue 函数,提供外部使用
export default Vue;

image-20230419135420661.png

4、创建 Rollup 配置文件

rollup 默认配置文件:项目根目录下rollup.config.js文件

创建 rollup.config.js,完成 rollup、babel 相关配置:

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
// src/rollup.config.js
import babel from 'rollup-plugin-babel'

// 导出 rollup 配置对象
export default {
// 打包入口
input: './src/index.js',
// 打包出口:可定义为数组,输出多种构件
output: {
// 打包输出文件
file: 'dist/vue.js',
// 打包格式(可选项):iife(立即执行函数)、esm(ES6 模块)、cjs(Node 规范)、umd(支持 amd + cjs)
format: 'umd',
// 使用 umd 打包需要指定导出的模块名,Vue 模块将会绑定到 window 上;
name: 'Vue',
// 开启 sourcemap 源码映射,打包时会生成 .map 文件;作用:浏览器调试ES5代码时,可定位到 ES6源代码所在行;
sourcemap: true,
},
// 使用 Rollup 插件转译代码
plugins: [
babel({
// 忽略 node_modules 目录下所有文件(**:所有文件夹下的所有文件)
exclude: 'node_modules/**'
})
]
}
5、创建 rollup 构建脚本

执行 Rollup 打包构建 Vue,创建 rollup-script 构建脚本:

1
2
3
4
5
6
7
8
9
10
11
// package.json
{
.........
// ollup 命令:默认会去找 node_module/bin/rollup;
// - -c:config 选项,使用配置文件,默认找 rollup.config.js;
// - -w:watch 选项,监听文件变化;当文件发生变化时重新打包;
"scripts": {
"dev": "rollup -c -w"
},
.........
}

dev 脚本解释:

  • rollup 命令:默认会去找 node_module/bin/rollup;
  • -c:config 选项,使用配置文件,默认找 rollup.config.js;
  • -w:watch 选项,监听文件变化;当文件发生变化时重新打包;
6、打包构建 Vue

执行构建脚本 npm run dev

image-20230419135603333.png

src/index.js 输出至 dist/vue.js 其中,vue.js.map 为 sourcemap 源码映射文件

image-20230419135710522.png

7、创建 Html 引入 Vue

创建 dist/index.html 引入 dist/vue.js,打印输出 Vue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!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>
<!-- 引入 vue.js,将会绑定到 window-->
<script src="./vue.js"></script>
<script>
console.log(Vue)
</script>
</body>
</html>

在浏览器中打开index.html,查看控制台输出,此时一个Vue的构建环境就搭建完成了

image-20230419135928971.png

3、Vue函数的封装

【目标】封装一个 Vue 函数并且在 index.html 中引入

【前置知识】

在js中函数和class都可以new,如下:有啥区别呢 ?

Snipaste_2023-08-20_16-04-06.png

class 类是用于创建对象的模板。

我们使用 class 关键字来创建一个类,类体在一对大括号 {} 中,我们可以在大括号 {} 中定义类成员的位置,如方法或构造函数。

每个类中包含了一个特殊的方法 **constructor()**,它是类的构造函数,这种方法用于创建和初始化一个由 class 创建的对象。

创建一个类的语法格式如下:

1
class ClassName {  constructor() { ... } }

在js中除了class函数也可以new,函数本身就是对象,在js中每定义一个函数都会同时生成一个以这个函数体为构造函数的对象,不信你试试

image-20230704111250514.png

可以通过对象来new出一个新的对象。定义 function Vue(options){} 时, 实际上生成了一个Function类型(预定义类型)的对象,对象名叫Vue,对象的构造函数就是这个函数的体。如下

我们在初始化Vue项目的时候使用到new关键字,这里的vue是使用函数定义的。目的是提升vue的灵活性。

1
2
3
4
function Vue (options) {
this.name = options.name;
this.data = options.data;
}

【思考】为什么vue使用函数定义而其他的watcher observer使用class定义?

【回答】核心目的:提升Vue的灵活性:

1、class的所有方法都是不可枚举的,而function声明的函数是可以枚举的。用户可以根据需要定制重写(重载)vue提供的成员方法

2、function 既能当常规函数来用,又能当做函数的属性来用,又能当类来用。相对class更加灵活。

3、对于内部定义的不希望修改的方法,通过class来定义,另外class声明的函数会有变量提升。

下面我们实例化一个Vue

1
2
3
4
let vm = new Vue({
name: 'kilito',
data: {}
});

此时在 vm 实例上就具有了 name 和 data 属性

接下来我们在src/index.js中定义这个类并导出。在构造函数中获取传入的options并挂载到vue实例上。

1
2
3
4
5
6
7
// src/index.js

function Vue (options) {
console.log('Vue构造器执行')
const vm = this;
vm.$options = options
}

并且在 dist/index.html中实例化一个Vue对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 引入 vue.js,将会绑定到 window-->
<script src="./vue.js"></script>
<script>
var vm = new Vue({
name: 'kilito',
data: {
a: {
b: {
c: 1
}
},
arr: [1, 2, 3, 4],
message: "hello world!"
}
});
</script>

image-20230505142517798.png

此时我们访问Vue对象中data里面定义的数据不能直接访问,必须通过vue.data.xxx访问,实际在Vue项目中data里面定义的数据是可以直击访问的,所以我们需要给data中的数据添加一个代理实现数据的直接访问。

4、核心函数 Object.defineProperty 的介绍和简单响应式的实现

【目标】能够了解Object.defineProperty的用法,并且实现一个简单的响应式

为了实现vue中的数据代理,我们需要首先了解一下vue中的响应式核心方法Object.defineProperty

Object.defineProperty 在 vue2 中起到了非常重要的作用,通过Object.defineProperty实现了数据的代理,数据响应式原理,以及vue中的一些重要成员方法。下面我们学习Object.defineProperty的基本概念和用法。

语法:**Object.defineProperty(obj, prop, descriptor)**
其中:obj要在其上定义属性的对象。prop要定义或修改的属性的名称。descriptor将被定义或修改的属性描述符。

参数: 1、obj : 第一个参数就是要在哪个对象身上添加或者修改属性

​ 2、prop : 第二个参数就是添加或修改的属性名

​ 3、desc : 配置项,一般是一个对象

1
2
3
4
5
6
7
8
9
desc 的详细配置
writable: 是否可重写
value: 当前值
enumerable: 是否可以遍历
configurable: 是否可再次修改配置项
get: 读取时内部调用的函数
set: 写入时内部调用的函数

当数据调用的时候触发get 方法,当数据修改的时候触发set方法
1
2
3
4
5
6
7
8
9
10
11
12
<script>
let a = {}
Object.defineProperty(a, 'b', {
value: 1,
})
// 通过 Object.defineProperty 设置的属性默认是不可修改,不可枚举,不可配置
Object.defineProperty(a, 'c', {
value: 2,
enumerable: true, // 是否可以枚举
writable: true // 是否可以修改
})
</script>

【思考】什么是响应式?

【回答】对外界的变化做出反应

【思考】数据响应式的核心思想是什么?

【回答】将数据变成可观察的

下面我们来实现一个简单的数据响应式过程,在 dist 下新建一个 **defineproperty.html **

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
<!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>
<input type="text">
</body>
<script>
// 1. 定义数据
// 2. 实现数据的渲染
// 3. 当视图改变数据改变
// 4. 当数据改变驱动视图更新

let data = {
value: 'hello kilito'
}

document.querySelector('input').addEventListener('input', (e) => {
console.dir(e.target.value)
data.value = e.target.value
})

document.querySelector('input').value = data.value

// 这里的value的作用是形成闭包,拓展函数体内部变量的作用域
function defineReactive(obj, prop, value, cb) {
Object.defineProperty(obj, prop, {
get() {
console.log('数据发生获取')
return value
},
set(val) {
console.log('数据发生了修改!', val)
// 将数据修改之后的值 val 在get中返回
value = val
cb(val)
}
})
}

function update(value) {
document.querySelector('input').value = value
}

defineReactive(data, 'value', data['value'], update)

// data.value = '123'
// 数据发生了修改! 123
// '123'
// 视图发生了改变 123
</script>

</html>

Snipaste_2023-08-20_16-04-59.png

【思考】数据获取的时候触发的哪个方法,数据修改的时候触发的哪个方法?

【回答】获取触发get 修改触发set

【思考】上面这段代码实现了什么呢?

【回答】实现了数据的双向绑定,数据改变视图改变,视图改变数据也改变

【思考】定义defineReactive函数的时候,里面第三个参数value的作用?

【回答】在函数体内部形成闭包结构,用开来拓展函数内部变量的作用域

5、通过data代理,实现数据的访问

【目标】实现data的代理可以直接通过vm实例获取data中定义的数据

【思考】在 vue 中的数据是存放在 data 中为什么可以通过 vm.XXX 直接访问数据呢?

【回答】通过数据代理实现数据的访问

image-20230704114327873.png

此时我们想获取数据 a 需要通过 Vue.$options.data.a ,但是在 vue 中只需要 this.a 就可以获取到 a 的值,这是怎么实现的呢?

在 Vue 中,可以在外部直接通过vm实例进行数据访问和操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let vm = new Vue({
data() {
return {
a: {
b: {
c: 1
}
},
arr: [1, 2, 3, 4],
message: "hello world!"
}
}
});
console.log(vm.message)
console.log(vm.arr.push(4))

当前代码中,外部通过vue实例只能拿到 vue.$options,想要拿到data需要 vue.$options.data,要想实现vue.messagevue.$options.data.message等效,就需要想办法将vue实例操作“代理”到$options.data上;这样,就实现了 Vue 的数据代理我们来观察一下vue的实例,在实例上有一个_data属性 还有我们定义的变量。

image-20230704115013875.png

首先,先做一次代理,将data挂载到 vue._data下(因为Object.defineProperty的第一个参数必须为一的对象,我们,第一层代理更加方便我们在实现属性的追加),这样 vue 实例就能够在外部通过vue._data.message获取到data.message

之后,再做一次代理,将 vue 实例操作 vue.message 代理到 vue.data 上,这样,外部就可以直接通过vue.message 获取到 data.message;

Vue 状态初始化阶段,通过 observe() 实现数据响应式之后,通过 Object.defineProperty 对 _data 中的数据操作进行劫持;将 vue.xxx 在 vue 实例上的取值操作,代理到 vue._data.xxx 上,这样可以简化书写。

下面我们开始实现数据的代理

data挂载到 vue._data下

1
2
3
4
5
6
7
8
function Vue (options) {

console.log('Vue构造函数执行')
const vm = this
vm.$options = options
// options.data 可能是对象也可能是函数
vm._data = typeof(options.data) === 'function' ? options.data() : options.data
}

将vue实例操作vue.message代理到vue._data上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 数据代理 实现非侵入的数据修改
// 定义代理方法
// 将vue实例上的操作,代理到 vue._data上

function _proxy(data) {
const that = this
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get () {
return this._data[key]
},
set (val) {
this._data[key] = val
}
})
})
}

然后在Vue的构造器中使用proxy方法代理数据

1
2
3
4
5
6
7
function Vue (options) {
console.log('Vue构造函数执行')
const vm = this
vm.$options = options
vm._data = typeof(options.data) === 'function' ? options.data() : options.data
_proxy.call(vm,vm._data)
}

此时我们再访问vue对象中的数据,就不需要.data了,观察打印结果:当从vue实例取值时,就会被代理到vm._data取值;

Snipaste_2023-08-20_15-54-59.png

【总结】vue中实现数据直接访问的实现步骤

1、将 data 暴露在 vue._ data 实例属性上
2、利用 Object.defineProperty 将 vue.xxx 操作代理到 vue.
_data 上