前言 在上一篇 vue3早已具備拋棄虛擬DOM的能力了文章中講了對於動態節點,vue做的優化是將這些動態節點收集起來,然後當響應式變數修改後進行靶向更新。那麼vue對靜態節點有沒有做什麼優化呢?答案是:當然有,對於靜態節點會進行“靜態提升”。這篇文章我們來看看vue是如何進行靜態提升的。 什麼是靜態 ...
前言
在上一篇 vue3早已具備拋棄虛擬DOM的能力了文章中講了對於動態節點,vue做的優化是將這些動態節點收集起來,然後當響應式變數修改後進行靶向更新。那麼vue對靜態節點有沒有做什麼優化呢?答案是:當然有,對於靜態節點會進行“靜態提升”。這篇文章我們來看看vue是如何進行靜態提升的。
什麼是靜態提升?
我們先來看一個demo,代碼如下:
<template>
<div>
<h1>title</h1>
<p>{{ msg }}</p>
<button @click="handleChange">change msg</button>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const msg = ref("hello");
function handleChange() {
msg.value = "world";
}
</script>
這個demo代碼很簡單,其中的h1標簽就是我們說的靜態節點,p標簽就是動態節點。點擊button按鈕會將響應式msg
變數的值更新,然後會執行render函數將msg
變數的最新值"world"渲染到p標簽中。
我們先來看看未開啟靜態提升之前生成的render函數是什麼樣的:
由於在vite項目中啟動的vue都是開啟了靜態提升,所以我們需要在 Vue 3 Template Explorer網站中看看未開啟靜態提升的render函數的樣子(網站URL為: https://template-explorer.vuejs.org/ ),如下圖將hoistStatic
這個選項取消勾選即可:
未開啟靜態提升生成的render函數如下:
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("template", null, [
_createElementVNode("div", null, [
_createElementVNode("h1", null, "title"),
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("button", { onClick: _ctx.handleChange }, "change msg", 8 /* PROPS */, ["onClick"])
])
]))
}
每次響應式變數更新後都會執行render函數,每次執行render函數都會執行createElementVNode
方法生成h1標簽的虛擬DOM。但是我們這個h1標簽明明就是一個靜態節點,根本就不需要每次執行render函數都去生成一次h1標簽的虛擬DOM。
vue3對此做出的優化就是將“執行createElementVNode
方法生成h1標簽虛擬DOM的代碼”提取到render函數外面去,這樣就只有初始化的時候才會去生成一次h1標簽的虛擬DOM,也就是我們這篇文章中要講的“靜態提升”。開啟靜態提升後生成的render函數如下:
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("h1", null, "title", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("template", null, [
_createElementVNode("div", null, [
_hoisted_1,
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleChange && _ctx.handleChange(...args)))
}, "change msg")
])
]))
}
從上面可以看到生成h1標簽虛擬DOM的createElementVNode
函數被提取到render函數外面去執行了,只有初始化時才會執行一次將生成的虛擬DOM賦值給_hoisted_1
變數。在render函數中直接使用_hoisted_1
變數即可,無需每次執行render函數都去生成h1標簽的虛擬DOM,這就是我們這篇文章中要講的“靜態提升”。
我們接下來還是一樣的套路通過debug的方式來帶你搞清楚vue是如何實現靜態提升的,註:本文使用的vue版本為3.4.19
如何實現靜態提升
實現靜態提升主要分為兩個階段:
-
transform
階段遍歷AST抽象語法樹,將靜態節點找出來進行標記和處理,然後將這些靜態節點塞到根節點的hoists
數組中。 -
generate
階段遍歷上一步在根節點存的hoists
數組,在render函數外去生成存儲靜態節點虛擬DOM的_hoisted_x
變數。然後在render函數中使用這些_hoisted_x
變數表示這些靜態節點。
transform階段
在我們這個場景中transform
函數簡化後的代碼如下:
function transform(root, options) {
// ...省略
if (options.hoistStatic) {
hoistStatic(root, context);
}
root.hoists = context.hoists;
}
從上面可以看到實現靜態提升是執行了hoistStatic
函數,我們給hoistStatic
函數打個斷點。讓代碼走進去看看hoistStatic
函數是什麼樣的,在我們這個場景中簡化後的代碼如下:
function hoistStatic(root, context) {
walk(root, context, true);
}
從上面可以看到這裡依然不是具體實現的地方,接著將斷點走進walk
函數。在我們這個場景中簡化後的代碼如下:
function walk(node, context, doNotHoistNode = false) {
const { children } = node;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (
child.type === NodeTypes.ELEMENT &&
child.tagType === ElementTypes.ELEMENT
) {
const constantType = doNotHoistNode
? ConstantTypes.NOT_CONSTANT
: getConstantType(child, context);
if (constantType > ConstantTypes.NOT_CONSTANT) {
if (constantType >= ConstantTypes.CAN_HOIST) {
child.codegenNode.patchFlag = PatchFlags.HOISTED + ` /* HOISTED */`;
child.codegenNode = context.hoist(child.codegenNode);
continue;
}
}
}
if (child.type === NodeTypes.ELEMENT) {
walk(child, context);
}
}
}
我們先在debug終端上面看看傳入的第一個參數node
是什麼樣的,如下圖:
從上面可以看到此時的node
為AST抽象語法樹的根節點,樹的結構和template
中的代碼剛好對上。外層是div標簽,div標簽下麵有h1、p、button三個標簽。
我們接著來看walk
函數,簡化後的walk
函數只剩下一個for迴圈遍歷node.children
。在for迴圈裡面主要有兩塊if語句:
-
第一塊if語句的作用是實現靜態提升
-
第二塊if語句的作用是遞歸遍歷整顆樹。
我們來看第一塊if語句中的條件,如下:
if (
child.type === NodeTypes.ELEMENT &&
child.tagType === ElementTypes.ELEMENT
)
在將這塊if語句之前,我們先來瞭解一下這裡的兩個枚舉。NodeTypes
和ElementTypes
NodeTypes
枚舉
NodeTypes
表示AST抽象語法樹中的所有node節點類型,枚舉值如下:
enum NodeTypes {
ROOT, // 根節點
ELEMENT, // 元素節點,比如:div元素節點、Child組件節點
TEXT, // 文本節點
COMMENT, // 註釋節點
SIMPLE_EXPRESSION, // 簡單表達式節點,比如v-if="msg !== 'hello'"中的msg!== 'hello'
INTERPOLATION, // 雙大括弧節點,比如{{msg}}
ATTRIBUTE, // 屬性節點,比如 title="我是title"
DIRECTIVE, // 指令節點,比如 v-if=""
// ...省略
}
看到這裡有的小伙伴可能有疑問了,為什麼AST抽象語法樹中有這麼多種節點類型呢?
我們來看一個例子你就明白了,如下:
<div v-if="msg !== 'hello'" title="我是title">msg為 {{ msg }}</div>
上面這段代碼轉換成AST抽象語法樹後會生成很多node節點:
-
div
對應的是ELEMENT
元素節點 -
v-if
對應的是DIRECTIVE
指令節點 -
v-if
中的msg !== 'hello'
對應的是SIMPLE_EXPRESSION
簡單表達式節點 -
title
對應的是ATTRIBUTE
屬性節點 -
msg為
對應的是ELEMENT
元素節點 -
{{ msg }}
對應的是INTERPOLATION
雙大括弧節點
ElementTypes
枚舉
div元素節點、Child組件節點都是NodeTypes.ELEMENT
元素節點,那麼如何區分是不是組件節點呢?就需要使用ElementTypes
枚舉來區分了,如下:
enum ElementTypes {
ELEMENT, // html元素
COMPONENT, // 組件
SLOT, // 插槽
TEMPLATE, // 內置template元素
}
現在來看第一塊if條件,你應該很容易看得懂了:
if (
child.type === NodeTypes.ELEMENT &&
child.tagType === ElementTypes.ELEMENT
)
如果當前節點是html元素節點,那麼就滿足if條件。
當前的node節點是最外層的div節點,當然滿足這個if條件。
接著將斷點走進if條件內,第一行代碼如下:
const constantType = doNotHoistNode
? ConstantTypes.NOT_CONSTANT
: getConstantType(child, context);
在搞清楚這行代碼之前先來瞭解一下ConstantTypes
枚舉
ConstantTypes
枚舉
我們來看看ConstantTypes
枚舉,如下:
enum ConstantTypes {
NOT_CONSTANT = 0, // 不是常量
CAN_SKIP_PATCH, // 跳過patch函數
CAN_HOIST, // 可以靜態提升
CAN_STRINGIFY, // 可以預字元串化
}
ConstantTypes
枚舉的作用就是用來標記靜態節點的4種等級狀態,高等級的狀態擁有低等級狀態的所有能力。比如:
NOT_CONSTANT
:表示當前節點不是靜態節點。比如下麵這個p標簽使用了msg
響應式變數:
<p>{{ msg }}</p>
const msg = ref("hello");
CAN_SKIP_PATCH
:表示當前節點在重新執行render函數時可以跳過patch
函數。比如下麵這個p標簽雖然使用了變數name
,但是name
是一個常量值。所以這個p標簽其實是一個靜態節點,但是由於使用了name
變數,所以不能提升到render函數外面去。
<p>{{ name }}</p>
const name = "name";
CAN_HOIST
:表示當前靜態節點可以被靜態提升,當然每次執行render函數時也無需執行patch
函數。demo如下:
<h1>title</h1>
CAN_STRINGIFY
:表示當前靜態節點可以被預字元串化,下一篇文章會專門講預字元串化。
從debug終端中可以看到此時doNotHoistNode
變數的值為true,所以constantType
變數的值為ConstantTypes.NOT_CONSTANT
。
getConstantType
函數的作用是根據當前節點以及其子節點拿到靜態節點的constantType
。
我們接著來看後面的代碼,如下:
if (constantType > ConstantTypes.NOT_CONSTANT) {
if (constantType >= ConstantTypes.CAN_HOIST) {
child.codegenNode.patchFlag = PatchFlags.HOISTED + ` /* HOISTED */`;
child.codegenNode = context.hoist(child.codegenNode);
continue;
}
}
前面我們已經講過了,當前div節點的constantType
的值為ConstantTypes.NOT_CONSTANT
,所以這個if語句條件不通過。
我們接著看walk
函數中的最後一塊代碼,如下:
if (child.type === NodeTypes.ELEMENT) {
walk(child, context);
}
前面我們已經講過了,當前child節點是div標簽,所以當然滿足這個if條件。將子節點div作為參數,遞歸調用walk
函數。
我們再次將斷點走進walk
函數,和上一次執行walk
函數不同的是,上一次walk
函數的參數為root根節點,這一次參數是div節點。
同樣的在walk
函數內先使用for迴圈遍歷div節點的子節點,我們先來看第一個子節點h1標簽,也就是需要靜態提升的節點。很明顯h1標簽是滿足第一個if條件語句的:
if (
child.type === NodeTypes.ELEMENT &&
child.tagType === ElementTypes.ELEMENT
)
在debug終端中來看看h1標簽的constantType
的值,如下:
從上圖中可以看到h1標簽的constantType
值為3,也就是ConstantTypes.CAN_STRINGIFY
。表明h1標簽是最高等級的預字元串,當然也能靜態提升。
h1標簽的constantType
當然就能滿足下麵這個if條件:
if (constantType > ConstantTypes.NOT_CONSTANT) {
if (constantType >= ConstantTypes.CAN_HOIST) {
child.codegenNode.patchFlag = PatchFlags.HOISTED + ` /* HOISTED */`;
child.codegenNode = context.hoist(child.codegenNode);
continue;
}
}
值得一提的是上面代碼中的codegenNode
屬性就是用於生成對應node節點的render函數。
然後以codegenNode
屬性作為參數執行context.hoist
函數,將其返回值賦值給節點的codegenNode
屬性。如下:
child.codegenNode = context.hoist(child.codegenNode);
上面這行代碼的作用其實就是將原本生成render函數的codegenNode
屬性替換成用於靜態提升的codegenNode
屬性。
context.hoist
方法
將斷點走進context.hoist
方法,簡化後的代碼如下:
function hoist(exp) {
context.hoists.push(exp);
const identifier = createSimpleExpression(
`_hoisted_${context.hoists.length}`,
false,
exp.loc,
ConstantTypes.CAN_HOIST
);
identifier.hoisted = exp;
return identifier;
}
我們先在debug終端看看傳入的codegenNode
屬性。如下圖:
從上圖中可以看到此時的codegenNode
屬性對應的就是h1標簽,codegenNode.children
對應的就是h1標簽的title文本節點。codegenNode
屬性的作用就是用於生成h1標簽的render函數。
在hoist
函數中首先執行 context.hoists.push(exp)
將h1標簽的codegenNode
屬性push到context.hoists
數組中。context.hoists
是一個數組,數組中存的是AST抽象語法樹中所有需要被靜態提升的所有node節點的codegenNode
屬性。
接著就是執行createSimpleExpression
函數生成一個新的codegenNode
屬性,我們來看傳入的第一個參數:
`_hoisted_${context.hoists.length}`
由於這裡處理的是第一個需要靜態提升的靜態節點,所以第一個參數的值_hoisted_1
。如果處理的是第二個需要靜態提升的靜態節點,其值為_hoisted_2
,依次類推。
接著將斷點走進createSimpleExpression
函數中,代碼如下:
function createSimpleExpression(
content,
isStatic = false,
loc = locStub,
constType = ConstantTypes.NOT_CONSTANT
) {
return {
type: NodeTypes.SIMPLE_EXPRESSION,
loc,
content,
isStatic,
constType: isStatic ? ConstantTypes.CAN_STRINGIFY : constType,
};
}
這個函數的作用很簡單,根據傳入的內容生成一個簡單表達式節點。我們這裡傳入的內容就是_hoisted_1
。
表達式節點我們前面講過了,比如:v-if="msg !== 'hello'"
中的msg!== 'hello'
就是一個簡單的表達式。
同理上面的_hoisted_1
表示的是使用了一個變數名為_hoisted_1
的表達式。
我們在debug終端上面看看hoist
函數返回值,也就是h1標簽新的codegenNode
屬性。如下圖:
此時的codegenNode
屬性已經變成了一個簡單表達式節點,表達式的內容為:_hoisted_1
。後續執行generate
生成render函數時,在render函數中h1標簽就變成了表達式:_hoisted_1
。
最後再執行transform
函數中的root.hoists = context.hoists
,將context
上下文中存的hoists
屬性數組賦值給根節點的hoists
屬性數組,後面在generate
生成render函數時會用。
至此transform
階段已經完成了,主要做了兩件事:
-
將h1靜態節點找出來,將該節點生成render函數的
codegenNode
屬性push到根節點的hoists
屬性數組中,後面generate
生成render函數時會用。 -
將上一步h1靜態節點的
codegenNode
屬性替換為一個簡單表達式,表達式為:_hoisted_1
。
generate
階段
在generate
階段主要分為兩部分:
-
將原本render函數內調用
createElementVNode
生成h1標簽虛擬DOM的代碼,提到render函數外面去執行,賦值給全局變數_hoisted_1
。 -
在render函數內直接使用
_hoisted_1
變數即可。
如下圖:
生成render函數外面的_hoisted_1
變數
經過transform
階段的處理,根節點的hoists
屬性數組中存了所有需要靜態提升的靜態節點。我們先來看如何處理這些靜態節點,生成h1標簽對應的_hoisted_1
變數的。代碼如下:
genHoists(ast.hoists, context);
將根節點的hoists
屬性數組傳入給genHoists
函數,將斷點走進genHoists
函數,在我們這個場景中簡化後的代碼如下:
function genHoists(hoists, context) {
const { push, newline } = context;
newline();
for (let i = 0; i < hoists.length; i++) {
const exp = hoists[i];
if (exp) {
push(`const _hoisted_${i + 1} = ${``}`);
genNode(exp, context);
newline();
}
}
}
generate
部分的代碼會在後面文章中逐行分析,這篇文章就不細看到每個函數了。簡單解釋一下genHoists
函數中使用到的那些方法的作用。
-
context.code
屬性:此時的render函數字元串,可以在debug終端看一下執行每個函數後render函數字元串是什麼樣的。 -
newline
方法:向當前的render函數字元串中插入換行符。 -
push
方法:向當前的render函數字元串中插入字元串code。 -
genNode
函數:在transform
階段給會每個node節點生成codegenNode
屬性,在genNode
函數中會使用codegenNode
屬性生成對應node節點的render函數代碼。
在剛剛進入genHoists
函數,我們在debug終端使用context.code
看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時的render函數code字元串只有一行import vue的代碼。
然後執行newline
方法向render函數code字元串中插入一個換行符。
接著遍歷在transform
階段收集的需要靜態提升的節點集合,也就是hoists
數組。在debug終端來看看這個hoists
數組,如下圖:
從上圖中可以看到在hoists
數組中只有一個h1標簽需要靜態提升。
在for迴圈中會先執行一句push
方法,如下:
push(`const _hoisted_${i + 1} = ${``}`)
這行代碼的意思是插入一個名為_hoisted_1
的const變數,此時該變數的值還是空字元串。在debug終端使用context.code
看看執行push
方法後的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到_hoisted_1
全局變數的定義已經生成了,值還沒生成。
接著就是執行genNode(exp, context)
函數生成_hoisted_1
全局變數的值,同理在debug終端看看執行genNode
函數後的render函數字元串是什麼樣的,如下圖:
從上面可以看到render函數外面已經定義了一個_hoisted_1
變數,變數的值為調用createElementVNode
生成h1標簽虛擬DOM。
生成render函數中return的內容
在generate
中同樣也是調用genNode
函數生成render函數中return的內容,代碼如下:
genNode(ast.codegenNode, context);
這裡傳入的參數ast.codegenNode
是根節點的codegenNode
屬性,在genNode
函數中會從根節點開始遞歸遍歷整顆AST抽象語法樹,為每個節點生成自己的createElementVNode
函數,執行createElementVNode
函數會生成這些節點的虛擬DOM。
我們先來看看傳入的第一個參數ast.codegenNode
,也就是根節點的codegenNode
屬性。如下圖:
從上圖中可以看到靜態節點h1標簽已經變成了一個名為_hoisted_1
的變數,而使用了msg
變數的動態節點依然還是p標簽。
我們再來看看執行這個genNode
函數之前render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時的render函數字元串還沒生成return中的內容。
執行genNode
函數後,來看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到,在生成的render函數中h1標簽靜態節點已經變成了_hoisted_1
變數,_hoisted_1
變數中存的是靜態節點h1的虛擬DOM,所以每次頁面更新重新執行render函數時就不會每次都去生成一遍靜態節點h1的虛擬DOM。
總結
整個靜態提升的流程圖如下:
整個流程主要分為兩個階段:
-
在
transform
階段中:-
將h1靜態節點找出來,將靜態節點的
codegenNode
屬性push到根節點的hoists
屬性數組中。 -
將h1靜態節點的
codegenNode
屬性替換為一個簡單表達式節點,表達式為:_hoisted_1
。
-
-
在
generate
階段中:-
在render函數外面生成一個名為
_hoisted_1
的全局變數,這個變數中存的是h1標簽的虛擬DOM。 -
在render函數內直接使用
_hoisted_1
變數就可以表示這個h1標簽。
-
關註(圖1)公眾號:【前端歐陽】,解鎖我更多vue原理文章。
加我(圖2)微信回覆「666」,免費領取歐陽研究vue源碼過程中收集的源碼資料,歐陽寫文章有時也會參考這些資料。同時讓你的朋友圈多一位對vue有深入理解的人。