在一年多前,我寫了 Vue-On-Clickout 這個 Vue.js 外掛來實現一個常常被遇到的需求:偵測「點擊一個物件的外面」的事件(通常是用在彈跳視窗或下拉選單等元件之上,讓使用者可以按別的地方就把元件關閉)。

在我寫該外掛之前,類似的外掛老早就已經多到很難數清了,但是我對它們都不滿意,因為它們的原理都是利用 Vue 當中的自訂 directive 去做,而自訂 directive 的一個最大缺點就是它不像 v-on directive 一樣可以直接寫表達式,而是必須傳入一個函數當作事件 handler。於是他們的外掛語法通常都會是類似這樣:

<div v-clickout="handler" v-on:click="open=true">...</div>

然後在 Vue 的元件宣告裡面放一個方法作為 handler:

methods: {
    handler() { this.open = false; }
}

可是不覺得這樣一整個很拖泥帶水嗎?為什麼我只是要執行一個簡短的 open=false 指令就得寫一個專門的 handler、只為了要傳遞給我的自訂 directive?而且兩種事件的語法也不一樣,一個寫 v-on:click(且可以縮寫成 @click)另一個是 v-clickout(且不能縮寫),這對我這種有程式碼強迫症的人來說真的很難接受。

因此我寫的 Vue-On-Clickout 的賣點就是我完全解決了這個問題。用我這個外掛的時候,兩種事件語法是完全一致的:

<div v-on:clickout="open=false" v-on:click="open=true">...</div>

而且當然也一樣可以縮寫成 @clickout。我的外掛之所以能夠做到這樣,是因為我用了一定程度的 reflection 手法去 patch 原有的 API;這部份待會再仔細解說。

我非常有自信我這個外掛絕對比之前的類似外掛都更討好,只是一年多過去了,我發現我這個外掛的下載人次還是最多一週 100 而已、但別人的外掛卻動輒每週數萬,這實在讓我很不甘心,所以昨天我就跑到臉書的 Vue.js 社團去宣傳一下我這個外掛,希望可以增加一點知名度。倒也慶幸我有這麼做,我很快就收到了一個回饋,希望這個可以支援近期中可能就會正式釋出的 Vue 3.0 版。我當然知道 Vue 3 快要推出了,不過因為我知道其程式架構可能還會持續異動,而且由於我用的 reflection 手法非常依賴 Vue 的底層程式碼特性、可能會因為 Vue 的結構異動而失效,所以之前我是沒有特別花時間研究如何支援 Vue 3;然而現在他們已經出到 RC 版了,後續就算會有異動應該也比較小,所以確實也差不多該是時間來搶先提供支援了。我大概花了兩三小時的功夫找到了適用於 Vue 3 的 reflection 手法,成功地使得在 Vue 3 中也完全可以使用一樣的語法,新的版本已經發布到 NPM 上了。

原理解說

所以我是如何做到用完全一樣的語法來提供自訂事件的?

這個外掛的運作原理大致分成兩個部份,一個是實際提供 clickout 事件的實作,另一個是讓 Vue.js 中的元件來註冊這項功能。前者的作法基本上每個人的作法都差不多,無非就是監聽 document 上的 click 事件,然後如果最初觸發點擊事件的元件並沒有被包含在我們關切的元件中(使用 Node.contains() 方法來檢查)的話,就對關切的元件觸發 clickout 事件。我的外掛有稍微再更精緻一點實作這部份的細節,包括對關注元件進行排序以便處理 clickout 事件的 bubble 順序(跟一般的事件相比是上下顛倒的)、以及 clickout 事件本身的 stopPropagation() 實作等等;另外我也 patch 了原生的 stopPropagation() 方法、使得就算 click 事件在中途被擋住、沒有傳遞到 document 上,我的外掛還是一樣會觸發 clickout。不過總之這一部份並沒有太多玄機可言。

學問比較大的是第二個部份:如何使得元件被名列在上述的「被關注元件」之中。我需要達到的目的是,如果在建立一個元件時發現上頭有寫著 v-on:clickout,就自動將它加入關注清單,而且若未來該元件從 DOM 中被移除,也要自動將它從關注清單中移除 1。既然要持續維護關注清單,那就必須要委託 Vue 來幫我監控這些元件什麼時候被加入和移出 DOM。

這部份 Vue 是有內建的手法的,但卻偏偏就是我不想要用的自訂 directive;在自訂 directive 當中,可以透過 bindunbind 的 hook(在 Vue 3 中改為 mountedunmounted 以統一詞彙)來自訂在元件加入或移出 DOM 的時候要做什麼事情。這部份其實確實是自訂 directive 很方便的一個地方,老實說用別的方法來追蹤元件的出入 DOM 都沒有這個作法來得好。可是只有自訂 directive 可以做到這一點,我沒辦法另外在 v-on:clickout 上面指定類似的 hook,但總而言之我就是不希望要求使用者在使用的時候除了 v-on:clickout 之外還要再打上自訂 directive 2

那怎麼辦呢?我的辦法就是:想辦法讓 Vue 在載入 template 的時候,一旦它發現某個元件有設定 v-on:clickout 事件,那就自動把它添加 v-clickout 的 directive。所以到頭來確實我仍舊是用了 directive,只是使用者不用自己打上去。這在精神上有點類似把 template 預處理了一次,只是我是在 runtime 的時候做這件事而已。

然而,就我所知,Vue 公開的 API 之中並沒有這種在 runtime 的時候預先處理 template 的自訂空間,無論是 Vue 2 或 Vue 3 都沒有。因此我最後達到這個目的的手法都有一定的程度上是在做 reflection,是可能會隨著 Vue 的更新而失效的手法,但是暫且我也沒有更好的辦法。

