這裡的響應式(Reactive)不同於CSS佈局的響應式(Responsive), 這裡的響應式是指資料和檢視的繫結,資料一旦更新,檢視會自動更新。下面讓我們來看看Vue是怎麼實現響應式的,Vue 2.0和Vue 3.0的實現原理還不一樣,我們來分開講。
Vue 2.0的響應式
Object.defineProperty
Vue 2.0的響應式主要用到了Object.defineProperty
,我們先來說說這個方法。Object.defineProperty(obj, prop, descriptor)
是用來定義屬性描述符的,它接收三個引數,第一個引數目標物件,第二個引數是目標物件裡面的屬性,第三個引數是想要設定的屬性描述符,包含如下幾個值及預設值
{
value: undefined, // 屬性的值
get: undefined, // 獲取屬性值時觸發的方法
set: undefined, // 設定屬性值時觸發的方法
writable: false, // 屬性值是否可修改,false不可改
enumerable: false, // 屬性是否可以用for...in 和 Object.keys()列舉
configurable: false // 該屬性是否可以用delete刪除,false不可刪除,為false時也不能再修改該引數
}
複製程式碼
對於一個普通的物件
var a = {b: 1}
複製程式碼
我們可以使用Object.getOwnPropertyDescriptor
來獲取一個屬性的描述符
你會發現a.b
這個屬性的writable
, enumerable
, configurable
這三個描述符都是true
,但是我們前面說他們的預設值是false
啊,這是怎麼回事呢?這是因為我們定義屬性的方法不一樣,我們最開始的定義這個屬性的時候已經給他賦值了,所以他已經是可寫的了。我們換一種宣告方式,用Object.defineProperty
直接宣告a.c
,再看看他的屬性描述符
我們定義的時候只指定了值為2,沒有指定其他描述符,那麼writable
, enumerable
, configurable
都是預設值false
,也就意味著a.c
不能修改,不能列舉,也不能再配置。即使你顯式a.c=3
也沒有用,他的值還是2,而且這樣寫在嚴格模式還會報錯。因為configurable
是false
,也不能通過Object.defineProperty
再修改描述符,會直接報錯:
set 和 get
這才是重頭戲,Vue就是通過set
和get
來實現的響應式,我們通過自己實現一個簡單版的Vue來講解這個問題。首先我們先定義一個vue:
function vue(){
this.$data = {a: 1};
this.el = document.getElementById('app');
this.virtualDom = '';
this.observer(this.$data);
this.render();
}
複製程式碼
我們需要在observer
方法裡面來實現set
和get
,因為我們要監聽的是值屬性,要是屬性本身又是一個物件,比如
{
a: {
b: {
c: 1
}
}
}
複製程式碼
我們需要遞迴的設定set
和get
來監聽裡面的值。我們簡單版的get
就直接返回值了,其實這裡可以進行優化,後面再講。set
方法接收一個引數newValue
,我們直接賦值給value,然後呼叫render方法更新介面
vue.prototype.observer = function(obj){
var value;
var self = this;
for(var key in obj){ // 遞迴設定set和get
value = obj[key];
if(typeof value === 'object'){
this.observer(value);
} else {
Object.defineProperty(this.$data, key, {
get: function(){
return value;
},
set: function(newValue){
value = newValue;
self.render();
}
});
}
}
}
複製程式碼
下面是render
方法:
vue.prototype.render = function(){
this.virtualDom = `I am ${this.$data.a}`;
this.el.innerHTML = this.virtualDom;
}
複製程式碼
這樣你每次修改$data.a
的時候,介面就會自動更新。需要注意的是,如果你設定了get
方法,但是沒有寫返回值,會預設返回undefined
,你每次讀這個屬性都是undefined
,如果設定了set
方法,值的更新就必須自己全部實現,不實現去賦值也不會成功。事實上,get
和set
需要優化的地方還很多,我們現在是一旦觸發set
就更新了整個DOM,但實際上我們可能有100個元件,其中只有一個元件使用了set
的值,這會造成很大的資源浪費。我們需要找出一個變數到底被哪些元件使用了,當變數更新的時候只去更新那些用到了的元件。這才是Vue真正的做法:
這樣我們的get
和set
就變成了這樣:
Object.defineProperty(this.$data, key, {
get: function(){
dep.depend(); // 這裡進行依賴收集
return value;
},
set: function(newValue){
value = newValue;
// self.render();
dep.notify(); // 這裡進行virtualDom更新,通知需要更新的元件render
}
});
複製程式碼
dep是Vue負責管理依賴的一個類,後面單獨開一篇文章講。
陣列的處理
陣列不能用Object.defineProperty
來處理,應該怎麼辦呢?Vue裡面運算元組,直接用下標更改,是沒有用的,必須使用push
, shift
等方法來操作,為什麼呢?
var a = [1, 2, 3];
a[0] = 10; // 這樣不能更新檢視
複製程式碼
其實Vue用裝飾者模式來重寫了陣列這些方法,在講這個之前我們先講講Object.create
Object.create
這個方法建立一個新物件,使用現有的物件來提供新建立的物件的__proto__
,接收兩個引數Object.create(proto[, propertiesObject])
。第一個引數是新建立物件的原型物件,第二個引數可選,如果沒有指定為 undefined
,則是要新增到新建立物件的不可列舉(預設)屬性(即其自身定義的屬性,而不是其原型鏈上的列舉屬性)物件的屬性描述符以及相應的屬性名稱。這些屬性對應Object.defineProperties()
的第二個引數。看實現類式繼承的例子:
// Shape - 父類(superclass)
function Shape() {
this.x = 0;
this.y = 0;
}
// 父類的方法
Shape.prototype.move = function(x, y) {
this.x += x;
this.y += y;
console.info('Shape moved.');
};
// Rectangle - 子類(subclass)
function Rectangle() {
Shape.call(this); // call super constructor.
}
// 子類續承父類
Rectangle.prototype = Object.create(Shape.prototype);
// 此時 Rectangle.prototype !== Shape.prototype
// 但是 Rectangle.prototype.__prpto__ === Shape.prototype
Rectangle.prototype.constructor = Rectangle; // 前面會改掉建構函式,重新設定建構函式
var rect = new Rectangle();
console.log('Is rect an instance of Rectangle?',
rect instanceof Rectangle); // true
console.log('Is rect an instance of Shape?',
rect instanceof Shape); // true
rect.move(1, 1); // Outputs, 'Shape moved.'
複製程式碼
多繼承例子:
function MyClass() {
SuperClass.call(this);
OtherSuperClass.call(this);
}
// 繼承一個類
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;
MyClass.prototype.myMethod = function() {
// do a thing
};
複製程式碼
我們回到Vue, 看看它陣列的裝飾者模式:
var arraypro = Array.prototype; // 獲取Array的原型
var arrob = Object.create(arraypro); // 用Array的原型建立一個新物件,arrob.__proto__ === arraypro,免得汙染原生Array;
var arr=['push', 'pop', 'shift']; // 需要重寫的方法
arr.forEach(function(method){
arrob[method] = function(){
arraypro[method].apply(this, arguments); // 重寫時先呼叫原生方法
dep.notify(); // 並且同時更新
}
});
// 對於使用者定義的陣列,手動將陣列的__proto__指向我們修改過的原型
var a = [1, 2, 3];
a.__proto__ = arrob;
複製程式碼
上面對於新物件arrob
的方法,我們是直接賦值的,這樣會有一個問題,就是使用者可能會不小心改掉我們的物件,所以我們可以用到我們前面講到的Object.defineProperty
來規避這個問題,我們建立一個公用方法def
專門來設定不能修改值的屬性
function def (obj, key, value) {
Object.defineProperty(obj, key, {
// 這裡我們沒有指定writeable,預設為false,即不可修改
enumerable: true,
configurable: true,
value: value,
});
}
// 陣列方法重寫改為
arr.forEach(function(method){
def(arrob, method, function(){
arraypro[method].apply(this, arguments); // 重寫時先呼叫原生方法
dep.notify(); // 並且同時更新
});
});
複製程式碼
Vue 3.0的響應式
3.0的響應式原理跟2.0類似,也是在get
的時候收集依賴,在set
的時候更新檢視。但是3.0使用了ES6的新API Proxy
和Reflect
,使用Proxy
相對於Object.defineProperty
有如下好處:
1. Object.defineProperty需要指定物件和屬性,對於多層巢狀的物件需要遞迴監聽,Proxy可以直接監聽整個物件,不需要遞迴;
2. Object.defineProperty的get方法沒有傳入引數,如果我們需要返回原值,需要在外部快取一遍之前的值,Proxy的get方法會傳入物件和屬性,可以直接在函式內部操作,不需要外部變數;
3. set方法也有類似的問題,Object.defineProperty的set方法傳入引數只有newValue,也需要手動將newValue賦給外部變數,Proxy的set也會傳入物件和屬性,可以直接在函式內部操作;
4. new Proxy()會返回一個新物件,不會汙染源原物件
5. Proxy可以監聽陣列,不用單獨處理陣列
複製程式碼
上面的vue.prototype.observer
可以改為:
vue.prototype.observer = function(obj){
var self = this;
this.$data = new Proxy(this.$data, {
get: function(target, key){
return target[key];
},
set: function(target, key, newValue){
target[key] = newValue;
self.render();
}
});
}
複製程式碼
Proxy還可以做資料校驗
// 需求,校驗一個人名字必須是中文,年齡必須大於18歲
var validator = {
name: function(value){
var reg = /^[\u4E00-\u9FA5]+$/;
if(typeof value === "string" && reg.test(value)){
return true;
}
return false;
},
age: function(value){
if(typeof value==='number' && value >= 18){
return true;
}
return false;
}
}
function person(age, name){
this.age = age;
this.name = name;
// 使用Proxy的set校驗,每次給物件屬性賦值或修改都會校驗
return new Proxy(this, {
get: function(target, key){
return target[key];
},
set: function(target, key, newValue){
// set的時候呼叫前面定義好的驗證規則,這其實就是策略模式
if(validator[key](newValue)){
return Reflect.set(target, key, newValue);
}else{
throw new Error(`${key} is wrong.`)
}
}
});
}
複製程式碼
let p = new Proxy(target, handler);
的第二個引數handler不僅可以在get和set時觸發,還可以在下列方法時觸發:
getPrototypeOf()
setPrototypeOf()
isExtensible()
preventExtensions()
getOwnPropertyDescriptor()
defineProperty()
has()
get()
set()
deleteProperty()
ownKeys()
apply()
construct()
複製程式碼
淺談虛擬DOM和diff演算法
我們有這樣一個模板:
<template>
<div id="123">
<p>
<span>111</span>
</p>
<p>
123
</p>
</div>
<div>
<p>456</p>
</div>
</template>
複製程式碼
這一段模板轉化為虛擬DOM的虛擬碼,以第一個div為例的虛擬碼:
{
dom: 'div',
props: {
id: 123
},
children: [
{
dom: 'p',
children: [
dom: 'span',
text: "111"
]
},
{
dom: 'p',
text: "123"
}
]
}
複製程式碼
每個節點都可以有多個children,每個child都是一個單獨的節點,結構是一樣的,也可以有自己的children。
在進行節點比對的時候,Vue只會進行同層的比較,比如有一個節點之前是:
<div>
<p>123</p>
</div>
複製程式碼
後面變成了
<div>
<span>456</span>
</div>
複製程式碼
比對是隻會比對第一層的div, 第二層是p和span比對,不會拿div和span進行比對,如下圖:
從資料改變的set方法開始的diff演算法如下圖所示:
如果新舊兩個節點完全不一樣了isSameVnode
返回false,則整個節點更新,如果節點本身是一樣的,就比較他們的子節點,下面是虛擬碼:
patchVnode(oldVnode, vnode){
const el = vnode.el = oldVnode;
let i, oldCh = oldVnode.children, ch = vnode.children;
if(oldVnode === vnode) return;
if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text){
// 僅僅是文字改變,更新文字
setTextContext(el, vnode.text);
}else{
updateEle();
if(oldCh&&ch&&oldCh!==ch){
// 都有子元素,但是變化了
updateChildren();
}else if(ch){
// 新的有子元素, 老的沒有,建立新元素
createEl(vnode);
}else if(oldCh){
// 老的有子元素,新的沒有,刪除老元素
removeChildren(el);
}
}
}複製程式碼
原創不易,每篇文章都耗費了作者大量的時間和心血,如果本文對你有幫助,請點贊支援作者,也讓更多人看到本文~~
更多文章請看我的掘金文章彙總