前言 在Vue3.5版本中響應式 Props 解構終於正式轉正了,這個功能之前一直是試驗性的。這篇文章來帶你搞清楚,一個String類型的props經過解構後明明應該是一個常量了,為什麼還沒丟失響應式呢?本文中使用的Vue版本為歐陽寫文章時的最新版Vue3.5.5 關註公眾號:【前端歐陽】,給自己一 ...
前言
在Vue3.5版本中響應式 Props 解構
終於正式轉正了,這個功能之前一直是試驗性
的。這篇文章來帶你搞清楚,一個String類型的props經過解構後明明應該是一個常量了,為什麼還沒丟失響應式呢?本文中使用的Vue版本為歐陽寫文章時的最新版Vue3.5.5
關註公眾號:【前端歐陽】,給自己一個進階vue的機會
看個demo
我們先來看個解構props的例子。
父組件代碼如下:
<template>
<ChildDemo name="ouyang" />
</template>
<script setup lang="ts">
import ChildDemo from "./child.vue";
</script>
父組件代碼很簡單,給子組件傳了一個名為name
的prop,name
的值為字元串“ouyang”。
子組件的代碼如下:
<template>
{{ localName }}
</template>
<script setup lang="ts">
const { name: localName } = defineProps(["name"]);
console.log(localName);
</script>
在子組件中我們將name
給解構出來了並且賦值給了localName
,講道理解構出來的localName
應該是個常量會丟失響應式的,其實不會丟失。
我們在瀏覽器中來看一下編譯後的子組件代碼,很簡單,直接在network中過濾子組件的名稱即可,如下圖:
從上面可以看到原本的console.log(localName)
經過編譯後就變成了console.log(__props.name)
,這樣當然就不會丟失響應式了。
我們再來看一個另外一種方式解構的例子,這種例子解構後就會丟失響應式,子組件代碼如下:
<template>
{{ localName }}
</template>
<script setup lang="ts">
const props = defineProps(["name"]);
const { name: localName } = props;
console.log(localName);
</script>
在上面的例子中我們不是直接解構defineProps
的返回值,而是將返回值賦值給props
對象,然後再去解構props
對象拿到localName
。
從上圖中可以看到這種寫法使用解構的localName
時,就不會在編譯階段將其替換為__props.name
,這樣的話localName
就確實是一個普通的常量了,當然會丟失響應式。
這是為什麼呢?為什麼這種解構寫法就會丟失響應式呢?彆著急,我接下來的文章會講。
從哪裡開下手?
既然這個是在編譯時將localName
處理成__props.name
,那我們當然是在編譯時debug了。
還是一樣的套路,我們在vscode中啟動一個debug
終端。
在之前的 通過debug搞清楚.vue文件怎麼變成.js文件文章中我們已經知道了vue
文件中的<script>
模塊實際是由vue/compiler-sfc
包的compileScript
函數處理的。
compileScript
函數位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
找到compileScript
函數就可以給他打一個斷點了。
compileScript函數
在debug
終端上面執行yarn dev
後在瀏覽器中打開對應的頁面,比如: http://localhost:5173/ 。此時斷點就會走到compileScript
函數中。
在我們這個場景中簡化後的compileScript
函數代碼如下:
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const scriptSetupAst = ctx.scriptSetupAst;
// 2.2 process <script setup> body
for (const node of scriptSetupAst.body) {
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
// defineProps
const isDefineProps = processDefineProps(ctx, init, decl.id);
}
}
}
}
// 3 props destructure transform
if (ctx.propsDestructureDecl) {
transformDestructuredProps(ctx);
}
return {
//....
content: ctx.s.toString(),
};
}
在之前的 為什麼defineProps巨集函數不需要從vue中import導入?文章中我們已經詳細講解過了compileScript
函數中的入參sfc
、如何使用ScriptCompileContext
類new一個ctx
上下文對象。所以這篇文章我們就只簡單說一下他們的作用即可。
-
入參
sfc
對象:是一個descriptor
對象,descriptor
對象是由vue文件編譯來的。descriptor
對象擁有template屬性、scriptSetup屬性、style屬性,分別對應vue文件的<template>
模塊、<script setup>
模塊、<style>
模塊。 -
ctx
上下文對象:這個ctx
對象貫穿了整個script模塊的處理過程,他是根據vue文件的源代碼初始化出來的。在compileScript
函數中處理script模塊中的內容,實際就是對ctx
對象進行操作。最終ctx.s.toString()
就是返回script模塊經過編譯後返回的js代碼。
搞清楚了入參sfc
對象和ctx
上下文對象,我們接著來看ctx.scriptSetupAst
。從名字我想你也能猜到,他就是script模塊中的代碼對應的AST抽象語法樹。如下圖:
從上圖中可以看到body
屬性是一個數組,分別對應的是源代碼中的兩行代碼。
數組的第一項對應的Node節點類型是VariableDeclaration
,他是一個變數聲明類型的節點。對應的就是源代碼中的第一行:const { name: localName } = defineProps(["name"])
數組中的第二項對應的Node節點類型是ExpressionStatement
,他是一個表達式類型的節點。對應的就是源代碼中的第二行:console.log(localName)
我們接著來看compileScript
函數中的外層for迴圈,也就是遍歷前面講的body數組,代碼如下:
function compileScript(sfc, options) {
// ...省略
// 2.2 process <script setup> body
for (const node of scriptSetupAst.body) {
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
// defineProps
const isDefineProps = processDefineProps(ctx, init, decl.id);
}
}
}
}
// ...省略
}
我們接著來看外層for迴圈裡面的第一個if語句:
if (node.type === "VariableDeclaration" && !node.declare)
這個if語句的意思是判斷當前的節點類型是不是變數聲明並且確實有初始化的值。
我們這裡的源代碼第一行代碼如下:
const { name: localName } = defineProps(["name"]);
很明顯我們這裡是滿足這個if條件的。
接著在if裡面還有一個內層for迴圈,這個for迴圈是在遍歷node節點的declarations
屬性,這個屬性是一個數組。
declarations
數組屬性表示當前變數聲明語句中定義的所有變數,可能會定義多個變數,所以他才是一個數組。在我們這裡只定義了一個變數localName
,所以 declarations
數組中只有一項。
在內層for迴圈,會去遍歷聲明的變數,然後從變數的節點中取出init
屬性。我想聰明的你從名字應該就可以看出來init
屬性的作用是什麼。
沒錯,init
屬性就是對應的變數的初始化值。在我們這裡聲明的localName
變數的初始化值就是defineProps(["name"])
函數的返回值。
接著就是判斷init
是否存在,也就是判斷變數是否是有初始化值。如果為真,那麼就執行processDefineProps(ctx, init, decl.id)
判斷初始化值是否是在調用defineProps
。換句話說就是判斷當前的變數聲明是否是在調用defineProps
巨集函數。
processDefineProps函數
接著將斷點走進processDefineProps
函數,在我們這個場景中簡化後的代碼如下:
function processDefineProps(ctx, node, declId) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId);
}
// handle props destructure
if (declId && declId.type === "ObjectPattern") {
processPropsDestructure(ctx, declId);
}
return true;
}
processDefineProps
函數接收3個參數。
-
第一個參數
ctx
,表示當前上下文對象。 -
第二個參數
node
,這個節點對應的是變數聲明語句中的初始化值的部分。也就是源代碼中的defineProps(["name"])
。 -
第三個參數
declId
,這個對應的是變數聲明語句中的變數名稱。也就是源代碼中的{ name: localName }
。
在 為什麼defineProps巨集函數不需要從vue中import導入?文章中我們已經講過了這裡的第一個if語句就是用於判斷當前是否在執行defineProps
函數,如果不是那麼就直接return false
我們接著來看第二個if語句,這個if語句就是判斷當前變數聲明是不是“對象解構賦值”。很明顯我們這裡就是解構出的localName
變數,所以代碼將會走到processPropsDestructure
函數中。
processPropsDestructure
函數
接著將斷點走進processPropsDestructure
函數,在我們這個場景中簡化後的代碼如下:
function processPropsDestructure(ctx, declId) {
const registerBinding = (
key: string,
local: string,
defaultValue?: Expression
) => {
ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
};
for (const prop of declId.properties) {
const propKey = resolveObjectKey(prop.key);
registerBinding(propKey, prop.value.name);
}
}
前面講過了這裡的兩個入參,ctx
表示當前上下文對象。declId
表示變數聲明語句中的變數名稱。
首先定義了一個名為registerBinding
的箭頭函數。
接著就是使用for迴圈遍歷declId.properties
變數名稱,為什麼會有多個變數名稱呢?
答案是解構的時候我們可以解構一個對象的多個屬性,用於定義多個變數。
prop
屬性如下圖:
從上圖可以看到prop
中有兩個屬性很顯眼,分別是key
和value
。
其中key
屬性對應的是解構對象時從對象中要提取出的屬性名,因為我們這裡是解構的name
屬性,所以上面的值是name
。
其中value
屬性對應的是解構對象時要賦給的目標變數名稱。我們這裡是賦值給變數localName
,所以上面他的值是localName
。
接著來看for迴圈中的代碼。
執行const propKey = resolveObjectKey(prop.key)
拿到要從props
對象中解構出的屬性名稱。
將斷點走進resolveObjectKey
函數,代碼如下:
function resolveObjectKey(node: Node) {
switch (node.type) {
case "Identifier":
return node.name;
}
return undefined;
}
如果當前是標識符節點,也就是有name屬性。那麼就返回name屬性。
最後就是執行registerBinding
函數。
registerBinding(propKey, prop.value.name)
第一個參數為傳入解構對象時要提取出的屬性名稱,也就是name
。第二個參數為解構對象時要賦給的目標變數名稱,也就是localName
。
接著將斷點走進registerBinding
函數,他就在processPropsDestructure
函數裡面。
function processPropsDestructure(ctx, declId) {
const registerBinding = (
key: string,
local: string,
defaultValue?: Expression
) => {
ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
};
// ...省略
}
ctx.propsDestructuredBindings
是存在ctx上下文中的一個屬性對象,這個對象裡面存的是需要解構的多個props。
對象的key就是需要解構的props。
key對應的value也是一個對象,這個對象中有兩個欄位。其中的local
屬性是解構props後要賦給的變數名稱。default
屬性是props的預設值。
在debug終端來看看此時的ctx.propsDestructuredBindings
對象是什麼樣的,如下圖:
從上圖中就有看到此時裡面已經存了一個name
屬性,表示props
中的name
需要解構,解構出來的變數名為localName
,並且預設值為undefined
。
經過這裡的處理後在ctx上下文對象中的ctx.propsDestructuredBindings
中就已經存了有哪些props需要解構,以及解構後要賦值給哪個變數。
有了這個後,後續只需要將script模塊中的所有代碼遍歷一次,然後找出哪些在使用的變數是props解構的變數,比如這裡的localName
變數將其替換成__props.name
即可。
transformDestructuredProps函數
接著將斷點層層返回,走到最外面的compileScript
函數中。再來回憶一下compileScript
函數的代碼,如下:
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const scriptSetupAst = ctx.scriptSetupAst;
// 2.2 process <script setup> body
for (const node of scriptSetupAst.body) {
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
// defineProps
const isDefineProps = processDefineProps(ctx, init, decl.id);
}
}
}
}
// 3 props destructure transform
if (ctx.propsDestructureDecl) {
transformDestructuredProps(ctx);
}
return {
//....
content: ctx.s.toString(),
};
}
經過processDefineProps
函數的處理後,ctx.propsDestructureDecl
對象中已經存了有哪些變數是由props解構出來的。
這裡的if (ctx.propsDestructureDecl)
條件當然滿足,所以代碼會走到transformDestructuredProps
函數中。
接著將斷點走進transformDestructuredProps
函數中,在我們這個場景中簡化後的transformDestructuredProps
函數代碼如下:
import { walk } from 'estree-walker'
function transformDestructuredProps(ctx) {
const rootScope = {};
let currentScope = rootScope;
const propsLocalToPublicMap: Record<string, string> = Object.create(null);
const ast = ctx.scriptSetupAst;
for (const key in ctx.propsDestructuredBindings) {
const { local } = ctx.propsDestructuredBindings[key];
rootScope[local] = true;
propsLocalToPublicMap[local] = key;
}
walk(ast, {
enter(node: Node) {
if (node.type === "Identifier") {
if (currentScope[node.name]) {
rewriteId(node);
}
}
},
});
function rewriteId(id: Identifier) {
// x --> __props.x
ctx.s.overwrite(
id.start! + ctx.startOffset!,
id.end! + ctx.startOffset!,
genPropsAccessExp(propsLocalToPublicMap[id.name])
);
}
}
在transformDestructuredProps
函數中主要分為三塊代碼,分別是for迴圈、執行walk
函數、定義rewriteId
函數。
我們先來看第一個for迴圈,他是遍歷ctx.propsDestructuredBindings
對象。前面我們講過了這個對象中存的屬性key是解構了哪些props,比如這裡就是解構了name
這個props。
接著就是使用const { local } = ctx.propsDestructuredBindings[key]
拿到解構的props在子組件中賦值給了哪個變數,我們這裡是解構出來後賦給了localName
變數,所以這裡的local
的值為字元串"localName"。
由於在我們這個demo中只有兩行代碼,分別是解構props和console.log
。沒有其他的函數,所以這裡的作用域只有一個。也就是說rootScope
始終等於currentScope
。
所以這裡執行rootScope[local] = true
後,currentScope
對象中的localName
屬性也會被賦值true。如下圖:
接著就是執行propsLocalToPublicMap[local] = key
,這裡的local
存的是解構props後賦值給子組件中的變數名稱,key
為解構了哪個props。經過這行代碼的處理後我們就形成了一個映射,後續根據這個映射就能輕鬆的將script模塊中使用解構後的localName
的地方替換為__props.name
。
propsLocalToPublicMap
對象如下圖:
經過這個for迴圈的處理後,我們已經知道了有哪些變數其實是經過props解構來的,以及這些解構得到的變數和props的映射關係。
接下來就是使用walk
函數去遞歸遍歷script模塊中的所有代碼,這個遞歸遍歷就是遍歷script模塊對應的AST抽象語法樹。
在這裡是使用的walk
函數來自於第三方庫estree-walker
。
在遍歷語法樹中的某個節點時,進入的時候會觸發一次enter
回調,出去的時候會觸發一次leave
回調。
walk
函數的執行代碼如下:
walk(ast, {
enter(node: Node) {
if (node.type === "Identifier") {
if (currentScope[node.name]) {
rewriteId(node);
}
}
},
});
我們這個場景中只需要enter
進入的回調就行了。
在enter
回調中使用外層if判斷當前節點的類型是不是Identifier
,Identifier
類型可能是變數名、函數名等。
我們源代碼中的console.log(localName)
中的localName
就是一個變數名,當遞歸遍歷AST抽象語法樹遍歷到這裡的localName
對應的節點時就會滿足外層的if條件。
在debug終端來看看此時滿足外層if條件的node節點,如下圖:
從上面的代碼可以看到此時的node節點中對應的變數名為localName
。其中start
和end
分別表示localName
變數的開始位置和結束位置。
我們回憶一下前面講過了currentScope
對象中就是存的是有哪些本地的變數是通過props解構得到的,這裡的localName
變數當然是通過props解構得到的,滿足裡層的if條件判斷。
最後代碼會走進rewriteId
函數中,將斷點走進rewriteId
函數中,簡化後的代碼如下:
function rewriteId(id: Identifier) {
// x --> __props.x
ctx.s.overwrite(
id.start + ctx.startOffset,
id.end + ctx.startOffset,
genPropsAccessExp(propsLocalToPublicMap[id.name])
);
}
這裡使用了ctx.s.overwrite
方法,這個方法接收三個參數。
第一個參數是:開始位置,對應的是變數localName
在源碼中的開始位置。
第二個參數是:結束位置,對應的是變數localName
在源碼中的結束位置。
第三個參數是想要替換成的新內容。
第三個參數是由genPropsAccessExp
函數返回的,執行這個函數時傳入的是propsLocalToPublicMap[id.name]
。
前面講過了propsLocalToPublicMap
存的是props名稱和解構到本地的變數名稱的映射關係,id.name
是解構到本地的變數名稱。如下圖:
所以propsLocalToPublicMap[id.name]
的執行結果就是name
,也就是名為name
的props。
接著將斷點走進genPropsAccessExp
函數,簡化後的代碼如下:
const identRE = /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/;
function genPropsAccessExp(name: string): string {
return identRE.test(name)
? `__props.${name}`
: `__props[${JSON.stringify(name)}]`;
}
使用正則表達式去判斷如果滿足條件就會返回__props.${name}
,否則就是返回__props[${JSON.stringify(name)}]
。
很明顯我們這裡的name
當然滿足條件,所以genPropsAccessExp
函數會返回__props.name
。
那麼什麼情況下不會滿足條件呢?
比如這樣的props:
const { "first-name": firstName } = defineProps(["first-name"]);
console.log(firstName);
這種props在這種情況下就會返回__props["first-name"]
執行完genPropsAccessExp
函數後回到ctx.s.overwrite
方法的地方,此時我們已經知道了第三個參數的值為__props.name
。這個方法的執行會將localName
重寫為__props.name
在ctx.s.overwrite
方法執行之前我們來看看此時的script模塊中的js代碼是什麼樣的,如下圖:
從上圖中可以看到此時的代碼中console.log
裡面還是localName
。
執行完ctx.s.overwrite
方法後,我們來看看此時是什麼樣的,如下圖:
從上圖中可以看到此時的代碼中console.log
裡面已經變成了__props.name
。
這就是在編譯階段將使用到的解構localName
變數變成__props.name
的完整過程。
這會兒我們來看前面那個例子解構後丟失響應式的例子,我想你就很容易想通了。
<script setup lang="ts">
const props = defineProps(["name"]);
const { name: localName } = props;
console.log(localName);
</script>
在處理defineProps
巨集函數時,發現是直接解構了返回值才會進行處理。上面這個例子中沒有直接進行解構,而是將其賦值給props
,然後再去解構props
。這種情況下ctx.propsDestructuredBindings
對象中什麼都沒有。
後續在遞歸遍歷script模塊中的所有代碼,發現ctx.propsDestructuredBindings
對象中什麼都沒有。自然也不會將localName
替換為__props.name
,這樣他當然就會丟失響應式了。
總結
在編譯階段首先會處理巨集函數defineProps
,在處理的過程中如果發現解構了defineProps
的返回值,那麼就會將解構的name
屬性,以及name
解構到本地的localName
變數,都全部一起存到ctx.propsDestructuredBindings
對象中。
接下來就會去遞歸遍歷script模塊中的所有代碼,如果發現使用的localName
變數能夠在ctx.propsDestructuredBindings
對象中找的到。那麼就說明這個localName
變數是由props解構得到的,就會將其替換為__props.name
,所以使用解構後的props依然不會丟失響應式。
關註公眾號:【前端歐陽】,給自己一個進階vue的機會
另外歐陽寫了一本開源電子書vue3編譯原理揭秘,看完這本書可以讓你對vue編譯的認知有質的提升。這本書初、中級前端能看懂,完全免費,只求一個star。