既然我用到的東西不是 Vue 的公開 API,那我是怎麼知道要從哪裡動手腳的呢?其實也沒有什麼大祕密,無非就是很辛苦地仔細逐行執行一次 Vue 的元件生成過程,去觀察 Vue 到底是透過什麼樣的步驟去處理 template,然後再從中想辦法看看有哪個步驟是我能夠透過外掛來加以動手腳的。這個研究的過程有點蠻力,不過真的還滿有趣的,尤其是在了解了 Vue 的處理機制之後找出插入點的這部份;這裡面最主要的困難在於 Vue 很多函數當然都是宣告在它自己的 scope 底下,我是沒辦法隨便亂改的(這到了 Vue 3 尤其更是如此,Vue 3 的程式碼非常地走 functional programming 的風格),而這點當然也並不是什麼意外的事情,畢竟 Vue 當然不希望外掛隨便來竄改它的內部運作機制,所以要找出可以鑽的漏洞也沒那麼簡單的。

Vue 2

對於 Vue 2 來說,我的外掛中最關鍵的是如下的程式碼:

Vue.mixin({
    beforeCreate() {
        let original_c = this._c;
        function hack_c(tag, data, children, normalizationType) {
            if(data && data.on && data.on[cout]) {
                data.directives = data.directives || [];
                if(!data.directives.some(d => d.name == cout))
                    data.directives.push({ name: cout, rawName: "v-" + cout });
            }
            return original_c(tag, data, children, normalizationType);
        }
        this._c = hack_c;
    }
});

這邊的原理是這樣的:在 Vue 2 的核心程式碼裡面,每一個 component 都會有一個叫作 _c 的方法(看名字就知道不是公開 API)是負責 VNode 的建構,我就把這個函數包了我自訂的一層,去判斷傳入的節點資料 data 是否有加上 v-on:clickout 事件,有的話就把資料裡頭再加上 v-clickout 即可。由於在 beforeCreate 的生命週期階段中 Vue 還沒有真的開始進行 VNode 的建構,在那個時間點修改 _c 恰好來得及。

Vue 3

可想而知,Vue 3 的內部運作流程跟 Vue 2 一定很不一樣,而且 Vue 3 很大的程度上走的是 functional programming 風格而非 OOP,這使得 component 不再有像 Vue 2 中那種 _c 方法可以被覆寫了。真的仔細地把 Vue 3 的建構流程走一遍,會發現 Vue 3 是從下面這一段程式碼開始編譯 component 的 render 函數:

Component.render = compile(Component.template, {
    isCustomElement: instance.appContext.config.isCustomElement,
    delimiters: Component.delimiters
});

其中 compile 函數的第二個參數是編譯過程的 options 物件,這個物件在若干層的函數傳遞間會陸續增加一些選項,其中包括最關鍵的 nodeTransforms 屬性,裡面定義了若干用來轉換 template 的 AST 的方法。本來這看起來就是正解,因為理論上我只要在 nodeTransforms 裡面加入我自己定義的轉換函數就行了:

function transform(node) {
    let props = node.props;
    if(!props) return;
    if(props.some(p => p.type == 7 && p.name == "on" && p.arg && p.arg.content == cout) &&
        !props.some(p => p.type == 7 && p.name == cout)) {
        props.push({
            "type": 7,
            "name": cout,
            "exp": {
                "type": 4,
                "content": "",
                "isStatic": false,
                "isConstant": false,
            },
            "modifiers": [],
        });
    }
}

然而,仔細觀察那個 options 傳遞的過程,我們將很傻眼的發現:至少截至 rc.5 版為止,Vue 根本從頭到尾沒有提供 API 讓我們可以自訂這個編譯 options 物件,該 options 物件從最一開始生成乃至會填入些什麼都完全是 Vue 自己在控制的,完全沒有地方可以讓我們加入新的東西到 nodeTransforms 選項裡面。

不過最後我還是發現了一個突破點:Vue 會引入 config 中的 isCustomElement() 函數,而這個函數是可以在外掛中自訂的。儘管 isCustomElement() 的作用跟 nodeTransforms 一點關係也沒有,但是它在編譯過程中確實會被呼叫、而且注意到它被呼叫的方式是這樣的:

if (!context.inVPre && !options.isCustomElement(tag)) {

啊哈,這樣就表示我們可以在 isCustomElement() 函數裡面透過 this 關鍵字來取得 options 物件(希望他們別改這段程式碼啊……不然就破功了)!於是我就用了如下的方式去自訂 isCustomElement(),以便一邊達到我的目的、同時也確保原本的功能不受影響:

// 底下的 Vue 變數是指 app 實體
let orgICE = Vue.config.isCustomElement;
function wrapICE(tag) {
    let transforms = (this.nodeTransforms = this.nodeTransforms || []);
    if(transforms.indexOf(transform) < 0) transforms.push(transform);
    return orgICE(tag);
}
Object.defineProperty(Vue.config, "isCustomElement", {
    get() { return wrapICE; },
    set(v) { orgICE = v; }
});

如此便實現了同樣的功能在 Vue 3 上的支援。


  1. 若不持續維護關注元件清單,另一個替代的方法就是用某種 CSS class 或 HTML 自訂屬性來識別關注的元件、然後在 click 事件觸發的時候每次都重新搜尋所有被標注的元件。但是除了我並不想要在元件上面作任何註記(純粹潔癖使然)之外,更重要的是每次都重新搜尋並重新排序的效能也不好,所以我選擇持續維護清單。 

  2. 我最近才發現至少有一個套件 @xunlei/vue-clickout 就是這樣做。 

分享此頁至:
最後修改日期: 2020/08/08

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。