和女朋友爭(zhēng)論了1個(gè)小時(shí),在vue用throttle居然這么黑盒?
開(kāi)篇
首先我們都知道,throttle(節(jié)流) 和 debounce(防抖) 是性能優(yōu)化的利器。
本文會(huì)簡(jiǎn)單介紹一下這兩個(gè)的概念,但是并不會(huì)對(duì)這兩個(gè)函數(shù)再進(jìn)行老生常談地說(shuō)原理了,而是會(huì)說(shuō)它和 vue 之間的愛(ài)恨情仇~,但是在步入正題以前,我們得先知道它的一些簡(jiǎn)介。
函數(shù)節(jié)流(throttle) 是指一定時(shí)間內(nèi) js 方法只運(yùn)行一次。
節(jié)流節(jié)流就是節(jié)省水流的意思,就像水龍頭在流水,我們可以手動(dòng)讓水流(在一定時(shí)間內(nèi))小一點(diǎn),但是他會(huì)一直在流。
函數(shù)節(jié)流的情況下,函數(shù)將每隔 n 秒執(zhí)行一次,常見(jiàn)的場(chǎng)景為:
DOM 元素的拖拽功能實(shí)現(xiàn)(mousemove) 搜索聯(lián)想(keyup) 計(jì)算鼠標(biāo)移動(dòng)的距離(mousemove) Canvas 模擬畫板功能(mousemove) 射擊游戲的 mousedown/keydown 事件(單位時(shí)間只能發(fā)射一顆子彈)
函數(shù)防抖(debounce) 只當(dāng)有足夠的空閑時(shí)間,才運(yùn)行代碼一次。
比如生活中的坐公交,就是一定時(shí)間內(nèi),如果有人陸續(xù)刷卡上車,司機(jī)就不會(huì)開(kāi)車。只有別人沒(méi)刷卡了,司機(jī)才開(kāi)車。(其實(shí)只要記住了節(jié)流的思想就能通過(guò)排除法判斷節(jié)流和防抖了)
函數(shù)防抖的情況下,函數(shù)將一直推遲執(zhí)行,造成不會(huì)被執(zhí)行的效果,常見(jiàn)的場(chǎng)景為:
每次 resize/scroll 觸發(fā)統(tǒng)計(jì)事件 文本輸入的驗(yàn)證(連續(xù)輸入文字后發(fā)送 AJAX 請(qǐng)求進(jìn)行驗(yàn)證,驗(yàn)證一次就好)
vue throttle
那么它們和 vue 結(jié)合會(huì)擦除怎么樣的火花呢?你有了以上的基礎(chǔ)知識(shí)后,下面正片就正式開(kāi)始了~ 最近和女朋友談了下 vue throttle 相關(guān)的問(wèn)題,一開(kāi)始以為是簡(jiǎn)單的的東西,沒(méi)想到真的討論了1個(gè)小時(shí).... 前方高能硬核,層層遞進(jìn)涉及到 vue 源碼。
初舞臺(tái)
問(wèn)題形態(tài)一:
<input?@input="download"?/>
...
methods:?{
?download:?()?{
??this.throttle(xxx)
?},
}
...
我們來(lái)分析為什么這樣是不行,首先我們來(lái)看看正常情況下 throttle 是怎么寫的,再來(lái)拆分拆分 throttle 。
window.addEventListener('mousemove',?throttle(xxx));
進(jìn)一步拆分
const?handleMove?=?throttle(xxx)
window.addEventListener('mousemove',?handleMove);
我們一直調(diào)用的是 handleMove 方法,而 throttle 的原理是依賴于 JS 的閉包原理,依賴于handleMove 中的閉包變量。而如果你在 handleMove 外層再套一層 download 函數(shù),賊無(wú)法讓 handleMove 中的閉包內(nèi)的變量進(jìn)行了緩存,因此也失去了throttle 的效果。
升溫
那我們來(lái)改造一下,看起來(lái)是正確地形態(tài)。
<input?@input="throttle(download(xxx))">
...
methods:?{
???download:?(xxx)?{
????..
???},
???throttle:?...
}
...
開(kāi)始一頓疑惑,沒(méi)錯(cuò)呀,這的確就是 throttle 正確寫法的樣子,為什么這樣就不行呢,再加上好久沒(méi)有寫 vue 的黑魔法了,一時(shí)不知道如何解釋。
趕緊偷偷查資料,默默地在谷歌輸入下了 vue debounce ...

