Vue Composition API 陷阱

前言

自从 React Hooks 出现之后,批评的声音不断,很多人说它带来了心智负担,因为相比传统的 Class 写法,useState/useEffect 的依赖于执行顺序的特点让人捉摸不透。与此相对的,在 Vue3 Composition API RFC 中,我们看到 Vue3 官方描述 CompositionAPI 是一个基于已有的”响应式”心智模型的更好方案,这让我们觉得好像不需要任何心智模型的切换就可以迅速投入到 Compositoin API 的开发中去。但在我尝试了一段时间后,发现事实并非如此,我们依然需要一些思维上的变化来适应新的 Compsition API。

Setup 陷阱

简单陷阱

先看一个 Vue2 简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  <div id="app">
{{count}}
<button @click="addCount"></button>
</div>
</template>
<script> export default {
data() {
return {
count: 0
}
},
methods: {
addCount() {
this.count += 1
}
}
};
</script>

在 Vue2 的心智模型中,我们总会在 data 中返回一个对象,我们并不关心对象的值是简单类型还是引用类型,因为它们都能很好的被响应式系统处理,就像上面这个例子一样。但是,如果我们不作任何心智模型的变化,就开始使用 Composition API,我们就容易写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div id="app">
{{count}}
<button @click="addCount"></button>
</div>
</template>
<script>
import { reactive } from '@vue/runtime-dom'
export default {
setup() {
const data = reactive({
count: 0
})
function addCount() {
data.count += 1
}
return {
count: data.count,
addCount
}
}
}; </script>

实际上,这段代码不能正常运作,当你点击 button 时,视图不会响应数据变化。原因是,我们先将 data 中的 count 取了出来,再合并到 this.$data 中,但是一旦 count 被取出来,它就是一个单纯的简单类型数据,响应式就丢了。

复杂陷阱

数据结构越复杂,我们就越容易落入陷阱,在这里我们把一段业务逻辑抽离到自定义 hooks 里,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { reactive, onMounted } from '@vue/runtime-dom'
export default function useSomeData() {
const data = reactive({
userInfo: {
name: 'default_name',
role: 'default_role'
},
projectList: []
})

onMounted(() => {
// 异步获取数据
fetch(...).then(result => {
const { userInfo, projectList } = result
data.userInfo = userInfo
data.projectList = projectList
})
})

return data
}

然后像往常一样,我们在业务组件中去使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
{{name}}
{{role}}
{{list}}
</div>
</template>
<script> import useSomeData from './useSomeData'
export default {
setup() {
const { userInfo, projectList } = useSomeData()
return {
name: userInfo.name // 响应式断掉
role: userInfo.role, // 响应式断掉
list: projectList // 响应式还是断掉
}
}
} </script>

我们看到,不管我们从响应式数据里取出什么(简单类型 or 引用类型),都会导致响应式断掉,进而无法更新视图。

所有这些问题的根源都是:setup 只会执行一次。

迁移到新的心智模型

  1. 时刻记住 setup 只会执行一次
  2. 永远不要直接使用简单类型
  3. 解构可能有风险,优先使用引用本身,而不是解构它
  4. 可以通过一些手段让解构变得安全

使用新心智模型来解决问题

简单陷阱:永远不要直接使用简单类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div id="app">
{{count}}
<button @click="addCount"></button>
</div>
</template>
<script> import { reactive, ref } from '@vue/runtime-dom'
export default {
setup() {
const count = ref(0) // 在这里使用ref包裹一层引用容器
function addCount() {
count.value += 1
}
return {
count,
addCount
}
}
}; </script>

复杂陷阱-方案 1:解构可能有风险,优先使用引用本身,而不是解构它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
{{someData.userInfo.name}}
{{someData.userInfo.role}}
{{someData.projectList}}
</div>
</template>
<script> import useSomeData from './useSomeData'
export default {
setup() {
const someData = useSomeData()
return {
someData
}
}
} </script>

复杂陷阱-方案 2:可以通过 computed 让解构变得安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { reactive, onMounted, computed } from '@vue/runtime-dom'
export default function useSomeData() {
const data = reactive({
userInfo: {
name: 'default_user',
role: 'default_role'
},
projectList: []
})

onMounted(() => {
// 异步获取数据
fetch(...).then(result => {
const { userInfo, projectList } = result
data.userInfo = userInfo
data.projectList = projectList
})
})

const userName = computed(() => data.userInfo.name)
const userRole = computed(() => data.userinfo.role)
const projectList = computed(() => data.projectList)

return {
userName,
userRole,
projectList
}
}
1
2
3
4
5
6
7
8
9
10
export default {
setup() {
const { userName, userRole, projectList } = useSomeData()
return {
name: userName // 是计算属性,响应式不会断掉
role: userRole, // 是计算属性,响应式不会断掉
list: projectList // 是计算属性,响应式不会断掉
}
}
}

