尝试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評論。