深入剖析Vue原始碼 - 資料代理,關聯子父元件

不做祖國的韭菜發表於2019-04-03

簡單回顧一下這個系列的前兩節,前兩節花了大篇幅講了vue在初始化時進行的選項合併。選項配置是vue例項化的第一步,針對不同型別的選項,vue提供的豐富選項配置策略以保證使用者可以使用不同豐富的配置選項。而在這一節中,我們會分析選項合併後的又兩步重要的操作: 資料代理和關聯子父元件關係,分別對應的處理過程為initProxy和initLifecycle。這章節的知識點也為後續的響應式系統介紹和模板渲染做鋪墊。

2.1 Object.defineProperty和Proxy

在介紹這一章的原始碼分析之前,我們需要掌握一下貫穿整個vue資料代理,監控的技術核心:Object.defineProperty 和 Proxy

2.1.1 Object.defineProperty

官方定義:Object.defineProperty()方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。 基本用法: Object.defineProperty(obj, prop, descriptor)

我們可以用來精確新增或修改物件的屬性,只需要在descriptor中將屬性特性描述清楚,descriptor的屬性描述符有兩種形式,一種是資料描述符,另一種是存取描述符。

資料描述符

  • configurable:資料是否可刪除
  • enumerable:屬性是否可列舉
  • value:屬性值,預設為undefined
  • writable:屬性是否可讀寫

存取描述符

  • configurable:資料可改變
  • enumerable:可列舉
  • get:一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。
  • set:一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。

注意: 資料描述符的value,writable 和 存取描述符的get, set屬性不能同時存在,否則會丟擲異常。 有了Object.defineProperty方法,我們可以方便的利用存取描述符中的getter/setter來進行資料監聽,在get,set鉤子中分別做不同的操作,這是vue雙向資料繫結原理的雛形,我們會在響應式系統的原始碼分析時具體闡述。

var o = {}
var value;
Object.defineProperty(o, 'a', {
    get() {
        console.log('獲取值')
        return value
    },
    set(v) {
        console.log('設定值')
        value = v
    }
})
o.a = 'sss' 
// 設定值
console.log(o.a)
// 獲取值
// 'sss'

複製程式碼

然而Object.defineProperty的get和set方法只能觀測到物件屬性的變化,對於陣列型別的變化並不能檢測到,這是用Object.defineProperty進行資料監控的缺陷,而vue中對於陣列型別的方法做了特殊的處理。 es6的proxy可以完美的解決這一類問題。

2.1.2 Proxy

Proxy 是es6的語法,和Object.defineProperty一樣,也是用於修改某些操作的預設行為,但是和Object.defineProperty不同的是,Proxy針對目標物件,會建立一個新的例項物件,並將目標物件代理到新的例項物件上, 本質的區別就是多了一層代理,外界對該物件的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。外界通過操作新的例項物件從而操作真正的目標物件。針對getter和setter的基本用法如下:

var obj = {}
var nobj = new Proxy(obj, {
    get(target, property) {
        console.log('獲取值')
        return target[property]
    },
    set(target, key, value) {
        console.log('設定值')
        return target[key]
    }
})
nobj.a = 1111 // 通過操作代理物件從而對映到目標物件上
// 設定值
// 獲取值
// 1111
console.log(nobj.a)
複製程式碼

Proxy 支援的攔截操作有13種之多,具體可以參照Proxy,上面提到,Object.defineProperty的get和set方法並不能監測到陣列的變化,而Proxy是否能做到呢?

var arr = [1, 2, 3]
let obj = new Proxy(arr, {
    get: function (target, key, receiver) {
        console.log("獲取陣列");
        return Reflect.get(target, key, receiver);
    },
    set: function (target, key, receiver) {
        console.log('設定陣列');
        return Reflect.set(target, key, receiver);
    }
})

obj.push(222) 
// '獲取陣列'
// '設定陣列'

複製程式碼

顯然proxy可以很容易的監聽到陣列的變化。

