vue3 使用有感

本文最后更新于:2021年11月15日 上午

假若没有看见光明,我本可以忍受黑暗。

场景

自上家公司从去年 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 了

1628152139838

如上图,就因为模板中的函数参数没有指定类型导致 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 官方文档

  1. 全局注册所有异步组件(能够以编程的方式注册的方法)

    const components: Record<string, Lazy<Component>> = {
      hello: () => import("hello"),
    };
    [...Object.entries(components)].forEach(([k, v]) => {
      app.component(k, defineAsyncComponent(v));
    });
  2. 通过 component 组件渲染

    <component is="hello" />

可以看到,基本上就是通过 map 的方式维护,要在局部引用之前还必须先在全局注册一下昭告天下 xd

使用 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 协议 ,转载请注明出处!