前言
本文是通过优秀作者【李永宁】得文章和视频学习得,内容或许大致与作者文章一样,因为是学习他,所以如果您对于vue的源码及其理论知识已经有一定的了解,建议直接阅读【李永宁】作者的【手写vue系列】
Vue3
的生态已经成熟, 其API组合语法糖
让我放弃了继续使用Vue2
的念头,但是随着写了那么多的V3项目, 让我好奇的不再是V3还有那些我未使用过的API?
而是wc, 这个nb,他是咋实现的啊,赶紧学习一下
,抱着这个态度,我决定先学习一下V2的源码技术
,因为V3
是支持V2
的写法的,所以先学习V2
以后在学习V3
会事半功倍,话不多说,直接开整
GITEE项目地址 下的手写VUE2源码
系列
通读顺序原理顺序
根据源码技术的实现,大概顺序为如下几个入口,先了解一下它们分别主要负责功能
main.js
调用入口 通过new Vue()
来使用的文件
index.js
构建入口 创建Vue
的方法和做一些逻辑层处理
initData.js
挂载响应式数据入口 挂载响应式数据到Vue
实例上
initMethodes.js
挂载方法入口 挂载Methdos
方法到Vue
实例上
initComputed.js
挂载计算属性入口 挂载Computed
方法到Vue
实例上
renderHelper.js
安装运行时的渲染帮助函数 比如_c
、_v
、_t
,这些函数会生成Vnode
patch.js
主要用于根据生成的AST数据
生成真实的DOM节点
,也是diff
算法的核心文件
mount.js
渲染文件 主要用于将template
模板转换为AST
对象
asyncUpdateQueue.js
异步队列,按序渲染Watcher
大致了解完以后,接下来逐步实现一个自己的Vue2框架
,学习建议,根据GITEE
里的代码一起学习,会更加容易理解~
new Vue
在使用
Vue
框架时,首先要使用的API
也就是实例化Vue
了, 那么在new Vue
的过程中Vue
都干了些什么呢? 注:本文只简单介绍源码构建中基本的原理构建流程
运行流程图
以下流程图为手写Vue2源码
项目的流程图,主要是抽取了Vue2源码技术
中的核心功能点, 如下(非Vue2全部执行过程)
现在就开始一起跟着流程图一步一步实现一个Vue核心框架吧~
实例化Vue
实例化 Vue
也就是通过 new Vue()
去实例化一个 Vue
对象并创造一个作用域空间, 在这个作用域空间下能够进行一些 Vue
的特性展示比如: 数据响应式更新
、vModel
、vBind
等, 有了这些特性, 极大程度的减轻了开发者的繁琐流程
main.js
首先先大致的列举出使用Vue时, 常用的语法
el
挂载的作用域空间,Vue
必须有一个最上层的Root根节点
来表示作用于空间data
声明响应式, 该属性有两种数据类型,分别是Object
和Function 闭包
methods
方法, 主要定义一些函数方法components
定义子组件的属性, 此属性下都是一个独立的组件对象computed
计算属性, 用于缓存data下响应式变量的操作, 缓存后如果绑定的data没有发生改变,就不会重新执行slot
插槽, 如果子组件没有children
, 那么就显示默认插槽数据
以上属性是重点实现Vue2
的一些特征, 像一些其他属性mounted
、created
、filters
等均可在实现以上属性后自行实现、其原理都可以在熟悉运用Vue2
框架后,实现出来
import Vue from "./index.js";
var vm = new Vue({
el: "#app", // 挂载点
data: {
name: "yunhe达摩院",
age: 16,
school: "郑州大学",
checked: true,
selectIndex: 0,
className: "bgRed",
},
// data也可以是闭包
// data(){
// return {
// name: "yunhe达摩院",
// age: 16,
// school: "郑州大学",
// checked: true,
// selectIndex: 0,
// className: "bgRed",
// }
// },
methods: {
printData() {
this.name = "printData 修改了 name数据";
},
},
// 组件
components: {
coms: {
template: `
<div>{{scopeSlot}}</div>
`,
data() {
return {
sonMsg: "son name is lyf",
};
},
},
// 插槽子组件
"scope-slot": {
template: `
<div>
<slot name="default" v-bind:slotKey="slotKey">
<div>插槽默认</div>
</slot>
</div>
`,
data() {
return {
slotKey: "scope slot content",
};
},
},
},
computed: {
getAge() {
return this.age * 2;
},
},
});
Vue 核心处理
在梳理完常用的属性后, 一起来看一下Vue函数的具体实现
import mount from "./compiler/mount.js"; // 渲染变量到Dom文件
import patch from "./compiler/patch.js"; // Diff算法核心文件
import renderHelper from "./compiler/renderHelper.js"; // 渲染器: 虚拟Dom渲染成真实Dom
import initComputed from "./initComputed.js"; // 初始化并挂载计算属性到Vue
import initData from "./initData.js"; // 初始化并挂载data到Vue
import initMethodes from "./initMethodes.js"; // 初始化并挂载方法到Vue
export default function Vue(options) {
// debugger;
this._init(options);
}
// _init 初始化Vue的工作流程
Vue.prototype._init = function (options) {
// 把数据配置挂载到Vue.$options 上
this.$options = options;
// 初始数据 (data methods props computed等)
initData(this);
// 初始化方法
initMethodes(this);
// 初始化计算属性
initComputed(this);
// 安装运行时的渲染工具函数
renderHelper(this);
// 在实例上安装 patch 函数
this.__patch__ = patch;
// 挂在到dom
if (this.$options.el) {
this.$mount();
}
};
// 挂在实例
Vue.prototype.$mount = function () {
// 挂载到页面上
mount(this);
};
以上代码我们重点看流程, 到目前为止, 我们需要知道头部的 import xxx from "xx.js"
是干什么用的, 因为后续我们要实现它, 这很重要!
在 Vue方法体中接受了一个options
, 这个options
就是new Vue({})
定义的一些属性data、computed、el
等
export default function Vue(options) {
// debugger;
this._init(options);
}
在_init
方法中, 分别去处理了这几个不同的属性
Vue.prototype._init = function (options) {
// 把数据配置挂载到Vue.$options上 方便在全局任何地方都可以拿到配置项
this.$options = options;
// 初始数据 (data methods props computed等)
initData(this);
// 初始化方法
initMethodes(this);
// 初始化计算属性
initComputed(this);
// 安装运行时的渲染工具函数
renderHelper(this);
// 在实例上安装 patch 函数
this.__patch__ = patch;
// 挂在到dom
if (this.$options.el) {
this.$mount();
}
};
处理数据层
initData
只处理data
initMethodes
只处理methods
initComputed
只处理computed
处理模板层
$mount
用于根据tempalte
模板生成AST对象
renderHelper
用于生成将处理过的AST对象
转换为虚拟DOM(VNode: Virtual Node)
的方法, 并且还是diff
更新算法的核心文件,当数据更新时, 负责对比节点更新__patch__
用于根据虚拟DOM
生成真实的DOM
以及DOM节点的属性
等等
处理数据层
initData
initData
文件主要负责将data
下的数据绑定到Vue
实例上, 使他可以通过this.xxx
进行访问, 并设置响应式拦截
import defineReactive from "./defineReactive.js";
import Dep from "./dep.js";
// 初始化数据 具体的方法实现
function initData(vm) {
const { data } = vm.$options;
// data 就是我们在首页 new Vue({data(){return {}}}) 的data
let _data;
if (data) {
// 把data的值挂载到vm实例上
// data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
_data = vm._data = typeof data === "function" ? data() : data;
}
for (const key in _data) {
// 把data数据代理到 _data 下 是他支持 this.xxx 调用
// 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
// props methods computed同理
proxyDataToVm(vm, "_data", key);
}
// 设置完代理以后 给data数据设置响应式更新
observe(vm._data);
}
// 响应式判断和设置响应式
function observe(data) {
// 如果数据已经是响应式 就不需要observe进行依赖收集 否则会造成 重复更新指定节点
if (data.__ob__) return value.__ob__;
return new Observer(data);
}
function Observer(data) {
// 给每个数据都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
Object.defineProperty(data, "__ob__", {
value: this,
// 数据可枚举
enumerable: false,
// 数据可修改
writable: true,
// 数据可配置
configurable: true,
});
// console.log("data ==> ", data);
// 对值进行依赖收集
data.__ob__.dep = new Dep();
// 给对象的每一项都设置响应式
this.walk(data);
}
// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
for (let key in obj) {
// 依次给对象设置拦截
defineReactive(obj, key, obj[key]);
}
};
// 挂载Data到实例Vm
function proxyDataToVm(target, sourceKey, key) {
// 代理 当访问指定targert对象时 例:Obj.a 代理到 Obj.data.a
Object.defineProperty(target, key, {
get() {
return target[sourceKey][key];
},
set(val) {
target[sourceKey][key] = val;
},
});
}
export default initData;
现在让我们一起来解释一下以上的几个重要部分
- 获取到Data响应式数据,并绑定到Vue实例上
function initData(vm) {
const { data } = vm.$options;
let _data;
if (data) {
_data = vm._data = typeof data === "function" ? data() : data;
}
for (const key in _data) {
// 把data数据代理到 _data 下 是他支持 this.xxx 调用
// 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
// props methods computed同理
proxyDataToVm(vm, "_data", key);
}
// 设置完代理以后 给data数据设置响应式更新
observe(vm._data);
}
// 挂载Data到实例Vm
function proxyDataToVm(target, sourceKey, key) {
// 代理 当访问指定targert对象时 例:Obj.a 代理到 Obj.data.a
Object.defineProperty(target, key, {
get() {
return target[sourceKey][key];
},
set(val) {
target[sourceKey][key] = val;
},
});
}
上文中说过data
有两种类型,所以要判断他的类型并取出它的值
_data = vm._data = typeof data === "function" ? data() : data;
取出data
数据以后, 需要让他支持通过 this.xxx
的方式来调用data
变量, 所以要把数据全部代理到Vue
原型上, 因为this
指向的就是Vue
示例, 设置完代理以后, 给data
添加拦截器, 当调用data
时, 我们要知道是谁调用了data
, 这对我们后面的响应式更新
很有必要
for (const key in _data) {
// 把data数据代理到 _data 下 是他支持 this.xxx 调用
// 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
// props methods computed同理
proxyDataToVm(vm, "_data", key);
}
}
// 设置完代理以后 给data数据设置响应式更新
observe(vm._data);
设置数据拦截 响应式中的重点
// 响应式判断和设置响应式
function observe(data) {
// 需要对数据defineProperty 进行重新观测
// 只对对象类型进行观测,非对象类型无法观测
if (typeof data !== "object" || data == null) return;
// 如果已经被观察过
if (data.__ob__) return value.__ob__;
return new Observer(data);
}
function Observer(data) {
// 给每个数据都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
Object.defineProperty(data, "__ob__", {
value: this,
// 数据可枚举
enumerable: false,
// 数据可修改
writable: true,
// 数据可配置
configurable: true,
});
// console.log("data ==> ", data);
// 对值进行依赖收集
data.__ob__.dep = new Dep();
// 给对象的每一项都设置响应式
this.walk(data);
}
// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
for (let key in obj) {
// 依次给对象设置拦截
defineReactive(obj, key, obj[key]);
}
};
我们对data
进行了依赖收集, 并且我们对依赖收集过的对象添加一个 __ob__
的属性, 这样当用户 this.xxx.b = this.xxx
将对象赋值给自身属性时, 依赖收集才不会触发死循环
// 响应式判断和设置响应式
function observe(data) {
// 需要对数据defineProperty 进行重新观测
// 只对对象类型进行观测,非对象类型无法观测
if (typeof data !== "object" || data == null) return;
// 如果已经被观察过
if (data.__ob__) return value.__ob__;
return new Observer(data);
}
如果不过滤掉已经依赖收集过的, 当我们依赖收集的时候, 会无限嵌套下去
对data
进行依赖收集以后,我们开始处理data
的属性,如果发现data
下还有Object
类型的数据,那么就继续依赖收集它
function Observer(data) {
// 给每个对象类型的属性都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
Object.defineProperty(data, "__ob__", {
value: this,
// 数据可枚举
enumerable: false,
// 数据可修改
writable: true,
// 数据可配置
configurable: true,
});
// 对data对象进行依赖收集
data.__ob__.dep = new Dep();
// 给对象的每一项都设置响应式 也就是拦截每一项
this.walk(data);
}
// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
for (let key in obj) {
// 依次给对象设置拦截 如果data下面还有属性是对象
if (typeof obj[key] === "object") {
observe(obj[key]);
} else {
// 如果不是对象直接拦截
defineReactive(obj, key, obj[key]);
}
}
};
defineReactive拦截器
Object.defineProperty
绑定过的对象,会变成响应式
化。也就是改变这个对象的时候会触发get
和set
事件。进而触发一些视图更新。
当我们使用 this.x
调用被Object.defineProperty
绑定过的对象时,会触发get
拦截,我们可以在get
拦截中处理我们的监听逻辑,我们使用``
import Dep from "./dep.js";
// 拦截器
export default function defineReactive(target, key, val) {
const dep = new Dep();
Object.defineProperty(target, key, {
get() {
// Dep.target 指向当前组件的渲染函数
if (Dep.target) {
dep.depend();
}
return val;
},
set(value) {
// 如果赋值相同就不更新
if (val === value) return;
val = value;
// 不考虑设置的值为对象的情况
// 通知更新
dep.notify();
},
});
}
上文代码我们对所有的变量进行了拦截,并通过Dep
让他成为了发布者
, 当被get
订阅的时候, 我们会拦截并将订阅者
存储到我们当前变量的Dep.watcher
中,示例图如下
修改一下代码, 输出查看拦截的Watcher
是否与调用的次数对应,我们主要输出age
和name
, 看看是否每一个变量都被调用了两次
import Vue from "./index.js";
new Vue({
el: "#app",
data: {
name: "张三",
age: 16,
},
computed: {
getAge() {
return this.age * 2;
},
getName() {
return this.name + "是个好人";
},
},
});
在template
模板中分别调用data
和computed
<body>
<div id="app">
<div>{{name}}</div>
<div>{{age}}</div>
<div>{{getAge}}</div>
<div>{{getName}}</div>
</div>
<script type="module" src="./main.js"></script>
</body>
在 defineReactive.js
拦截中输出
export default function defineReactive(target, key, val) {
const dep = new Dep();
console.log(`${key} ==> `, dep);
...
}
查看浏览器输出,我们发现已经根据指定的key
, 将我们的订阅者
全部输出了出来了
订阅者分别为:
data
: 在模板中调用age
触发data
响应式拦截收集的Watcher
computed
: 在模板中调用getAge
触发计算属性拦截的watcher
, 并将dirty
设置为false
, 后续继续读取不会再执行
当我们的data
更新时, 会触发dep.notify
, dep.notify
就是去遍历调用我们订阅者watcher
的回调函数,由图可知,当我们修改name
时, 会触发{{name}} 模板调用
和 computed 计算属性
的更新
我们在浏览器修改一下name
, 查看触发的watcher
更新是否是我们订阅的两个watcher
defineReactive.js
// set 部门添加 console.log(`更新的dep ===> `, dep.watcher);
set(value) {
if (val === value) return;
val = value;
console.log(`更新的dep ===> `, dep.watcher);
debugger;
dep.notify();
},
});
当我们修改name
的时候, 触发了name
的set
拦截, 修改name
数据后, 我们调用dep.notify
方法通知订阅name
的watcher
逐个更新。 示例输出结果很明显, 触发了图中两个订阅者 Watcher
的更新
以上介绍完拦截器以后, 来重点分析一下上文中提到的Dep
究竟干了什么事情,为什么对我们做到响应式变量更新那么重要!
Dep 发布者
当在渲染层使用
data
变量时候,会触发data
变量的defineReactive
的get
拦截而进行依赖收集, 当data
变量发生改变,Dep
会去通知订阅者watcher
更新状态
Dep
说白了他唯一的作用就是通知更新
, 在观察者模式
下, 将发布者
和订阅者
通过某种手段连接起来,当发布者
发布新状态时, 订阅者
能都做到及时的响应, 这就是响应式的原理核心
export default class Dep {
constructor() {
this.watcher = [];
}
static target = null;
depend() {
this.watcher.push(Dep.target);
}
// 通知依赖 watcher 更新
notify() {
this.watcher.forEach((sub) => {
sub.update();
});
}
}
继续参考一下上文中的示例图
Dep
类就是一个很明显的发布者
类, 订阅者
通过depend
来完成订阅, 当发生变化时, 通过 notify
方法, 通知更新状态
depend() {
this.watcher.push(Dep.target);
}
// 通知依赖 watcher 更新
notify() {
this.watcher.forEach((sub) => {
sub.update();
});
重点: 一个data
变量可以被多次订阅
, 例如:模板层调用
、计算属性调用
、过滤器调用
等, 当data
属性发生改变, 会通知和这个data
绑定的watcher
更新, 不会影响其他的变量执行更新
Watcher 订阅者
上文说过
Dep
是发布者
,watcher
是订阅者
, 如果只有发布者
, 没有订阅者
, 那就好比拿着喇叭在沙漠里吆喝买菜:啊~ 五块五块~ 全场五块~ 但是根本无人问津~
Watcher
就是负责根据发布者
的通知执行回调, 它就好比一个打工人任人使唤~~
import Dep from "./dep.js"
class Watcher {
// cb 是修改指定dom的闭包回调,只修改某一个
constructor(cb) {
// 当在视图层面调用 new Wathcer 时, 把当前的节点赋值给 target
// console.log('this ===> ', this);
Dep.target = this
this._cb = cb
// 执行以下回调修改参数
this._cb()
// 处理完毕后 要制空 否则会阻断后面的变量
Dep.target = null
}
// 执行更新
update() {
this._cb()
}
}
export default Watcher
当前
展示Watcher
暂不支持Computed
计算属性缓存, 在initComputed
模块中会有详细介绍
在当前Watcher
中, 有一个update
方法, 用来执行this._cb
方法, 这个方法就是我们在new Watcher(cb)
时传进来的回调函数, 也就是我们想让它做什么事情
例如:在Vue1当中, 我们通过缓存修改Dom节点回调来实现响应式更新
function compuleTextNode(node, vm) {
// 获取匹配的结果
// 替换dom得值
// 写一个回调 专门修改当前这个节点的数据
// 缓存content 因为cb替换后就没有了
const contentCache = node.textContent;
function cb() {
// 取出 {{ 引用的变量 }}
const braceRegx = /({{.*?}})/g;
// 可变动的
let TContent = contentCache;
const getBrace = TContent.match(braceRegx);
for (const keyItem in getBrace) {
// 获取每一项 {{name}} or {{age}}
const keyBrace = getBrace[keyItem].replace(/\s*/g, "");
// 获取花括号下的key
const keyData = keyBrace.match(/{{(.*?)}}/);
// 替换成真实的数据
TContent = TContent.replace(getBrace[keyItem], vm[keyData[1]]);
}
node.textContent = TContent || `${key} 未定义`;
}
// 订阅后即可支持动态修改
new Watcher(cb);
}
上文中我们将替换变量模板字符串
封装成了一个方法, 传递给了Watcher
, 当我们模板的值更新时, 告诉Watcher
执行一下(也就是update
)这个回调函数就可以完成模板的更新.
initMethodes
对于
methods
初始化我没有做过多的处理,这里仅仅只将他绑定到vm
实例上,感兴趣的同学可以将项目clone
下来后自行补充
export default function initMethodes(vm) {
const methods = vm.$options.methods || {};
// console.log("methods ==> ", methods);
// 挂载到 $vm 上 是他可以通过 this.xxx() 调用
for (const key in methods) {
proxyMethdosToVm(vm, methods, key);
}
}
// 代理方法到Vm实例上
function proxyMethdosToVm(target, sourceKey, key) {
Object.defineProperty(target, key, {
get() {
return target[sourceKey][key];
},
set(val) {
console.log("methdos is not set function");
},
});
}
initComputed
如果一定要我在
Vue
中选出一个最实用的API
, 那么我一定选择computed
计算属性
先来回顾计算属性的特征
- 缓存结果:只有依赖项更新的时候才会触发重新计算,否则使用上一次计算缓存的结果
- 惰性求值:只有在真正读取它的 value 时,才会进行计算求值
惰性求值
我们定义了两个计算属性, 当我们在Dom
层或者js
代码中使用 computed
时, 它才会进行计算。如果一个计算属性, 计算开销非常非常大, 但它没有被任何地方使用, 也不会进行求值
computed: {
getAge() {
return this.age + '岁了';
},
getName() {
return this.name + "是个好人";
},
},
缓存结果
当我们在页面上同时调用多次Computed
计算属性时, 我们会发现, Computed
函数只执行了一次
computed: {
getAge() {
console.log('getAge 执行了')
return this.age + '岁了';
},
getName() {
return this.name + "是个好人";
},
},
<div id="app">
<div>{{getAge}}</div>
<div>{{getAge}}</div>
<div>{{getAge}}</div>
<div>{{getAge}}</div>
<div>{{getAge}}</div>
<div>{{getAge}}</div>
</div>
接着我们在页面调用6
次getAge
计算属性,我们会发现 getAge
计算属性函数只执行了一次, 这就是计算属性
的核心, 会在第一次执行时缓存
计算属性执行的结果, 在后续的调用中直接读取缓存的结果
, 这样当我们有一笔巨大的计算开销时, 计算属性
可以帮助我们省下很多的性能空间
实现缓存
import Watcher from "./watcher.js";
export default function initComputed(vm) {
// 获取配置项
const computeds = vm.$options.computed || {};
// 记录 watcher
const watcher = (vm._watcher = Object.create(null));
// 挂载到 $vm 上 是他可以通过 this.xxx() 调用
for (const key in computeds) {
// 实例化watcher 回调函数默认懒执行
watcher[key] = new Watcher(computeds[key], { lazy: true }, vm);
// 将computed属性代理到Vue实例上
proxyMethdosToVm(vm, key);
}
}
// 代理计算属性computed到Vm实例上
function proxyMethdosToVm(vm, key) {
const descriptor = {
get() {
// 拿到计算属性
const watcher = vm._watcher[key];
if (watcher.dirty) {
// watcher.dirty 为true 表示在第一次渲染中没有执行缓存
// 那么就执行它 通知watcher执行 computed回调函数 将回调函数赋值给 watcher.value
watcher.evalute();
}
return watcher.value;
},
set() {
console.log("computed is not setter function");
},
};
Object.defineProperty(vm, key, descriptor);
}
proxyMethdosToVm
方法将我们的 computed
属性挂载到vm
实例上, 挂载方式和initData
雷同,但是需要注意的是, 因为计算属性具有缓存
的特性, 所以当我们get
的时候就不仅仅是Watcher
订阅了
官方使用了两个参数标记当前Watcher是否是计算属性
options
: { lazy: true } 表示是计算属性watcher.dirty
: 保存当前计算属性的缓存状态,true
需要执行缓存false
表示已经缓存过了,不需要缓存了
const watcher = (vm._watcher = Object.create(null));
// 挂载到 $vm 上 是他可以通过 this.xxx() 调用
for (const key in computeds) {
// 实例化watcher 回调函数默认懒执行
watcher[key] = new Watcher(computeds[key], { lazy: true }, vm);
// 将computed属性代理到Vue实例上
proxyMethdosToVm(vm, key);
}
我们将计算属性computeds[key]
保存到Watcher
中, 并lazy: true
标注这个Watcher
是个计算属性
修改一下Watcher.js
import queueWatcher from "./asyncUpdateQueue.js";
import Dep, { popTarget, pushTarget } from "./dep.js";
let uid = 0;
class Watcher {
// cb 是修改指定dom的闭包回调,只修改某一个
constructor(cb, options = {}, vm = null) {
// 序号 异步有序执行时需要 Uid 不能重复
this._Uid = uid++;
// 当在视图层面调用 new Wathcer 时, 把当前的节点赋值给 target
this._cb = cb;
this.vm = vm;
// 记录配置项
this.options = options;
// dirty 计算属性实现缓存的本质 状态 true 未缓存 false 已缓存
this.dirty = !options.dirty;
// 记录cb的执行结果
this.value = null;
// 如果非懒执行, 则直接执行cb函数,cb函数执行的过程会触发 vm.xx 的属性读取行为
if (!options.lazy) {
this.get();
}
}
}
/**
* GET负责执行 Watcher 的 cb 函数
* 负责执行时的依赖收集
*/
Watcher.prototype.get = function () {
pushTarget(this);
this.value = this._cb.apply(this.vm);
popTarget();
};
/**
* 执行 computed 计算属性的回调函数并缓存
*/
Watcher.prototype.evalute = function () {
// 出发计算函数 cb
this.get();
// 状态改为已缓存 实现一次刷新周期内 computed 只执行一次
this.dirty = false;
};
export default Watcher;
改善过的Watcher
用了一个lazy
属性区分了data
和computed
, 如果lazy
不存在或者为false
, 那么就表示不是计算属性
而是data
, 可以直接去获取他的值, 如果是计算属性lazy: true
这里不会做任何处理, 因为计算属性是惰性执行
的, 当在模板
层或者js
层面调用计算属性时, 才会去执行它并缓存
// 如果非懒执行, 则直接执行cb函数,cb函数执行的过程会触发 vm.xx 的属性读取行为
if (!options.lazy) {
this.get();
}
get
方法主要用于执行 _cb
回调函数, 在initComputed.js
中, 我们将computed[key]
传递给了 Watcher
的回调,那么在computed
计算属性中, 我们读取data
的方式是通过 this.xxx
的方式去读取的,所以我们要将_cb
的回调函数的this
指向vm
实例
Watcher.prototype.get = function () {
pushTarget(this);
this.value = this._cb.apply(this.vm);
popTarget();
};
当我们在Dom
层面调用计算属性时, 会触发我们的拦截器get
get() {
// 拿到计算属性
const watcher = vm._watcher[key];
if (watcher.dirty) {
// watcher.dirty 为true 表示在第一次渲染中没有执行缓存
// 那么就执行它 通知watcher执行 computed回调函数 将回调函数赋值给 watcher.value
watcher.evalute();
}
return watcher.value;
}
当拦截到get
操作时,会首先从vm实例上取出对应的Watcher
, 并通过watcher.dirty
判断计算属性是否已经被计算过, 如果没有就使用 watcher.evalute
方法执行计算属性,然后返回watcher.value
计算的结果。
那么当第二次在调用相同的计算属性进来时,watcher.dirty
已经变成了false
, 就表示不需要在进行计算,这时直接使用watcher.value
即可
evalute
Watcher.prototype.evalute = function () {
// 出发计算函数 cb
this.get();
// 状态改为已缓存 实现一次刷新周期内 computed 只执行一次
this.dirty = false;
};
evalute
方法就是执行计算属性, 并将结果赋值给 Watcher.value
存储起来, 然后修改Watcher
的dirty
至此,计算属性缓存就完成了
依赖项更新时重新计算
当计算属性的依赖项发生改变时, 如何进行重新计算?因为在初始化计算的时候,已经将dirty
设置为了false
, 表示后续的调用不需要重新执行计算属性。
所以现在需要在watcher
更新update
的时候检测一下,如果是lazy = true
表示是懒执行也就是计算属性, 那就直接将当前Watcher
的dirty
设置为true
, 然后什么也不做, 等到Dom
更新的时候,会触发computed
的get
操作, 这个时候因为dirty: true
,所以会去重新执行计算属性,实现依赖项更新计算属性同步计算
Watcher.prototype.update = function () {
// 如果是计算属性 lazy 为true表示懒执行也就是计算属性
if (this.options.lazy) {
// 将 dirty 置为 true, 当组件更新时,重新执行 updateComponent 方法,进而执行 render 函数
// 生成新的 vnode, patch 更新阶段将 vnode 变成真实的DOM 节点
// 发生了 this.getAge 的属性读取操作,从而触发 get, 这是 watcher.dirty为true
// 所以会去重新执行 evalute 方法 计算新的 computed 的值
// 执行结束 将dirty 置为false,本次刷新周期内不会重复执行
this.dirty = true;
} else {
// 渲染 render 更新组件
...
}
};
处理模板层
上文中, 我们介绍了
数据响应式绑定
以及computed计算属性
的实现, 到这里就不再对于处理数据层
进行过多的阐述, 处理完数据层
以后, 我们开始介绍一下渲染数据
到模板层
的实现逻辑
模板转换AST结构对象
Vue
采用了一种虚拟DOM
(vnode
)的方式, 来对我们的页面模板进行缓存和更新, 这种处理模板的优势是可以避免对无修改模板进行更新而浪费性能, 那么vue
是如何将我们的模板转换为vnode
的呢? 接下来我们就一起来看一看Vue
是如何转换模板文件
为AST
对象的
修改模板调用
<div id="app">
<div v-on:click="printData('name','张三')">{{name}}</div>
<div>hh{{age}}</div>
<div>{{getAge}}</div>
<div>{{newObj}}</div>
<div>{{getAge}}</div>
<div>{{getSchool}}</div>
<div>{{getAge}}</div>
<div>{{school}}</div>
<div v-bind:class="className">{{name}}</div>
<input type="text" v-model="name" />
<input type="checkbox" v-model="checked" />
<div>checked is: {{checked}}</div>
<div>selectIndex is: {{selectIndex}}</div>
<select v-model="selectIndex">
<option value="Ipad">Ipad</option>
<option value="Coco">Coco</option>
<option value="HUAWEI">HUAWEI</option>
</select>
</div>
mount.js
渲染文件入口
import compileToFunction from "./compileToFunction.js";
export default function mount(vm) {
if (!vm.$options.render) {
// 如果没有render 就去编译,编译后会给render函数赋值
let templateStr = "";
const { el, template } = vm.$options;
// 如果存在 template 表示是子组件
if (template) {
templateStr = template;
} else if (el) {
// el 存在 表示是挂载点 也就是根节点
// <div id="app"></div> outerHTML表示的整个标签 innerHTML表示里面的东西
templateStr = document.querySelector(el).outerHTML;
// 在实例上记录挂载点,记录我们得最外层的父元素, 也就是#app 子元素要插入到这里
vm.$el = document.querySelector(el);
}
// 生成渲染函数
const render = compileToFunction(templateStr);
....
}
}
templateStr
是我们通过 document.querySelector(el).outerHTML
获取的 dom节点
字符串, 也就是我们在new Vue
时候定义的作用域el
块<div id="app"></div>
, 内容如下
输出的内容就是我们的tempalte模板字符串
, Vue
就是通过对模板字符串的处理来转换为我们的AST
数据结构的, 现在我们已经拿到了我们的模板字符串, 我们可以开始进行转换了
compiler/compileToFunction.js
转换模板文件为AST
的核心文件
import parseAstToStr from "./parseAstToStr.js";
export default function compileToFunction(templateStr) {
// 将模板字符串编译为 ast 树结构
const Ast = parseAstToStr(templateStr);
....
}
模板字符串转换AST对象的原理
比如我们的template
模板字符串如下
<div id="app" class="123" v-bind:style="123123">
<div>{{name}}</div>
<div>{{age}}</div>
<div>{{getAge}}</div>
<div>{{getName}}</div>
</div>
字符串匹配规则:
- 匹配
<
表示标签的开始位置 - 匹配
>
或者/>
表示开始标签闭合的位置 根据匹配的内容可以获取到div id="app" class="123" v-bind:style="123123"
, 可以根据这段信息来分析出标签名称
(div
)和标签的属性
(id、class、v-bind:style
) - 匹配
</
标签结束的位置,可以拿到标签开始和结束之前的元素,这段数据就是他的子元素 <div>{{name}}</
div> 判断是否是表达式, 如果是就转换为响应式数据(在这里调用会触发get
拦截)
大致匹配原理如上, 还有一些特殊的匹配规则例如:过滤注释
等
逻辑实现
export default function parseAstToStr(templateStr) {
let root = null;
// 备份模板,不要直接修改模板
let html = templateStr;
// 存放元素的 ast 对象
const stack = [];
// 遍历处理html字符串
// debugger;
while ((html = html.trim())) {
const startIdx = html.indexOf("<");
if (startIdx === 0) {
// 匹配到标签 <div <img <input 等
if (html.indexOf("</") === 0) {
// 结束标签 标签结束以后执行的 (处理标签属性)
parseEnd();
} else {
// 开始标签 分离标签中的 标签名 和 标签属性
parseStartTag();
}
} else if (startIdx > 0) {
// 处理文本 <div>这里面的文本</div>
const nextStartIdx = html.indexOf("<");
if (stack.length) {
// 如果stack不为空说明文字属于栈顶元素的文本节点
processChars(html.slice(0, nextStartIdx));
}
// 处理完截取掉
html = html.slice(nextStartIdx);
}
}
return root;
}
首先是对模板字符串进行了trim
, 然后匹配html.indexOf("<")
开始标签后, 会通过parseStartTag
方法来匹配开始节点的结束标记
处,也就是匹配到了实例中 <div id="app" class="123" v-bind:style="123123">
, 并分离出标签名
和属性
parseStartTag()
分离开始标签的名称
和属性
/*
* 处理开始标签:
*/
function parseStartTag() {
// 找到结束标记
const Inx = html.indexOf(">");
// 截取开始标签内的所有内容 获取它的属性 div id="a" class="vb"
const content = html.slice(1, Inx);
// 更新截取后的html
html = html.slice(Inx + 1);
let tagName = "";
let attrStr = "";
// 找到 div id="a" class="vb" 中的第一个空格 用于区分标签名和属性
const firstSpaceIdx = content.indexOf(" ");
if (firstSpaceIdx === -1) {
// 没有空格表示没有属性 <div></div>
tagName = content;
} else {
// 标签名
tagName = content.slice(0, firstSpaceIdx);
// 属性名
attrStr = content.slice(firstSpaceIdx + 1);
}
// 分离成数组 ['id="1"', 'class="2"']
const attrs = attrStr ? attrStr.split(" ") : [];
// 处理属性数组,得到map对象
const attrsToMaps = parseAstToMap(attrs);
// 生成AST对象
const elementAst = generateToAst(tagName, attrsToMaps);
if (!root) {
root = elementAst;
}
// 将处理的ast对象插入stack栈中
stack.push(elementAst);
}
分离属性
方法 和 根据属性
和标签名
定义的vnode
结构生成方法
// 数组转换map
function parseAstToMap(attrArr) {
const attrMap = {};
for (const attr of attrArr) {
// v-model="123"
// 分离后变成 ['v-model', '"123"']
const [attrName, attrValue] = attr.split("=");
// debugger
attrMap[attrName] = attrValue.replace(/"|'/g, "");
// debugger;
}
return attrMap;
}
// 生成AST对象 AST对象结构
function generateToAst(tag, attrMap) {
return {
// 元素节点类型
type: 1,
// 标签名
tag,
// 原始属性对象
rawAttr: attrMap,
// 子节点
children: [],
// 父节点
parent: [],
};
}
// 处理文本
function processChars(text) {
if (!text.trim()) return;
// 构建文本节点的AST对象
const textAST = {
type: 3,
text,
};
// 文本如果有引入变量 {{name}}
if (text.match(/{{(.*)}}/)) {
textAST.expression = RegExp.$1.trim();
}
// 将文本节点放到栈顶元素的肚子里
stack[stack.length - 1].children.push(textAST);
}
处理完以后匹配的字符串后, 会对已经处理过的内容进行截取, 也就是说, 当我们处理过一个 '<' 开始标记
后, 处理过的这段字符串会被截取, 此时我们的模板字符串变成了这样:
<div>{{name}}</div>
<div>{{age}}</div>
<div>{{getAge}}</div>
<div>{{getName}}</div>
</div>
注意:我们的AST
结构中需要保存元素的父节点
和子节点
, 子节点很容易找, 那就是标签开始标记
和结束标记
内的所有元素都是子元素
, 那么如何获取节点的父元素?
这里Vue
官方采用了一个节点处理栈
, 把匹配到的头节点
存放到栈
中, 当匹配到结束节点
后, 会移除这个栈元素
, 表示当前节点已经处理完成。
当我们匹配到头节点
并处理完毕属性后,在处理节点内容的过程中遇到了子元素的头节点
那么会将子元素入栈
处理, 等子元素
遇到结束标记
后会进行移除, 子元素移除后,此时栈顶元素就是这个子元素
的父元素
, 因为父元素
还没有匹配到结束标识
, 流程图如下:
匹配到结束标记会执行parseEnd()
// 结束标记
function parseEnd() {
// 将闭合标签从html中截断 </div> </body> </xsxsxsxs>
// 我们无法知道标签的长度 所以只能匹配后面的那个字符 > 而不是匹配 </
const endIdx = html.indexOf(">");
html = html.slice(endIdx + 1);
// 截取完闭合标记后执行
processElement();
}
当一个节点处理完毕
后, 我们就要开始处理他的属性
了, 因为我们传递的可能有一些: v-bind:value
v-model='xxx'
@click="xxx"
绑定式的属性, 标签默认情况下不支持这些属性, 所以我们要进行处理和转换
/*
*节点闭合标签被处理完执行
*</div> 被截取掉以后执行
*主要处理节点上面的 v-bind, v-on, v-model等等
*/
function processElement() {
// 栈顶标签处理完以后,弹出栈顶标签 并进一步处理
const curPop = stack.pop();
// 获取栈顶元素的属性
const { rawAttr } = curPop;
// console.log("rawAttr ===> ", rawAttr);
// rawAttr 是未处理的attr属性列表 我们要将处理好的可以使用的绑定到 attrs属性列表中去
curPop.attrs = {};
// 获取属性名列表
const attrKeysArr = Object.keys(rawAttr); // ['v-model', 'v-bind:title', 'v-on']等
// console.log("attrKeysArr ===> ", attrKeysArr);
// 遍历属性名
for (const item of attrKeysArr) {
// console.log("key ========> ", item);
let modelReg = /v-model(.*?)/; // 匹配 v-model="value"
let bindReg = /^(v-bind:|:)(.*?)/; // 匹配 v-bind:title 和 :title
let onReg = /^(v-on:|@)(.*?)/; // 匹配 v-on:click 和 @click
if (modelReg.test(item)) {
// 如果有双向绑定
let bindKey = item.replace(modelReg, "");
processVModel(curPop, bindKey);
} else if (bindReg.test(item)) {
// 如果有v-bind绑定 curPop key value
let key = item.replace(bindReg, "");
// console.log("key bindReg ===> ", key, `v-bind:${key}`);
processVBind(curPop, key, rawAttr[`v-bind:${key}`]);
} else if (onReg.test(item)) {
// 如果有v-on绑定 curPop key value
let key = item.replace(onReg, "");
// console.log("key onReg ===> ", key);
processVOn(curPop, key, rawAttr[`v-on:${key}`]);
} else {
// 如果是常规元素比如 name、id、style静态
curPop.attrs[item] = rawAttr[item];
}
}
}
通过for
循环遍历截取的属性, 如果有命中以下任何一个正则表达式, 就表示是我们动态传递过来的, 需要进行处理, 如果没有命中表示是原生的属性
,直接用即可
let modelReg = /v-model(.*?)/; // 匹配 v-model="value"
let bindReg = /^(v-bind:|:)(.*?)/; // 匹配 v-bind:title 和 :title
let onReg = /^(v-on:|@)(.*?)/; // 匹配 v-on:click 和 @click
处理动态属性
/*
* 处理属性上的v-model双向绑定
* 将处理结果放置到 curPop.attrs 的 VModel 上
* 使用场景
* <select v-model="xx"></select>
* <input type="text" v-model="xx"></input>
* <input type="checkbox" v-model="xx"></input>
*/
function processVModel(curPop, bindKey) {
const { tag, attrs, rawAttr } = curPop;
const { type, "v-model": vModelValue } = rawAttr;
if (tag == "input") {
// 输入框 checkbox'
if (/text/.test(type)) {
attrs.vModel = { tag, type: "text", value: vModelValue };
}
if (/checkbox/.test(type)) {
attrs.vModel = { tag, type: "checkbox", value: vModelValue };
}
} else if (tag == "textarea") {
// 文本域
attrs.vModel = { tag, type: "textarea", value: vModelValue };
} else if (tag == "select") {
// 选择框
attrs.vModel = { tag, value: vModelValue };
}
}
/*
* 处理属性上的v-model双向绑定
* 将处理结果放置到 curPop.attrs 的 VBind 上
* <div v-bind:style="xxx" v-bind:id="xxx"></div>
*/
function processVBind(curPop, bindKey, bindValue) {
curPop.attrs.vBind = {
[bindKey]: bindValue,
};
}
/*
* 处理属性上的v-model双向绑定
* 将处理结果放置到 curPop.attrs 的 VOn 上
* <div v-on:click=""></div>
*/
function processVOn(curPop, vOnKey, vOnValue) {
curPop.attrs.vOn = { [vOnKey]: vOnValue };
}
文中不涉及插槽的处理, 插槽的原理其实很简单, 即时根据匹配到的 slot
标签名去替换传递过来的子元素, 可以自行翻阅示例代码
至此, tempalte
模板转换AST
对象就处理完了, 回顾一下转换逻辑无非就几点
- 根据匹配
标签开始
标记符, 来筛出标签的标签名
和属性集合
并转换成AST
对象 - 根据匹配
标签结束
标记符, 来处理动态属性
- 根据匹配到的
文本
内容, 筛选出是否使用了表达式并存储
处理好的AST
数据结构如下:
根据AST结构生成VNODE渲染函数
上文中我们将
template
转换成了AST
结构的对象, 下一步就是根据生成的AST
对象生成VNODE
渲染函数,VNODE
是Vue
的一个最大的特性, 在模板更新渲染时, 通过VNODE
的新旧节点对比更新,能够极大程度的节省浏览器性能
现在, 我们需要将AST
对象转换为vnode
也就是虚拟DOM
, 虚拟DOM
和真实DOM
是对应的
在我们生成的AST
对象中, 通过标签类型将标签划分了大致两个分类(插槽本文不涉及)
- 节点类型:
div
、input
、select
、span
、a
等标签 - 文本类型: 纯内容节点内的元素,
<div>123</div>
中的123
就是文本类型的
现在我们需要通过不同的类型, 去处理生成不同的Vnode
renderHelper.js
import VNode from "./vnode.js";
/**
* 在 Vue 实例上安装运行时的渲染帮助函数,比如 _c、_v,这些函数会生成 Vnode
* @param {VueContructor} target Vue 实例
*/
export default function renderHelper(target) {
/**
* @param {vm} target vm 实例
* 将 ._c 和 ._v 绑定到 vm 实例上去
* 1. _c 创建VNode标签
* 2. _v 创建文本节点
*/
target._c = createElement;
target._v = createTextNode;
}
/**
* 根据标签信息创建 Vnode
* @param {string} tag 标签名
* @param {Map} attr 标签的属性 Map 对象
* @param {Array<Render>} children 所有的子节点的渲染函数
*/
function createElement(tag, attr, children) {
// 标签节点: 标签名 属性 children
return VNode(tag, attr, children, this);
}
/**
* 生成文本节点的 VNode
* @param {*} textAst 文本节点的 AST 对象
*/
function createTextNode(textAst) {
// 文本节点只有 text
return VNode(null, null, null, this, textAst);
}
/**
* VNode
* @param {*} tag 标签名
* @param {*} attr 属性 Map 对象
* @param {*} children 子节点组成的 VNode
* @param {*} text 文本节点的 ast 对象
* @param {*} context Vue 实例
* @returns VNode
*/
export default function VNode(tag, attr, children, context, text = null) {
return {
// 标签名称 div span a select
tag,
// 属性 Map 对象
attr,
// 父节点
parent: null,
// 子节点组成的 Vnode 数组
children,
// 文本节点的 Ast 对象
text,
// Vnode 的真实节点
elm: null,
// Vue 实例
context,
};
}
以上代码中, 我们定义了createElement
和createTextNode
来分别处理节点
和文本
, 并将其绑定在了 vm
的 _c
、_t
, 这样我就可以在全局调用生成vnode
函数
target._c = createElement;
target._v = createTextNode;
target
指向的是 vm
示例, 因为我们在_init
初始化时候将this
传递给了renderHelper
// 初始化 把data绑定到
Vue.prototype._init = function (options) {
...
// 安装运行时的渲染工具函数
renderHelper(this);
...
};
将生成vnode
的函数绑定到vm
实例上以后, 现在开始处理AST
对象, AST
对象是一个Obj
结构的数据, 所以要递归
去处理它, 在compileToFunction.js
中, 我们将parseAstToStr
处理的Ast
传递给generate
处理成渲染Vnode
函数
import generate from "./generate.js";
import parseAstToStr from "./parseAstToStr.js";
export default function compileToFunction(templateStr) {
// 将模板字符串编译为 ast 树结构
const Ast = parseAstToStr(templateStr);
// console.log(" Ast======> ", Ast);
// 从Ast结构生成渲染函数
const render = generate(Ast);
return render;
}
generate.js
Ast
对象是一个嵌套关系的结构对象, 如果一个节点的children
属性有值就表示它具有子节点,在children
属性中存在的也是多个独立的节点,所以要继续去循环处理判断它是否具有children
, 如果没有children
就表示到这里就是最里层了也就是文本节点
,就去读取它的text
属性
- 节点有
children
: 表示具有子元素, 遍历调用子元素 - 节点有
text
: 表示是文本节点, 直接读取后终止当前遍历
节点有 children
节点有 text
实现
/**
* 从 ast 生成渲染函数
* @param {*} ast ast 语法树
* @returns 渲染函数
*/
export default function generate(ast) {
// 渲染函数字符串形式
const renderStr = getElement(ast);
// 根据不同的类型 调用不同的处理函数
// 通过 new Function 将字符串形式的函数变成可执行函数,并用 with 为渲染函数扩展作用域链
return new Function(`with(this) { return ${renderStr} }`);
}
/**
* 解析 ast 生成 渲染函数
* @param {*} ast 语法树
* @returns {string} 渲染函数的字符串形式
*/
function getElement(ast) {
// console.log("ast => ", ast);
const { tag, rawAttr, attrs } = ast;
// 生成属性 Map 对象,静态属性 + 动态属性
// rawAttr 是保留的原生的属性 id class 等
// attr 是处理的 v-bind:title @click 等动态属性
const attrObj = { ...rawAttr, ...attrs };
// 处理子节点,得到一个所有子节点渲染函数组成的数组
const children = genChildren(ast);
// console.log("children ==> ", children);
// 生成 VNode 的可执行方法
return `_c('${tag}', ${JSON.stringify(attrObj)}, [${children}])`;
}
/**
* 处理 ast 节点的子节点,将子节点变成渲染函数
* @param {*} ast 节点的 ast 对象
* @returns [childNodeRender1, ....]
*/
function genChildren(ast) {
const ret = [];
const { children } = ast;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.type === 3) {
// 文本
ret.push(`_v(${JSON.stringify(child)})`);
} else if (child.type === 1) {
// 如果子节点有节点类型 继续递归执行
ret.push(getElement(child));
}
}
return ret;
}
getElement
和 genChildren
分别用于处理 父节点
和子节点
, 当一个节点有children
属性, 会去优先处理子元素, 将处理好的子元素VNode
插入到父元素Vnode
的children
里。
递归处理结束后, 处理后的内容如下:
_c
是生成标签函数, 会在执行函数 target._c
时, 生成标签, _v
是生成文本函数, 会在执行target._v
时, 生成文本
但是由于我们在递归时输出的是字符串类型的函数体结构(为什么生成字符串
而不是直接调用, 因为我们在生成渲染函数时
不需要调用执行, 调用的时机是new Watcher
传递渲染函数, watcher
的回调函数会在初次渲染
时立即执行render
渲染函数, 因为render
需要在初次渲染
和更新渲染
时调用, 所以创建时不需要调用)
如何将字符串函数体
转化为函数,并让他能够调用vm
实例上的_v
、_c
With 拓展符
官方解释
With
通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身
什么意思呢? 大概就是我想使用一个对象的多个属性, 比如obj
, 我需要用到obj
里面的一些属性, 但是我不想每次都使用obj.a
、obj.b
、obj.c
, 这样会使我的代码变得很累赘, with
就是可以解决这个问题的(但是通常也可以使用es6
的解构
运算符)
const obj = {
a:1,
b:2,
c:3,
...
}
with
使用多个Obj
元素计算
with (obj) {
a+b+c
}
输出查看结果
with
可以使我们快速的调用对象用的一些属性
大致了解完with
的作用后, 我们看上文中的示例代码:
export default function generate(ast) {
const renderStr = getElement(ast);
// 通过 new Function 将字符串形式的函数变成可执行函数,并用 with 为渲染函数扩展作用域链
return new Function(`with(this) { return ${renderStr} }`);
}
with(this) { return ${renderStr} }
, 我们在with
中使用了this
, this
指向的就是我们调用的时候当前的作用域
在mount.js
中, 我们将 render
绑定到了vm
实例上
// 将渲染函数挂载到 $options 上
vm.$options.render = render;
所以with(this)
就指向了vm
实例, 这样当我们执行的 _c
或者 _v
时, 就等于调用了 this._c
和 this._v
, 随后通过 new Function
将字符串函数体
转换为函数
示例程序:
实例中 new Function
将我们的字符串函数体 const funStr = function a(){return 123}
转换为了可执行函数, 执行fun()()
即可执行字符串函数
体转换的函数
将渲染vnode的函数render
,绑定到vm
的实例上, 因为需要通过with
拿到_c
、_v
, 并且全局可以使用它
export default function mount(vm) {
...
// 生成渲染函数
const render = compileToFunction(templateStr);
// 将渲染函数挂载到 $options 上
vm.$options.render = render;
}
模板渲染
上文中, 我们处理了
响应式数据
、计算属性
、模板字符串
转换AST对象
然后在转换vnode
, 处理完这些流程以后, 我们就可以通过vnode
来创建真实的Dom
, 并将表达式数据(data、computed)
绑定到真实Dom
下
模板渲染大致流程:
将渲染函数
注册到Watcher
中, 一个组件
对应一个组件渲染Watcher
import mountComponent from "./mountComponent.js";
...
export default function mount(vm) {
....
mountComponent(vm);
}
mountComponent.js
import Watcher from "../watcher.js";
import Vue from "../index.js";
/**
* 根据处理好的render进行编译
*/
export default function mountComponent(vm) {
/**
* updateComponent更新组件的的函数
* 当data修改时候调用vm._update函数实行页面响应时
*/
const updateComponent = () => {
/**
* vm._render() 返回的是处理过的ast对象
*/
vm._update(vm._render());
};
// 实例化一个渲染 Watcher,当响应式数据更新时,这个更新函数会被执行
new Watcher(updateComponent);
}
/**
* 负责执行 vm.$options.render 函数
*/
Vue.prototype._render = function () {
/**
* 给 render 函数绑定 this 上下文为 Vue 实例
* this.$options.render 返回的是一个渲染函数 用来渲染dom节点
*/
return this.$options.render.apply(this);
};
/*
* 负责对比节点完成更新
*/
Vue.prototype._update = function (vnode) {
// 老的 VNode
const prevVNode = this._vnode;
// 新的 VNode
this._vnode = vnode;
// debugger;
if (!prevVNode) {
// 老的 VNode 不存在,则说明时首次渲染根组件
// this.$el 老节点
// vnode 新节点
this.$el = this.__patch__(this.$el, vnode);
} else {
// 后续更新组件或者首次渲染子组件,都会走这里
this.$el = this.__patch__(prevVNode, vnode);
}
};
mountComponent
方法注册了一个组件渲染Watcher
, 回调函数是我们上文中处理过的render
生成vnode
的函数
const updateComponent = () => {
/**
* vm._render() 返回的是处理过的ast对象
*/
vm._update(vm._render());
};
当我们的组件第一次执行
或者更新
时, 都会重新执行render
函数生成新的vnode
, 而不是直接更新DOM层, 避免了浏览器直接更新真实Dom
所消耗的高额性能
render
函数我们使用apply
改变了this
指向, 因为我们在将字符串函数体转换为函数的时候, 使用了with
Vue.prototype._render = function () {
/**
* 给 render 函数绑定 this 上下文为 Vue 实例
* this.$options.render 返回的是一个渲染函数 用来虚拟dom节点
*/
return this.$options.render.apply(this);
};
update
更新函数, 会在执行时进行判断处理是否是初次更新
, 判断条件就是判断是否拥有老vnode
/*
* 负责对比节点完成更新
*/
Vue.prototype._update = function (vnode) {
// 老的 VNode
const prevVNode = this._vnode;
// 新的 VNode
this._vnode = vnode;
if (!prevVNode) {
// 老的 VNode 不存在,则说明时首次渲染根组件
// this.$el 老节点
// vnode 新节点
this.$el = this.__patch__(this.$el, vnode);
} else {
// 后续更新组件或者首次渲染子组件,都会走这里
this.$el = this.__patch__(prevVNode, vnode);
}
};
patch
patch
的逻辑主要由两块:
- 初次渲染根据
vnode
创建真实dom
- 更新时对比新旧
vnode
对依赖DOM
块进行更新
patch
实现逻辑
/**
* 初始渲染和后续更新的入口
* @param {VNode} oldVnode 老的 VNode
* @param {VNode} vnode 新的 VNode
* @returns VNode 的真实 DOM 节点
*/
export default function patch(oldVnode, vnode) {
// console.log("vnode ===> 新节点 ==> ", vnode);
if (oldVnode && !vnode) {
// 老节点存在,新节点不存在,销毁了
return;
}
if (!oldVnode) {
// 如果没有老节点,说明是初次渲染
createElm(vnode);
} else {
// 更新
if (oldVnode.nodeType) {
// nodeType是真实节点携带的
// 父节点
const parent = oldVnode.parentNode;
// 参考节点,即老的 vnode 的下一个节点 也就是 script标签,新节点要插在 script 的前面
const referNode = oldVnode.nextSibling;
// 创建元素 将vnode变成真是节点,并添加到父节点内
createElm(vnode, parent, referNode);
// 移除老的 vnode
parent.removeChild(oldVnode);
} else {
// 更新 update
patchVndoe(oldVnode, vnode);
}
}
return vnode.elm;
}
生成虚拟DOM
后, 会进入patch
处理阶段, 也就是生成真实DOM
和更新DOM
的逻辑层
- 没有
oldVnode
: 初次渲染全部渲染DOM节点
- 有
oldVode
: 更新DOM节点
第一次更新 dom
代码的处理逻辑
为什么更新的时候要判断oldVnode.nodeType
因为第一次渲染是全部节点渲染
if (oldVnode.nodeType) {
// nodeType是真实节点携带的
// 父节点
const parent = oldVnode.parentNode;
// 参考节点,即老的 vnode 的下一个节点 也就是 script标签,新节点要插在 script 的前面
const referNode = oldVnode.nextSibling;
// 创建元素 将vnode变成真是节点,并添加到父节点内
createElm(vnode, parent, referNode);
// 移除老的DOM 移除页面上最开始的`DOM`
parent.removeChild(oldVnode);
} else {
// 更新 diff 算法核心 update
patchVndoe(oldVnode, vnode);
}
当我们根据template
生成ast
结构时, template
已经存在, 并且是真实的DOM
在未渲染前, 页面是这个样子的
所以当我们初次渲染
更新时, 会去根据页面上已经存在的dom (<div>{{name}}</div>)
更新成对应的新生成的真实DOM (<div>我是name变量对应的数据</div>)
, 也就是说在我们根据vnode
生成真实的DOM
后, 会替换移除掉页面上最开始的DOM
, 当我们再次更新时 oldVnode
就不会再是原生的DOM
属性,而是我们生成的vnode
解构
替换原生 DOM
前
移除原生DOM, 更新成自己生成的真实DOM
后
根据 vnode
创建 真实dom
创建 真实dom
的逻辑和上文中根据AST数据结构生成vnode虚拟dom
是相似的, 都是通过递归创建, 最后将创建的子元素
插入到父元素内
在创建文本节点时会判断文本中是否存在表达式
调用, 如果text.expression
有值那么就表示有表达式, 就通过vm
实例去取出对应的数据, 在取出时会触发data
的get
拦截, 更新时会重新执行渲染更新
/**
* 创建元素
* @param {*} vnode VNode
* @param {*} parent VNode 的父节点,真实节点
* @param {*} referNode 参考节点
* @returns
*/
function createElm(vnode, parent, referNode) {
// 记录节点的父节点 因为我们要将当前创建的元素 插入到父节点内
vnode.parent = parent;
// 创建自定义组件,如果是非组件,则会继续后面的流程
if (createComponent(vnode)) return;
const { attr, children, text } = vnode;
if (text) {
// text存在说明是文本节点
// 创建文本节点,并插入到父节点内
vnode.elm = createTextNode(vnode);
} else {
// 元素节点
// 创建元素节点,在vnode上记录对应的dom节点
vnode.elm = document.createElement(vnode.tag);
// 给元素设置属性
setAttribute(attr, vnode);
// 递归创建子节点 子节点的父元素是当前得本身 也就是vnode.elm
for (let i = 0, len = children.length; i < len; i++) {
createElm(children[i], vnode.elm);
}
}
// 如果存在 parent,表示知道是谁的子节点, 将创建的节点插入到父节点内
// 如果没有表示是最外层的 因为#app是作用域,是最外层的,所以要将他插入到对应的位置,也就是获取他的同级元素,因为我们的作用域可以是任何地方
if (parent) {
const elm = vnode.elm;
if (referNode) {
// 如果是#app,就插入到下方兄弟元素的上方
parent.insertBefore(elm, referNode);
} else {
// 否则插入父元素内
parent.appendChild(elm);
}
}
}
/**
* 创建文本节点
* @param {*} textVNode 文本节点的 VNode
*/
function createTextNode(textVNode) {
let { text } = textVNode,
textNode = null;
if (text.expression) {
// 存在表达式,这个表达式的值是一个响应式数据
const value = textVNode.context[text.expression];
textNode = document.createTextNode(
typeof value === "object" ? JSON.stringify(value) : String(value)
);
} else {
// 纯文本
textNode = document.createTextNode(text.text);
}
return textNode;
}
setAttribute
属性设置, 在vnode
渲染中我们只是区分了动态属性
和原生属性
, 并没有对动态属性进行进一步的处理, 所以在渲染真实Dom
时,我们要进行动态属性
的实现 (vOn
、vModel
、vBind
)
也就是说, 动态属性处理的时机是: 在渲染真实DOM
时,根据vnode
层面处理的动态属性对象
的集合来实现动态属性
的具体逻辑:
本文只处理vOn
、vModel
、vBind
, 感兴趣的同学可以自行实现其他动态绑定操作
/**
* 给节点设置属性
* @param {*} attr 属性 Map 对象
* @param {*} vnode
*/
function setAttribute(attrs, vnode) {
// 遍历属性,如果是普通属性,直接设置,如果是指令,则特殊处理
for (let name in attrs) {
if (name === "vModel") {
// v-model 指令
const { tag, value } = attrs.vModel;
setVModel(tag, value, vnode);
} else if (name === "vBind") {
// v-bind 指令
setVBind(vnode);
} else if (name === "vOn") {
// v-on 指令
setVOn(vnode);
} else {
// 普通属性
vnode.elm.setAttribute(name, attrs[name]);
}
}
}
/**
* v-model 的原理
* @param {*} tag 节点的标签名
* @param {*} value 属性值
* @param {*} node 节点
*/
function setVModel(tag, value, vnode) {
const { context: vm, elm } = vnode;
// vm[value]
if (tag === "select") {
// 选择框
// 下拉框,<select></select>
Promise.resolve().then(() => {
// 利用 promise 延迟设置,直接设置不行,
// 因为这会儿 option 元素还没创建
elm.value = vm[value];
});
elm.addEventListener("change", function () {
vm[value] = elm.value;
});
} else if (tag === "input" && elm.type === "text") {
// 文本框,<input type="text" />
elm.value = vm[value];
elm.addEventListener("input", function () {
vm[value] = elm.value;
});
} else if (tag === "input" && vnode.elm.type === "checkbox") {
// 多选框
elm.checked = vm[value];
elm.addEventListener("change", function () {
vm[value] = elm.checked;
});
}
}
/**
* v-bind 原理
* @param {*} vnode
*/
function setVBind(vnode) {
const {
attr: { vBind },
elm,
context: vm,
} = vnode;
// 处理bind得绑定数据
for (const attrName in vBind) {
// v-bind:id="name" 变成id="123"
elm.setAttribute(attrName, vm[vBind[attrName]]);
// 删除掉 v-bind:id
elm.removeAttribute(`v-bind:${attrName}`);
}
}
/**
* v-on 原理
* @param {*} vnode
*/
function setVOn(vnode) {
const {
attr: { vOn },
elm,
context: vm,
} = vnode;
for (const eventName in vOn) {
elm.addEventListener(eventName, function (...args) {
// 调用方法,因为方法也被代理到vm上 可以通过 this. 来访问,例如 this.app()
vm[vOn[eventName]].apply(vm, args);
});
}
}
vModel
的逻辑相对来说比较复杂, 因为需要根据不同的标签处理不同的逻辑, 这里只处理了三个标签, 在vue
源码中, 处理的量是非常庞大的, 这也就是我们用起来非常舒服的核心, 尤大
已经给我们所有可能的操作全部实现了一遍
到这里为止, 我们已经处理了:
- 根据
tempalte
模板文件生成ast
对象 - 根据
ast
对象创建生成vnode
渲染方法 - 根据生成的
vnode
构建真实的dom
下一步, 也就是Vue
处理模板更新的核心 diff算法
等真正看懂diff算法
以后, 我相信你说一句尤大NB
diff 更新
上文中, 我们在初次渲染时, 会根据生成的
虚拟Dom Vnode
的属性, 生成对应的真实的DOM
, 如果vnode
嵌套层数较高时,每次都重新渲染全部的真实DOM
, 会造成一定的性能消耗, 所以当我们data
时, 不能直接重新生成真实的DOM
, 我们应该只修改被更新数据
所调用的DOM层
。 这个时候diff
算法出现了, 他就是帮助我们解决避免大面积更新真实DOM
的救世主
patch
方法中, 当data
更新时, 会执行处理patchVndoe(oldVnode, vnode)
方法, 分别传入老vnode虚拟节点
和新生成的虚拟节点
patchVndoe
更新逻辑如下:
- 如果新老节点
oldVnode === vnode
一致, 表示当前dom没有发生改变 - 如果新老节点都存在子节点:
vnode.children
oldVnode.children
, 那么就使用diff算法
去更新 - 如果新节点有子节点, 老节点不存在子节点, 就新增节点
- 如果新节点没有子节点, 老节点有子节点, 就移除节点
- 如果新节点存在
表达式 vnode.text.expression
文本节点, 验证表达式的新旧值, 不同替换相同略之 - .........(其他逻辑暂不多叙述)
/**
* 对比新老节点 oldVnode 和 vnode , 找出其中的不同,然后更新老节点
* @param {*} oldVnode
* @param {*} vnode
*/
function patchVndoe(oldVnode, vnode) {
// 没有进行更新
if (oldVnode === vnode) return;
// 如果有更新 就将旧的 vnode 同步到新的vnode上
vnode.elm = oldVnode.elm;
// 拿到新老 vnode 得孩子节点
const newChild = vnode.children;
const oldChild = oldVnode.children;
// 新节点不存在文本节点
if (!vnode.text) {
// 新老节点都存在子节点
if (newChild && oldChild) {
// diff处理
updateChildren(newChild, oldChild);
} else if (newChild && !oldChild) {
// 新节点有孩子,老节点没孩子 新增节点
} else if (oldChild && !newChild) {
// 老节点有孩子 新节点没孩子 删除老节点
}
} else {
// 如果存在文本节点
if (vnode.text.expression) {
// 文本中是否存在表达式
// 获取表达式的新值 也就是更新后的新值
const value = JSON.stringify(vnode.context[vnode.text.expression]);
try {
// 获取表达式旧值
const oldValue = oldVnode.elm.textContent;
if (value !== oldValue) {
// 新表达式值和旧表达式数值不一样 更新
oldVnode.elm.textContent = value;
}
} catch (error) {
// 防止更新时候遇到插槽
}
} else {
// 文本不存在表达式
}
}
if (newChild === oldChild) {
}
}
diff
算法 验证新旧节点更新
updateChildren
diff
验证逻辑
diff
算法采用的是双端对比算法
diff
算法在处理前做了四种假设猜想
双端对比法
两对指针判断
- 新节点数组的开始位置、新节点数组的结束位置
- 就节点数组的开始位置、就节点数组的结束位置
对比逻辑验证
- 如果
新节点开始位置和新节点结束位置重复
表示新节点先遍历结束, 剩余的节点就是需要删除的节点 - 如果
旧节点开始位置和旧节点结束位置重复
表示旧节点先遍历结束, 剩余的节点就是需要新增的节点
具体的验证逻辑参考一下假设, 命中假设一
和命中假设四
完美的阐述了双端对比
的优势和逻辑
diff四种假设
- 假设一: 新开始节点和老开始节点 是同一个节点
newStartNdoe
oldStartNode
- 假设二: 新开始节点和老结束节点 是同一个节点
newStartNdoe
oldEndNode
- 假设三: 新结束节点和老开始节点 是同一个节点
newEndNode
oldEndNode
- 假设四: 新结束节点和老结束节点 是同一个节点
newEndNode
oldEndNodem
命中假设一
命中假设二
命中假设三
命中假设四
没有命中任何假设
如果diff
没有命中以上四种假设逻辑, 那么就会老老实实的去进行遍历对比更新, 一般增删改查都会命中四种假设, 除非当数据完全替换时, 数据完全替换必然要发生子元素全部重新渲染DOM
实现逻辑
/**
* diff算法 对比孩子节点,找出不同点,然后将不同点更新到老节点
*/
function updateChildren(newChild, oldChild) {
// 四个游标 分别记录新老节点的遍历位置和总长度
let newStartIdx = 0;
let newEndIdx = newChild.length - 1;
let oldStartIdx = 0;
let oldEndIdx = oldChild.length - 1;
// 遍历 循环遍历所有的新老节点,找出节点不一样的地方,然后更新
// 做假设降低时间复杂度 两端对比
while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
// 获取新开始记录节点
const newStartNdoe = newChild[newStartIdx];
// 新结束节点
const newEndNode = newChild[newEndIdx];
// 老开始节点
const oldStartNode = oldChild[oldStartIdx];
// 老结束节点
const oldEndNode = oldChild[oldEndIdx];
// 假设
// newCHILDREN A B C D
// oldCHILDREN a b c d
// 假设一: 新开始节点 和 老开始节点 是同一个节点
// 假设二: 新开始节点 和 老结束节点 是同一个节点
// 假设三: 新结束节点 和 老开始节点 是同一个节点
// 假设四: 新结束节点 和 老结束节点 是同一个节点
// 如果没有命中以上任何假设 就老老实实遍历
if (someVnode(newStartNdoe, oldStartNode)) {
// 命中假设一
patchVndoe(oldStartNode, newStartNdoe);
// 移动游标
newStartIdx++;
oldStartIdx++;
} else if (someVnode(newStartNdoe, oldEndNode)) {
// 命中假设二
patchVndoe(oldEndNode, newStartNdoe);
// 将老节点移动到新开始的位置
oldEndNode.elm.parentNode.insertBefore(
oldEndNode.elm,
oldChild[newStartIdx]
);
// 移动游标
newStartIdx++;
oldEndIdx--;
} else if (someVnode(newEndNode, oldStartNode)) {
// 命中假设三
patchVndoe(oldEndNode, newEndNode);
// 将老节点移动到新开始的位置
oldEndNode.elm.parentNode.insertBefore(
oldEndNode.elm,
oldChild[newEndIdx]
);
// 移动游标
newEndIdx--;
oldStartIdx++;
} else if (someVnode(newEndNode, oldEndNode)) {
// 命中假设四
patchVndoe(oldEndNodem, newEndNode);
// 移动游标
newStartIdx--;
oldStartIdx--;
} else {
// 都没命中 老老实实遍历
}
}
// 跳出循环,说明有一个节点已经遍历结束们需要处理下后续的事情
if (newStartIdx < newEndIdx) {
// 说明老节点遍历结束, 则将剩余的新节点添加到dom中
} else if (oldStartIdx < oldEndIdx) {
// 说明新节点先遍历结束 则将剩余的老节点删除掉
}
}
/**
* 判断两个节点是否是同一个
*/
function someVnode(a, b) {
// if (!a && !b) return false;
return a && b && a.key === b.key && a.tag === b.tag;
}
假设猜想的优势
:渲染真实DOM
是非常消耗性能的, 尤其是大批量的渲染, 当对比更新子组件
时, 如果循环语句
命中一条假设猜想
, 就可以少判断渲染一次
当老节点先遍历结束
就表示新节点有新增
的DOM
, 创建节点插入当新节点先遍历结束
就表示新节点有要移除
的DOM
, 移除多余节点
asyncUpdateQueue.js
异步更新队列
当侦听到数据变化, Vue
将开启一个队列, 并缓冲
在同一事件循环中发生的所有数据变更。如果同一个 watcher
被多次触发, 只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的
将watcher
加入到异步队列,让其有序执行
/**
* 响应式数据更新时,dep通知watcher执行update
* 让update 方法执行 this._cb 函数 进而更新 DOM
*/
Watcher.prototype.update = function () {
if (this.options.lazy) {
...
} else {
// 加入异步队列
// 将当前watcher放入到watcher异步更新队列 按次更新
queueWatcher(this);
}
};
异步更新逻辑:
- 根据模板渲染时调用
data
生成的watcher
, 进行标识UID
排序,UID
越小执行优先级越高 - 如果队列中没有刷新的
watcher
, 直接入队后执行即可 - 如果队列中有刷新的
watcher
队列, 就插入到指定到位置中 例如当前执行的: [2,3,6,7,8] , 新插入的是4
, 那么就需要插入到6
的前面组成[2,3,4,6,7,8]
使其有序执行 - 执行完毕后移除掉已经执行过的
watcher
queueWatcher
实现逻辑
// 全局watcher队列
const queue = [];
// 标识当前的watcher队列是否正在被刷新
let flushing = false;
// 表示callbacks数组中是否已经存在一个刷新的watcher队列的函数
let waiting = false;
// 存放刷新 watcher 队列的函数,或者用户调用 nextTick 方法时的回调函数
const callbacks = [];
// 表示当前浏览器任务队列中是否已经存在callback数组的函数了
let penging = false;
export default function queueWatcher(watcher) {
// debugger;
// 如果队列中已经存在当前watcher 就不需要重复添加
if (queue.includes(watcher)) return;
// watcher不存在 并且当前队列不在刷新状态 就入队
// 因为在刷新中入队会影响watcher执行的顺序
if (!flushing) {
// 直接入队
queue.push(watcher);
} else {
// 当前 watcher 队列正在被刷新
// 当你的 watcher 回调函数存在更改响应式数据的情况时
// 这个情况下就会出现刷新watcher队列时,进来新的wather
// 但是因为队列是有序的,所以要将这个watcher插入到合适的地方,保证队列的有序进行
// 例如
// 刷新中的watcher队列UID为 [3,4,5,7,9]
// 现在因为用户的nextTick回调 回调了一个 watcher 8
// 我们要插入到指定的位置也就是 [3,4,5,7,watcher8,9]
let flag = false;
for (let i = queue.length - 1; i >= 0; i--) {
if (queue[i]._Uid < watcher._Uid) {
// 找到了要插入的位置 也就是 queue[i]的后面
queue.splice(i + 1, 0, watcher);
flag = true;
break;
}
}
// 如果循环完毕没找到比自己小的 那自己就是最小的
// 直接插入到头部即可
if (!flag) {
queue.unshift(watcher);
}
}
if (!waiting) {
// 保证callbacks数组中只有一个刷新watcher队列的函数
waiting = true;
nextTick(flushSchedulerQueue);
}
}
// 负责刷新watcher队列函数 由flushCallbacks 函数调用
function flushSchedulerQueue() {
// flushing true 表示正在刷新
flushing = true;
// 给 watcher 队列 排序 根据 watcher 的 this._Uid
queue.sort((a, b) => a._Uid - b._Uid);
console.log("刷新 flushSchedulerQueue queue ==> ", queue);
// 依次执行队列的 run 方法
while (queue.length) {
const watcher = queue.shift();
// 执行第一个 因为要按顺序
console.log("watcher.run ==> ", watcher._cb);
watcher.run();
}
// 走到这里 watcher 队列已经为空了 控制状态初始化
flushing = waiting = false;
}
function nextTick(cb) {
callbacks.push(cb);
// 没有存在callback任务队列
if (!penging) {
// pengding 设置为true 表示开始刷新队列
penging = true;
// 放入到浏览器的异步队列
Promise.resolve().then(flushCallbacks);
}
}
// 负责刷新 callback数组 也就是执行callback中的每一个函数
function flushCallbacks() {
// 表示浏览器任务队列已经被拿到执行栈执行了
// 新的 flushCallbacks可以入栈了
penging = false;
while (callbacks.length) {
const cb = callbacks.shift();
// 执行第一个 因为要按顺序
cb();
}
}
Vue 源码面试常见题
-
谈谈你对Vue中响应式数据的理解
答:
Vue
中响应式的实现是通过defineReactive
拦截对象方法来实现的, 拦截时给每个对象注册了一个Dep
发布者对象, 当data
数据发生改变的时候, 会触发拦截器的get
拦截方法, 将调用订阅者
通过depend
存储到watcher
数组中, 当数据更新时, 触发set
拦截, 并通过Dep 的 notify
方法, 通知这个data
的订阅者进行更新 -
如何理解Vue中的模板编译原理?
- 将
template
模板转换成ast
结构对象 - 根据
ast
结构对象, 创建生成节点 _c
、文本 _v
函数, 递归生成vnode
的渲染函数render
函数体字符串, 通过new Function
和with
将函数体字符串转换为可执行函数 - 根据
vnode虚拟dom
生成或更新真实DOM
-
说明nextTick的原理?
答:nextTick
是一个微任务异步任务
, 用于将任务加入到异步更新队列
, 防止阻碍宏任务
执行, nextTick
最后的判断兼容其实就是通过settimeout
Promise.resolve()
来实现的
-
computed是怎么实现的?
答:computed
实现缓存和重新计算 需要依靠watcher
, watcher
中会判断当前的watcher
是否是惰性
的, 如果是惰性
的,那么就会在调用的时候才会去计算结果,计算结果后将dirty
改为false
表示已缓存, 当依赖项修改的时候, 会执行Watcher的update
更新函数将dirty
属性改为true
, 当模板渲染对比更新时,会重新执行并缓存
-
Vue为什么要用
vnode
?
vnode
就是用js对象来描述真实Dom树
, 是对真实Dom
的映射- 由于直接操作
Dom
性能低,但是js
层的操作效率高,可以将Dom
操作转化成对象操作。最终通过diff
算法比对差异进行更新Dom
-
Vue的diff算法原理是什么?
Vue的diff算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式+双指针方式比较
- 先比较两个节点是不是相同节点
- 相同节点比较属性,复用老节点
- 先比较儿子节点,考虑老节点和新节点
children
的情况 - 假设对比:头头、尾尾、头尾、尾头
结尾
看到这里, 想必你应该已经对Vue
源码的大部分实现原理
有了一定的认识
和理解
, 但是阅读源码
不止于源码, 我们要知道在一个优秀的框架中使用了这种逻辑思想, 那必然是有一定的意义的, 我们要能将这种优秀的思想和实现逻辑转换为自己的东西, 并在自己的项目中能够加以运用, 这才是阅读源码的核心目的, 如果只是想仅仅了解他的实现, 那不如去直接阅读面试经验
, 面试经验
足够让你理解这些思想的原理, 但是当你去实现的时候, 你会发现, 你也就仅仅只会说出这些原理罢了
补充一句
本文中涉及的理解和观点均为本人自己的理解, 如有错误请提出我会及时改正
参考优秀博主
一个人学习的路上难免有波折, 感谢一下大佬的讲解和文章著作, 让我提升了自己的阅历和知识面~~ 感谢