复杂陷阱-方案 3:方案 2 需要额外写一些 computed 属性,比较麻烦,我们还可以通过 toRefs 让解构变得安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { reactive, onMounted } from '@vue/runtime-dom'
export default function useSomeData() {
const data = reactive({
userInfo: {
name: 'default_user',
role: 'default_role'
},
projectList: []
})

onMounted(() => {
// 异步获取数据
fetch(...).then(result => {
const { userInfo, projectList } = result
data.userInfo = userInfo
data.projectList = projectList
})
})
// 使用toRefs
return toRefs(data)
}
1
2
3
4
5
6
7
8
9
10
11
12
export default {
setup() {
// 现在userInfo和projectList都已经被ref包裹了一层
// 这层包裹会在template中自动解开
const { userInfo, projectList } = useSomeData();
return {
name: userInfo.value.name, // ???好了吗
role: userInfo.value.role, // ???好了吗
list: projectList, // ???好了吗
};
},
};

你以为这样就好了吗?其实这里有一个陷阱中的陷阱:projectList 好了,但是 name 和 role 依然是响应式断开的状态,因为 toRefs 只会”浅“包裹,实际上 useSomeData 返回的结果是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
const someData = useSomeData()

{
userInfo: {
value: {
name: '...', // 依然是简单类型,没有被包裹
role: '...' // 依然是简单类型,没有被包裹
}
},
projectList: {
value: [...]
}
}

因此,我们的 useSomeData 如果想要通过 toRefs 实现真正的解构安全,需要这样写:

1
2
3
4
5
6
7
8
9
import { reactive, onMounted } from '@vue/runtime-dom'
export default function useSomeData() {
...
// 让每一层级都套一层ref
return toRefs({
projectList: data.projectList,
userInfo: toRefs(data.userInfo)
})
}

建议:使用自定义 hooks 返回数据的时候,如果数据的层级比较简单,可以直接使用 toRefs 包裹;如果数据的层级比较复杂,建议使用 computed。

绕过陷阱

上述操作其实是 Vue 官方使用 CompositionAPI 的标准方式,因为 CompositionAPI 完全就是按照 setup 只会执行一次进行设计的。但是不可否认的是,这的确带来了许多心智负担,因为我们不得不时刻关注响应式数据到底能不能解构,不然一不小心就容易调到坑里。

其实所有这些问题都出在 setup 只会执行一次,那么有没有办法解决呢?有的,可以使用 JSX 或 h 的写法,绕过 setup 只会执行一次的问题:

还是这个存在安全隐患的自定义 hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { reactive, onMounted } from '@vue/runtime-dom'
export default function useSomeData() {
const data = reactive({
userInfo: {
name: 'default_name',
role: 'default_role'
},
projectList: []
})

onMounted(() => {
// 异步获取数据
fetch(...).then(result => {
const { userInfo, projectList } = result
data.userInfo = userInfo
data.projectList = projectList
})
})

return data
}

使用 JSX 或 h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import useSomeData from "./useSomeData";
export default {
setup() {
const someData = useSomeData();
return () => {
const {
userInfo: { name, role },
projectList,
} = someData;
return (
<div>
{name}
{role}
{projectList}
</div>
);
};
},
};

在使用 JSX 或 h 的时候,setup 需要返回一个函数,这个函数其实就是 render 函数,它在数据变化时会重新执行,所以我们只需要把解构的逻辑放到 render 函数里,那么就解决了 setup 只会执行一次的问题。

后记

我们可能需要一些约定,来约束自定义 hooks 的使用方式。但是官方并没有给出,这将导致我们 hooks 会写的五花八门,并且漏洞百出。目前来看,”不要解构“是最安全的方式。

我专门就这个问题请教了 yyx 大佬,大佬给出了一个”约定”,那就是尽量少使用“解构”。这我也很无奈。其实我是希望官方能够给出一个工具,让我们减少在自定义 hooks 中犯错误的可能性。(toRefs 其实就是这样的一个工具,但是它并不能解决所有问题)

原文连接