嘗試Vue,並及一點Vuetify和TS體會

renyuneyun 2023年01月13日(週五) 1 mins

由於踏入了Solid的世界,而且需要開發Solid App,於是在聖誕節和公曆新年那段時間利用假期學了一下Vue,實踐了一下TS,同時學了一點Vuetify,搞出了個權限探索App。整體感覺是Vue挺好理解以及好用的,這裏簡單記錄一下整個過程下來的感覺。

Vue體會

我之前糾結過是(先)學Vue還是(先)學React。本來我是有點舉棋不定的,不過在綜合了一些人的對比後,我毅然選了Vue。主要理由無他,只是因爲Vue用專門的模板,而React全部從代碼中返回。畢竟它是用來寫界面的,而且是網頁,專門的模板聽起來更對味一些。

模板

Vue的模板是合法的HTML,但在模板中有一些自己的特殊指令,且可以使用腳本/程序中的變量或函數。這些指令可以極大簡化頁面編寫的複雜度,尤其是比如事件監聽/響應的v-on(或者其簡寫@),將變量(的值)直接賦予/綁定到屬性或Vue props的v-bind(或其簡寫:),簡化控制DOM的循環語句和條件語句。而Vue的變量強調「響應式」,即若變量有修改,則頁面會因而進行對應改動(而非完全重繪)。這些設計極大簡化了界面的編寫難度,畢竟我這種並不搞前端的人最擔心的地方就是在數據更新後的重繪,而在Vue上我則完全不用操心。

當然客觀地說,React等(現代?)框架都支持類似的用法,不過書寫的地方和具體的語法可能不同。但我畢竟沒有系統學過其他的,不知道它們用起來究竟是什麼感覺。

要說遺憾還是有一點的,那就是模板中不能創建變量,或者說給變量賦名。我對此的主要需求是在循環中我有時候需要反覆引用某個對象的某個屬性下的其他東西,但每次我都需要完整的寫出變量和屬性的名字,挺麻煩的。

響應式數據

Vue的響應式數據是通過對普通數據進行包裝實現的(ref()reactive()),這樣原則上可以將任何數據都轉變爲響應式的——包括對象和數組,Vue會深入其嵌套層級去檢查。當然了,如果需要解包,那麼可能需要額外操心,以避免丟失響應性。不過除此之外,在我的使用中,沒有出現響應性意外丟失的問題。

但我似乎沒有辦法把某個庫創建的對象變成響應式,可能和實現方法有關。當然我還有別的需求,所以我最後對它求助了Pinia(見後文)。

由於JS的語言特性,ref()響應式對象必須使用其.value屬性來訪問。於是使用它時一定要再三檢查是否記得拆箱獲取其.value屬性。我自己有好幾次在if中忘了拆箱,因而程序工作不正常。在reactive()對象上沒有這個煩惱,但reactive()本身又只能包裝一個對象之類的東西,不能包裝基礎數據類型。

在基本的響應式數據之外,Vue還允許定義衍生的響應式數據,即所謂的「計算數據」。計算數據之間也可以有依賴關係,不過應該不能成環。Vue在追蹤數據變化時會自動重新計算相關的計算數據,而無關的計算數據則會維持原先緩存的值,所以不用擔心效率問題。

組件

Vue的組件也很好理解——(在最典型的模式即單文件組件模式下)一個文件就是一個組件,其中包含了組件的模板、樣式和代碼。在任何一個組件中都可以導入其他組件,然後在其模板中直接使用。組件用起來和其他的HTML元素沒有不同。

組件間通信/數據傳遞基礎狀況下僅有兩種:1.父組件向子組件(的props)傳遞數據,在子組件中該數據的響應性依然維持,但子組件對數據只讀;2.子組件通過信號通知父組件(不能越級)事件發生,並附帶數據。都很好理解,很直覺化,我很喜歡。

在信號之外,Vue中也有槽(slot)。但千萬不要和Qt的信號與槽機制搞混,槽在Vue中的意義完全不同。官方文檔中管它叫插槽,但我覺得叫槽位更符合我的思維習慣。

