vite + vue3 + ts 使用总结

2,800 阅读8分钟

现阶段开发 vue3 项目使用 vite + ts 应该已成为标准范式了吧,新的语法体验 vue composition api 再配合 script setup 谁用谁知道啊,开发和构建方面,vite 作为下一代构建工具,想必大家也有所了解,使用 ES6 module + ESbuild 支持本地开发,速度和效率起飞啊,就一个字——爽,对于 TypeScript,感觉都没必要说了,现在还没上车的赶紧的了~

前言

vite 作为一个构建工具,我们目前了解如何使用即可,该工具足够优秀,默认对很多功能都做到了支持(css modulelessscss),且其作为 vue之父尤大大的作品,对于 vue也有着较好的支持,目前使用率也已经很高了,nuxt等多个大项目都已支持,结合文档和社区,目前使用足够了,也无需担心疑难杂症~,快上车~

vue3,感觉变化最大的就是全面拥抱了函数式编程,结合 composition api 现在真的可以做到对于复杂业务代码的轻松管理,抛弃以前的 this this 一大堆,不友好的mixin ,使用现在的 hooks处理,逻辑复用、功能模块拆分简直太方便了,语法、api使用上也优雅方便了很多,很值得一试

另一大亮点就是 vue3TS的良好支持,现在项目可以全面拥抱 TS写法了,然后与 setup 再结合我接下来推荐的几款工具,组合起来使用,简直不要太爽

对于 TS,首先要做到对类型的定义,这是和传统写JS有所不同的地方,但是这一步却是很必要也很值得的,这对你接下来的工作或者说对于这个项目的以后都是有很大的好处

如这样一个场景,和后端接口对接:

在前期,我们拿到接口文档,按照格式和类型定义好对应 TS类型,并结合 Mock写好接口和业务逻辑,使用时通过TS可以高效的完成代码开发,同时可以极大的避免犯错,对于后期维护迭代有很大的保障

import.meta

使用 vite做为构建工具,可以通过 import.meta获取对应方法方便快速的处理业务

