第一部分 Vue2响应式原理
1. Object.defineProperty
diff
Object.defineProperty(obj, prop, descriptor)
- obj:必需,目标对象
- prop:必需,需定义或修改的属性名
- descriptor:必需,目标属性所拥有的特性
descriptor描述符包含以下内容:
-
value
:被定义的属性值,默认为undefined -
writable
:属性值是否可以被重写,true可以重写,false不能重写。默认值为false -
enumerable
:属性值是否可以被枚举(使用for...in或Object.keys()),true可以被枚举,false不能被枚举。默认为false -
configurable
:是否可以删除属性或是否可以再次修改属性的特性(writable, configurable, enumerable),true可以被删除或可以重新设置特性,false不能被可以被删除或不可以重新设置特性。默认为false
存取器getter/setter
如果使用getter或setter方法时不能允许再使用writable和value这两个属性
-
getter:当访问该属性时,该方法会被执行。函数的返回值会作为该属性的值返回
-
setter:当属性值修改时,该方法会被执行。该方法将接受唯一参数,即该属性新的参数值
javascript
var obj = {};
var initValue = 'hello';
Object.defineProperty(obj,"newKey",{
get:function(){
//当获取值的时候触发的函数
return initValue;
},
set:function (value){
//当设置值的时候触发的函数,设置的新值通过参数value拿到
initValue = value;
}
});
//获取值
console.log(obj.newKey); //hello
//设置值
obj.newKey = 'change value';
console.log( obj.newKey ); //change value
不要在getter中再次获取该属性值,也不要在setter中再次设置改属性,否则会栈溢出!
2. 数据代理
构建Vue实例对象代码如下所示:
csharp
let vm = new Vue({
data:{
message:'hello vue.js'
}
})
由于是通过new Vue
创建的Vue实例对象,所以如果想模拟Vue底层的数据代理原理,在创建Vue时应该构建一个Vue类
,并且类会接收一个options
配置对象。
暂时先不考虑
new Vue(函数)
的情况,只讨论new Vue({...})
kotlin
class Vue {
constructor(options){
this.$options = options
this._data = options.data
// 初始化数据代理
this.initData()
}
initData(){
let data = this._data
let keys = Object.keys(data)
// 通过Object.defineProperty对options中的每一个属性实现代理
for (let i = 0;i < keys.length; i++) {
Object.defineProperty(this,keys[i],{
enumerable:true,
configurable:true,
// 函数名称可有可无,名称函数是为了后续操作方便
get:function proxyGetter(){
return data[keys[i]]
},
set:function proxySetter(value){
data[keys[i]] = value
}
})
}
}
}
3. 数据劫持
当data中的属性值是基本数据类型时,单层for循环即可实现数据代理和劫持。
javascript
class Vue {
constructor(options) {
this.$options = options
this._data = options.data
this.initData()
}
initData(){
let data = this._data
let keys = Object.keys(data)
// 数据代理
for(let i = 0; i <keys.length; i++){
Object.defineProperty(this,keys[i],{
enumerable:true,
configurable:true,
get:function proxyGetter() {
return data[keys[i]]
},
set:function proxySetter(value) {
data[keys[i]] = value
}
})
}
// 数据劫持
for(let i=0;i<keys.length;i++){
let value = data[keys[i]];
Object.defineProperty(this,keys[i],{
enumerable:true,
configurable:true,
get: function reactiveGetter(){
console.log(`data中的${keys[i]}被读取`);
return value;
},
set: function reactiveSetter(val){
if(val === value) return;
console.log(`data中的${keys[i]}被修改为${val}`);
value = val;
}
})
}
}
}
4. 数据递归劫持
css
let vm = new Vue({
data:{
message:'hello',
person:{
name:'小明',
age:25,
sex:"Man"
}
}
});
🤕:修改person的age属性时无法触发proxySetter监听函数
当data中的属性值为引用类型时,单层for循环则无法实现数据的深度劫持,需要对以上方法进行改写。
javascript
// 数据劫持
observe(data);
// 判断data的数据类型
function observe(data) {
let type = Object.prototype.toString.call(data);
if (type !== '[object Object]' && type !== '[object Array]') {
return;
}
// 通过vue内置的Observer类实现引用类型的劫持
new Observer(data);
}
// 抽离数据劫持代码
function defineReactive(obj, key, value) {
// 递归判断数据类型
observe(obj[key]);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log(`${key}被读取`);
return value;
},
set: function reactiveSetter(val) {
if (val === value) return;
console.log(`${key}被修改为${val}`);
value = val;
}
})
}
// 实现内置的Observer类
class Observer {
constructor(data) {
this.walk(data);
}
// 抽离遍历data属性值的方法
walk(data) {
let keys = Object.keys(data);
for (let i = 0; i < keys.length; i++) {
defineReactive(data,keys[i],data[keys[i]]);
}
}
}
5. Watcher监听
Vue项目中Watcher监听的调用如下:
javascript
// 第一种方式
vm.$watcher('message',()=>{
....
})
// 第二种方式
watch:{
message(){
....
}
}
Watcher监听的逻辑:
- 每个属性可以有多个监听回调,所以需要使用
数组
存放每个属性的监听回调函数 - 一个回调可能包含多个属性,所以getter需要具有
收集
当前回调的能力 - 回调触发前需要收集,触发后需要移除来节省内存空间,所以可以存放在一个
公共区域
- 属性的回调函数
异步执行
- 属性的多个相同的回调函数会
合并执行
实现Watcher数据监听
第一步:实现存放监听回调的筐"发布-订阅者模式
"
javascript
class Dep{
constructor(){
this.subs = []
}
// 收集回调
depend(){
if(Dep.target){
this.subs.push(Dep.target)
}
}
// 触发回调
notify(){
this.subs.forEach(watcher=>{
watcher.run()
})
}
}
第二步:发布/订阅监听的回调函数
csharp
get: function reactiveGetter() {
// 订阅
dep.depend();
},
set: function reactiveSetter(val) {
// 发布
dep.notify();
}
第三步:构建watcher类(每个回调函数都是一个watcher实例对象)
kotlin
let watchId = 0; // 回调函数唯一标识
let watchQueue = []; // 当前执行的回调函数队列
class Watcher{
// vm实例、exp属性、cb回调函数
constructor(vm,exp,cb){
this.vm = vm
this.exp = exp
this.cb = cb
// id自增,保持唯一性
this.id = ++watchId
this.get()
}
// 对属性求值
get(){
Dep.target = this
this.vm[this.exp]
Dep.target = null
}
// 更新队列中的回调函数,并异步执行当前回调
run(){
if(watchQueue.indexOf(this.id) !== -1){
return;
}
watchQueue.push(this.id);
let index = watchQueue.length-1;
// 通过promise实现异步执行
Promise.resolve().then(()=>{
this.cb.call(this.vm);
watchQueue.splice(index,1)
})
}
}
6. $set设置响应式属性
vue.$set
方法可以随时添加响应式的属性,其实现逻辑如下所示:
- 在创建observer实例时,再创建一个新的
dep筐
,并挂在observer实例上 - 把observer实例挂载到对象的
__ob__
属性上 - 触发getter时,把
watcher
收集一份到之前的"筐"和创建的新dep筐
里 - 用户调用$set时,手动地触发
__ob__.dep.notify()
- 在notify()执行前调用
defineReactive
把新的属性也定义成响应式
kotlin
function set(target, key, val) {
var ob = target.__ob__; // 1. 新框
if (isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key);
target.splice(key, 1, val);
// 2. 收集watcher
if (ob && !ob.shallow && ob.mock) {
observe(val, false, true);
}
return val;
}
// 4. 将属性定义为响应式
defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock);
// 5. 触发通知函数
{
ob.dep.notify({
type: "add" /* TriggerOpTypes.ADD */,
target: target,
key: key,
newValue: val,
oldValue: undefined
});
}
return val;
}
7. Vue2响应式存在的问题
- 对象:通过Object.defineProperty属性拦截实现响应式
- 缺点1:对象每一个属性都需要重写,添加getter和setter
- 缺点2:对象新增属性无法监测,需要借助$set方法
- 缺点3:对象删除属性无法监测,需要借助$delete方法
- 缺点4:多层对象需要通过递归实现响应式
- 数组:重写数组原生API(push,shift,unshift,pop,splice,sort,reverse),实现数组元素的响应式
- 缺点1:无法通过Object.defineProperty拦截,需要单独处理
- 缺点2:通过索引改变数组,或者改变数组长度无法触发视图更新
- 缺点3:ES6新增的Map、Set数据结构不支持
第二部分 虚拟DOM和diff算法
1. 虚拟DOM
Virtual DOM 就是用js对象来描述真实DOM,是对真实DOM的抽象
。
直接操作真实DOM性能很低,而且js层的操作效率相对较高,所以可以将DOM操作转化成对象操作
,最终通过diff算法
比对差异更新真实DOM。
虚拟DOM不依赖真实平台环境从而也可以实现跨平台
应用。
1.1 createElement函数
createElement方法接收三个参数:
- type表示标签类型
- props表示标签属性
- children表示标签子元素
createElement方法返回值为一个Element实例对象,用于创建虚拟节点。
typescript
function createElement(type,props,children){
return new Element(type,props,children)
}
kotlin
class Element{
constructor(type,props,children){
this.type = type
this.props = props
this.children = children
}
... ...
}
虚拟DOM数据格式实例
sel
:元素选择器elm
:对应的真实DOM节点,undefined表示该虚拟DOM还没有被添加到DOM树上key
:元素唯一标识符data
:元素的标签属性text
:文本内容children
:子元素
1.2 render函数
render函数将虚拟DOM转化为真实DOM
ini
function render(vDom){
const { type, props,children } = vDom;
const el = document.createElement(type);
// 处理props属性
for(let key in props){
switch (key){
case 'value':
if(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'){
el.value = props[key];
}else{
el.setAttribute(key,props[key]);
}
break;
case 'style':
el.style.cssText = props[key];
break;
default:
el.setAttribute(key,props[key]);
}
}
// 处理子元素
children.map((c)=>{
c = c instanceof Element ? render(c) : document.createTextNode(c);
el.appendChild(c);
})
return el;
}
1.3 renderDOM函数
renderDOM函数用于渲染真实DOM到页面上
scss
function renderDOM(el,rootEl){
// el虚拟DOM元素,rootEl根节点
rootEl.appendChild(el);
}
renderDOM(vDom,document.getElementById('app'))
2. Diff算法
2.1 传统diff算法
假设新旧虚拟DOM看作两棵节点树,节点个数为n
- 左侧树的节点需要与右侧树的节点一一对比,需要O(n²)复杂度
- 删除未找到的节点,寻找合适节点放到被删除位置,需要O(n)复杂度
- 添加新节点,需要O(n)复杂度
所以,传统diff算法比较新旧虚拟DOM的复杂度是O(n³)
2.2 Vue中的diff优化
- 只比较同一层级节点,对于节点间跨层级的移动操作忽略不计
- 标签名不同直接删除,不再进行深度比较
- 标签名和key都相同,则认为是相同的节点,不再进行深度比较
Vue中的diff算法比较新旧虚拟DOM的复杂度是O(n)
2.3 diff算法逻辑
当数据发生变化的时候,会触发setter,通知所有的订阅者Watcher,订阅者会调用patch方法更新虚拟DOM及真实DOM。
以下代码仅包括关键环节,具体细节在此处省略
scss
function patch(oldNode,vNode){
// 如果oldNode不是虚拟节点,则通过emptyNodeAt方法根据oldNode创建虚拟节点
if(!isVnode(oldNode)){
oldNode = emptyNodeAt(oldNode);
}
// 是否为同一个节点
if(sameVnode(oldNode,vNode)){
// 继续进行节点间的diff对比
patchVnode(oldNode,vNode,insertedVnodeQueue);
}else{
// 删除旧节点,创建新节点
elm = oldNode.elm;
parent = api.parentNode(elm);
createElm(vNode,insertedVnodeQueue);
if(parent !== null){
removeVnodes(parent,[oldNode],0,0);
}
}
}
sameVnode()方法判断是否为同一个节点
vbnet
function sameVnode(vnode1,vnode2){
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
patchVnode()方法进行两个节点的diff对比
scss
function patchVnode(oldVnode,newVnode){
if(oldVnode === newVnode) return;
if(newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)){
// 新虚拟节点有text属性
if(newVnode.text !== oldVnode.text){
oldVnode.elm.innerText = newVnode.text;
}
}else{
// 新虚拟节点没有text属性
if(oldVnode.children !== undefined && oldVnode.children.length > 0){
// 需要递归处理oldVnode和newVnode的子元素
updateChildren(oldVnode.children,newVnode.children)
}else{
oldVnode.elm.innerHTML = '';
for(let i=0;i<newVnode.children.length;i++){
let dom = createElement(newVnode.children[i]);
oldVnode.elm.appendChild(dom);
}
}
}
}
updateChildren()方法进行子节点的对比
对于同一节点的children元素,需要添加唯一key值进行区分。
节点的对比过程包括四个指针:旧前、旧后、新前、新后
节点的对比流程如下所示:
- 首尾对比:新前&旧前,新后&旧后
- 交叉对比:新后&旧前,新前&旧后
如果命中一种查找条件后,不会继续向下执行其他查找条件 如果四个条件均为命中,则需要在oldVnode中遍历查找当前节点
- 1️⃣/2️⃣命中:删除多余旧节点,创建新节点
- 3️⃣新后与旧前命中:将
新后
指针指向的节点移动到老节点的旧后指针的后面
- 4️⃣新前与旧后命中:将
新前
指针指向的节点移动到老节点的旧前指针的前面
ini
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
var oldStartIdx = 0; // 旧前
var newStartIdx = 0; // 新前
var oldEndIdx = oldCh.length - 1; // 旧后
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newEndIdx = newCh.length - 1; // 新后
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
}else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}else {
... ...
}
}
}
2.4 Vue2与Vue3的差异
两者在剩余节点的处理方式上略有不同:
Vue2:Key To Index哈希表
-
首先进行
新老节点头尾对比
,头与头、尾与尾对比,寻找未移动的节点
-
新老节点头尾对比完后,进行
交叉对比
,头与尾、尾与头对比,寻找移动后可复用的节点
-
然后在剩余新老节点中对比寻找可复用节点,创建一个
旧节点的key To Index哈希表
记录key,然后继续遍历新节点索引
,通过key查找能复用的旧的节点
-
节点遍历完成后,通过新老索引,进行
移除多余旧节点
或者增加新节点
的操作
Vue3:剩余节点与旧节点索引哈希表
-
创建一个映射关系数组,存放
新节点数组中的剩余节点
与旧节点数组索引
的映射关系 -
通过数组直接确定
可复用节点
,然后通过映射关系表计算最长递增子序列
(子序列内所有节点位置均正确,不用更新) -
最后将新节点数组中的
剩余节点移动
到正确的位置
2.5 patch更新
通过diff算法对比新旧虚拟DOM树后,会得到一个patches补丁集合,其数据格式如下所示:
go
patches={
'0':[ // 元素下标
{
type:'ATTR', // 补丁类型
attr:'list-wrap' // 新虚拟DOM较旧虚拟DOM的变化
}
],
'2':[
{
type:'ATTR',
attr:'list-wrap'
}
],
... ...
}
将获取的patches补丁包作用在真实DOM上,完成真实DOM得更新操作。
第三部分 Vue3响应式原理
1. Proxy
Proxy对象用于创建一个普通对象的代理,也可以理解成在对象前面设了一层拦截,包括基本操作的拦截和一些自定义操作(比如一些赋值、属性查找、函数调用等)。
ini
var proxy = new Proxy(target, handler);
target
:目标对象,需要代理的对象handler
:代理行为,包括各种操作的拦截函数
2. Reflect
Reflect是es6为操作对象而提供的新API,设计它的目的有:
-
把Object对象上一些明显属于语言内部的方法放到Reflect对象身上,比如Object.defineProperty
-
修改某些object方法返回的结果
-
让Object操作都变成函数行为
-
Reflect对象上的方法和Proxy对象上的方法一一对应,这样就可以让Proxy对象方便地调用对应的Reflect方法
scss
Reflect.get(target, propertyKey, receiver)等价于target[propertyKey]
Reflect.get方法查找并返回target对象的propertyKey属性,如果没有该属性,则返回undefined
scss
Reflect.set(target, propertyKey, value, receiver)等价于target[propertyKey] = value
Reflect.set方法设置target对象的propertyKey属性等于value
3. Proxy和Reflect的使用
javascript
const obj = {
name: 'win'
}
const handler = {
get: function(target, key){
return Reflect.get(...arguments)
},
set: function(target, key, value){
return Reflect.set(...arguments)
}
}
const data = new Proxy(obj, handler);
4. 使用Proxy和Reflect完成响应式
reactive是一个返回Proxy对象的函数
javascript
function reactive(obj){
const handler = {
get(target, key, receiver){
console.log('get--', key)
const value = Reflect.get(...arguments);
if(typeof value === 'object'){
return reactive(value)
}else{
return value
}
},
set(target, key, val, receiver){
console.log('set--', key, '=', value)
return Reflect.set(...arguments)
}
};
return new Proxy(obj, handler);
}
const data = reactive({name: 'win'});