在Vue中,槽位是在組件中預留的空白(待定)區域;在使用該組件時,可以向槽位中填入具體的渲染內容。在這個基本用法之外,還允許組件對一個槽申明一系列數據,在父組件填寫槽位的內容時,可以使用這一系列數據(官方稱爲作用域插槽)。這是一個很有意思的逆向數據傳遞方案,尤其是有人在此基礎上推演到極致提出了「無渲染組件(renderless component)」這種用法。在我看來,這個東西有點像React中使用Context之類東西的手法。不過畢竟我沒仔細學過React,理解可能有點問題。

然而我不是很喜歡(主要是不適應)這種思路,所以我在自己的組件中使用的全部是正向的數據傳遞(除了模板中引用其他人的組件不得不這樣)。但基礎的數據傳遞方式有很明顯的侷限,所以我也用了另外兩種數據傳遞方式:依賴注入,以及全局狀態庫(使用Pinia)。

數據傳遞和共享

前面說的基礎的數據傳遞方式有侷限性,因爲它們都是父子組件之間一對一的。而如果想要跨組件層級,或在兩個相鄰組件間傳遞,這種方法就無法輕易完成。

當然了,無法輕易完成也不是無法完成。比如想要跨組件層級,你完全可以給每個組件都聲明同樣的一個/些props,然後一路傳遞下去。但這樣實在太蠢了,又麻煩又影響重用性的,畢竟中間路徑上的組件完全不需要那個/些props,原則上根本不需要知道它的存在。

依賴注入,我們可以穿透組件層級進行數據傳遞/共享。只需要在某個組件中聲明它有個數據可以進行傳遞(它「提供」某個數據),然後在想要使用的(子孫)組件中獲取(「注入」)它即可。

而如果想在兩個相鄰組件間共享,則情況略複雜一點。如果數據在父組件中,而且子組件中不太需要修改,那麼可以從父組件分別通過props的方案傳遞給它們。但如果想在「任意」兩個組件中共享,或者想在兩個層級不確定的組件間共享,那麼就需要使用全局狀態庫了。Vue有自己的官方全局狀態庫,以前是Vuex,而到我開始使用的現在則是Pinia。這個狀態庫也沿用了一樣的響應式模型,用起來非常順手。後文專門對這個庫展開一點討論。

頁面刷新和熱更新

理所當然的,如果頁面刷新了,那麼所有在入口中所寫的語句都會重新執行,所有數據都會被重新初始化。

在開發中,由於Vue(或者其實是Vite)提供了熱更新的能力,我會期待它在熱更新後保留之前數據。然而在一開始那幾天在更新後頁面(組件)卻是空白,讓我以爲熱更新後數據不會保留,我可能哪裏配置錯了。我着實花了一點時間纔意識到問題在哪。但意識到之後就發現自己是多蠢。

其主要原因是watchwatchEffect的區別。我原本是使用watch來檢測變化,然後在回調中更新數據,再自動通過響應式來更新界面。但在熱更新中,watch的回調並不會被觸發,所以頁面會維持空白,就好像數據不存在一樣。解決這個問題其實很簡單,只要把watch換成watchEffect即可(當然語句也要相應調整),因爲watchEffect會在一開始就執行一次。

所以看起來熱更新後入口的語句全部會重新執行,所以watchEffect的內容纔會執行。

TS特殊事項

在使用TS時,絕大多數時候類型都可以被自動推導,這讓我從JS版的代碼轉爲TS時十分順利:很多時候都只需要在<script setup>上加上lang="ts"即可。但也有一些特殊情況下需要額外考慮的內容。

Vue對使用TS時聲明props等東西的建議是通過泛型參數傳遞類型。這很好,而且可以明確聲明某個prop是可選的。絕大多數時候,其默認值和我的預計一樣。但我在某個組件中希望有一個布爾型的prop是可選的,同時默認爲true,這就出現問題了。後來查到可以通過withDefaults()來解決(參考typescript - Vue 3 how to pass an optional boolean prop? - Stack Overflow,其中還有另一種方法)。

其他