环境变量获取

  • package.json中以命令行的形式定义("dev:development": "vite --mode development"
  • .env中以配置文件的形式定义(VITE_APP_ENV = dev

.env.development 文件如下配置时:

VITE_APP_ENV = dev
VITE_APP_TITLE = 我是标题

注意:

当要读取在 .env.development中配置的变量时,需要确定 vite --mode developmentmode参数为 development

vite.config.ts

可以用函数式的写法直接读取到 mode参数(如果你想在该文件中读取.env.development中的配置信息,可以通过结合dotenv库实现)

import { UserConfig, ConfigEnv } from 'vite';

export default ({ command, mode }: ConfigEnv): UserConfig => {
  console.log(command, mode);

  return {
    // ...
  }
}

在组件中

console.log('my config env: ', import.meta.env)打印内容如下

// console.log(import.meta.env)
{
  BASE_URL: "/"
  DEV: false
  MODE: "development"
  PROD: true
  SSR: false
  VITE_APP_ENV: "dev"
  VITE_APP_TITLE: "我是标题"
}

注意:

配合.env/.env.development/.env.production等文件设置环境变量使用时,变量Key应该以VITE_为前缀

{
  "script":{
    "dev": "vite --mode development"
  }
}

为了防止意外泄漏 env 变量给客户端,只有以 VITE_为前缀的变量才会暴露给 Vite 处理的代码。只会向您的客户端源代码VITE_SOME_KEY公开import.meta.env.VITE_SOME_KEY,但DB_PASSWORD不会。

批量处理文件

import.meta.globEager

// 读取当前目录下的所有 .ts文件
const modules = import.meta.globEager('./**/*.ts')

ref和reactive

都可以用来定义响应式数据

ref

主要用于定义基本类型,使用时需要通过 .value 读取或修改

基本类型:除去Object,包括:StringNumberbooleannullundefined

控制台打印数据结构为 RefImpl

// ref
const count = ref(0)

count.value++
console.log(count.value)

在定义基本类型时,响应式原理是与 vue2.x类似的 Object.defineProperty(),通过 getset读取、修改数据

不过,ref也可以定义引用类型的数据,注意,当定义引用类型时,其内部实现则是借助 reactive

可以通过打印数据在控制台查看结构,分别为:RefImplProxy

reactive

只能定义引用类型,即Object,包括:ObjectArrayDatefunction,定义基本类型时会警告

使用时,直接通过属性读写

// reactive
const state = reactive({count:0})

state.count++
console.log(state.value)

reactive默认对对象内的所有属性都进行响应式处理,并可以实现深层监听

该响应式能力是通过 ES6 Proxy实现的,其可以做到对属性的新增删除监听,解决了 defineProperty的缺陷,并且对嵌套属性有着良好的支持,可以轻松实现 a.b.c.d=xx的响应式更新

ProxyReflect均为ES6语法,一般他俩是一起配合使用,可以很好的做到安全优雅的对属性做出劫持更新

小结

模板 tempalte 会自动解包,在模板中使用时,不需要 .value

对于引用类型来说,简单理解即:ref本质也是reactive,ref(obj)等价于reactive({value: obj})ref 的底层实现就是 reactive

可以发现,所谓响应式其实就是对属性的劫持

refreactive定义的数据,每一层都是响应式的

watch、watchEffect

监听响应式数据的变化

watch

基本语法和 vue2类似,不过在这里有一些不同的使用方式

监听 ref定义的响应式数据(基本类型)

  • 函数式写法需要 .value,监听的是一个值的变化
const count = ref(0);
const str = ref('abc');

// 1. 普通写法
// watch可以省略 .value
watch(count, (val, old) => console.log({ val, old }));

// 2. 函数写法
watch(
  () => count.value,
  (val, old) => console.log({ val, old }),
);

// 3. 数组写法
watch(
  () => [count.value, str.value],
  (val, old) => console.log({ val, old }),
);

监听 ref定义的响应式数据(引用类型)

  • 需明白的是,ref定义引用类型,内部是使用 reactive实现的,因此,需要通过 .value拿到响应式对象,再进行属性监听
const refState = ref({
  count: 0,
  str: 'abc',
});
// 1. 普通写法,无效
// => refState.value 有效
watch(refState, (val, old) => console.log({ val, old }));
// 2. 函数写法
watch(
  () => refState.value.count,
  (val, old) => console.log({ val, old }),
);

监听 reactive定义的响应式数据

  • 需要针对属性监听 state.count
const state = reactive({
  count: 0,
  str: 'abc',
  a: {
    b: {
        c: 'a-b-c',
      },
    },
});

// 1. 普通写法
// 结果:val, old 新旧值相同,
// watch(state, (val, old) => console.log({ val, old }));

// 2. 函数写法
// 结果:指定属性变化才会触发
watch(
  () => state.value.a.b.c, // 只监听指定的 属性
  (val, old) => console.log({ val, old }),
);

watchEffect

接收一个函数,不需要设置监听对象,该方法会自动接管函数内部使用到的依赖,当依赖发生更新时,触发该函数执行

该函数会初始化默认执行一次

watchEffect(()=>{

  if(state.count>1){
    // 只要 count变化,这个 watchEffect函数就会执行一次
    // 当 count > 1时,做对应行为
  }

})

watch、watchEffect 小结

使用watch时,需要考虑的情况比较多

watch更强调结果,watchEffect强调过程

就用法方面来说 watchEffect似乎更简单易用~

shallowRef和shallowReactive

  • 递归监听和非递归监听

refreactive都属于递归监听,也就是数据的每一层都是响应式的,如果数据量比较大,非常消耗性能,非递归监听只会监听数据的第一层。

script setup 写法的 props 、context处理方式

在以 <script setup lang="ts">形式使用 setup时,默认整个 script都是setup的函数作用域,我们不必再一一 return定义的每个变量和方法,可以直接使用

但,对于 propsemit的定义,以及 ctx属性的获取问题

vue也针对此,为我们提供了3个新API

  • defineProps
  • defineEmit
  • useContext
// 这3个api和setup 的属性一一对应
setup(props, { emit, ctx}){}

如果想通过父组件获取子组件的属性,需要在子组件中通过 defineExpose定义需要暴露的属性

// 子组件 Child
const count = ref(0)
defineExpose({
  count,
});

// 父组件
// <Child ref="ChildRef" />
const ChildRef = ref<RefType<{ count: number }>>(0);
const count = ChildRef.value.count

更多API见官方文档,说的很详细,这里就不再赘述了

props Type 类型定义问题

抛开 vue默认的几种基本类型,在一些特殊场景需要定义比较复杂的类型,需要通过 PropType 配合使用

如定义菜单路由类型

props: {
    menuData: {
        type: Array as PropType<MenuDataItem[]>,
        default: () => [],
    },
}

这里如果按照常规类型 Array很难满足我们的需求(只知道是个数据,但是数据形状并不清楚),原始类型写法很难精确推导各属性的类型定义

prop、ref、emit 数据通信

prop

强调单项数据流(父=>子),类似react,主要用来传参给子组件

ref

两种用法:

  • 以引用的方式把子组件的实例指给 ref,使得可以在父组件中获取到子组件中的所有属性和方法,可以通过配合 defineExpose API来实现

  • 用于获取DOM元素

// 如:在使用echarts绑定DOM节点时
// <div class="chart-box" ref="chartRef"></div>

const chartRef = ref<HTMLDivElement | null>(null);
echarts.init(unref(chartRef))

emit

主要用于子组件向父组件传递参数和通信emit(子=>父),父组件通过事件方法@event接收

<!-- 父组件 -->
emit('getMessage', '我是父组件!')

<!-- 子组件 -->
<child @event="handleMethod">

jsx 语法

在使用的过程中发现,jsx配合模板语法有着极大的灵活性,对于jsx语法,有 react开发经验的应该会感到很熟悉,开发体验上很相似

但是,对于vue来说,有着得天独厚的的优势,它本身作为模板语法,通过对实例方法的注入,指令的使用等,可以快速而高效的进行开发,在一些场景下, jsx语法 + vue模板语法有着完全不同的体验~

如:在模板中可以 <div @click="$router.push('xx')" v-auth="create"></div>

Table组件

以比较常见的table组件为例,我们封装好可复用的分页逻辑后,再把 columns单出拆分出来用,使用jsx语法封装,根据不同组件使用做不同的配置,这样也更方便维护

export function columnsConfig(refresh: () => void) {
  // ... 其他业务逻辑

  const columns: ColumnProps[] = [
    {
      title: 'IP地址和端口',
      dataIndex: 'ip',
      width: 150,
      customRender: ({ record }) => `${record.ip}:${record.port}`,
    },
    {
      title: '操作',
      key: 'action',
      width: 200,
      fixed: 'right',
      customRender: ({ record }) =>
        <Space>
          <Button type="primary" onClick={() => router.push(`/app/product/detail/${record.id}`)}>详情</Button>
          <Divider type="vertical" />
          {
            record.isSelf && <Popconfirm
              title="你确定要退出网络吗?"
              onConfirm={async () => {
                const res = await fetchApi.delete(record.id);
                if (res) {
                  message.success(`已申请退出网络`);
                  // 触发列表更新
                  refresh?.();
                }
              }}
            >
              <Button>删除</Button>
            </Popconfirm>
          }
        </Space>
    },
  ];

  return columns;
}

当 action操作列 业务比较复杂时,需要频繁的和其他数据通信,我们也可以把action操作列剥离出,在 vue内部处理,再配合 Table组件的再封装处理

Table组件封装

<template>
  <a-table :columns="columns">
    <!-- 函数式写法自定义 操作列 -->
    <template #action="{ record }">
      <template v-for="(action, index) in getActions" :key="`${index}-${action.label}`">
        <!-- 气泡确认框 -->
        <a-popconfirm
          v-if="action.enable"
          :title="action?.title"
          @confirm="action?.onConfirm(record)"
          @cancel="action?.onCancel(record)"
        >
          <a @click.prevent="() => {}" :type="action.type">{{ action.label }}</a>
        </a-popconfirm>
        <!-- 按钮 -->
        <a v-else @click="action?.onClick(record)" :type="action.type">{{ action.label }}</a>
        <!-- 分割线 -->
        <a-divider type="vertical" v-if="index < getActions.length - 1" />
      </template>
    </template>
  </a-table>
</template>
<script lang="ts">
// action 操作列
const getActions = computed(() => {
  return (toRaw(props.actions) || [])
    .filter((action) => hasPermission(action.auth))
    .map((action) => {
      const { popConfirm } = action;
      return {
        type: 'link',
        ...action,
        ...(popConfirm || {}),
        enable: !!popConfirm,
      };
    });
});
</script>

使用

// <Table :columns="columns" :actions="tableActions"/>

export const columns = [
  // ...
  {
    title: '操作',
    key: 'action',
    width: 120,
    slots: { customRender: 'action' },
  },
]

const tableActions = ref([
  {
    label: '编辑',
    auth: AuthEnum.user_update, // 配置按钮权限
    onClick: async (row) => {
      modalState.visible = true;
      const res = await store.fetchDetail(row.id);
      if (res) formModel.value = res;
    },
  }
  // ...
]

这是我在上个项目中实战使用的一点心得,对于开发效率提升还是很明显的,维护起来也是很方便,更多用法也欢迎大家一起交流学习,就目前体验来说vue3很棒~

event bus

vue3中移除了实例中挂载 $emit的行为,如果想继续使用可以单独下载对应的 npm包,如:mitt,该包很轻量,仅 200byte

api与用法类似,只是改变为了函数式创建,需要确保单个操作的 emitter创建唯一

import mitt from 'mitt'

const emitter = mitt()

export emitter

二级路径嵌套域名的部署配置

2022.03.09更新

简单举例

  • 无嵌套域名访问时,举例:www.exmple.com
  • 存在嵌套域名访问时,举例 www.exmple.com/my

项目配置

针对生产环境,从vite build 入手

  1. 直接在 vite.config.ts中配置 base属性为 base: '/my/'base默认为 /
  2. package.json中配置 scripts中的构建命令,动态传参配置 "build": "vite build --base=/my/"

以上配置的属性值都可以通过 import.meta.env.BASE_URL 获取到

注意

当完成以上配置后,你会发现,通过链接和路由是可以正常访问的,但是存在两个问题

  1. 通过路由跳转的新的地址栏路径是缺少 /my/路径的
  2. 手动刷新浏览器会返回 www.exmple.com,以及返回404的情况

问题一:

是因为我们只配置了静态文件构建生成的路径参数,但是没有配置相关路由的,举例:路由跳转是 router.push('/home'),现在应该跳转为 router.push('/my/home'),这里需要同步配置 routebase属性保持一致。

直接在全局实例的时候统一配置即可:

// vue3.x 新版本路由通过 createWebHashHistory 函数参数配置
export const router = createRouter({
  history: createWebHashHistory('/my/'), 
  routes,
});

// vue2.x 老版配置本如下
const router = new VueRouter({
  mode: 'history',
  base: '/my/',
  routes
});

为了方便维护你也可以这样:createWebHashHistory(import.meta.env.BASE_URL)

问题二:

那是因为在history模式下,只是动态的通过js操作window.history来改变浏览器地址栏里的路径,并没有发起http请求,但是当我直接在浏览器里输入这个地址的时候,就一定要对服务器发起http请求,但是这个目标在服务器上又不存在,所以会返回404

不过通过以上配置过路由base之后,保持路径和路由一致也一同解决了

结语

这篇文章其实相当于自己的学习笔记,也是为了加深印象,在使用的过程中记录了遇到的一些问题,希望可以给自己和大家带来一些帮助。就内容而言属于入门使用水平,目前暂未涉及到深水区,本文会根据使用情况持续更新