搜到了一些正確的打開(kāi)方式。

發(fā)現(xiàn)它這樣是可以使用的,而我將他寫到模板中不行。
emm。查不到,那開(kāi)始思考?為什么這個(gè)寫法不行?等等,我剛剛說(shuō)了什么?把時(shí)間倒退 3.3 秒前... (為什么是3.3秒,因?yàn)槿祟惼骄f(shuō)話語(yǔ)速是200字/分鐘)
寫法?對(duì)啊,是寫法,這個(gè)只是 vue 的模板語(yǔ)法,真實(shí)瀏覽器運(yùn)行的并不是這個(gè)樣子啊。
感覺(jué)有思路了!快快快,快找 vue 模板編譯完后的樣子
在瀏覽器輸入下下了vue 模板 在線這幾個(gè)關(guān)鍵詞。

很快我們就查到了這個(gè)地址 https://template-explorer.vuejs.org/
我們將我們的模板輸入到左側(cè)的輸入框。

我們得到了這樣的一個(gè)解析后的 render 函數(shù)。
function?render()?{
??with(this)?{
????return?_c('input',?{
??????on:?{
????????"input":?function?($event)?{
??????????throttle(download(xxx));
????????}
??????}
????})
??}
}
在這里我們看到,我們能大概知道,通過(guò)解析后,input 監(jiān)聽(tīng)方法已經(jīng)被包裹了一層函數(shù)。也很容猜出,最終解析成真正的綁定的函數(shù)會(huì)變成以下這個(gè)樣子。
xxxx.addEventListener('input',?function?($event)?{
??throttle(download(xxx));
})
如果是這個(gè)樣子的 throttle ,我相信有了解 throttle 的朋友們一眼就能看出來(lái),這樣子的 throttle 是完全不起效果的。
而我們剛才資料中查詢到的方式呢?
function?render()?{
??with(this)?{
????return?_c('input',?{
??????on:?{
????????"input":?click
??????}
????})
??}
}
這種方式下,vue 是直接傳遞綁定的實(shí)踐方法的,并不會(huì)有任何包裝。
所以真相只有一個(gè)
果然是 vue 模板的黑魔法!?。。?!
進(jìn)階
那我們通過(guò) vue 的源碼來(lái)探索一下,vue 的模板解析的原理,來(lái)加深一些我們的印象。
由于這里部分是 vue 事件編譯相關(guān)的代碼,我們很容易地找到了 vue 源碼(目前看的是 v2.6.12版本)的位置。
https://github.com/vuejs/vue/blob/v2.6.12/src/compiler/codegen/events.js#L96
我們看到 vue 源碼中含關(guān)于事件生成是以下代碼。
const?fnExpRE?=?/^([\w$_]+|\([^)]*?\))\s*=>|^function(?:\s+[\w$]+)?\s*\(/
const?fnInvokeRE?=?/\([^)]*?\);*$/
const?simplePathRE?=?/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/
...
const?isMethodPath?=?simplePathRE.test(handler.value)
const?isFunctionExpression?=?fnExpRE.test(handler.value)
const?isFunctionInvocation?=?simplePathRE.test(handler.value.replace(fnInvokeRE,?''))
if?(!handler.modifiers)?{
??//?判斷如果是個(gè)方法或者是函數(shù)表達(dá)式,就返回?value
??if?(isMethodPath?||?isFunctionExpression)?{
????return?handler.value
??}
??/*?istanbul?ignore?if?*/
??if?(__WEEX__?&&?handler.params)?{
????return?genWeexHandler(handler.params,?handler.value)
??}
??//?如果不滿足以上的情況就會(huì)包一層方法
??return?`function($event){${
????isFunctionInvocation???`return?${handler.value}`?:?handler.value
??}}`?//?inline?statement
}?else?{
?...
}
由于我們的是沒(méi)有 修飾符(modifiers)的,因此我們關(guān)于含有修飾符的代碼注釋了,防止不必要的干擾。
為了能更好地梳理情況,我們將 isMethodPath 稱作方法路徑,而將 isFunctionExpression稱作函數(shù)表達(dá)式,isFunctionInvocation稱為函數(shù)調(diào)用(雖然英文就是這個(gè)意思,但是為了大家都能看明白吧)
通過(guò)以上代碼我們能明白,如果這個(gè)事件的寫法,滿足 isMethodPath 或者滿足isFunctionExpression。那么我們?cè)谑录械膶懛〞?huì)被直接返回,否則的話,會(huì)被包一層 function。
我們一一來(lái)看看關(guān)于事件的情景。isMethodPath 的判斷方法是const simplePathRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/, 乍一看有點(diǎn)長(zhǎng),我們通過(guò)可視化工具分析分析。
https://jex.im/regulex/