2.2 initProxy

有了這些理論基礎,我們往下看vue的原始碼,在初始化合並選項後,vue接下來的操作是為vm例項設定一層代理,代理的目的是為vue在模板渲染時進行一層資料篩選。如果瀏覽器不支援Proxy,這層代理檢驗資料則會失效。(檢測資料會放到其他地方檢測)

{
    // 對vm例項進行一層代理
    initProxy(vm);
}
// 代理函式
var initProxy = function initProxy (vm) {
    // 瀏覽器如果支援es6原生的proxy,則會進行例項的代理,這層代理會在模板渲染時對一些非法或者不存在的字串進行判斷,做資料的過濾篩選。
    if (hasProxy) {
        var options = vm.$options;
        var handlers = options.render && options.render._withStripped
            ? getHandler
            : hasHandler;
        // 代理vm例項到vm屬性_renderProxy
        vm._renderProxy = new Proxy(vm, handlers);
    } else {
        vm._renderProxy = vm;
    }
};

如何判斷瀏覽器支援原生proxy
// 是否支援Symbol 和 Reflect
var hasSymbol =
    typeof Symbol !== 'undefined' && isNative(Symbol) &&
    typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys);
function isNative (Ctor) {
    // Proxy本身是建構函式,且Proxy.toString === 'function Proxy() { [native code] }'
    return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}
複製程式碼

看到這裡時,心中會有幾點疑惑。

  • 什麼時候會觸發這層代理進行資料檢測?
  • getHandler 和 hasHandler的場景分別是什麼?

要解決這個疑惑,我們接著往下看:

  • 1.在元件的更新渲染時會呼叫vm例項的render方法(具體模板引擎如何工作,我們放到相關專題在分析),我們觀察到,vm例項的render方法在呼叫時會觸發這一層的代理。
Vue.prototype._render = function () {
    ···
    // 呼叫vm._renderProxy
    vnode = render.call(vm._renderProxy, vm.$createElement);
}
複製程式碼

也就是說模板引擎<div>{{message}}</div>的渲染顯示,會通過Proxy這層代理對資料進行過濾,並對非法資料進行報錯提醒。

  • 2.handers函式會根據options.render 和 options.render._withStripped執行不同的代理函式getHandler,hasHandler。當使用類似webpack這樣的打包工具時,我們將使用vue-loader進行模板編譯,這個時候options.render 是存在的,並且_withStripped的屬性也會設定為true,關於編譯版本和執行版本的區別不在這一章節展開。先大致瞭解使用場景即可。
2.2.1 代理場景

接著上面的問題,vm例項代理時會根據是否是編譯的版本決定使用hasHandler或者getHandler,我們先預設使用的是編譯版本,因此我們單獨分析hasHandler的處理函式,getHandler的分析類似。

var hasHandler = {
    // key in obj或者with作用域時,會觸發has的鉤子
    has: function has (target, key) {
        ···
    }
};
複製程式碼

hasHandler函式定義了has的鉤子,前面介紹過proxy有多達13個鉤子,has是其中一個,它用來攔截propKey in proxy的操作,返回一個布林值。除了攔截 in 操作符外,has鉤子同樣可以用來攔截with語句下的作用物件。例如

var obj = {
    a: 1
}
var nObj = new Proxy(obj, {
    has(target, key) {
        console.log(target) // { a: 1 }
        console.log(key) // a
        return true
    }
})

with(nObj) {
    a = 2
}
複製程式碼

而在vue的render函式的內部,本質上也是呼叫了with語句,當呼叫with語句時,該作用域下變數的訪問都會觸發has鉤子,這也是模板渲染時會觸發代理攔截的原因。

var vm = new Vue({
    el: '#app'     
})
console.log(vm.$options.render)

//輸出, 模板渲染使用with語句
ƒ anonymous() {
    with(this){return _c('div',{attrs:{"id":"app"}},[_v(_s(message)+_s(_test))])}
}
複製程式碼

