前言
我們每天寫vue
程式碼時都在用defineProps
,但是你有沒有思考過下面這些問題。為什麼defineProps
不需要import
匯入?為什麼不能在非setup
頂層使用defineProps
?defineProps
是如何將宣告的 props
自動暴露給模板?
舉幾個例子
我們來看幾個例子,分別對應上面的幾個問題。
先來看一個正常的例子,common-child.vue
檔案程式碼如下:
<template>
<div>content is {{ content }}</div>
</template>
<script setup lang="ts">
defineProps({
content: String,
});
</script>
我們看到在這個正常的例子中沒有從任何地方import
匯入defineProps
,直接就可以使用了,並且在template
中渲染了props
中的content
。
我們再來看一個在非setup
頂層使用defineProps
的例子,if-child.vue
檔案程式碼如下:
<template>
<div>content is {{ content }}</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const count = ref(10);
if (count.value) {
defineProps({
content: String,
});
}
</script>
程式碼跑起來直接就報錯了,提示defineProps is not defined
透過debug搞清楚上面幾個問題
在我的上一篇文章 vue檔案是如何編譯為js檔案 中已經帶你搞清楚了vue
檔案中的<script>
模組是如何編譯成瀏覽器可直接執行的js
程式碼,其實底層就是依靠vue/compiler-sfc
包的compileScript
函式。
當然如果你還沒看過我的上一篇文章也不影響這篇文章閱讀,這裡我會簡單說一下。當我們import
一個vue
檔案時會觸發@vitejs/plugin-vue包的transform
鉤子函式,在這個函式中會呼叫一個transformMain
函式。transformMain
函式中會呼叫genScriptCode
、genTemplateCode
、genStyleCode
,分別對應的作用是將vue
檔案中的<script>
模組編譯為瀏覽器可直接執行的js
程式碼、將<template>
模組編譯為render
函式、將<style>
模組編譯為匯入css
檔案的import
語句。genScriptCode
函式底層呼叫的就是vue/compiler-sfc
包的compileScript
函式。
一樣的套路,首先我們在vscode的開啟一個debug
終端。
然後在node_modules
中找到vue/compiler-sfc
包的compileScript
函式打上斷點,compileScript
函式位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
。在debug
終端上面執行yarn dev
後在瀏覽器中開啟對應的頁面,比如:http://localhost:5173/ 。此時斷點就會走到compileScript
函式中,我們在debug
中先來看看compileScript
函式的第一個入參sfc
。sfc.filename
的值為當前編譯的vue
檔案路徑。由於每編譯一個vue
檔案都要走到這個debug中,現在我們只想debug
看看common-child.vue
檔案,所以為了方便我們在compileScript
中加了下面這樣一段程式碼,並且去掉了在compileScript
函式中加的斷點,這樣就只有編譯common-child.vue
檔案時會走進斷點。
compileScript
函式
我們再來回憶一下common-child.vue
檔案中的script
模組程式碼如下:
<script setup lang="ts">
defineProps({
content: String,
});
</script>
我們接著來看compileScript
函式的入參sfc
,在上一篇文章 vue檔案是如何編譯為js檔案 中我們已經講過了sfc
是一個descriptor
物件,descriptor
物件是由vue
檔案編譯來的。descriptor
物件擁有template
屬性、scriptSetup
屬性、style
屬性,分別對應vue
檔案的<template>
模組、<script setup>
模組、<style>
模組。在我們這個場景只關注scriptSetup
屬性,sfc.scriptSetup.content
的值就是<script setup>
模組中code
程式碼字串,sfc.source
的值就是vue
檔案中的原始碼code字串。詳情檢視下圖:
compileScript
函式內包含了編譯script
模組的所有的邏輯,程式碼很複雜,光是原始碼就接近1000行。這篇文章我們不會去通讀compileScript
函式的所有功能,只會講處理defineProps
相關的程式碼。下面這個是我簡化後的程式碼:
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
if (node.type === "VariableDeclaration" && !node.declare || node.type.endsWith("Statement")) {
// ....
}
}
ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);
let runtimeOptions = ``;
const propsDecl = genRuntimeProps(ctx);
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`;
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);
return {
//....
content: ctx.s.toString(),
};
}
在compileScript
函式中首先呼叫ScriptCompileContext
類生成一個ctx
上下文物件,然後遍歷vue
檔案的<script setup>
模組生成的AST抽象語法樹
。如果節點型別為ExpressionStatement
表示式語句,那麼就執行processDefineProps
函式,判斷當前表示式語句是否是呼叫defineProps
函式。如果是那麼就刪除掉defineProps
呼叫程式碼,並且將呼叫defineProps
函式時傳入的引數對應的node
節點資訊存到ctx
上下文中。然後從引數node
節點資訊中拿到呼叫defineProps
宏函式時傳入的props
引數的開始位置和結束位置。再使用slice
方法並且傳入開始位置和結束位置,從<script setup>
模組的程式碼字串中擷取到props
定義的字串。然後將擷取到的props
定義的字串拼接到vue
元件物件的字串中,最後再將編譯後的setup
函式程式碼字串拼接到vue
元件物件的字串中。
ScriptCompileContext
類
ScriptCompileContext
類中我們主要關注這幾個屬性:startOffset
、endOffset
、scriptSetupAst
、s
。先來看看他的constructor
,下面是我簡化後的程式碼。
import MagicString from 'magic-string'
class ScriptCompileContext {
source = this.descriptor.source
s = new MagicString(this.source)
startOffset = this.descriptor.scriptSetup?.loc.start.offset
endOffset = this.descriptor.scriptSetup?.loc.end.offset
constructor(descriptor, options) {
this.s = new MagicString(this.source);
this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset);
}
}
在前面我們已經講過了descriptor.scriptSetup
物件就是由vue
檔案中的<script setup>
模組編譯而來,startOffset
和endOffset
分別就是descriptor.scriptSetup?.loc.start.offset
和descriptor.scriptSetup?.loc.end.offset
,對應的是<script setup>
模組在vue
檔案中的開始位置和結束位置。
descriptor.source
的值就是vue
檔案中的原始碼code字串,這裡以descriptor.source
為引數new
了一個MagicString
物件。magic-string
是由svelte的作者寫的一個庫,用於處理字串的JavaScript
庫。它可以讓你在字串中進行插入、刪除、替換等操作,並且能夠生成準確的sourcemap
。MagicString
物件中擁有toString
、remove
、prependLeft
、appendRight
等方法。s.toString
用於生成返回的字串,我們來舉幾個例子看看這幾個方法你就明白了。
s.remove( start, end )
用於刪除從開始到結束的字串:
const s = new MagicString('hello word');
s.remove(0, 6);
s.toString(); // 'word'
s.prependLeft( index, content )
用於在指定index
的前面插入字串:
const s = new MagicString('hello word');
s.prependLeft(5, 'xx');
s.toString(); // 'helloxx word'
s.appendRight( index, content )
用於在指定index
的後面插入字串:
const s = new MagicString('hello word');
s.appendRight(5, 'xx');
s.toString(); // 'helloxx word'
我們接著看constructor
中的scriptSetupAst
屬性是由一個parse
函式的返回值賦值,parse(descriptor.scriptSetup.content, this.startOffset)
,parse
函式的程式碼如下:
import { parse as babelParse } from '@babel/parser'
function parse(input: string, offset: number): Program {
try {
return babelParse(input, {
plugins,
sourceType: 'module',
}).program
} catch (e: any) {
}
}
我們在前面已經講過了descriptor.scriptSetup.content
的值就是vue
檔案中的<script setup>
模組的程式碼code
字串,parse
函式中呼叫了babel
提供的parser
函式,將vue
檔案中的<script setup>
模組的程式碼code
字串轉換成AST抽象語法樹
。
現在我們再來看compileScript
函式中的這幾行程式碼你理解起來就沒什麼難度了,這裡的scriptSetupAst
變數就是由vue
檔案中的<script setup>
模組的程式碼轉換成的AST抽象語法樹
。
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
流程圖如下:
processDefineProps
函式
我們接著將斷點走到for
迴圈開始處,程式碼如下:
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
}
遍歷AST抽象語法樹
,如果當前節點型別為ExpressionStatement
表示式語句,並且processDefineProps
函式執行結果為true
就呼叫ctx.s.remove
方法。這會兒斷點還在for
迴圈開始處,在控制檯執行ctx.s.toString()
看看當前的code
程式碼字串。
從圖上可以看見此時toString
的執行結果還是和之前的common-child.vue
原始碼是一樣的,並且很明顯我們的defineProps
是一個表示式語句,所以會執行processDefineProps
函式。我們將斷點走到呼叫processDefineProps
的地方,看到簡化過的processDefineProps
函式程式碼如下:
const DEFINE_PROPS = "defineProps";
function processDefineProps(ctx, node, declId) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId);
}
ctx.propsRuntimeDecl = node.arguments[0];
return true;
}
在processDefineProps
函式中首先執行了isCallOf
函式,第一個引數傳的是當前的AST語法樹
中的node
節點,第二個引數傳的是"defineProps"
字串。從isCallOf
的名字中我們就可以猜出他的作用是判斷當前的node
節點的型別是不是在呼叫defineProps
函式,isCallOf
的程式碼如下:
export function isCallOf(node, test) {
return !!(
node &&
test &&
node.type === "CallExpression" &&
node.callee.type === "Identifier" &&
(typeof test === "string"
? node.callee.name === test
: test(node.callee.name))
);
}
isCallOf
函式接收兩個引數,第一個引數node
是當前的node
節點,第二個引數test
是要判斷的函式名稱,在我們這裡是寫死的"defineProps"
字串。我們在debug console
中將node.type
、node.callee.type
、node.callee.name
的值列印出來看看。
從圖上看到node.type
、node.callee.type
、node.callee.name
的值後,可以證明我們的猜測是正確的這裡isCallOf
的作用是判斷當前的node
節點的型別是不是在呼叫defineProps
函式。我們這裡的node
節點確實是在呼叫defineProps
函式,所以isCallOf
的執行結果為true
,在processDefineProps
函式中是對isCallOf
函式的執行結果取反。也就是!isCallOf(node, DEFINE_PROPS)
的執行結果為false
,所以不會走到return processWithDefaults(ctx, node, declId);
。
我們接著來看processDefineProps
函式:
function processDefineProps(ctx, node, declId) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId);
}
ctx.propsRuntimeDecl = node.arguments[0];
return true;
}
如果當前節點確實是在執行defineProps
函式,那麼就會執行ctx.propsRuntimeDecl = node.arguments[0];
。將當前node
節點的第一個引數賦值給ctx
上下文物件的propsRuntimeDecl
屬性,這裡的第一個引數其實就是呼叫defineProps
函式時給傳入的第一個引數。為什麼寫死成取arguments[0]
呢?是因為defineProps
函式只接收一個引數,傳入的引數可以是一個物件或者陣列。比如:
const props = defineProps({
foo: String
})
const props = defineProps(['foo', 'bar'])
記住這個在ctx
上下文上面塞的propsRuntimeDecl
屬性,後面生成執行時的props
就是根據propsRuntimeDecl
屬性生成的。
至此我們已經瞭解到了processDefineProps
中主要做了兩件事:判斷當前執行的表示式語句是否是defineProps
函式,如果是那麼將解析出來的props
屬性的資訊塞的ctx
上下文的propsRuntimeDecl
屬性中。
我們這會兒來看compileScript
函式中的processDefineProps
程式碼你就能很容易理解了:
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
}
遍歷AST語法樹
,如果當前節點型別是ExpressionStatement
表示式語句,再執行processDefineProps
判斷當前node
節點是否是執行的defineProps
函式。如果是defineProps
函式就呼叫ctx.s.remove
方法將呼叫defineProps
函式的程式碼從原始碼中刪除掉。此時我們在debug console
中執行ctx.s.toString()
,看到我們的code
程式碼字串中已經沒有了defineProps
了:
現在我們能夠回答第一個問題了:
為什麼defineProps
不需要import
匯入?
因為在編譯過程中如果當前AST抽象語法樹
的節點型別是ExpressionStatement
表示式語句,並且呼叫的函式是defineProps
,那麼就呼叫remove
方法將呼叫defineProps
函式的程式碼給移除掉。既然defineProps
語句已經被移除了,自然也就不需要import
匯入了defineProps
了。
genRuntimeProps
函式
接著在compileScript
函式中執行了兩條remove
程式碼:
ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);
這裡的startOffset
表示script
標籤中第一個程式碼開始的位置, 所以ctx.s.remove(0, startOffset);
的意思是刪除掉包含<script setup>
開始標籤前面的所有內容,也就是刪除掉template
模組的內容和<script setup>
開始標籤。這行程式碼執行完後我們再看看ctx.s.toString()
的值:
接著執行ctx.s.remove(endOffset, source.length);
,這行程式碼的意思是將</script >
包含結束標籤後面的內容全部刪掉,也就是刪除</script >
結束標籤和<style>
模組。這行程式碼執行完後我們再來看看ctx.s.toString()
的值:
由於我們的common-child.vue
的script
模組中只有一個defineProps
函式,所以當移除掉template
模組、style
模組、script
開始標籤和結束標籤後就變成了一個空字串。如果你的script
模組中還有其他js
業務程式碼,當程式碼執行到這裡後就不會是空字串,而是那些js
業務程式碼。
我們接著將compileScript
函式中的斷點走到呼叫genRuntimeProps
函式處,程式碼如下:
let runtimeOptions = ``;
const propsDecl = genRuntimeProps(ctx);
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`;
從genRuntimeProps
名字你應該已經猜到了他的作用,根據ctx
上下文生成執行時的props
。我們將斷點走到genRuntimeProps
函式內部,在我們這個場景中genRuntimeProps
主要執行的程式碼如下:
function genRuntimeProps(ctx) {
let propsDecls;
if (ctx.propsRuntimeDecl) {
propsDecls = ctx.getString(ctx.propsRuntimeDecl).trim();
}
return propsDecls;
}
還記得這個ctx.propsRuntimeDecl
是什麼東西嗎?我們在執行processDefineProps
函式判斷當前節點是否為執行defineProps
函式的時候,就將呼叫defineProps
函式的引數node
節點賦值給ctx.propsRuntimeDecl
。換句話說ctx.propsRuntimeDecl
中擁有呼叫defineProps
函式傳入的props
引數中的節點資訊。我們將斷點走進ctx.getString
函式看看是如何取出props
的:
getString(node, scriptSetup = true) {
const block = scriptSetup ? this.descriptor.scriptSetup : this.descriptor.script;
return block.content.slice(node.start, node.end);
}
我們前面已經講過了descriptor
物件是由vue
檔案編譯而來,其中的scriptSetup
屬性就是對應的<script setup>
模組。我們這裡沒有傳入scriptSetup
,所以block
的值為this.descriptor.scriptSetup
。同樣我們前面也講過scriptSetup.content
的值是<script setup>
模組code
程式碼字串。請看下圖:
這裡傳入的node
節點就是我們前面存在上下文中ctx.propsRuntimeDecl
,也就是在呼叫defineProps
函式時傳入的引數節點,node.start
就是引數節點開始的位置,node.end
就是引數節點的結束位置。所以使用content.slice
方法就可以擷取出來呼叫defineProps
函式時傳入的props
定義。請看下圖:
現在我們再回過頭來看compileScript
函式中的呼叫genRuntimeProps
函式的程式碼你就能很容易理解了:
let runtimeOptions = ``;
const propsDecl = genRuntimeProps(ctx);
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`;
這裡的propsDecl
在我們這個場景中就是使用slice
擷取出來的props
定義,再使用\n props: ${propsDecl},
進行字串拼接就得到了runtimeOptions
的值。如圖:
看到runtimeOptions
的值是不是就覺得很熟悉了,又有name
屬性,又有props
屬性。其實就是vue
元件物件的code
字串的一部分。name
拼接邏輯是在省略的程式碼中,我們這篇文章只講props
相關的邏輯,所以name
不在這篇文章的討論範圍內。
現在我們能夠回答前面提的第三個問題了。
defineProps
是如何將宣告的 props
自動暴露給模板?
編譯時在移除掉defineProps
相關程式碼時會將呼叫defineProps
函式時傳入的引數node
節點資訊存到ctx
上下文中。遍歷完AST抽象語法樹後
,然後從上下文中存的引數node
節點資訊中拿到呼叫defineProps
宏函式時傳入props
的開始位置和結束位置。再使用slice
方法並且傳入開始位置和結束位置,從<script setup>
模組的程式碼字串中擷取到props
定義的字串。然後將擷取到的props
定義的字串拼接到vue
元件物件的字串中,這樣vue
元件物件中就有了一個props
屬性,這個props
屬性在template
模版中可以直接使用。
拼接成完整的瀏覽器執行時js
程式碼
我們再來看compileScript
函式中的最後一坨程式碼;
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);
return {
//....
content: ctx.s.toString(),
};
這裡先呼叫了ctx.s.prependLeft
方法給字串開始的地方插入了一串字串,這串拼接的字串看著腦瓜子痛,我們直接在debug console
上面看看要拼接的字串是什麼樣的:
看到這串你應該很熟悉,除了前面我們拼接的name
和props
之外還有部分setup
編譯後的程式碼,其實這就是vue
元件物件的code
程式碼字串的一部分。
當斷點執行完prependLeft
方法後,我們在debug console
中再看看此時的ctx.s.toString()
的值是什麼樣的:
從圖上可以看到vue
元件物件上的name
屬性、props
屬性、setup
函式基本已經拼接的差不多了,只差一個})
結束符號,所以執行ctx.s.appendRight(endOffset,
}));
將結束符號插入進去。
我們最後再來看看compileScript
函式的返回物件中的content
屬性,也就是ctx.s.toString()
,content
屬性的值就是vue
元件中的<script setup>
模組編譯成瀏覽器可執行的js
程式碼字串。
為什麼不能在非setup
頂層使用defineProps
?
同樣的套路我們來debug
看看if-child.vue
檔案,先來回憶一下if-child.vue
檔案的程式碼。
<template>
<div>content is {{ content }}</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const count = ref(10);
if (count.value) {
defineProps({
content: String,
});
}
</script>
將斷點走到compileScript
函式的遍歷AST抽象語法樹
的地方,我們看到scriptSetupAst.body
陣列中有三個node
節點。
從圖中我們可以看到這三個node
節點型別分別是:ImportDeclaration
、VariableDeclaration
、IfStatement
。很明顯這三個節點對應的是我們原始碼中的import
語句、const
定義變數、if
模組。我們再來回憶一下compileScript
函式中的遍歷AST抽象語法樹
的程式碼:
function compileScript(sfc, options) {
// 省略..
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
if (
(node.type === "VariableDeclaration" && !node.declare) ||
node.type.endsWith("Statement")
) {
// ....
}
}
// 省略..
}
從程式碼我們就可以看出來第三個node
節點,也就是在if
中使用defineProps
的程式碼,這個節點型別為IfStatement
,不等於ExpressionStatement
,所以程式碼不會走到processDefineProps
函式中,也不會執行remove
方法刪除掉呼叫defineProps
函式的程式碼。當程式碼執行在瀏覽器時由於我們沒有從任何地方import
匯入defineProps
,當然就會報錯defineProps is not defined
。
總結
現在我們能夠回答前面提的三個問題了。
-
為什麼
defineProps
不需要import
匯入?因為在編譯過程中如果當前
AST抽象語法樹
的節點型別是ExpressionStatement
表示式語句,並且呼叫的函式是defineProps
,那麼就呼叫remove
方法將呼叫defineProps
函式的程式碼給移除掉。既然defineProps
語句已經被移除了,自然也就不需要import
匯入了defineProps
了。 -
為什麼不能在非
setup
頂層使用defineProps
?因為在非
setup
頂層使用defineProps
的程式碼生成AST抽象語法樹
後節點型別就不是ExpressionStatement
表示式語句型別,只有ExpressionStatement
表示式語句型別才會走到processDefineProps
函式中,並且呼叫remove
方法將呼叫defineProps
函式的程式碼給移除掉。當程式碼執行在瀏覽器時由於我們沒有從任何地方import
匯入defineProps
,當然就會報錯defineProps is not defined
。 -
defineProps
是如何將宣告的props
自動暴露給模板?編譯時在移除掉
defineProps
相關程式碼時會將呼叫defineProps
函式時傳入的引數node
節點資訊存到ctx
上下文中。遍歷完AST抽象語法樹後
,然後從上下文中存的引數node
節點資訊中拿到呼叫defineProps
宏函式時傳入props
的開始位置和結束位置。再使用slice
方法並且傳入開始位置和結束位置,從<script setup>
模組的程式碼字串中擷取到props
定義的字串。然後將擷取到的props
定義的字串拼接到vue
元件物件的字串中,這樣vue
元件物件中就有了一個props
屬性,這個props
屬性在template
模版中可以直接使用。
關注公眾號:前端歐陽
,解鎖我更多vue
乾貨文章,並且可以免費向我諮詢vue
相關問題。