vue3 使用有感
本文最后更新于:2022年2月7日 晚上
假若没有看见光明,我本可以忍受黑暗。
场景
自上家公司从去年 5 月份开始成功推广 react 之后,很长一段时间吾辈一直在使用它,而今年,离职之后新的公司再次使用 vue3,因而见证了两个 team 踏入了同一条河流。不过 vue 作者说 vue3 使用 ts 重写,对其支持很好,吾辈姑且安心了一点,但经过近一个月的实践,吾辈还是发现了种种问题。
从 vue 迁移到 react 的原因参考: 2020 吾辈在公司推动的前端技术演进
基本感受
- vue3 的生态不稳定,而且仍然比较小
- vue3 和 ts 的结合仍然不算好
- webstorm/vscode 对 vue3+ts 的支持比较糟糕
vue3 的生态
vue 官方承认自身社区比 react 更小,但他们很明智的没有说出来,这究竟代表了什么。
- vue+ts 结合的体验很糟糕,下面会详述
- vue 的大版本升级虽然没有 react 那么快,但每次升级都是完全破坏性的,整个社区都要重新构建
- ant-design-vue 2 使用 vue3 重构,但使用体验仍然比不上官方的 ant-design(例如 Table 的能力还远远比不上 ant-design)
- antv 系列官方基本优先或仅支持 react,例如只有 react 版本的 G2Plot/Graphin
- 结合 storybook 有点恶心,需要在字符串中写模板
- vue-router 开发环境和生产环境的行为不一致,开发环境支持
import()
,生产环境则必须是() => import()
- 许多周边 npm 包都是 next/beta/rc 版本
- 异步组件比较奇葩,用了一种 map 的方式实现
vue+ts 结合
- 模板是个失败的设计,很多问题都是由模板衍生而来
- props 和 ts 结合仍有问题
- 模板中只能编写 js,但 vue-tsc 会使用 tsc 检查,这会导致一些奇怪的问题很难解决
- 使用 tsx 也会存在一些问题,但可能是现有条件下最好的解决方案(antdv 使用该方式),参考:极致的开发体验!Vite + Vue 3 + tsx 完整教程
vue 模板到底有什么问题?
相比于 vue 模板,jsx(或者说 react)的设计非常巧妙,它不需要 props/attrs/emits/slot/指令 这一系列 vue 的功能,而仅仅只需要 props,而它能够使得 dts 就能够完全支持 react 组件的接口定义,进而使编辑器能够通过 ts 做提示、导航和重构。而 vue 一时看起来方便开发者的设计,例如 props 支持定义类型、必填校验及 getter/setter,未在 props 声明的属性会被放到 attrs 并自动绑定到组件根元素上,前者在和 ts 结合时导致要重复定义 props 的类型,后者导致在支持 fragments 时反而需要手动指定绑定 attrs 的元素。
下面是一个 antdv List 组件的使用示例,如果不看文档,没人知道 slot 到底可以挂的是羊头,还是狗肉
<template>
<a-list item-layout="horizontal" :data-source="data">
<a-list-item slot="renderItem" slot-scope="item, index">
<a-list-item-meta
description="Ant Design, a design language for background applications, is refined by Ant UED Team"
>
<a slot="title" href="https://www.antdv.com/">{{ item.title }}</a>
<a-avatar
slot="avatar"
src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png"
/>
</a-list-item-meta>
</a-list-item>
</a-list>
</template>
vscode vetur 和 jetbrians(web-types) 为了解决开发体验的问题选择了两条不同的道路,但都存在一些问题,参考:web-types.json 目前仍然有问题,包括某些 slot/item 仍然是错误的,可以想见,既然连 antdv 这么流行的 vue ui 组件库都无法保证没有问题,更别提整个 vue 生态中的其他 ui 组件库了。
props 和 ts 结合还有什么问题?
props 在 vue3 的 ts 有所增强,至少能够使用 PropType
复杂类型了,例如下面
import { defineComponent, PropType } from "vue";
interface User {
name: string;
age: number;
}
export default defineComponent({
name: "List",
props: {
// 使用 PropType 定义复杂类型
list: Array as PropType<User[]>,
},
setup(props) {
// 这儿的 props 类型是正确的
console.log(props.list);
return {};
},
});
但它仍然存在一些问题
无法复用现有的类型。例如当我们已经有一个后端返回的数据结构类型了,而我们希望组件仅需要其中的部分字段,而这无法使用 ts 的类型操作完成。究其原因,还是因为 vue 的 props 定义仍然是值而非类型。
无法使用 ts 的可选属性。vue props 仍保留定义 required/default/validator
的功能,所以并不能使用 ts 的可选属性。下面的示例代码将上面限制表现的的淋漓尽致
// react
interface User {
name: string;
age?: number; // 利用了 ts 的可选属性
address: string;
}
// 使用 ts 的类型操作,只取 name/age 字段
const User: React.FC<Pick<User, "name" | "age">> = (props) => {
return (
<div>
{props.name} {props.age}
</div>
);
};
// vue
defineComponent({
name: "User",
props: {
// 重新定义,和 ts 格格不入
name: {
type: String,
required: true,
},
age: Number,
},
});
无法使用泛型。最典型的莫过于 List 组件,我们希望传入的 data 数据决定 slot 中参数的类型,目前这是不可能的。下面是 react 中的泛型组件示例(本质上是泛型函数,对 state=>ui
的抽象是真的彻底)
// 定义泛型函数
function List<T>(props: {
list: T[];
renderItem(item: T, i: number): ReactNode;
}) {
return <ul>{props.list.map(props.renderItem)}</ul>;
}
interface User {
name: string;
age?: number;
}
export const Hello: React.FC = () => {
return (
<List
list={[{ name: "琉璃" }] as User[]}
renderItem={(item, i) => <li key={i}>{item.name}</li>}
/>
);
};
模板中只能编写 js,但 vue-tsc 会使用 tsc 检查,这会导致一些奇怪的问题很难解决
模板里不能使用 ts,但 vue-tsc 却会检查 ts,然后就 GG 了
如上图,就因为模板中的函数参数没有指定类型导致 vue-tsc 报告错误了,但模板中也确实不能定义参数类型,所以目前只能将之放到 script 中并在 setup 函数返回(是不是感觉挺蠢的?)
以及模板中出现下面这个错误,也很奇葩(估计是粘合层 vue-tsc 没有修改 tsc 的类型检查器)
src/App.vue:25:14 - error TS2322: Type '{ for: string; }' is not assignable to type 'DetailedHTMLProps<LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>'.
Property 'for' does not exist on type 'DetailedHTMLProps<LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>'. Did you mean 'htmlFor'?
需要更换 vue-tsc 为 vls,配置 vite 插件 vite-plugin-checker
更新依赖
yarn add -D vls vite-plugin-checker
yarn remove vue-tsc
配置 vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import checker from "vite-plugin-checker";
export default defineConfig({
plugins: [vue(), checker({ typescript: true, vls: true })],
});
如果遇到下面这个错误
src/main.ts:2:17 - error TS2307: Cannot find module './App.vue' or its corresponding type declarations.
2 import App from './App.vue'
请在 vite-env.d.ts 或 env.d.ts 中声明 vue 文件的类型定义
declare module "*.vue" {
import { DefineComponent } from "vue";
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>;
export default component;
}
webstorm/vscode 对 vue3+ts 的支持比较糟糕
具体表现在提示、导航和重构。吾辈在想,是不是 vue 的开发者都习惯了这种问题,代码导航靠 C-F 一个个查找,重构时 CS-F 一个个手动替换(群友吐槽说:“不然怎样,就 vue 那个框架设计,完全不考虑静态分析好吧……估计反馈给 yyx,得到的回答是:这么爱静态分析,滚去用 angular”)
- 在 monorepo 中 vue-ts 提示很慢
- monorepo 中同时包含 react 子模块时,无法使用 vue tsx,webstorm 会默认为是 react tsx
- 重构不支持自动重命名 ref/setup 返回值/模板引用
一些感受到的优点
上面吐槽了那么多,vue 及其社区也并非没有可取之处
- vue3 的 hooks api 对心智负担确实更小,不太容易出现 react hooks 两个烦人的问题
- 强制要求依赖,但很容易错误(各种依赖引发的问题)
- 状态声明的方式独树一帜,与一般 js 隔离了。例如许多基于闭包实现的高阶函数都必须重写,但在 vue hooks 中则不用(好吧其实也需要,但至少能生效)
- vite/vuepress 很好用,比 snowpack/docusaurus 更好用(即便吾辈使用的是 react 技术栈,但仍然使用它们作为构建和文档工具)
其他
使用异步组件
参考:vue3 官方文档
全局注册所有异步组件(能够以编程的方式注册的方法)
const components: Record<string, Lazy<Component>> = { hello: () => import("hello"), }; [...Object.entries(components)].forEach(([k, v]) => { app.component(k, defineAsyncComponent(v)); });
通过
component
组件渲染<component is="hello" />
可以看到,基本上就是通过 map 的方式维护,要在局部引用之前还必须先在全局注册一下昭告天下 xd
更新
使用 defineAsyncComponent
包裹之后就像普通组件一样使用就好了
const AsyncHelloWorld = defineAsyncComponent(() => import('./HelloWorld.vue'))
<AsyncHelloWorld />
使用 provide/inject 模拟 react context
为什么要模拟 react context?
因为 vue3 仍然基于字符串实现的 provide/inject,而字符串可以任意写且不包含类型。
import { inject, provide } from "vue";
// context 实例本身就已经包含了 key 与值的类型了
interface Context<T> {
key: Symbol;
init?: T;
}
/**
* 创建一个上下文,用于在当前组件的所有子孙组件共享状态
* 基于 provide/inject 实现,但支持强类型,而且使用 Symbol,永不重复而且无需考虑命名问题
* @param init
* @param key
*/
export function createContext<T>(init?: T, key?: string): Context<T> {
return {
key: Symbol(key),
init,
};
}
/**
* 为所有子孙组件注入状态
* @param context
* @param value
*/
export function useProvide<T>(context: Context<T>, value: T) {
provide(context.key, value);
}
/**
* 使用祖先节点注入的状态,可能为 null/undefined
* @param context
*/
export function useInject<T>(context: Context<T>): T | undefined {
return inject(context.key, context.init);
}
使用
const stringContext = createContext();
useProvide(stringContext, "hello");
const value = useInject(stringContext);
console.log(value);
关于这点,官方提出了自己的解决方案,参考: https://v3.cn.vuejs.org/api/composition-api.html#provide-inject
const key: InjectionKey<string> = Symbol();
provide(key, "foo"); // 若提供非字符串值将出错
const foo = inject(key); // foo 的类型: string | undefined
总结
上面只是吾辈的一些吐槽,但考虑到成本问题,在公司推广 react 短期来看仍然是不现实的(vue3、ts 甚至 esnext 都有人未能基本掌握)。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!