再次思考:我們知道with語句是不推薦使用的,一個最主要的原因是效能問題,查詢不是變數屬性的變數,較慢的速度會影響效能一系列效能問題。

官方給出的解釋是: 為了減少編譯器程式碼大小和複雜度,並且也提供了通過vue-loader這類構建工具,不含with的版本。

2.2.2 代理檢測過程

接著上面的分析,在模板引擎render渲染時,由於with語句的存在,訪問變數時會觸發has鉤子函式,該函式會進行資料的檢測,比如模板上的變數是否是例項中所定義,是否包含_, $這類vue內部保留關鍵字為開頭的變數。同時模板上的變數將允許出現javascript的保留變數物件,例如Math, Number, parseFloat等。

var hasHandler = {
    has: function has (target, key) {
        var has = key in target;
        // isAllowed用來判斷模板上出現的變數是否合法。
        var isAllowed = allowedGlobals(key) ||
            (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data));
            // _和$開頭的變數不允許出現在定義的資料中,因為他是vue內部保留屬性的開頭。
        // warnReservedPrefix警告不能以$ _開頭的變數
        // warnNonPresent 警告模板出現的變數在vue例項中未定義
        if (!has && !isAllowed) {
            if (key in target.$data) { warnReservedPrefix(target, key); }
            else { warnNonPresent(target, key); }
        }
        return has || !isAllowed
    }
};

// 模板中允許出現的非vue例項定義的變數
var allowedGlobals = makeMap(
    'Infinity,undefined,NaN,isFinite,isNaN,' +
    'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' +
    'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' +
    'require' // for Webpack/Browserify
);
複製程式碼

2.3 initLifecycle

分析完initProxy方法後,接下來是initLifecycle的過程。簡單概括,initLifecycle的目的是將當前例項新增到父例項的$children屬性中,並設定自身的$parent屬性指向父例項。這為後續子父元件之間的通訊提供了橋樑。舉一個具體的應用場景:

<div id="app">
    <component-a></component-a>
</div>
Vue.component('component-a', {
    template: '<div>a</div>'
})
var vm = new Vue({ el: '#app'})
console.log(vm) // 將例項物件輸出
複製程式碼

由於vue例項向上沒有父例項,所以vm.$parent為undefined,vm的$children屬性指向子元件componentA 的例項。

深入剖析Vue原始碼 - 資料代理,關聯子父元件

子元件componentA的 $parent屬性指向它的父級vm例項,它的$children屬性指向為空

深入剖析Vue原始碼 - 資料代理,關聯子父元件

原始碼解析如下:

function initLifecycle (vm) {
    var options = vm.$options;
    // 子元件註冊時,會把父元件的例項掛載到自身選項的parent上
    var parent = options.parent;
    // 如果是子元件,並且該元件不是抽象元件時,將該元件的例項新增到父元件的$parent屬性上,如果父元件是抽象元件,則一直往上層尋找,直到該父級元件不是抽象元件,並將,將該元件的例項新增到父元件的$parent屬性
    if (parent && !options.abstract) {
        while (parent.$options.abstract && parent.$parent) {
        parent = parent.$parent;
        }
        parent.$children.push(vm);
    }
    // 將自身的$parent屬性指向父例項。
    vm.$parent = parent;
    vm.$root = parent ? parent.$root : vm;

    vm.$children = [];
    vm.$refs = {};

    vm._watcher = null;
    vm._inactive = null;
    vm._directInactive = false;
    // 該例項是否掛載
    vm._isMounted = false;
    // 該例項是否被銷燬
    vm._isDestroyed = false;
    // 該例項是否正在被銷燬
    vm._isBeingDestroyed = false;
}

複製程式碼

最後簡單講講抽象元件,在vue中有很多內建的抽象元件,例如<keep-alive></keep-alive>,<slot><slot>等,這些抽象元件並不會出現在子父級的路徑上,並且它們也不會參與DOM的渲染。



相關文章