通過(guò)可視化可以看出,我們的事件方式如果是以上形態(tài)就會(huì)通過(guò)正則的檢驗(yàn)(例如 handle, handle['xx'], handle["xx"],handle[xxx], handle[0], console.log )這些情況都是不會(huì)被包裹一層函數(shù)。
還有一種情況就是 正則 const fnExpRE = /^([\w$_]+|\([^)]*?\))\s*=>|^function(?:\s+[\w$]+)?\s*\(/。

簡(jiǎn)單來(lái)講就是寫一個(gè)匿名函數(shù), (xx) => {} 或者 funciton(){}。
除了以上兩種情況之外的所有情況都會(huì)被包含一層方法。
還記得 vue 的官方教程中,我們寫模板語(yǔ)法的時(shí)候,以下兩種方式是等價(jià)的。
1.
2.
因?yàn)樵诰幾g的時(shí)候,他們會(huì)分別被編譯成以下形態(tài)。
xxx.onclick?=?handle
xxx.onclick?=?function($event)?{
?return?handler();
}
通過(guò)包一層函數(shù)來(lái)達(dá)到相同的目的,現(xiàn)在你能明白了吧?在 vue 中寫,怎么寫都不會(huì)出問(wèn)題,有時(shí)候可能是你偶然手誤,它都講這些情況考慮在內(nèi)了,就像是吃飯一樣,飯已經(jīng)喂到我們嘴邊了。
而在被函數(shù)包裹的情況又分了兩種情況。
isFunctionInvocation ? return ${handler.value} : handler.value
isFunctionInvocation的檢測(cè)就是將函數(shù)調(diào)用的部分去掉,如果去掉后,滿足方法路徑的情況,那么就會(huì)多一個(gè) return。

我們來(lái)畫個(gè)圖總結(jié)一下。