其實我一開始看的是選項式開發的文檔,覺得聲明式的組件對我這個新手應該更友好。當時着實花了一點時間試圖去理解爲什麼有的東西是個函數(比如data()),有的東西是個對象(比如method)。但後來在用工具初始化了個項目,然後稍微調整之後,忽然意識到組合式其實更順手。對我來說,組合式的開發更像是在正常寫程序,不需要去記憶那些特殊的語法規則,且同時所有東西(數據和函數)都會被Vue的模板自動識別。唯一需要操心的就是聲明和使用的順序,因爲編譯器只會正向拾取。但這對有C背景的人來說其實是基本功了。

當然了,我一直用的都是<script setup>,所以不清楚如果只是<script>體驗上究竟有什麼不同。後來我也加上了<script setup lang="ts">,來順便獲取一下使用TS的經驗。

不過我還沒有使用路由之類的東西,因爲到目前爲止我的所有功能都還在同一個頁面上。之後早晚還是要用的。到時候也許會更新一下本文,也許(如果沒什麼特殊的)就不更新了。

Pinia和Vuetify

由於我需要全局狀態來管理Solid的登錄狀態,那麼使用Pinia算是一個理所當然的選擇。爲了讓UI更美觀點,也爲了可以直接使用別人已經寫好的實用組件,我選了Vuetify作爲UI庫。

挑選UI庫的時候,我主要是參考了這裏整理的信息。其實我糾結過是選Vuetify、Quasar還是Ionic Vue,因爲後兩者理論上是可以跨平臺編譯成native code的。但出於學習成本和版本新舊的考慮,我最後選了專門針對Vue開發且時下正在進行大版本更新進程中的Vuetify。

Pinia

我用Pinia對Solid登錄狀態(@inrupt/solid-client-authn-browserSession)進行了包裝,將其中我最需要的幾個屬性(是否登錄,WebID等)暴露爲一級的響應式對象屬性。然後創建了登錄和登出的相關方法(在Pinia中叫動作),並在合適的地方註冊了事件監聽鉤子,以便在相應的時機更新它們(參考源代碼)。

我其實簡單嘗試了直接將創建的Session對象放在響應式包裝中,但它並沒有如我預期那樣工作。當然了,那是在使用Pinia之前,或許和一些其他實現細節有關。

整體而言,Pinia的使用很順手,畢竟是Vue的官方庫,思路很一致。最主要需要注意的就是Pinia的庫需要在Vue實例化之後纔存在,所以在工具代碼中不能將庫放在全局調用。另外就是解包時如果想要維持響應性需要使用storeToRefs()

不過不知道出於什麼緣故,Pinia無法正確存儲@inrupt/solid-client-authn-browserSession——從庫中讀取的對象的類型不再是Session。我向Pinia報告了這個問題,然後很快被改爲了討論,但沒有任何評論或其他後文。我理解項目組沒有能力去追查每一個第三方庫的兼容問題,但覺得如此粗暴將一個問題推脫下去不太好——要麼是Pinia的實現有bug,要麼是JS有什麼特性導致一些數據無法存儲在Pinia的庫中(但文檔完全沒有提及)。

Vuetify

Vuetify是一個UI庫,我選用它的理由在前文已經說過了。在我選擇的時候,它正在向第3版邁進,雖然已經基本完成,但還差幾個組件(比如我比較在意的樹狀視圖,用來展示目錄樹)。

用法上沒什麼特別意外的,因爲它就是配套Vue開發的,各種設定全部利用Vue的機制。裏邊的一些組件用到了前面提到的作用域插槽/槽位,用來傳遞一些HTML屬性,給我解釋了爲什麼這個機制需要存在。

我用的時候發現,Form在其內容取消disable後對內容合法性的檢查有問題。暫時還沒弄明白是我哪裏做得不對還是個bug。

TS及其他記錄

在這個過程中,我也遇到了一些其他問題,其幾乎都是因爲我對TS或JS不夠熟悉所致。這裏簡單記述一下。當然,由於我確實沒有深入使用,所以理解也許也仍然有一些問題,歡迎指正。

鍵的類型

我需要傳遞進來一個數據,然後將其作爲鍵來從一個對象中取值。由於數據是我自己代碼中的,所以我很確定它們存在。然而TS的語法不允許我這麼做,因爲它會認爲這個鍵可能不存在,然後報一個乍看起來關係不大的錯誤:

