vue原理探索
資料響應式實現
實現資料追蹤變化
當你把一個普通的 JavaScript 物件傳給 Vue 例項的 data 選項,Vue 將遍歷此物件所有的屬性,並使用 Object.defineProperty 把這些屬性全部轉為 getter/setter。Object.defineProperty 是 ES5 中一個無法 shim 的特性,這也就是為什麼 Vue 不支援 IE8 以及更低版本瀏覽器的原因。
Object.defineProperty(object, propertyname, descriptor) 將屬性新增到物件,或修改現有屬性的特性。 //descriptor是描述符 configurable :可配置性 enumerable :可列舉性 value :設定屬性的值 writable :僅當屬性的值可以被賦值操作修改時設定為true。預設為false。 get :屬性的getter方法,獲取值時觸發。 set:屬性的setter方法,設定值時觸發。 function Archiver() { var temperature = null; var archive = []; Object.defineProperty(this, 'temperature', { get: function() { console.log('get!'); return temperature; }, set: function(value) { temperature = value; archive.push({ val: temperature }); } }); this.getArchive = function() { return archive; }; } var arc = new Archiver(); arc.temperature; // 'get!' arc.temperature = 11; arc.temperature = 13; arc.getArchive(); // [{ val: 11 }, { val: 13 }] Vue在初始化資料的時候會遍歷data代理這些資料 function initData (vm) { let data = vm.$options.data vm._data = data const keys = Object.keys(data) let i = keys.length while(i--) { const key = keys[i] proxy(vm,`_data`, key) } observe(data) } function proxy (target, sourceKey, key) { Object.defineProperty(target,key, { enumerable: true, configurable: true, get() { return this[sourceKey][key] } set () { return this[sourceKey][key]= val } }) }
這些 getter/setter 對使用者來說是不可見的,但是在內部它們讓 Vue 追蹤依賴,在屬性被訪問和修改時通知變化。這裡需要注意的問題是瀏覽器控制檯在列印資料物件時 getter/setter 的格式化並不同,所以你可能需要安裝 vue-devtools 來獲取更加友好的檢查介面。
每個元件例項都有相應的 watcher 例項物件,它會在元件渲染的過程中把屬性記錄為依賴,之後當依賴項的 setter 被呼叫時,會通知 watcher 重新計算,從而致使它關聯的元件得以更新。
使用vm.$watch 觀測資料變化 const vm = new Vue({ el:'#app', data:{ msg:1 } }) vm.$watch("msg", () => console.log("msg變了")); vm.msg = 2; //輸出「msg變了」
檢測變化需要注意的事項
受現代 JavaScript 的限制 (以及廢棄 Object.observe),Vue 不能檢測到物件屬性的新增或刪除。由於 Vue 會在初始化例項時對屬性執行 getter/setter 轉化過程,所以屬性必須在 data 物件上存在才能讓 Vue 轉換它,這樣才能讓它是響應的。例如:
var vm = new Vue({
data:{
a:1
}
})
// `vm.a` 是響應的
vm.b = 2
// `vm.b` 是非響應的
Vue 不允許在已經建立的例項上動態新增新的根級響應式屬性 (root-level reactive property)。然而它可以使用 Vue.set(object, key, value) 方法將響應屬性新增到巢狀的物件上:
宣告響應式資料
由於 Vue 不允許動態新增根級響應式屬性,所以你必須在初始化例項前宣告根級響應式屬性,哪怕只是一個空值:
var vm = new Vue({
data: {
// 宣告 message 為一個空值字串
message: ''
},
template: '<div>{{ message }}</div>'
})
// 之後設定 `message`
vm.message = 'Hello!'
如果你未在 data 選項中宣告 message,Vue 將警告你渲染函式正在試圖訪問的屬性不存在。
這樣的限制在背後是有其技術原因的,它消除了在依賴項跟蹤系統中的一類邊界情況,也使 Vue 例項在型別檢查系統的幫助下執行的更高效。而且在程式碼可維護性方面也有一點重要的考慮:data 物件就像元件狀態的概要,提前宣告所有的響應式屬性,可以讓元件程式碼在以後重新閱讀或其他開發人員閱讀時更易於被理解。
非同步更新佇列
可能你還沒有注意到,Vue 非同步執行 DOM 更新。只要觀察到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際 (已去重的) 工作。Vue 在內部嘗試對非同步佇列使用原生的 Promise.then 和 MessageChannel,如果執行環境不支援,會採用 setTimeout(fn, 0) 代替。
例如,當你設定 vm.someData = ‘new value’ ,該元件不會立即重新渲染。當重新整理佇列時,元件會在事件迴圈佇列清空時的下一個“tick”更新。多數情況我們不需要關心這個過程,但是如果你想在 DOM 狀態更新後做點什麼,這就可能會有些棘手。雖然 Vue.js 通常鼓勵開發人員沿著“資料驅動”的方式思考,避免直接接觸 DOM,但是有時我們確實要這麼做。為了在資料變化之後等待 Vue 完成更新 DOM ,可以在資料變化之後立即使用 Vue.nextTick(callback) 。這樣回撥函式在 DOM 更新完成後就會呼叫。例如:
<div id="example">{{message}}</div>
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // 更改資料
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message' // true
})
在元件內使用 vm.$nextTick() 例項方法特別方便,因為它不需要全域性 Vue ,並且回撥函式中的 this 將自動繫結到當前的 Vue 例項上:
Vue.component('example', {
template: '<span>{{ message }}</span>',
data: function () {
return {
message: '沒有更新'
}
},
methods: {
updateMessage: function () {
this.message = '更新完成'
console.log(this.$el.textContent) // => '沒有更新'
this.$nextTick(function () {
console.log(this.$el.textContent) // => '更新完成'
})
}
}
})
訂閱釋出設計模式
訂閱者訂閱資訊,然後釋出者釋出資訊通知訂閱者更新。
模板編譯
complie 最終生成 render 函式,等待呼叫。這個方法分為三步:
- parse 函式解析 template
- optimize 函式優化靜態內容
- generate 函式建立 render 函式字串
parse 解析
在瞭解 parse 的過程之前,我們需要了解 AST,AST 的全稱是 Abstract Syntax Tree,也就是所謂抽象語法樹,用來表示程式碼的資料結構。在 Vue 中我把它理解為巢狀的、攜帶標籤名、屬性和父子關係的 JS 物件,以樹來表現 DOM 結構。 下面是 Vue 裡的 AST 的定義:
我們可以看到 AST 有三種類型,並且通過 children 這個欄位層層巢狀形成了樹狀的結構。而每一個 AST 節點存放的就是我們的 HTML 元素、插值表示式或文字內容。AST 正是 parse 函式生成和返回的。 parse 函式裡定義了許多的正則表示式,通過對標籤名開頭、標籤名結尾、屬性欄位、文字內容等等的遞迴匹配。把字串型別的 template 轉化成了樹狀結構的 AST。
// parse 裡定義的一些正則
export const onRE = /^@|^v-on:/ //匹配 v-on
export const dirRE = /^v-|^@|^:/ //匹配 v-on 和 v-bind
export const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/ //匹配 v-for 屬性
export const forIteratorRE = /\((\{[^}]*\}|[^,]*),([^,]*)(?:,([^,]*))?\)/ //匹配 v-for 的多種形式
我們可以把這個過程理解為一個擷取的過程,它把 template 字串裡的元素、屬性和文字一個個地截取出來,其中的細節十分瑣碎,涉及到各種不同情況(比如不同型別的 v-for,各種 vue 指令、空白節點以及父子關係等等),我們不再贅述。
假設我們有一個元素
texttext,在 parse 完之後會變成如下的結構並返回: ele1 = {
type: 1,
tag: "div",
attrsList: [{name: "id", value: "test"}],
attrsMap: {id: "test"},
parent: undefined,
children: [{
type: 3,
text: 'texttext'
}
],
plain: true,
attrs: [{name: "id", value: "'test'"}]
}
optimize 優化
在第二步中,會對 parse 生成的 AST 進行靜態內容的優化。靜態內容指的是和資料沒有關係,不需要每次都重新整理的內容。標記靜態節點的作用是為了在後面做 Vnode 的 diff 時起作用,用來確認一個節點是否應該做 patch 還是直接跳過。optimize 的過程分為兩步:
- 標記所有的靜態和非靜態結點
- 標記靜態根節點
標記所有的靜態和非靜態結點
function markStatic (node: ASTNode) {
// 標記 static 屬性
node.static = isStatic(node)
if (node.type === 1) {
// 注意這個判斷邏輯
if (
!isPlatformReservedTag(node.tag) &&
node.tag !== 'slot' &&
node.attrsMap['inline-template'] == null
) {
return
}
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)
if (!child.static) {
node.static = false
}
}
}
}
- isStatic 函式
isStatic 函式顧名思義是判斷該節點是否 static 的函式,符合如下內容的節點就會被認為是 static 的節點:
1. 如果是表示式AST節點,直接返回 false
2. 如果是文字AST節點,直接返回 true
3. 如果元素是元素節點,階段有 v-pre 指令 ||
1. 沒有任何指令、資料繫結、事件繫結等 &&
2. 沒有 v-if 和 v-for &&
3. 不是 slot 和 component &&
4. 是 HTML 保留標籤 &&
5. 不是 template 標籤的直接子元素並且沒有包含在 for 迴圈中
則返回 true
if 判斷條件
- !isPlatformReservedTag(node.tag):node.tag 不是 HTML 保留標籤時返回true。
- node.tag !== ‘slot’:標籤不是slot。
- node.attrsMap[‘inline-template’] == null:node不是一個內聯模板容器。
如果滿足上面的所有條件,那麼這個節點的 static 就會被置為 false 並且不遞迴子元素,當不滿足上面某一個條件時,遞迴子元素判斷子元素是否 static,只有所有元素都是 static 的時候,該元素才是 static。
標記靜態根節點
這部分理解起來很簡單,只有當一個節點是 static 並且其不能只擁有一個靜態文字節點時才能被稱為 static root。因為作者認為這種情況去做優化,其消耗會超過獲得的收益。
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
node.staticRoot = true
return
} else {
node.staticRoot = false
}
generate 生成 render
生成 render 的 generate 函式的輸入也是 AST,它遞迴了 AST 樹,為不同的 AST 節點建立了不同的內部呼叫方法,等待後面的呼叫。生成 render 函式的過程如下:
幾種內部方法
_c:對應的是 createElement 方法,顧名思義,它的含義是建立一個元素(Vnode)
_v:建立一個文字結點。
_s:把一個值轉換為字串。(eg: {{data}})
_m:渲染靜態內容
{render: “with(this){return _c(‘div’,{attrs:{“id”:”test”}},[[_v(_s(val))]),_v(” “),_m(0)])}”}
整個 Vue 渲染過程,前面我們說了 complie 的過程,在做完 parse、optimize 和 generate 之後,我們得到了一個 render 函式字串。 那麼接下來 Vue 做的事情就是 new watcher,這個時候會對繫結的資料執行監聽,render 函式就是資料監聽的回撥所呼叫的,其結果便是重新生成 vnode。當這個 render 函式字串在第一次 mount、或者繫結的資料更新的時候,都會被呼叫,生成 Vnode。如果是資料的更新,那麼 Vnode 會與資料改變之前的 Vnode 做 diff,對內容做改動之後,就會更新到我們真正的 DOM 上啦~
render方式書寫元件
<script type="text/javascript">
Vue.component('child', {
render: function (createElement) {
return createElement(
'h' + this.level, // tag name 標籤名稱
this.$slots.default // 子元件中的陣列
)
},
props: {
level: {
type: Number,
required: true
}
}
})
new Vue({
el:"#div1"
})
關於createElement方法,他是通過render函式的引數傳遞進來的,這個方法有三個引數:
第一個引數主要用於提供dom的html內容,型別可以是字串、物件或函式。比如”div”就是建立一個 <div>標籤
第二個引數(型別是物件)主要用於設定這個dom的一些樣式、屬性、傳的元件的引數、繫結事件之類,具體可以參考 官方文件 裡這一小節的說明
第三個引數(型別是陣列,陣列元素型別是VNode)主要用於說是該結點下有其他節點的話,就放在這裡。
virtual dom原理
DOM是文件物件模型(Document Object Model)的簡寫,在瀏覽器中我們可以通過js來操作DOM,但是這樣的操作效能很差,於是Virtual Dom應運而生。我的理解,Virtual Dom就是在js中模擬DOM物件樹來優化DOM操作的一種技術或思路。
virtual-dom(後文簡稱vdom)的概念大規模的推廣還是得益於react出現,virtual-dom也是react這個框架的非常重要的特性之一。相比於頻繁的手動去操作dom而帶來效能問題,vdom很好的將dom做了一層對映關係,進而將在我們本需要直接進行dom的一系列操作,對映到了操作vdom,而vdom上定義了關於真實dom的一些關鍵的資訊,vdom完全是用js去實現,和宿主瀏覽器沒有任何聯絡,此外得益於js的執行速度,將原本需要在真實dom進行的建立節點,刪除節點,新增節點等一系列複雜的dom操作全部放到vdom中進行,這樣就通過操作vdom來提高直接操作的dom的效率和效能。