而我們的情況是怎么樣的呢?
throttle(download(xxx))
顯然我們既不滿足方法路徑、也不滿足函數(shù)表達(dá)式,因此就會(huì)出現(xiàn)我們上述的 "bug",讓我們的 throttle 失效了。
至此,我們已經(jīng)清楚了關(guān)于 vue 中的黑魔法了,vue 給我們帶來(lái)便利的同時(shí),我們運(yùn)用的不好,或者說(shuō)不理解它的一些思想原理,就會(huì)發(fā)生一些神奇的事情。
最佳
所以上述說(shuō)了這么多,我們需要有個(gè)最佳的實(shí)踐方案。
升華
那么我們?cè)賮?lái)解釋一個(gè)問(wèn)題,外部導(dǎo)入和內(nèi)部 methods 的差異性?
先說(shuō)以上寫法是會(huì)出錯(cuò)的。
因?yàn)樵谖覀兡0逯袑懙姆椒?,必須?methods 中的方法,否則就會(huì)找不到。
也許這樣我們直接像在模板中寫 throttle 就必須將這個(gè)函數(shù)定義在 methods 中,這樣是非常不友好的,因?yàn)闀?huì)反直覺(jué),對(duì)于太久沒(méi)寫的我(T T忘記了)。
那為什么不可以直接寫在模板上面呢,其實(shí)這也和 vue 的編譯相關(guān)的,因?yàn)?vue 模板中的方法都會(huì)被編譯成 _vm.xxx,舉個(gè)例子。
<template>
?<input?@click="debounce(download(xxx))"?/>
template>
以上模板代碼會(huì)被編譯成這個(gè)樣子。
/*?template?*/
var?__vue_render__?=?function()?{
????var?_vm?=?this;
????var?_h?=?_vm.$createElement;
????var?_c?=?_vm._self._c?||?_h;
????return?_c("input",?{
??????on:?{
????????click:?function($event)?{
??????????_vm.debounce(_vm.download(_vm.xxx));
????????}
??????}
????})
};
以上才是真正在瀏覽器執(zhí)行的代碼,所以我們可以很清楚地看到 _vm 中是不存在 debounce,這也是 template 只能訪問(wèn) vue 中定義的方法與變量。
試探邊緣
我們?cè)賮?lái)探究一下 vue 3.0 是否對(duì)這個(gè)有改動(dòng)。
答案是: 沒(méi)有。
我特地去找了 ?@vue/compiler-sfc 進(jìn)行了測(cè)試。
const?sfc?=?require('@vue/compiler-sfc');
const?template?=?sfc.compileTemplate({
????filename:?'example.vue',
????source:?'',
????id:?''
});
//?output
import?{?createVNode?as?_createVNode,?openBlock?as?_openBlock,?createBlock?as?_createBlock?}?from?"vue"
export?function?render(_ctx,?_cache)?{
??return?(_openBlock(),?_createBlock("input",?{
????onInput:?_cache[1]?||?(_cache[1]?=?$event?=>?(_ctx.throttle(_ctx.download(_ctx.xxx))));
結(jié)尾
從這一次的探索來(lái)看,vue 自身模板語(yǔ)言需要很多心智模型,而在本實(shí)例中,vue給了我們很多語(yǔ)法糖,讓我們沉醉其中,不得不說(shuō)這樣的方式很舒服,但是總有一天我們獨(dú)自承受這些苦楚。
這就不得不討論到 React 的 JSX,雖然它麻煩,對(duì)我們很殘酷,但是我們對(duì)自身的行為更加可控(雖然 vue 也可以用 JSX,但是 Templates 依舊是是官方推薦的方法)我也能理解 vue 上述的這些表現(xiàn),因?yàn)樗鼛臀覀冏隽撕芏嗵幚?,?duì)于某些情況它需要給我們注入 $event, 也就是我們常用的事件對(duì)象,但是別人幫我們手把手處理了這些事情,也使得我們慢慢忘記了它原本的形態(tài),一旦出現(xiàn)問(wèn)題,會(huì)讓我們舉手無(wú)措。而 JSX 中則要求我們寫出完整的代碼,這樣的方式使得我們寫什么都需要付出額外的勞動(dòng),也許像 vue 官方文檔中所說(shuō),談?wù)?JSX 和 vue 的 Templates 是膚淺的的,但是不管怎么樣,每個(gè)人都會(huì)對(duì)它有不一樣的理解,不一樣的喜好,所以自己總結(jié)了一下。
都學(xué)就完si兒了 :)