ts(7053) : Element implicitly has an 'any' type because expression of type 'string' can't be used to index

解法的核心是使用keyof關鍵字。典型的用法是在類型聲明中使用,如這個回答中的例子。但也可以如下面這樣進行類型轉換:

const text = computed(() => permissionText[props.type.toLowerCase() as keyof {}])

不過吧,我不是很理解爲什麼這樣做是對的……按理說這個{}對象的鍵也未必就是permissionText的鍵啊。

TS自定義函數檢查空值與編譯器識別

這個小節標題看起來有點難以理解,但其實我把例子舉出來就很好理解了:我自定義了一個isEmpty(s: string | null | undefined): boolean函數用來檢查傳入的s變量是否是空或空白字符串,但編譯器並不知道,於是下面這段代碼會報錯,說s可能是空的:

if (isBlank(s)) {
    console.log(s.toLowerCase());
}

我去翻查了一會,換了幾次關鍵詞,最終找到SO上的這個問題,然後追蹤到這個文檔,裏邊解釋了如何讓編譯器識別該行爲。我只需要把isEmpty()函數的簽名改爲下面這樣即可:

isEmpty(s: string | null | undefined): s is null | undefined

另外我還看到了另一個問題,裏邊說對於更通用的類似問題(不侷限於布爾類型返回值),可以使用assert modifier(修飾器?)。

JS對象複製及比較

JS提供了很方便的對象複製方式:{...myObj}。雖然這樣無法進行深拷貝,但對於許多情況都已經足夠用了。

然而同時,JS又無法很輕易地比較兩個對象:==判斷的是兩個操作數的(內容的)引用目標地址是否一致;===判斷的是兩個操作數本身是否完全一樣(是同一個地址)。而且JS似乎也不提供運算符重載機制,所以也無法自定義==的行爲。

於是比較對象就很麻煩,需要單獨寫個函數,並且每次都需要明確調用那個函數來進行。

這點一開始對我造成了挺大的困擾,因爲我本來以爲既然對象和數組都是內置數據類型,那麼==也理當去對比其內容。

for和in

JS最典型的for each循環使用for (const x of arr) {}的形式,其中arr需要是個某種迭代器,比如數組或一些類的實例(或用戶自定義的實現了迭代器協議的類/對象)。但這個結構不支持對普通的對象迭代。

而對於對象,JS專門提供了一個for (const x in obj)的形式,用來迭代對象的(字符串)鍵。我對於它爲什麼需要提供兩個很不理解——即使是歷史原因,也大可擴展原先的,而不是再增加一個。

類似地,JS的in操作符只能用來判定key是否在對象中,而不能用來判斷其他的,比如元素是否在數組中。而且似乎也沒有辦法重載運算符,甚至還不如前面的for each至少還能實現Iterator來達成。

這個設計我也覺得挺迷的。也許有什麼深層考慮只是我沒理解罷。

本文簡單介紹了一下我最近學習Vue,使用TS,涉獵Vuetify和Pinia的經歷體會,以及我遇到的一些問題。整體來說,我很喜歡Vue的設計以及使用體驗,尤其是它對TS的完善支持(畢竟它說自己就是TS寫的)。其中由於JS的特性做了一定設計上的取捨,不過我覺得挺能理解;而且團隊似乎也在嘗試擴展編譯器,以期自動插入指令,使其更易用(比如前面提到的ref()對象的拆箱問題)。

路途中當然也遇到一些問題,其中一部分解決了,一部分暫時還沒解決,一部分大約沒法解決,不過這些都和Vue本體無關。Vuetify試過了,之後也許會去試試Quasar或者Ionic Vue。畢竟Material Design雖然設計語言統一,看着也挺養眼,但畢竟是谷歌給手機等設備設計的,在桌面瀏覽器上使用始終有點奇怪。更何況我可能想要設計一些自定義的部件或者風格,Vuetify完全無法做到。


Related posts:

您可以在Hypothesis上的該群組內進行評論,或使用下面的Disqus評論。