為了 Vue 組件測試,你需要為每個(gè)事件綁定的方法加上括號(hào)嗎?

本文由華為云體驗(yàn)技術(shù)團(tuán)隊(duì)松塔同學(xué)分享
先說結(jié)論,當(dāng)然不是!Vue 組件測試,尤其是組件觸發(fā)事件的測試,有成熟的示例。我們同樣要關(guān)注測試的原則,例如將組件當(dāng)成黑盒,不關(guān)心其內(nèi)部實(shí)現(xiàn),而只關(guān)心與其交互。本文是借由一次 Vue 組件測試,引發(fā)對(duì) Vue 源碼和 Spy 函數(shù)的延伸探討。
假設(shè)你寫了一個(gè) Vue 組件,它大概長這樣:
它定義了data
和disabled
作為 props,前者作為組件的數(shù)據(jù)輸入,后者用來定義組件的功能開關(guān)。組件被點(diǎn)擊時(shí),會(huì)拋出confirm
事件,不過當(dāng)disabled
為true
時(shí),confirm
事件不會(huì)被觸發(fā)。
當(dāng)你想為這個(gè)組件寫一些單元測試時(shí),可能會(huì)這樣寫:
valid
初始化時(shí)為false
,即MyComponent
一開始不會(huì)拋出confirm
事件,當(dāng)valid
被改變后,點(diǎn)擊MyComponent
,confirm
事件才被拋出。
這段單元測試會(huì)在最后一句報(bào)錯(cuò),顯示spy
實(shí)際被觸發(fā) 0 次。實(shí)際上,spy
永遠(yuǎn)不會(huì)被觸發(fā),即使valid
初始化時(shí)為true
也是如此。
然而,將模板里的方法調(diào)用調(diào)整一下,加上括號(hào),單元測試就按照預(yù)期通過了:
為什么加不加括號(hào)會(huì)引起單元測試的邏輯變化?
模板語法
首先我們需要看一看模板在編譯時(shí),處理@confirm="handleConfirm()"
和@confirm="handleConfirm"
有什么不同。
從@vue/compiler-sfc
的compileTemplate
方法開始一路往下分析,會(huì)發(fā)現(xiàn)模板編譯的核心方法是@vue/compiler-core
這個(gè)包中的baseCompile
方法。這個(gè)方法主要干三件事:
調(diào)用
baseParse
方法解析 HTML,生成基礎(chǔ)的 AST。由于 Vue 在 HTML 上增加了許多語法特性(v-if、v-for、v-bind 等等),需要做對(duì)應(yīng)解析。
<div @click="handleConfirm()" />
?生成的 AST

<div @click="handleConfirm" />
?生成的 AST

查看生成的 AST 結(jié)構(gòu)后可以發(fā)現(xiàn),加不加括號(hào)對(duì)結(jié)構(gòu)并不會(huì)產(chǎn)生影響。二者都生成了 v-on 的 prop,exp
中的 content 未對(duì)原始內(nèi)容做出改動(dòng)。
進(jìn)一步對(duì) AST 做解析和轉(zhuǎn)換。這一步引入了
nodeTransforms
和directiveTransforms
對(duì)象,其實(shí)是在./transforms
目錄下的一系列函數(shù):
光從名字就可以看出來,依舊是對(duì) Vue 的語法特性做的一些工作,最終在 AST 的每個(gè)節(jié)點(diǎn)上增加codegenNode
,這個(gè)屬性將會(huì)被用在第三步生成渲染函數(shù)過程中。經(jīng)過 transform 這一步后,生成的codegenNode
如下:
<div @click="handleConfirm()" />
?的?codegenNode

<div @click="handleConfirm" />
?的?codegenNode

二者 prop 中的 value 值有所差異,type 是 typescript 定義的 enum,編譯后變成了數(shù)字,還原后前者的類型從SIMPLE_EXPRESSION
變成了COMPOUND_EXPRESSION
,后者仍保持之前的SIMPLE_EXPRESSION
。
造成二者差異的原因,需要深入transformOn
這個(gè)對(duì) v-on 語法轉(zhuǎn)換的方法。它根據(jù) AST 節(jié)點(diǎn)的exp
和arg
,生成codegenNode
中props
下的屬性。簡化一下有關(guān)exp
的邏輯,核心代碼如下:
首先對(duì)exp
做判斷,是否是 member expression、是否是 inline statement,是否有多個(gè) statement。然后出現(xiàn)了exp
的改寫,根據(jù)判斷生成了 compound expression,實(shí)際就是轉(zhuǎn)換成了函數(shù)表達(dá)??磥?code>isMemberExp、isInlineStatement
這兩個(gè)判斷影響了最終codegenNode
的生成。
Member Expression
這是個(gè)來源于 AST 定義的概念,JavaScript 中經(jīng)常有對(duì)象屬性的指向,例如:
這里a.x
就是 member expression,transformOn
中調(diào)用isMemberExpression
來做判斷,實(shí)際就是調(diào)用 babel parser 的能力分析,簡化來說:
這里 MemberExpression、OptionalMemberExpression、Identifier 都被認(rèn)定成了 member expression。OptionalMemberExpression 即帶有 optional chaining (?.) 的表達(dá)式。Identifier 也被包括的原因是,在模板中一般會(huì)省略主對(duì)象,如 this、或者 setup 中返回的對(duì)象。
<div @click="handleConfirm" />
中handleConfirm
就是 Identifier,它指向的就是我們?cè)?script 中定義的函數(shù)。
isInlineStatement
的判斷中還出現(xiàn)了一個(gè)條件fnExpRE.test(exp.content)
,這是函數(shù)表達(dá)式的正則判斷:

雖然直接在模板里聲明函數(shù)很罕見,但是 Vue 并沒有限制這種做法。
那exp
如果既不是 member expression,也不是函數(shù)表達(dá)式,transformOn
就把它當(dāng)作 inline statement。實(shí)際上這是我們?cè)谌粘J褂脮r(shí)比較常見的作法,例如只是簡單對(duì)變量賦值,那就無需在<script>
中聲明函數(shù),而是簡寫為:
而讓這段代碼生效的原因,就在于transformOn
編譯時(shí)將exp
包裹了一層函數(shù)聲明。它調(diào)用createCompoundExpression
,將$event 作為函數(shù)入?yún)ⅲ瑥亩购瘮?shù)內(nèi)能獲取到:
3. 由上一步生成的codegenNode
,轉(zhuǎn)換成最終的渲染函數(shù)。重點(diǎn)看一下帶括號(hào)的表達(dá)式生成的渲染函數(shù):
with statement 是在模板中可以省略 this 的原因。
對(duì)比
將以上分析做一個(gè)總結(jié),我們可以將編譯后結(jié)果簡化一下,那么帶括號(hào)的函數(shù)表達(dá):
不帶括號(hào)的函數(shù)表達(dá):
Mock Function
我們已經(jīng)搞清楚在編譯階段,帶不帶括號(hào)的函數(shù)表達(dá)有什么區(qū)別。接下來就要研究這個(gè)區(qū)別對(duì)于 Mock 行為產(chǎn)生了什么影響。Vitest 內(nèi)部利用 tinyspy 來實(shí)現(xiàn) mock 功能,本文并不會(huì)深入 tinyspy 的具體實(shí)現(xiàn),因?yàn)?JavaScript spy 庫大同小異,而背后的 JavaScript 語言特性才是本文真正想分享的。spy 函數(shù)的基本功能就是提供對(duì)目標(biāo)函數(shù)的監(jiān)視,例如執(zhí)行次數(shù),出入?yún)⒌取R粋€(gè)函數(shù)在聲明后,JavaScript 無法讓我們二次修改它的內(nèi)容,因此通常來說 spy 庫會(huì)將原本函數(shù)的引用指向新的實(shí)現(xiàn)。一個(gè)簡單的 spy 函數(shù)可以是這樣:
它將object[method]
指向了新的函數(shù),首先更新函數(shù)執(zhí)行的次數(shù)、記錄每次執(zhí)行的入?yún)?,然后?code>call執(zhí)行原始函數(shù)。
對(duì)應(yīng)到本文的例子中,當(dāng)我們聲明const spy = vi.spyOn(wrapper.vm, 'handleConfirm')
后,wrapper.vm.handleConfirm
就被指向了 spy 生成的新函數(shù),這個(gè)改動(dòng)針對(duì)的是 Vue 實(shí)例對(duì)象,而我們由模板編譯生成的渲染函數(shù)仍保持不變。因此const prop = { onClick: ctx.handleConfirm }
中onClick
仍指向原始函數(shù)的引用,無論 handleConfirm 之后怎么改變,其在渲染函數(shù)生成后就從始至終不變了。而const prop = { onClick: ($event) => { ctx.handleConfirm() } }
中ctx.handleConfirm()
會(huì)在點(diǎn)擊回調(diào)觸發(fā)后解析,此時(shí)就會(huì)指向spyOn
定義的新函數(shù)了。
總結(jié)
當(dāng)搞清楚模板語法生成事件回調(diào)的邏輯后,我們就會(huì)發(fā)現(xiàn)這其實(shí)是一個(gè)經(jīng)典的對(duì)象引用指向的問題。受限于 JavaScript 語言特性,mock 行為實(shí)際上創(chuàng)建了一個(gè)新的函數(shù),而上下文若仍保持著對(duì)原函數(shù)的引用,那 mock 行為不會(huì)按照預(yù)期運(yùn)行也就可以理解了。
當(dāng)你想要測試組件是否正確地 emit,也許應(yīng)該嘗試@vue/test-utils
中的emitted()
方法。或者將視角拉得更高,從最終頁面呈現(xiàn)的內(nèi)容來判斷。
關(guān)于 OpenTiny

OpenTiny?是一套企業(yè)級(jí) Web 前端開發(fā)解決方案,提供跨端、跨框架、跨版本的?TinyVue 組件庫,包含基于 Angular+TypeScript 的?TinyNG 組件庫,擁有靈活擴(kuò)展的低代碼引擎?TinyEngine,具備主題配置系統(tǒng)TinyTheme?/ 中后臺(tái)模板?TinyPro/?TinyCLI?命令行等豐富的效率提升工具,可幫助開發(fā)者高效開發(fā) Web 應(yīng)用。
歡迎加入?OpenTiny 開源社區(qū)。添加微信小助手:opentiny-official 一起參與交流前端技術(shù)~更多視頻內(nèi)容也可關(guān)注B站、抖音、小紅書、視頻號(hào)
OpenTiny?也在持續(xù)招募貢獻(xiàn)者,歡迎一起共建
OpenTiny 官網(wǎng):https://opentiny.design/
OpenTiny 代碼倉庫:https://github.com/opentiny/
TinyVue 源碼:https://github.com/opentiny/tiny-vue
TinyEngine 源碼:?https://github.com/opentiny/tiny-engine
歡迎進(jìn)入代碼倉庫 Star??TinyEngine、TinyVue、TinyNG、TinyCLI~
如果你也想要共建,可以進(jìn)入代碼倉庫,找到?good first issue標(biāo)簽,一起參與開源貢獻(xiàn)~