前言 我們每天寫vue代碼時都在用defineProps,但是你有沒有思考過下麵這些問題。為什麼defineProps不需要import導入?為什麼不能在非setup頂層使用defineProps?defineProps是如何將聲明的 props 自動暴露給模板? 舉幾個例子 我們來看幾個例子,分別 ...
前言
我們每天寫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
相關問題。