從這一小節開始,正式進入
Vue
原始碼的核心,也是難點之一,響應式系統的構建。這一節將作為分析響應式構建過程原始碼的入門,主要分為兩大塊,第一塊是針對響應式資料props,methods,data,computed,wather
初始化過程的分析,另一塊則是在保留原始碼設計理念的前提下,嘗試手動構建一個基礎的響應式系統。有了這兩個基礎內容的鋪墊,下一篇進行原始碼具體細節的分析會更加得心應手。
7.1 資料初始化
回顧一下之前的內容,我們對Vue
原始碼的分析是從初始化開始,初始化_init
會執行一系列的過程,這個過程包括了配置選項的合併,資料的監測代理,最後才是例項的掛載。而在例項掛載前還有意忽略了一個重要的過程,資料的初始化(即initState(vm)
)。initState
的過程,是對資料進行響應式設計的過程,過程會針對props,methods,data,computed
和watch
做資料的初始化處理,並將他們轉換為響應式物件,接下來我們會逐步分析每一個過程。
function initState (vm) {
vm._watchers = [];
var opts = vm.$options;
// 初始化props
if (opts.props) { initProps(vm, opts.props); }
// 初始化methods
if (opts.methods) { initMethods(vm, opts.methods); }
// 初始化data
if (opts.data) {
initData(vm);
} else {
// 如果沒有定義data,則建立一個空物件,並設定為響應式
observe(vm._data = {}, true /* asRootData */);
}
// 初始化computed
if (opts.computed) { initComputed(vm, opts.computed); }
// 初始化watch
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch);
}
}
複製程式碼
7.2 initProps
簡單回顧一下props
的用法,父元件通過屬性的形式將資料傳遞給子元件,子元件通過props
屬性接收父元件傳遞的值。
// 父元件
<child :test="test"></child>
var vm = new Vue({
el: '#app',
data() {
return {
test: 'child'
}
}
})
// 子元件
Vue.component('child', {
template: '<div>{{test}}</div>',
props: ['test']
})
複製程式碼
因此分析props
需要分析父元件和子元件的兩個過程,我們先看父元件對傳遞值的處理。按照以往文章介紹的那樣,父元件優先進行模板編譯得到一個render
函式,在解析過程中遇到子元件的屬性,:test=test
會被解析成{ attrs: {test: test}}
並作為子元件的render
函式存在,如下所示:
with(){..._c('child',{attrs:{"test":test}})}
複製程式碼
render
解析Vnode
的過程遇到child
這個子佔位符節點,因此會進入建立子元件Vnode
的過程,建立子Vnode
過程是呼叫createComponent
,這個階段我們在元件章節有分析過,在元件的高階用法也有分析過,最終會呼叫new Vnode
去建立子Vnode
。而對於props
的處理,extractPropsFromVNodeData
會對attrs
屬性進行規範校驗後,最後會把校驗後的結果以propsData
屬性的形式傳入Vnode
構造器中。總結來說,props
傳遞給佔位符元件的寫法,會以propsData
的形式作為子元件Vnode
的屬性存在。下面會分析具體的細節。
// 建立子元件過程
function createComponent() {
// props校驗
var propsData = extractPropsFromVNodeData(data, Ctor, tag);
···
// 建立子元件vnode
var vnode = new VNode(
("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
data, undefined, undefined, undefined, context,
{ Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
asyncFactory
);
}
複製程式碼
7.2.1 props的命名規範
先看檢測props
規範性的過程。**props
編譯後的結果有兩種,其中attrs
前面分析過,是編譯生成render
函式針對屬性的處理,而props
是針對使用者自寫render
函式的屬性值。**因此需要同時對這兩種方式進行校驗。
function extractPropsFromVNodeData (data,Ctor,tag) {
// Ctor為子類構造器
···
var res = {};
// 子元件props選項
var propOptions = Ctor.options.props;
// data.attrs針對編譯生成的render函式,data.props針對使用者自定義的render函式
var attrs = data.attrs;
var props = data.props;
if (isDef(attrs) || isDef(props)) {
for (var key in propOptions) {
// aB 形式轉成 a-b
var altKey = hyphenate(key);
{
var keyInLowerCase = key.toLowerCase();
if (
key !== keyInLowerCase &&
attrs && hasOwn(attrs, keyInLowerCase)
) {
// 警告
}
}
}
}
}
複製程式碼
重點說一下原始碼在這一部分的處理,HTML對大小寫是不敏感的,所有的瀏覽器會把大寫字元解釋為小寫字元,因此我們在使用DOM
中的模板時,cameCase(駝峰命名法)的props
名需要使用其等價的 kebab-case
(短橫線分隔命名) 命代替。
即: <child :aB="test"></child>
需要寫成<child :a-b="test"></child>
7.2.2 響應式資料props
剛才說到分析props
需要兩個過程,前面已經針對父元件對props
的處理做了描述,而對於子元件而言,我們是通過props
選項去接收父元件傳遞的值。我們再看看子元件對props
的處理:
子元件處理props
的過程,是發生在父元件_update
階段,這個階段是Vnode
生成真實節點的過程,期間會遇到子Vnode
,這時會呼叫createComponent
去例項化子元件。而例項化子元件的過程又回到了_init
初始化,此時又會經歷選項的合併,針對props
選項,最終會統一成{props: { test: { type: null }}}
的寫法。接著會呼叫initProps
, initProps
做的事情,簡單概括一句話就是,將元件的props
資料設定為響應式資料。
function initProps (vm, propsOptions) {
var propsData = vm.$options.propsData || {};
var loop = function(key) {
···
defineReactive(props,key,value,cb);
if (!(key in vm)) {
proxy(vm, "_props", key);
}
}
// 遍歷props,執行loop設定為響應式資料。
for (var key in propsOptions) loop( key );
}
複製程式碼
其中proxy(vm, "_props", key);
為props
做了一層代理,使用者通過vm.XXX
可以代理訪問到vm._props
上的值。針對defineReactive
,本質上是利用Object.defineProperty
對資料的getter,setter
方法進行重寫,具體的原理可以參考資料代理章節的內容,在這小節後半段也會有一個基本的實現。
7.3 initMethods
initMethod
方法和這一節介紹的響應式沒有任何的關係,他的實現也相對簡單,主要是保證methods
方法定義必須是函式,且命名不能和props
重複,最終會將定義的方法都掛載到根例項上。
function initMethods (vm, methods) {
var props = vm.$options.props;
for (var key in methods) {
{
// method必須為函式形式
if (typeof methods[key] !== 'function') {
warn(
"Method \"" + key + "\" has type \"" + (typeof methods[key]) + "\" in the component definition. " +
"Did you reference the function correctly?",
vm
);
}
// methods方法名不能和props重複
if (props && hasOwn(props, key)) {
warn(
("Method \"" + key + "\" has already been defined as a prop."),
vm
);
}
// 不能以_ or $.這些Vue保留標誌開頭
if ((key in vm) && isReserved(key)) {
warn(
"Method \"" + key + "\" conflicts with an existing Vue instance method. " +
"Avoid defining component methods that start with _ or $."
);
}
}
// 直接掛載到例項的屬性上,可以通過vm[method]訪問。
vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
}
}
複製程式碼
7.4 initData
data
在初始化選項合併時會生成一個函式,只有在執行函式時才會返回真正的資料,所以initData
方法會先執行拿到元件的data
資料,並且會對物件每個屬性的命名進行校驗,保證不能和props,methods
重複。最後的核心方法是observe
,observe
方法是將資料物件標記為響應式物件,並對物件的每個屬性進行響應式處理。與此同時,和props
的代理處理方式一樣,proxy
會對data
做一層代理,直接通過vm.XXX
可以代理訪問到vm._data
上掛載的物件屬性。
function initData(vm) {
var data = vm.$options.data;
// 根例項時,data是一個物件,子元件的data是一個函式,其中getData會呼叫函式返回data物件
data = vm._data = typeof data === 'function'? getData(data, vm): data || {};
var keys = Object.keys(data);
var props = vm.$options.props;
var methods = vm.$options.methods;
var i = keys.length;
while (i--) {
var key = keys[i];
{
// 命名不能和方法重複
if (methods && hasOwn(methods, key)) {
warn(("Method \"" + key + "\" has already been defined as a data property."),vm);
}
}
// 命名不能和props重複
if (props && hasOwn(props, key)) {
warn("The data property \"" + key + "\" is already declared as a prop. " + "Use prop default value instead.",vm);
} else if (!isReserved(key)) {
// 資料代理,使用者可直接通過vm例項返回data資料
proxy(vm, "_data", key);
}
}
// observe data
observe(data, true /* asRootData */);
}
複製程式碼
最後講講observe
,observe
具體的行為是將資料物件新增一個不可列舉的屬性__ob__
,標誌物件是一個響應式物件,並且拿到每個物件的屬性值,重寫getter,setter
方法,使得每個屬性值都是響應式資料。詳細的程式碼我們後面分析。
7.5 initComputed
和上面的分析方法一樣,initComputed
是computed
資料的初始化,不同之處在於以下幾點:
computed
可以是物件,也可以是函式,但是物件必須有getter
方法,因此如果computed
中的屬性值是物件時需要進行驗證。- 針對
computed
的每個屬性,要建立一個監聽的依賴,也就是例項化一個watcher
,watcher
的定義,可以暫時理解為資料使用的依賴本身,一個watcher
例項代表多了一個需要被監聽的資料依賴。
除了不同點,initComputed
也會將每個屬性設定成響應式的資料,同樣的,也會對computed
的命名做檢測,防止與props,data
衝突。
function initComputed (vm, computed) {
···
for (var key in computed) {
var userDef = computed[key];
var getter = typeof userDef === 'function' ? userDef : userDef.get;
// computed屬性為物件時,要保證有getter方法
if (getter == null) {
warn(("Getter is missing for computed property \"" + key + "\"."),vm);
}
if (!isSSR) {
// 建立computed watcher
watchers[key] = new Watcher(vm,getter || noop,noop,computedWatcherOptions);
}
if (!(key in vm)) {
// 設定為響應式資料
defineComputed(vm, key, userDef);
} else {
// 不能和props,data命名衝突
if (key in vm.$data) {
warn(("The computed property \"" + key + "\" is already defined in data."), vm);
} else if (vm.$options.props && key in vm.$options.props) {
warn(("The computed property \"" + key + "\" is already defined as a prop."), vm);
}
}
}
}
複製程式碼
顯然Vue
提供了很多種資料供開發者使用,但是分析完後發現每個處理的核心都是將資料轉化成響應式資料,有了響應式資料,如何構建一個響應式系統呢?前面提到的watcher
又是什麼東西?構建響應式系統還需要其他的東西嗎?接下來我們嘗試著去實現一個極簡風的響應式系統。
7.6 極簡風的響應式系統
Vue
的響應式系統構建是比較複雜的,直接進入原始碼分析構建的每一個流程會讓理解變得困難,因此我覺得在儘可能保留原始碼的設計邏輯下,用最小的程式碼構建一個最基礎的響應式系統是有必要的。對Dep,Watcher,Observer
概念的初步認識,也有助於下一篇對響應式系統設計細節的分析。
7.6.1 框架搭建
我們以MyVue
作為類響應式框架,框架的搭建不做贅述。我們模擬Vue
原始碼的實現思路,例項化MyVue
時會傳遞一個選項配置,精簡的程式碼只有一個id
掛載元素和一個資料物件data
。模擬原始碼的思路,我們在例項化時會先進行資料的初始化,這一步就是響應式的構建,我們稍後分析。資料初始化後開始進行真實DOM
的掛載。
var vm = new MyVue({
id: '#app',
data: {
test: 12
}
})
// myVue.js
(function(global) {
class MyVue {
constructor(options) {
this.options = options;
// 資料的初始化
this.initData(options);
let el = this.options.id;
// 例項的掛載
this.$mount(el);
}
initData(options) {
}
$mount(el) {
}
}
}(window))
複製程式碼
7.6.2 設定響應式物件 - Observer
首先引入一個類Observer
,這個類的目的是將資料變成響應式物件,利用Object.defineProperty
對資料的getter,setter
方法進行改寫。在資料讀取getter
階段我們會進行依賴的收集,在資料的修改setter
階段,我們會進行依賴的更新(這兩個概念的介紹放在後面)。因此在資料初始化階段,我們會利用Observer
這個類將資料物件修改為相應式物件,而這是所有流程的基礎。
class MyVue {
initData(options) {
if(!options.data) return;
this.data = options.data;
// 將資料重置getter,setter方法
new Observer(options.data);
}
}
// Observer類的定義
class Observer {
constructor(data) {
// 例項化時執行walk方法對每個資料屬性重寫getter,setter方法
this.walk(data)
}
walk(obj) {
const keys = Object.keys(obj);
for(let i = 0;i< keys.length; i++) {
// Object.defineProperty的處理邏輯
defineReactive(obj, keys[i])
}
}
}
複製程式碼
7.6.3 依賴本身 - Watcher
我們可以這樣理解,一個Watcher
例項就是一個依賴,資料不管是在渲染模板時使用還是在使用者計算時使用,都可以算做一個需要監聽的依賴,watcher
中記錄著這個依賴監聽的狀態,以及如何更新操作的方法。
// 監聽的依賴
class Watcher {
constructor(expOrFn, isRenderWatcher) {
this.getter = expOrFn;
// Watcher.prototype.get的呼叫會進行狀態的更新。
this.get();
}
get() {}
}
複製程式碼
那麼哪個時間點會例項化watcher
並更新資料狀態呢?顯然在渲染資料到真實DOM
時可以建立watcher
。$mount
流程前面章節介紹過,會經歷模板生成render
函式和render
函式渲染真實DOM
的過程。我們對程式碼做了精簡,updateView
濃縮了這一過程。
class MyVue {
$mount(el) {
// 直接改寫innerHTML
const updateView = _ => {
let innerHtml = document.querySelector(el).innerHTML;
let key = innerHtml.match(/{(\w+)}/)[1];
document.querySelector(el).innerHTML = this.options.data[key]
}
// 建立一個渲染的依賴。
new Watcher(updateView, true)
}
}
複製程式碼
7.6.4 依賴管理 - Dep
watcher
如果理解為每個資料需要監聽的依賴,那麼Dep
可以理解為對依賴的一種管理。資料可以在渲染中使用,也可以在計算屬性中使用。相應的每個資料對應的watcher
也有很多。而我們在更新資料時,如何通知到資料相關的每一個依賴,這就需要Dep
進行通知管理了。並且瀏覽器同一時間只能更新一個watcher
,所以也需要一個屬性去記錄當前更新的watcher
。而Dep
這個類只需要做兩件事情,將依賴進行收集,派發依賴進行更新。
let uid = 0;
class Dep {
constructor() {
this.id = uid++;
this.subs = []
}
// 依賴收集
depend() {
if(Dep.target) {
// Dep.target是當前的watcher,將當前的依賴推到subs中
this.subs.push(Dep.target)
}
}
// 派發更新
notify() {
const subs = this.subs.slice();
for (var i = 0, l = subs.length; i < l; i++) {
// 遍歷dep中的依賴,對每個依賴執行更新操作
subs[i].update();
}
}
}
Dep.target = null;
複製程式碼
7.6.5 依賴管理過程 - defineReactive
我們看看資料攔截的過程。前面的Observer
例項化最終會呼叫defineReactive
重寫getter,setter
方法。這個方法開始會例項化一個Dep
,也就是建立一個資料的依賴管理。在重寫的getter
方法中會進行依賴的收集,也就是呼叫dep.depend
的方法。在setter
階段,比較兩個數不同後,會呼叫依賴的派發更新。即dep.notify
const defineReactive = (obj, key) => {
const dep = new Dep();
const property = Object.getOwnPropertyDescriptor(obj);
let val = obj[key]
if(property && property.configurable === false) return;
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
// 做依賴的收集
if(Dep.target) {
dep.depend()
}
return val
},
set(nval) {
if(nval === val) return
// 派發更新
val = nval
dep.notify();
}
})
}
複製程式碼
回過頭來看watcher
,例項化watcher
時會將Dep.target
設定為當前的watcher
,執行完狀態更新函式之後,再將Dep.target
置空。這樣在收集依賴時只要將Dep.target
當前的watcher push
到Dep
的subs
陣列即可。而在派發更新階段也只需要重新更新狀態即可。
class Watcher {
constructor(expOrFn, isRenderWatcher) {
this.getter = expOrFn;
// Watcher.prototype.get的呼叫會進行狀態的更新。
this.get();
}
get() {
// 當前執行的watcher
Dep.target = this
this.getter()
Dep.target = null;
}
update() {
this.get()
}
}
複製程式碼
7.6.6 結果
一個極簡的響應式系統搭建完成。在精簡程式碼的同時,保持了原始碼設計的思想和邏輯。有了這一步的基礎,接下來深入分析原始碼中每個環節的實現細節會更加簡單。
7.7 小結
這一節內容,我們正式進入響應式系統的介紹,前面在資料代理章節,我們學過Object.defineProperty
,這是一個用來進行資料攔截的方法,而響應式系統構建的基礎就是資料的攔截。我們先介紹了Vue
內部在初始化資料的過程,最終得出的結論是,不管是data,computed
,還是其他的使用者定義資料,最終都是呼叫Object.defineProperty
進行資料攔截。而文章的最後,我們在保留原始碼設計思想和邏輯的前提下,構建出了一個簡化版的響應式系統。完整的功能有助於我們下一節對原始碼具體實現細節的分析和思考。
- 深入剖析Vue原始碼 - 選項合併(上)
- 深入剖析Vue原始碼 - 選項合併(下)
- 深入剖析Vue原始碼 - 資料代理,關聯子父元件
- 深入剖析Vue原始碼 - 例項掛載,編譯流程
- 深入剖析Vue原始碼 - 完整渲染過程
- 深入剖析Vue原始碼 - 元件基礎
- 深入剖析Vue原始碼 - 元件進階
- 深入剖析Vue原始碼 - 響應式系統構建(上)
- 深入剖析Vue原始碼 - 響應式系統構建(中)
- 深入剖析Vue原始碼 - 響應式系統構建(下)
- 深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!
- 深入剖析Vue原始碼 - 揭祕Vue的事件機制
- 深入剖析Vue原始碼 - Vue插槽,你想了解的都在這裡!
- 深入剖析Vue原始碼 - 你瞭解v-model的語法糖嗎?
- 深入剖析Vue原始碼 - Vue動態元件的概念,你會亂嗎?
- 徹底搞懂Vue中keep-alive的魔法(上)
- 徹底搞懂Vue中keep-alive的魔法(下)