什麼是 DOM?
如果我們把這個 HTML 載入到瀏覽器中,瀏覽器建立這些節點,用來顯示網頁。所以這個HTML對映到一系列DOM節點,然後我們可以使用JavaScript進行操作。例如:
let item = document.getElementByTagName('h1')[0]
item.textContent = "New Heading"
VDOM
網頁可以有很多DOM節點,這意味著DOM樹可以有數千個節點。這就是為什麼我們有像Vue這樣的框架,幫我們幹這些重活兒,並進行大量的JavaScript呼叫。
然而,搜尋和更新數千個DOM節點很明顯會變慢。這就是Vue和其他類似框架有一種叫做虛擬DOM的東西。虛擬DOM是表示DOM的一種方式。例如,這個HTML也可以通過一個虛擬節點來表示,看起來像這樣。如您所見,它只是一個JavaScript物件。
<div>Hello</div>
{
tag: 'div',
children: [
{
text: 'Hello'
}
]
}
Vue知道如何使用此虛擬節點並掛載到DOM上,它會更新我們在瀏覽器中看到的內容。實際上還有一個步驟其中,Vue基於我們的模板建立一個渲染函式,返回一個虛擬DOM節點。
渲染函式可以是這樣的:
render(h) {
return h('div', 'hello')
}
當元件更改時,Render函式將重新執行,它將建立另一個虛擬節點。然後傳送舊的 VNode 和新的 VNode 到Vue中進行比較並以最高效的方式在我們的網頁上更新。
我們可以將虛擬DOM和實際DOM的關係類比為藍圖和實際建築的關係。假設我更改了29樓的一些資料。我改變了傢俱的佈局還加了一些櫥櫃。我有兩種方法可以改變。首先,我可以拆除29樓的一切從頭開始重建。或者我可以創造新的藍圖,比較新舊藍圖並進行更新以儘可能減少工作量。這就是虛擬DOM的工作原理。Vue 3讓這些更新更快並且更高效。
核心模組
Vue 的三個核心模組:
- Reactivity Module 響應式模組
- Compiler Module 編譯器模組
- Renderer Module 渲染模組
響應式模組允許我們建立 JavaScript 響應物件並可以觀察其變化。當使用這些物件的程式碼執行時,它們會被跟蹤,因此,它們可以在響應物件發生變化後執行。
編譯器模組獲取 HTML 模板並將它們編譯成渲染函式。這可能在執行時在瀏覽器中發生,但在構建 Vue 專案時更常見。這樣瀏覽器就可以只接收渲染函式。
渲染模組的程式碼包含在網頁上渲染元件的三個不同階段:
- 渲染階段
- 掛載階段
- 補丁階段
在渲染階段,將呼叫 render 函式,它返回一個虛擬 DOM 節點。
在掛載階段,使用虛擬DOM節點並呼叫 DOM API 來建立網頁。
在補丁階段,渲染器將舊的虛擬節點和新的虛擬節點進行比較並只更新網頁變化的部分。
現在讓我們來看一個例子,一個簡單元件的執行。它有一個模板,以及在模板內部使用的響應物件。首先,模板編譯器將 HTML 轉換為一個渲染函式。然後初始化響應物件,使用響應式模組。接下來,在渲染模組中,我們進入渲染階段。這將呼叫 render 函式,它引用了響應物件。我們現在監聽這個響應物件的變化,render 函式返回一個虛擬 DOM 節點。接下來,在掛載階段,呼叫 mount 函式使用虛擬 DOM 節點建立 web 頁面。最後,如果我們的響應物件發生任何變化,正在被監視,渲染器再次呼叫render函式,建立一個新的虛擬DOM節點。新的和舊的虛擬DOM節點,傳送到補丁函式中,然後根據需要更新我們的網頁。
渲染器機制
擁有虛擬DOM層有一些好處,最重要的是它讓元件的渲染邏輯完全從真實DOM中解耦,並讓它更直接地重用框架的執行時在其他環境中。例如,Vue允許第三方開發人員建立自定義渲染解決方案目標,不僅僅是瀏覽器也包括IOS和Android等原生環境,也可以使用API建立自定義渲染器直接渲染到WebGL而不是DOM節點。在Vue 2中我們實際上已經有了這種能力但是,我們在Vue 2中提供的API沒有正式記錄並且需要分叉原始碼。所以這給維護帶來了很大的負擔,對開發這些定製解決方案的開發人員在Vue 3中,我們讓自定義渲染器API成為一等公民。因此開發人員可以直接拉取Vue執行時核心作為依賴項,然後利用自定義渲染器API構建自己的自定義渲染器。事實上,我們已經有了早期使用者報告他們已經成功地構建了一個使用Vue 3 API關於虛擬DOM的WebGL渲染器。
另一個重要方面,它提供了能力以程式設計方式構造、檢查、克隆以及操作所需的DOM結構,在實際返回渲染引擎之前你可以利用JavaScript的全部能力做到這些。這個能力很重要,因為總會有某些情況在UI程式設計中使用模板語法會有一些限制,你只需要一種有充分靈活性的合適的程式語言來表達潛在的邏輯。現在,這種情況實際上是相當罕見的在日常UI開發中。但當你在創作一個庫的時候,這種情況更常見或編寫UI元件套件,你打算上傳供第三方開發者使用。讓我們想象一下一個,像複雜型別的頂部框這樣的元件或者一個與一堆文字相關聯的輸入框,這些型別的元件通常包含很少的標記,但它們將包含很多互動邏輯在這些情況下,模板語法有時候會限制你更容易地表達潛在的邏輯,或者有時候你會發現自己在模板中加入了很多邏輯,但你還是有很多邏輯在JavaScript 中而 render 函式允許你把這些邏輯組合在一個地方你通常不需要想太多關於這些情況下的標記。
所以我理解是模板會完成你要做的事在99%的情況下你只需要寫出HTML就好了,但偶爾可能想做些更可控的事情在,你需要編寫一個渲染函式。Vue 2中的渲染函式如下所示,
render(h) {
return h (
'div', {
attrs: {
id: foo
},
on: {
click: this.onClick
},
'hello'
})
}
所以這是元件定義中的一個選項,相對於提供一個 template 選項,在 Vue 2 中你可以為元件提供一個渲染函式,你會得到 h 引數,直接作為渲染函式的引數。你可以用它來創造我們稱之為虛擬DOM節點,簡稱 vnode。
vnode 接受三個引數:
- 第一個引數是型別,所以我們在這裡建立一個 div。
- 第二個引數是一個物件包含 vnode 上的所有資料或屬性,API有點冗長從某種意義上說,你必須指明傳遞給節點的繫結型別。例如,如果要繫結屬性你必須把它巢狀在attrs物件下如果要繫結事件偵聽器你得把它列在 on 下面。
- 第三個引數是這個 vnode 的子節點。所以直接傳遞一個字串是一個方便的 API,表明此節點只包含文字子節點,但它也可以是包含更多子節點的陣列。所以你可以在這裡有一個陣列並且巢狀了更多的巢狀 h 呼叫。
在Vue 3中我們改變了API,目標是簡化它。
import { h } from 'vue'
render () {
return h(
'div',
{
id: 'foo',
onClick: this.onClick
},
'hello'
})
}
第一個顯著的變化是我們現在有了一個扁平的 props
結構。當你呼叫 h 時,第二個引數現在總是一個扁平的物件。你可以直接給它傳遞一個屬性,這裡我們只是給它一個 ID。按慣例監聽器以 on 開頭,所以任何帶 on 的都會自動繫結為一個監聽器所以你不必考慮太多巢狀的問題。
在大多數情況下,你也不需要思考是應將其作為 attribute 繫結還是DOM屬性繫結,因為 Vue 將智慧地找出為你做這件事的最好方法。我們檢查這個 key 是否作為屬性存在在原生 DOM 中。如果存在,我們會將其設定為 property,如果它不存在,我們將它設定為一個attribute。
render API 的另一項改動是 h helper 現在是直接從 Vue 本身全域性匯入的。一些使用者在 Vue 2 中因為 h 在這裡傳遞而在這裡面 h 又很特別,因為它繫結到當前元件例項。當你想拆分一個大的渲染函式時,你必須把這個 h 函式一路傳遞給這些分割函式。所以,這有點困難,但有了全域性引入的 h 你匯入一次就可以分割你的渲染函式,在同一個檔案裡分割多少個都行。
渲染函式不再有 h 引數了,在內部它確實接收引數,但這只是編譯器使用的用來生成程式碼。當使用者直接使用時,他們不需要這個引數。所以,如果你用 TypeScript 使用定義的元件 API 你也會得到 this 的完整型別推斷。
Q&A
1.我知道原始的那種虛擬 Dom 的實現得到了啟發來自其他專案對嗎?
是的有一個庫叫snabbdomVue 2基本上就是從這個庫中分離出來的。
2.好的然後是Vue 3,你在這裡的編碼方式只是改進了Vue 2的模式嗎?
好吧,Vue 3是一個徹底的重寫,幾乎從頭開始一切都是定製的顯然,有現有的演算法看起來像沒有變化,因為這些是我們看到社群在做廣泛研究的領域所以這是建立在所有這些以前的實現的基礎上的但程式碼本身現在是從頭開始。
3.都是用TypeScript寫的,對吧?
是的,都是 TypeScript 寫的。
何時/如何使用 render 函式
看看渲染函式在 Vue 中是什麼樣子。在 Vue 2 中,一個傳統的 Vue 元件,有一個 template 選項,但是為了重用渲染函式我們可以用一個名為 render
的函式來代替它,我們會通過引數得到這個稱為 h(hyperscript)。但在這裡,我們只是示範一下我們如何在 Vue 3 中使用它。我們會從 vue 匯入 h,我們可以用它來返回 h。
import { h } from 'vue'
const App = {
render () {
return h('div')
}
}
// 等效模板中的普通 div
1.所以它返回 div 的 JavaScript 物件表示?
完全正確。
2.那麼,你的虛擬dom就像…編譯器?是編譯器接收它嗎?
是渲染器,渲染器接收它。
3.然後它實際上進行 dom 呼叫將其帶入瀏覽器?
完全正確。
所以我們可以給這個虛擬節點一些 props,
import { h } from 'vue'
const App = {
render () {
return h(
'div',
{
id: 'hello'
},
[
h('span','world')
]
)
}
}
// <div id="hello"><span>world</span></div>
現在,我們知道如何生成靜態結構。但是當人們第一次使用 render 函式會問 “我該怎麼寫,比如說,v-if
或者 v-for
”?我們沒有像 v-if
或者類似的東西。相反,您可以直接使用 JavaScript。
import { h } from 'vue'
const App = {
render () {
return this.ok
? h('div',{ id: 'hello' },[h('span','world')]
: h('p', 'other branch')
)
}
}
如果 ok 的值為 true,它將呈現 div,反之,它將呈現 p。同樣,如果你想做 v-else-if 你需要巢狀這個三元表示式:
import { h } from 'vue'
const App = {
render () {
return this.ok
? h('div',{ id: 'hello' },[h('span','world')]
: this.otherCondition
? h('p', 'other branch')
: h('span')
)
}
}
我想你可能會喜歡建立一個變數,將不同的節點新增到該變數。所以當你不得不將這整個東西巢狀在一個表示式呼叫中這會很有用,但你不必這麼做。
import { h } from 'vue'
let nodeToReturn
if(this.ok) {
nodeToReturn = ...
} else if () {
}
const App = {
render () {
return this.ok
? h('div',{ id: 'hello' },[h('span','world')]
: this.otherCondition
? h('p', 'other branch')
: h('span')
)
}
}
這就是 JavaScript 靈活的地方,這看起來更像普通的 JavaScript。當你的程式碼變得更加複雜時您可以使用普通的 JavaScript 重構技巧使它們更容易理解。
我們討論了 v-if
, 接下來看看 v-for
。 類似的,你也可以給它們加上 key,這是渲染函式中的渲染列表。
import { h } from 'vue'
const App = {
render () {
return this.list.map(item => {
return h('div', {key: item.id}, item.text)
}))
}
}
在渲染函式中,您可能要處理插槽。當你寫一個重標記元件(markup heavy component),或者我更喜歡稱之為特性元件(feature component),它與你的應用程式的外觀佈局結構有關,將實際的 HTML 顯示給使用者。對於那些型別的元件,我更喜歡始終使用模板。只有在我必須使用渲染函式的時候,比如我在寫一些功能型的元件,有時會期望獲取一些插槽內容,將其打包或者以某種方式操縱他們。在 Vue 3 裡預設插槽將暴露在這個 this.$slot.default
。如果對於元件什麼都沒有提供,這將是 undefined
,所以你得先檢查一下它的存在。如果它存在,它將永遠是一個陣列。有了作用域槽,我們可以將 props
傳遞給作用域槽,所以把資料傳遞到作用域槽只是通過傳遞一個引數到這個函式呼叫中。因為這是一個陣列你可以將它直接放在 children 位置。
import { h } from 'vue'
const App = {
render () {
const slot = this.$slot.default
? this.$slot.default()
: []
return h('div', slot)
}
}
你可以在 render 函式中用插槽做一件很強大的事,比如以某種方式操縱插槽,因為它只是一個 JavaScript 物件陣列,你可以用 map
遍歷它。
import { h } from 'vue'
const App = {
render () {
const slot = this.$slot.default
? this.$slot.default()
: []
slot.map(vnode => {
return h('div', [vnode])
})
}
}
這裡有一個例子,截住並更改插槽資料。假設我們有一個堆疊元件(tack component),在一些使用者介面庫(UI libraries)中很常見。你可以傳遞很多屬性給它,得到巢狀的堆疊渲染結果,有點像 HTML 中 ul
和 ol
的預設樣式。
<Stack size="4">
<div>hello</div>
<Stack size="4">
<div>hello</div>
<div>hello</div>
</Stack>
</Stack>
渲染成這樣:
<div class="stack">
<div class="mt-4">
<div>hello</div>
</div>
<div class="mt-4">
<div class="stack">
<div class="mt-4">
<div>hello</div>
</div>
</div>
</div>
</div>
這裡有一個普通的基於模板的語法,在同一個插槽內它們都是預設插槽,你能做的只有渲染這個部分,在模板很難實現。但是你可以用渲染函式來實現,程式化的遍歷插槽內的每個專案然後把它們變成別的東西。
import { h } from 'vue'
const Stack = {
render () {
const slot = this.$slots.default
? this.$slots.default()
: []
return h(
'div',
{class: 'stack'},
slot.map(child => {
return h(
'div',
{class: `mt-${this.$props.size}`},
[child]
)
})
)
}
}
我們用 slot.map
生成新的 vnode 列表,原來的子插槽被包裝在裡面。有了這個,我們把它放到一個 stack.html 檔案裡。
stack.html
<script src="https://unpkg.com/vue@next"></script>
<style>
.mt-4 {
margin: 10px
}
</style>
<div id="app"></div>
<script>
const { h, createApp } = Vue
const Stack = {
render() {
const slot = this.$slots.default
? this.$slots.default()
: []
return h(
'div',
{ class: 'stack' },
slot.map(child => {
return h('div', { class: `mt-${this.$attrs.size}` }, [child])
// this.$props.size ?
})
)
},
}
const App = {
components: {
Stack
},
template: `
<Stack size="4">
<div>hello</div>
<Stack size="4">
<div>hello</div>
<div>hello</div>
</Stack>
</Stack>
`
}
createApp(App).mount('#app')
</script>
當你創作這些底層的公用設施元件,有時真的會遇到麻煩,這時渲染函式更有效。但話說回來,也需要了解每種方法的利弊,這些是為了讓你更好地理解在什麼情況下應該使用模板或使用渲染函式。基本上是當你用一個模板時遇到限制時,比如你就像我們剛才看到的那樣,可能改為使用渲染函式會更有效。當你意識到想表達的邏輯用 JavaScript 更容易而不是使用模板語法時就使用它。從我的經驗來看,這種情況在您創作可重用的功能元件,要跨多個應用程式共享或者在組織內部共享時更常見。在日常開發中你主要是在編寫特性元件,模板通常是有效的方式,模板的好處是更簡單,當你有很多標記的時候會通過編譯器優化,它的另一個好處是它更容易讓設計師接管元件並用CSS設計樣式。因此,Vue 提供了這兩個選項,當情況出現的時候以便您可以選擇合適的方式。