很长一段时间以来,老有人私信跟我说,Vue 很先进,代表了未来,你不要沉迷在 React 这个年老色衰的技术栈里自娱自乐啦,睁开眼睛去看看世界吧,别的技术栈都发展早就比 React 更先进啦!!
然后这些人估计都不会相信,我真的有非常认真的去把 Vue 从头到尾学了个遍,甚至 Vue2、Vue3 的原理我也理清楚了... 很多年前,我甚至还在我的付费小册《JavaScript核心进阶》里,聊设计模式的时候,分享一个 Vue2 简易版的实现原理作为案例...
不仅如此,我还睁眼看了 rust 生态的 Leptos
,看了 Solid.js,看了 Svelte,还看了鸿蒙开发的 ArkUI,看了 Android 开发的 compose,这些都是近些年新出的技术方案,我都有认真的学过奥
也不知道这些喊我睁眼看世界的,有没有我看得多...
然后这篇文章,我就主要以吐槽 Vue 的语法设计为主,来聊一下我 Vue3 的学习和使用体会
一、ref 与 reactive
一个很不好的体验就是 ref 与 reactive 都太容易丢失响应了。为了防止丢失响应,我需要随时注意我的数据使用方式,我不能随心所欲的按照 JavaScript 的基础语法去任意妄为。
首先严格践行语义化的我,第一反应是不想使用 ref。因为 ref 是 引用的缩写,从语义上来说,他是不应该具备响应性的。但是偏偏 Vue3 的语法设计就没这么讲究,于是我的语义化思维,在我学习和使用响应式数据时给我造成了极大的困扰...
我刚开始在项目中,就偏好于使用 reactive
。但是现实很快就把我的偏好捏碎了。比如下面这个例子,我将一个列表作为响应性数据定义在 reactive
中
let data = reactive([])
但是我万万没想到的是,这样使用是有问题的。因为当我从接口里面获得一个新数据的时候,想要直接用新的列表覆盖初始列表,结果居然没有什么好的办法能让这种覆盖生效!!!
// 接口请求成功之后执行,数据失去响应
data = result.data
然后我就只能这么写
// 定义
const res = reactive({ list: [] })
// 接口请求成功之后调用
res.list = result.data
我这个组件只有一个响应式数据的时候,就贼难受,所以我就想着法又加了一个,这样就舒服一点了
const res = reactive({
list: [],
show: false
})
这样处理之后呢,我想着用的时候,就很自然的想着用解构语法来使用吧。但是呢,响应性又丢失了...
这样写不行
const {lsit, show} = reactive({
list: [],
show: false
})
这样也不行
const data = reactive({
list: [],
show: false
})
const {list, show} = data
必须要引入一个新的 api 来解决这个问题 toRefs
let data = reactive({
list: [],
open: false
})
const {list, open} = toRefs(data)
然后我就含泪看着我的 reactive 被强行变成了 ref. 这其实我还勉强可以接受,最令我崩溃的是,由于 list 和 open 都被转化成 ref,因此使用的时候,我必须这样用,把 .value
的尾巴加回来...
list.value = [
{message: 'hello'},
{message: 'world'}
]
所以 reactive
一个符合语义的响应式 api,给我的使用感受就是,在设计上就是非常失败。但是一个不太符合语义的的响应式 api ref
被处理得还相对好一些。在这样的情况之下,也就不得不更多的在项目中使用 ref
。
但是使用 ref 的时候,除了不符合语义化之外,还不符合一致性。因为在 script
中使用,我们必须加上 .value
来处理。但是呢,template
中又不用... 甚至如果我为了一致性在模板中用了还会出问题...
直接给我一直以来自认为良好的编码标准干碎了...
所以我现在的用法是,使用 reactive
,但是忍痛放弃解构,从而避免使用 toRefs
。尽量避免使用 ref
。虽然很多人发文章说官方强烈建议使用 ref
,但是确实语义和一致性有点挑战我的底线。当然我也知道他在能力上处理得更好一些。
二、传参设计得真复杂
从一个新手的角度,要理解 Vue3 的参数传递,居然是一件学习成本非常高的事情。原因就是因为为了确保响应性和区分普通参数,这里又设计了许多新的 api 来解决问题
首先是参数的类型很复杂。
因为 Vue 中设计了一个指令系统,用于处理一些条件渲染的逻辑。比如 v-if
<h1 v-if="awesome">Vue is awesome!</h1>
等价于
awesome && <h1>Vue is awesome!</h1>
但是这个机制就由此就导致了在父组件使用自定义组件时,往子组件传参就变得非常复杂。因为在子组件内部就没办法统一接收属性参数了。因为有的属性呢,他是自定义指令,是不应该往下传的,但是有的指令呢,又需要往下传
例如事件回调
<button v-on:click="greet">Greet</button>
这个时候我们在学习的时候,就必须的保证区分如下几种传参
一种就是有特殊含义的内置指令或者自定义指令
<h1 v-if="awesome">Vue is awesome!</h1>
<h1 v-else>Oh no 😢</h1>
一种是函数类型的参数,多为事件回调函数
<MyComponent @some-event="callback" />
一种是数据绑定类型的,这种被 Vue 官方成为动态类型。
<BlogPost :title="post.title" />
还有一种就是正常的参数传递,这种被 Vue 官方文档称为静态参数类型。
<BlogPost title="My journey with Vue" />
然后这里有一个很魔性的约定,如果你参数的 key 值使用横杠的方式,如下
<MyComponent greeting-message="hello" />
到子组件接收的时候,它居然强制把这个key 的写法改了 ... ...
你得写成这样才能被接收
defineProps({
greetingMessage: String
})
还有一个对我来说,误导性更大的一种情况。就是当我试图使用静态参数类型传递一个静态对象时,你猜怎么着?传不了!
我只能改成动态的绑定写法,才能正常传递。这里为啥误导性很强呢,因为在我看来,虽然我声明的是一个对象,但是他就是一个静态的数据,也不是一个响应式数据
// 我觉得这是一个静态数据
const a = {
message: 'hello'
}
<HelloWrold :message='a' />
所以这个就给我干懵了。没办法,虽然我已经知道怎么用了,但是我到现在也不太确定官方文档说的动态属性表达的准确定义是啥。
然后完了之后呢,还有一种参数类型,叫做透传 Attributes,比如像这种,他可以直接在内部元素贴上去生效
<MyButton class="large" />
所以我个人的感受就是,不仅设计得复杂,还有一些我觉得不够合理的地方。有人说,这个学习成本低,我是不太信的。
✓但是我得说一下,这些,我都学会了,也知道怎么区分怎么用了,非常的熟练,难不到我。但不妨碍我不喜欢这样的设计。
三、接收参数的迷惑行为
在子组件中,接收参数我最迷惑的一个行为是
defineProps({
msg: String
})
当我这样写的时候,可以直接在 template 中使用 msg
<template>
<h1>{{ msg }}</h1>
</template>
但是在 <scirpt>
中就用不了... 不一致的表现让我觉得非常难受。
然后另外一个让我觉得非常难受的语法设计就是对于事件回调函数的处理。例如我想要通过 @click
传递一个回调函数到子组件,但是这个时候,子组件怎么接收这个回调函数呢?
他的接收逻辑,又跟 props 的逻辑完全不一样了。
我认为的常规逻辑无非就是在父组件中,一个 key=value 的方式传递下去,然后在子组件中通过识别 key
来获得这个 value,但是 Vue 又设计了一个新的思路,重新用了一个宏来处理这个事情
<script setup>
defineEmits(['inFocus', 'submit'])
</script>
而且调用的逻辑我也觉得有点懵... 这是啥?连回调函数的执行都不见了...
<script setup>
const emit = defineEmits(['inFocus', 'submit'])
function buttonClick() {
emit('submit')
}
</script>
我宁愿这样传
<HelloWrold :onclick='clickhandler' />
这样接收和使用,更符合我的一致性的想法。
<script setup>
const props = defineProps({
msg: String,
onclick: Function
})
</script>
<template>
<h1 @click='onclick' text-center'>{{ msg }}</h1>
</template>
四、watch 的怪异行为
在 watch 的时候,也有一个奇怪的行为。那就是当我使用 reactive 声明了状态时,偶尔想要某个属性被 watch 一下,结果却发现,普通的属性值,居然不可以...
const obj = reactive({ count: 0 })
// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
console.log(`Count is: ${count}`)
})
我必须额外提供一个 getter 函数才能做到
// 提供一个 getter 函数
watch(
() => obj.count,
(count) => {
console.log(`Count is: ${count}`)
}
)
这又给我偏执的想要使用 reactive
增加了不舒服的感觉... 难顶啊。当然有的人比较喜欢用 watchEffect
,但是这里有一个非常重要的问题,就是他会自动追踪所有能访问到的响应式属性。很明显这是一种简单粗暴,并且也存在冗余监听风险的一种方式。他的响应性依赖关系并不明确,所以我并不是很喜欢使用它。
五、总结
很显然, Vue3 为了底层的 Proxy 实现原理,在逐步放弃虚拟 DOM 的过程中,在语法设计上做了非常多的牺牲和妥协,它为了解决数据响应性丢失的问题,新增了许多的 api。因为很多东西是不得不这么处理,否则能力上就会存在问题。所以很多人在说,React 为什么不拥抱 Signal,难道你真的认为,拥抱了 Signal,就不会做出任何牺牲吗?全是正向收益?
别做梦了!不可能的!哪怕是 Solid.js 这种没有历史负担,重新设计的类 React 框架,在响应性的丢失上也备受困扰,怎么可能那么简单就全是正向的收益?
无论是从语法设计的角度来考虑,还是从设计模式的方向来考虑,基于类似 signal 的底层实现,语法表现上明显更适合设计为面向对象。我们可以基于装饰器和依赖注入来完整底层逻辑的设想,例如
<script setup>
@Inject
class HelloWorld extends Vue {
@reactive
counter = 0
...
}
</script>
<template>
...
</template>
✓如果能通过解析省掉 script 和 template 标签就更好了
这样设计之后,就完全不需要担心任何响应性丢失
的问题。从而极大的降低了学习成本和使用心智负担。深度使用之后给我整体感受就是,Vue3 拥抱函数式,拥抱得很勉强。一方面是上手难度提高了,另外一方面是使用过程中的心智负担也变重了。所以,从 Vue2 切换到 Vue3,绝非有的人认为的那么平滑,甚至可以说是重新学了另外一个框架。甚至我认为,React 开发者到 Vue3 才是平滑的切换,他们比 Vue2 开发者更容易接受 Vue3. 并且一个很有意思的事情是,如果你要学习 Vue3 的最佳实践,我这篇写给 React 开发者的文章,反而完美的契合了 Vue3 的使用思考。
除此之外,由于 Vue2 发展得非常成熟,所以哪怕 Vue3 已经发布了四周年了,Vue3 的学习资料也经常和 Vue2 混杂在一起,它的被接受程度远低于预期。
也许越往后发展,angular 更有机会重新大放异彩。毕竟他的底层逻辑和上层表现是一脉相承的,有比较扎实的设计理论基础,angular 在保持现有开发方式不变的情况下,拥抱 signal 非常的自然。
只能说,自定义 hook 这种的逻辑复用的方式,和面向对象中的继承、注入、mixin 等方式相比,确实在易用性和可读性上的优势太明显了,因此函数式才这么受欢迎,大多数新的前端框架都在这个模式下实现自己的理念... 但是在语法层面,React 依然是逻辑最自恰的。