Skip to content

Vue3

MVVM 架构

MVVM, Model-View-ViewModel

  1. View 视图层, Vanilla DOM
  2. ViewModel 视图模型层: Vue
  3. Model 模型层: Vanilla JavaScript

使用 vscode 调试

json
// launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "chrome",
      "request": "launch",
      "name": "Vue: chrome",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}/src"
    }
  ]
}

Vue 新特性

重写双向数据绑定

  • Vue2 的双向数据绑定基于 Object.defineProperty(); 创建一个 Vue 实例时, for...in 遍历 data 字段中的所有属性, 使用 Object.defineProperty() 将属性转换为 getter 和 setter
  • Vue3 的双向数据绑定基于 Proxy 代理对象

优点

  1. 不需要数据备份
  2. 可以监听数组的索引和 length 属性
  3. 可以监听新增属性, 删除属性操作

虚拟 DOM 性能优化

  • Vue2 中, 每次使用 diff 算法更新虚拟 DOM 时, 都是全量对比
  • Vue3 中, 每次使用 diff 算法更新虚拟 DOM 时, 只对比有 patch 标记的节点

Vue Fragments

Vue 允许组件有多个根节点, 支持 JSX, TSX

vue
<template>
  <div>root1</div>
  <div>root2</div>
</template>
jsx
render() {
  return (
    <>
      <div>root1</div>
      <div>root2</div>
    </>
  )
}

创建 Vue 项目

Vue 脚手架

bash
pnpm create vue@latest
pnpm dlx create-vue@latest

pnpm create vite@latest
pnpm dlx create-vite@latest

Vue 项目结构

  • public 公有目录会被直接 cp -r 到 dist 目录下, 不会被 vite 打包
  • src/assets 静态资源目录会被 vite 打包
  • src/App.vue Vue 应用的根组件
  • src/main.ts Vue 应用的入口 JS/TS 文件, 导入 ./App.vue 根组件并创建 App 对象, 并挂载到 index.html, 也可以导入全局样式, 全局 api
  • index.html Vue 应用的入口 HTML 文件, <div id="app"></div> 是 App 对象的挂载点

SFC

SFC, Single File Component 单文件组件

对于 .vue 文件

  • script 标签: setup 只能有一个, 非 setup 可以有多个
  • template 标签: 只能有一个
  • style 标签: 可以有多个

风格指南

setup 函数

vue
<script lang="ts">
import { ref } from "vue";

export default {
  setup() {
    const cnt = ref(1);
    const addCnt = () => {
      cnt.value++;
    };
    // 一定要 return!
    return {
      cnt,
      addCnt,
    };
  },
};
</script>
  • 单向绑定: 模型 (数据) 改变 --> 视图 (页面) 改变. 例: 插值; v-bind 指令
  • 双向绑定: 模型 (数据) 改变 <-> 视图 (页面) 改变. 例: v-model 指令, 常用于输入框

setup 语法糖

vue
<script lang="ts" setup>
import { ref } from "vue";
const cnt = ref(1);
const addCnt = () => {
  cnt.value++;
};
</script>

vue 指令

  • v-text 渲染文本字符串, 会忽略子节点
  • v-html 渲染 HTML 字符串, 会忽略子节点, 不支持渲染 Vue 组件
  • v-if, v-else-if, v-else 节点的条件渲染, 不渲染则将节点卸载, 表现为注释节点 <!-- v-if -->, 操作 DOM
  • v-show 节点的显示/隐藏: 改变内联 CSS 样式 display: none, 操作 CSS
  • v-on 为元素绑定事件
  • v-bind 为元素绑定属性, 模型到视图的单向绑定; v-bind 也可以绑定 style
  • v-model 模型, 视图的双向绑定, 本质是 v-bind 和 v-on 的语法糖
  • v-for 遍历元素
  • v-once 性能优化, 只渲染一次
  • v-memo 性能优化, 缓存
vue
<script lang="ts" setup>
const eventName = "click";
const handleClick = (ev) => console.log(ev);
</script>

<template>
  <!-- 动态事件名 -->
  <button @[eventName]="handleClick">log</button>
</template>
  • v-on: 可以简写为 @
  • v-bind: 可以简写为 :
  • v-model 本质是 v-bind 和 v-on 的语法糖
vue
<template>
  <input v-model="text" />
  <!-- 等价于 -->
  <input v-bind:value="text" @input="text = $event.target.value" />
  <!-- 等价于 -->
  <input :value="text" @input="(ev) => (text = ev.target.value)" />
</template>
vue
<script lang="ts" setup>
const evType = ref("click");
function clickHandler(ev: Event) {
  console.log("[Child] evType:", evType);
}
</script>

<template>
  <!-- 动态事件名 -->
  <!-- ev: PointerEvent -->
  <!-- evType: click -->
  <div
    @click="
      (ev) => {
        console.log('[Parent] ev:', ev);
      }
    "
  >
    <button v-on:[evType]="clickHandler">点击</button>
    <button @[evType]="(ev: Event) => clickHandler(ev)">点击</button>
    <!-- 阻止事件冒泡 -->
    <button @[evType].stop="clickHandler">点击</button>
  </div>
</template>

这里点击 button 子元素时, 事件会冒泡到 div 父元素, 触发 div 父元素的点击事件, 可以使用 .stop 修饰符阻止事件冒泡

事件传播分为 3 个阶段: 捕获阶段, 目标阶段和冒泡阶段

v-on 指令的修饰符原生 JS (Vanilla JS)
v-on:[evType].stopev.stopPropagation(); .stop 指令: 阻止事件冒泡
v-on:[evType].preventev.preventDefault(); .prevent 指令: 阻止事件的默认行为
v-on:[evType].captureelem.addEventListener(evType, listener, true /* useCapture */).capture 指令: 事件在捕获阶段触发, 而不是在默认的冒泡阶段触发
v-on:[evType].self.self 指令: 只触发本元素绑定的事件, 不触发从子元素冒泡的事件
v-on:[evType].onceelem.removeEventListener(*args) .once 指令: 事件只触发一次, 触发后移除监听器
@scroll.passive.passive 指令: 对于滚动, 触摸事件, 不调用 ev.preventDefault(), 提高流畅度
@keydown.enter键修饰符 Key Modifiers: 按 enter 键
@click.ctrl系统修饰符 System Modifiers: 按 ctrl 键并点击
vue
<script lang="ts" setup>
const autofill = ref('')
function handleEnter(ev: Event) {
  console.log('[handleEnter] ev: ', ev)
  console.log('[handleEnter] autofill:', autofill)
  autofill.value = 'Autofill context'
}
</script>

<!-- v-model: 双向绑定 -->
<template>
  <input
    id="text"
    type="text"
    @keydown.enter="handleEnter"
    v-model="autofill"
    placeholder="按 enter 键自动填充"
  />
</template>

v-memo

  • v-memo 接收一个依赖项数组
  • 组件更新时, 如果 v-memo 标记的元素的依赖项都未改变, 则跳过该元素的更新
  • 依赖项数组为空时, v-memo="[]" 等价于 v-once, 该元素只会渲染一次
vue
<script lang="ts" setup>
const cnt = ref(1);
const cnt2 = ref(1);
const addCnt = () => {
  cnt.value++;
};
const addCnt2 = () => {
  cnt2.value++;
};
</script>

<template>
  <!-- addCnt2 时, 不会触发组件更新 -->
  <div v-memo="[cnt]">cnt: {{ cnt }}; cnt2: {{ cnt2 }}</div>
  <button @click="() => addCnt()">addCnt</button>
  <button @click="() => addCnt2()">addCnt2</button>
</template>

虚拟 DOM 和 diff 算法

vnode: Virtual DOM Node

真实 DOM 的属性过多, 操作真实 DOM 浪费性能, 虚拟 DOM 是一个 JS 对象

diff 算法

  1. 前序对比: 从头到尾对比 vnode 类型和 key, 相同则复用, 不同则转到 2
  2. 后序对比: 从尾到头对比 vnode 类型和 key, 相同则复用, 不同则转到 3
  3. 如果旧节点全部 patch, 有多余的新节点, 则新增 (挂载)
  4. 如果新节点全部 patch, 有多余的旧节点, 则删除 (卸载)
  5. 特殊情况: 乱序 (基于最长递增子序列 LIS)
    • 例: 原序列 2,3,4,0,6,1 的最长递增子序列为 2,3,4
    • 将原 vnode 序列的最长递增子序列作为参照序列, 复用, 新增或删除不在参照序列中的节点
  • 错误实践: 使用索引 index (拼接其他值) 作为 key
  • 正确实践: 使用唯一 id 作为 key
vue
<script lang="ts" setup>
const arr = ref<string[]>(["a", "b", "c", "d"]);
</script>

<template>
  <!-- eslint-disable-next-line vue/require-v-for-key -->
  <span v-for="val of arr">
    <!-- 没有 key -->
    {{ val }}
  </span>
  <br />

  <span :key="idx" v-for="(val, idx) of arr">
    <!-- 有 key -->
    {{ val }}
  </span>
  <br />

  <button @click="(console.log($event), arr.splice(2, 0, 'e'))">splice</button>
</template>

ref

家族成员: ref, shallowRef, isRef, triggerRef, customRef

  • ref 深层响应式, 底层会调用 triggerRef 强制收集依赖, 触发深层响应式
  • shallowRef 浅层响应式, 只响应 .value 的改变
  • isRef 判断是否为使用 ref, shallowRef 创建的响应式对象
    • isRef(refObj) === true, isRef(shallowRefObj) === true
    • isRef(reactiveObj) === false, isRef(shallowReactiveObj) === false
  • triggerRef 调用 triggerRef 强制收集依赖, 触发深层响应式, shallowRef + triggerRef 等价于 ref

同时使用 ref 和 shallowRef 时, shallowRef 的浅层响应式会失效, 表现为深层响应式 (参考 setUser4)

vue
<script lang="ts" setup>
import { ref, shallowRef, triggerRef } from "vue";

const user = ref({ name: "Alice", age: 1 });
const user2 = shallowRef({ name: "Bob", age: 2 });

// user.value.age++
const setUser = () => user.value.age++;
// 无改变
const setUser2 = () => user2.value.age++;
// user2.value.age++
const setUser3 = () =>
  (user2.value = { ...user2.value, age: user2.value.age + 1 });
// user.value.age++; user2.value.age++
const setUser4 = () => {
  user.value.age++;
  user2.value.age++;
};
// user2.value.age++
const setUser5 = () => {
  user2.value.age++;
  triggerRef(user2); // 强制收集依赖
};
</script>

<template>
  <main>
    <div>user: {{ JSON.stringify(user) }}</div>
    <div>user2: {{ JSON.stringify(user2) }}</div>

    <button @click="setUser">setUser</button>
    <button @click="setUser2">setUser2</button>
    <button @click="setUser3">setUser3</button>
    <button @click="setUser4">setUser4</button>
    <button @click="setUser5">setUser5</button>
  </main>
</template>

customRef

vue
<script lang="ts" setup>
import { customRef, type Ref } from "vue";

const primaryValue = "raw";

function debouncedRef<T>(value: T, timeout: number) {
  let timer: number | null = null;
  const ret: Ref<T> = customRef<T>(
    (
      track: () => void /** 收集依赖 */,
      trigger: () => void /** 触发更新 */,
    ) => {
      return {
        get: () => {
          track(); // 收集依赖
          return value;
        },
        set: (newValue: T) => {
          if (timer) {
            clearTimeout(timer);
          }
          timer = setTimeout(() => {
            value = newValue;
            trigger(); // 触发更新
            timer = null;
          }, timeout);
        },
      };
    },
  );
  return ret;
}

const str = debouncedRef(primaryValue, 3000);
const setPrimaryValue = () => {
  str.value = "brandNew";
};
</script>

<template>
  <main>
    <div>str: {{ str }}</div>
    <button @click="setPrimaryValue">setPrimaryValue</button>
  </main>
</template>

ref 绑定 DOM 元素

vue
<script lang="ts" setup>
import { ref, onMounted, useTemplateRef } from "vue";

const sameName = ref<HTMLDivElement>();
// 也可以使用 useTemplateRef
const divElem = useTemplateRef("sameName");
onMounted(() => {
  console.log(sameName.value?.innerText);
  console.log(divElem.value?.innerText);
});
</script>

<template>
  <div ref="sameName">Awesome Vue</div>
</template>

reactive, readonly

家族成员: reactive, shallowReactive

  • reactive 深层响应式, 底层会调用 triggerRef 强制收集依赖, 触发深层响应式
  • shallowReactive 浅层响应式, 只响应 .[keyName] 的改变
  • readonly 返回一个只读的响应式对象

相同的, 同时使用 reactive 和 shallowReactive 时, shallowReactive 的浅层响应式会失效, 表现为深层响应式

reactive 对比 React 的 useState

  1. React 的 useState 可以接收任意的数据类型
  2. reactive 只能接收引用数据类型
  3. React 的 useState 返回一个普通对象 (状态) 和 set 函数, 调用 set 函数更新状态时, 必须修改该普通对象 (状态) 的引用的指向
  4. reactive 返回一个代理对象, 不能修改该代理对象的引用的指向, 否则会失去响应式!

ref 对比 reactive

ts
const refObj = ref(1);
const refObj2 = ref({ name: "lark", age: 1 });
const reactiveObj = reactive({ name: "lark", age: 1 });
  1. ref 可以接收任意的数据类型; reactive 只能接收引用数据类型
  2. ref 存取时需要加 .value; reactive 不需要
  3. ref 更适合简单数据结构; reactive 更适合复杂数据结构
  4. reactiveObj 是一个 Proxy 对象
    • ref 接收基本数据类型时, refObj.value 是一个基本数据类型的值
    • ref 接收引用数据类型时, refObj2.value 是一个 Proxy 对象
  5. 可以直接对 refObj.value 赋值; 不能直接对 reactiveObj 赋值, 否则会失去响应式!

readonly

ts
import { reactive, readonly } from "vue";

const items = reactive<string[]>([]);
const readonlyItems = readonly(items);
readonlyItems.push("item");
console.log(items, readonlyItems); // [] []

items.push("item");
console.log(items, readonlyItems); // ["item"] ["item"]

ref/reactive 深层响应式

使用的 ref/reactive 创建的响应式对象更新时, 会更新整个 template, 类似 React 重新执行整个组件函数

toRef, toRefs, toRaw

  • toRef: 将 ref/reactive 创建的响应式对象上的属性值, 转换为响应式对象 (值是绑定的)
  • toRefs: 将 ref/reactive 创建的响应式对象上的属性值, 批量解构为响应式对象 (值是绑定的)
  • toRaw: 将代理对象 refObj.value, reactiveObj 转换为普通对象
  • toRef/toRefs 作用于普通对象时, 视图不会更新 (没有 track, trigger)
ts
// 实现 toRefs
const myToRefs = (obj) => {
  const ret = {};
  for (const k in obj) {
    ret[k] = toRef(obj, k);
  }
  return ret;
};
// 实现 toRaw
const myToRaw = (obj) => obj["__v_raw"];

computed 计算属性

  • 计算属性 computed({ getter, setter })
  • 只读的计算属性 computed(getter)
  • 计算属性会缓存计算结果, 只有当依赖项改变时, 才会重新计算 (基于脏值检测)
ts
const firstName = ref("Tiancheng");
const lastName = ref("Hang");
const fullName = computed<string>({
  get() {
    return firstName.value + "-" + lastName.value;
  }, // getter
  set(newVal: string) {
    [firstName.value, lastName.value] = newVal.split("-");
  }, // setter
});

const readonlyFullName = computed<string>(
  () => firstName.value + "-" + lastName.value, // getter
);

watch 侦听器

数据源

  1. 一个 ref/reactive 变量
  2. 一个 getter 函数 () => refObj.value[.prop]() => reactiveObj.prop
  3. 1,2 的数组

watch

ts
const refObj = ref(
  /* (.value) deep = 0 */ {
    // deep = 1
    foo: {
      // deep = 2
      bar: {
        // deep = 3
        type: "ref",
      },
    },
  },
);

// ref 创建的响应式对象, 默认浅层侦听 deep: false; deep: 0
watch(
  refObj,
  (newVal, oldVal) => {
    console.log("[watch] newVal", newVal);
    console.log("[watch] oldVal", oldVal);
  },
  { deep: 3 },
);
ts
const reactiveObj = reactive(
  /* deep = 0 */ {
    // deep = 1
    foo: {
      // deep = 2
      bar: {
        // deep = 3
        type: "reactive",
      },
    },
  },
);

// reactive 创建的响应式对象, 默认深层侦听 deep: true
watch(
  reactiveObj,
  (newVal, oldVal) => {
    console.log("[watch2] newVal", newVal);
    console.log("[watch2] oldVal", oldVal);
  },
  { deep: 3 },
);
ts
const name = ref("lark");

// 返回停止侦听的函数
// 调用 watchHandle() 或 watchHandle.stop() 停止侦听
const watchHandle = watch(
  [refObj, reactiveObj, name],
  (newVal, oldVal, onCleanup) => {
    console.log("[watch3] newVal:", newVal);
    console.log("[watch3] oldVal:", oldVal);
  } /** watchCallback */,
  {
    // deep: true, // 默认 false, 深层侦听
    immediate: false,
    // 是否立即执行 watchCallback
    // 默认 false, 即默认懒执行 watchCallback
    flush: "pre", // "pre" | "post" | "sync", 默认 pre
    // pre: 组件挂载, 更新前调用 watchCallback
    // post: 组件挂载, 更新后调用 watchCallback
    // sync: 同步调用 watchCallback
    once: false, // 一次性侦听, watchCallback 只调用一次
  } /** options */,
);

// 可以传递一个 getter, 侦听响应式对象中指定的属性
watch(
  [() => refObj.value.foo.bar.type, () => reactiveObj.foo.bar.type],
  (newVal, oldVal) => {
    console.log("[watch4] newVal:", newVal);
    console.log("[watch4] oldVal:", oldVal);
  },
);

watchEffect

不需要指定依赖项, 自动侦听 (自动收集 watchEffectCallback 中的响应式依赖), 默认立即执行 watchEffectCallback

ts
// 返回停止侦听的函数
// 调用 watchHandle() 或 watchHandle.stop() 停止侦听
const watchHandle = watchEffect(
  // watchEffectCallback
  (onCleanup) => {
    console.log("[watchEffect]", msg.value, msg2.value);
    onCleanup(() => {
      console.log("[onCleanup]", msg.value, msg2.value);
    });
  },
  {
    flush: "post", // "pre" | "post" | "sync"
    // pre: 组件挂载, 更新前调用 watchCallback
    // post: 组件挂载, 更新后调用 watchCallback
    // sync: 同步调用 watchCallback
    onTrigger: (ev) => {
      console.log(ev);
    }, // 调试选项
    onTrack: (ev) => {
      console.log(ev);
    }, // 调试选项
  },
);

总结: 未指定 deep 时, 地址改变则可以侦听到, 地址未改变则侦听不到

组件的生命周期

setup 语法糖中, 将 beforeCreate, created 合并为 setup

组件的生命周期: setup -> onBeforeMount -> onMounted -> onBeforeUpdate -> onUpdated -> onBeforeUnmount -> onUnmount

  1. setup 创建阶段
  2. onBeforeMount 挂载前, 获取不到 DOM
  3. onMounted 挂载后, 可以获取到 DOM
  4. onRenderTriggered 触发更新后, 回调函数接收一个事件对象, 可以同时获取到 newValue 和 oldValue, 调试用 hook, 不属于组件生命周期
  5. onBeforeUpdate 更新前, 获取的是 oldValue
  6. onRenderTracked 收集依赖后, 回调函数接收一个事件对象, 只能获取到 newValue, 调试用 hook, 不属于组件生命周期
  7. onUpdated 更新后, 获取的是 newValue
  8. onBeforeUnmount 卸载前, 可以获取到 DOM
  9. onUnmounted 卸载后, 获取不到 DOM

父子组件通信

子组件中使用宏函数 defineProps 定义自定义属性

宏函数

  1. 宏函数只能在 setup 代码块中使用
  2. 宏函数不需要显式导入
  3. 宏函数 defineProps 编译 (Vue -> JS) 时执行, 编译为组件的 props

父传子, defineProps 对比 useAttrs

父组件

vue
<script lang="ts" setup>
import { ref, reactive } from "vue";
import ChildDemo from "./ChildDemo.vue";

// 父子组件传参
const str_ = "str_parent";
const refStr_ = ref("refStr_parent");
const reactiveArr_ = reactive([6, 6, 6]);
</script>

<template>
  <div>ParentDemo: {{ str_ }} {{ refStr_ }} {{ reactiveArr_ }}</div>
  <ChildDemo
    :str="str_"
    :refStr="refStr_"
    :reactiveArr="reactiveArr_"
    extraAttr="1"
    extraAttr2="2"
  />
  <!-- str_ 不是响应式的, refStr_, reactiveArr_ 是响应式的 -->
  <button @click="str_ += '!'">setStr</button>
  <button @click="refStr_ += '!'">setRefStr</button>
  <button @click="reactiveArr_.push(6)">setReactiveArr</button>
</template>

子组件

vue
<script lang="ts" setup>
import { useAttrs } from 'vue';

const props = defineProps(["str", "refStr", "reactiveArr"]);
// {str: 'str_parent', refStr: 'refStr_parent', reactiveArr: Proxy(Array)}
console.log("[Child] props:", props);

const attrs = useAttrs();
// {extraAttr: '1', extraAttr2: '2'}
console.log("[Child] attrs:", attrs);
</script>

<template>
  <!-- template 中, 使用 props.propName 或直接使用 propName 都可以 -->
  <div>ChildDemo: {{ str }} {{ props.refStr }} {{ reactiveArr }}</div>
</template>
ts
import { toRefs, useAttrs } from 'vue'

const props = defineProps({
  str: {
    type: String,
    default: 'str_default',
  },
  refStr: {
    type: String,
    default: 'refStr_default',
  },
  reactiveArr: {
    type: Array<number>, // Array
    default: () => [5, 2, 8], // 引用类型必须转换为箭头函数
  },
})

const { str, refStr, reactiveArr } = toRefs(props)
console.log('[ChildDemo] props:', str, refStr, reactiveArr)
ts
const props = defineProps<{
  str?: string;
  refStr?: string;
  reactiveArr?: number[];
}>();

console.log("[ChildDemo] props:", props.str, props.refStr, props.reactiveArr);
ts
const props = withDefaults(
  defineProps<{
    str?: string;
    refStr?: string;
    reactiveArr?: number[];
  }>(),
  {
    str: "str_default",
    refStr: "refStr_default",
    reactiveArr: () => [5, 2, 8], // 引用类型必须转换为箭头函数
  },
);

console.log("[ChildDemo] props:", props.str, props.refStr, props.reactiveArr);

Grandparent 传 Child

vue
<script lang="ts" setup>
import { reactive, ref } from "vue";
import ParentDemo from "./ParentDemo.vue";

const a = ref(1);
const b = reactive({ v: 2 });
const addA = (da: number) => (a.value += da);
</script>

<template>
  <div>
    <!-- v-bind="{ p1: "v1", p2: "v2" }" 等价于 :p1="v1" :p2="v2" -->
    <ParentDemo :a="a" :b="b" :addA="addA" :="{ p1: 'v1', p2: 'v2' }" />
  </div>
</template>
vue
<script lang="ts" setup>
import { useAttrs } from "vue";
import ChildDemo from "./ChildDemo.vue";

const props = defineProps(["a", "b", "addA"]);
// {a: 1, b: Proxy(Object), addA: ƒ}
console.log("[ParentDemo] props:", props);

const attrs = useAttrs();
// {p1: 'v1', p2: 'v2'}
console.log("[ParentDemo] attrs:", attrs);
</script>

<template>
  <div>
    <div>[ParentDemo] a={{ a }} b={{ b }} attrs={{ attrs }}</div>
    <ChildDemo :a="a" :b="b" :addA="addA" :="attrs" />
  </div>
</template>
vue
<script lang="ts" setup>
import { useAttrs } from "vue";

const props = defineProps(["p1", "p2"]);
// {p1: 'v1', p2: 'v2'}
console.log("[ChildDemo] props:", props);

const attrs = useAttrs();
// {a: 1, b: Proxy(Object)}
console.log("[ChildDemo] attrs:", attrs);
</script>

<template>
  <div>
    <p>[ChildDemo] p1={{ p1 }} p2={{ p2 }} attrs={{ attrs }}</p>
    <button @click="(attrs.addA as Function)(1)">Add grandparent's a</button>
  </div>
</template>

子传父

  1. 子组件使用 defineEmits 定义自定义事件
  2. 子组件派发自定义事件, emit 发射参数给父组件
  3. 父组件为子组件的自定义事件绑定回调函数, 监听子组件派发的自定义事件; 自定义事件派发时, 父组件接收子组件发射的参数, 作为回调函数的参数

子组件

vue
<script lang="ts" setup>
// 子组件使用 defineEmits 定义自定义事件
// 自定义事件名 evName, evName2
const emit = defineEmits(['evName', 'evName2'])

const emitToParent = (ev: Event) => {
  // 子组件派发自定义事件, emit 发射参数给父组件
  emit('evName', ev)
}
const emitToParent2 = () => {
  emit('evName2', 'foo', 'bar')
}
</script>

<template>
  <button @click="(ev) => emitToParent(ev)">子传父</button>
  <button @click="emitToParent2">子传父2</button>
</template>
ts
// 子组件使用 defineEmits 定义自定义事件
// 自定义事件名 evName, evName2
const emit = defineEmits<{
  (e: 'evName', arg: Event): void,
  (e: 'evName2', arg: string, arg2: string): void
}>()

const emitToParent = (ev: Event) => {
  // 子组件派发自定义事件, emit 发射参数给父组件
  emit('evName', ev)
}
const emitToParent2 = () => {
  emit('evName2', 'foo', 'bar')
}
ts
// 子组件使用 defineEmits 定义自定义事件
// 自定义事件名 evName, evName2
const emit = defineEmits<{
  evName: [arg: Event], // 具名元组
  evName2: [arg: string, arg2: string] // 具名元组
}>()

const emitToParent = (ev: Event) => {
  // 子组件派发自定义事件, emit 发射参数给父组件
  emit('evName', ev)
}
const emitToParent2 = () => {
  emit('evName2', 'foo', 'bar')
}

父组件

vue
<script lang="ts" setup>
import ChildDemo from "./ChildDemo.vue";
// 子传父
// 自定义事件派发时, 父组件接收子组件发射的数据, 作为回调函数的参数
const receiveFromChild = (...args: unknown[]) => console.log(args);
</script>

<template>
  <!-- 父组件为子组件的自定义事件绑定回调函数, 监听子组件派发的自定义事件 -->
  <ChildDemo
    @evName="(...args: unknown[]) => receiveFromChild(args)"
    @evName2="receiveFromChild"
  />
</template>

子组件暴露接口

子组件使用 defineExpose 暴露接口, 包括属性和方法

vue
<script lang="ts" setup>
defineExpose({
  name: "lark",
  getAge() {
    return 23;
  },
});
</script>

<template>ChildDemo</template>
vue
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import ChildDemo from "./ChildDemo.vue";

const sameName = ref<InstanceType<typeof ChildDemo>>();
onMounted(() => {
  console.log(
    "[ParentDemo] Child expose:",
    sameName.value?.name,
    sameName.value?.getAge(),
  );
});
</script>

<template>
  <ChildDemo ref="sameName" />
</template>

兄弟组件通信

方式 1: 通过父组件转发 (forward)

BoyDemo.vue -> ParentDemo.vue (forward) -> GirlDemo.vue

vue
<!-- Boy 组件使用 defineEmits 定义自定义事件 -->
<script lang="ts" setup>
// const emit = defineEmits(['customEvent'])
const emit = defineEmits<{
  customEvent: [flag: boolean, timestamp: string]; // 具名元组
}>();

let flag = false;
const emitArgs = () => {
  flag = !flag;
  const timestamp = new Date().toLocaleTimeString();
  emit("customEvent", flag, timestamp);
};
</script>

<template>
  <!-- 点击按钮以触发自定义事件, 向父组件发射参数 -->
  <button @click="emitArgs">emitArgs</button>
</template>
vue
<!-- 父组件为子组件的自定义事件绑定回调函数
自定义事件发生时, 父组件接收子组件发射的参数, 作为回调函数的参数 -->
<script lang="ts" setup>
import { ref } from "vue";
import BoyDemo from "./BoyDemo.vue";
import GirlDemo from "./GirlDemo.vue";

const flag = ref<boolean>(false);
const timestamp = ref<string>("");

const receiveArgs = (flag_: boolean, timestamp_: string) => {
  flag.value = flag_;
  timestamp.value = timestamp_;
};
</script>

<template>
  <div>
    <BoyDemo
      @custom-event="
        (flag: boolean, timestamp: string) => receiveArgs(flag, timestamp)
      "
    />
    <!-- 转发: 将父组件接收的 BoyDemo 子组件发射的参数, 传递给 GirlDemo 子组件 -->
    <GirlDemo :flag="flag" :timestamp="timestamp" />
  </div>
</template>
vue
<script lang="ts" setup>
defineProps<{
  flag: boolean;
  timestamp: string;
}>();
</script>

<template>
  <div>[GirlDemo] flag: {{ flag }}</div>
  <div>[GirlDemo] timestamp: {{ timestamp }}</div>
</template>

方式 2: 事件总线 (发布/订阅)

BoyDemo 发布, GirlDemo 订阅, 无需父组件参与

ts
// type TCallback = (...args: any[]) => void | Promise<void>
interface ICallback {
  (...args: any[]): void | Promise<void>;
}

class Bus {
  evName2cbs: Map<string, Set<ICallback>> = new Map();

  // 发布
  pub(evName: string, ...args: unknown[]) {
    const cbs = this.evName2cbs.get(evName);
    if (!cbs) {
      return;
    }
    for (const cb of cbs) {
      cb.apply(this, args);
    }
  }

  // 订阅
  sub(evName: string, cb: ICallback) {
    const cbs = this.evName2cbs.get(evName);
    if (!cbs) {
      this.evName2cbs.set(evName, new Set([cb]));
      return;
    }
    cbs.add(cb);
  }

  // 取消订阅
  off(evName: string, cb: ICallback) {
    const cbs = this.evName2cbs.get(evName);
    if (!cbs) {
      return;
    }
    cbs.delete(cb);
    if (cbs.size === 0) {
      this.evName2cbs.delete(evName);
    }
  }

  // 订阅一次
  once(evName: string, cb: ICallback) {
    const onceCb = (...args: Parameters<typeof cb>) => {
      cb.apply(this, args);
      this.off(evName, cb);
    };
    this.sub(evName, onceCb);
  }
}

export default new Bus();
vue
<script lang="ts" setup>
import bus from "./bus";

let flag = false;
const emitArgs = () => {
  flag = !flag;
  // 发布
  bus.pub("customEvent", flag, new Date().toLocaleTimeString()); // 发布
};
</script>

<template>
  <button @click="emitArgs">emitArgs</button>
</template>
vue
<script lang="ts" setup>
import { ref } from "vue";
import bus from "./bus";

const flag = ref(false);
const timestamp = ref("");

// 订阅
bus.sub("customEvent", (flag_: boolean, timestamp_: string) => {
  flag.value = flag_;
  timestamp.value = timestamp_;
});
</script>

<template>
  <div>[GirlDemo] flag: {{ flag }}</div>
  <div>[GirlDemo] timestamp: {{ timestamp }}</div>
</template>

mitt 发布/订阅库

vue
<script setup lang="ts">
import mitt from "mitt";

const emitter = mitt();

const handlerA = (args: unknown) => console.log("[handlerA] args:", args);
const handlerB = (args: unknown) => console.log("[handlerB] args:", args);
emitter.on("eventA", handlerA);
emitter.on("eventB", handlerB);
emitter.on("*", (evName, args) => console.log("[*]:", evName, args));
</script>

<template>
  <button @click="emitter.emit('eventA', { a: 1 })">emitA</button>
  <button @click="emitter.emit('eventB', { b: 2 })">emitB</button>
  <button @click="emitter.off('eventA', handlerA)">offA</button>
  <button @click="emitter.off('eventB', handlerB)">offB</button>
  <button @click="emitter.all.clear()">clear</button>
</template>

依赖注入 provide/inject

类似的技术: IoC/DI

  • 控制反转 IoC, Inversion of Control, 不手动 new 对象, 或导入对象, 而是从容器 (Map) 中取对象
  • 依赖注入 DI, Dependency Injection: 不导出对象, 而是将对象放到容器 (Map) 中

provide/inject: 祖先 provide 提供, 并 inject 注入到后代, 实现祖孙通信

vue
<script lang="ts" setup>
import { provide, ref } from "vue";
import ParentDemo from "./ParentDemo.vue";

const colorVal = ref("lightpink");
// 祖先 provide 提供
provide("colorKey" /** key */, colorVal /** value */);
// 可以提供一个 readonly 的 colorVal, 防止后代组件修改
// provide('colorKey', readonly(colorVal))
</script>

<template>
  <div>[Grandparent] colorVal: {{ colorVal }}</div>
  <button @click="colorVal = 'lightpink'">lightpink</button>
  <ParentDemo />
</template>
vue
<script lang="ts" setup>
import { inject, ref, type Ref } from "vue";
import ChildDemo from "./ChildDemo.vue";
// 并 inject 注入到后代
const injectedColor = inject<Ref<string>>(
  "colorKey",
  ref("unknown-color") /** defaultVal */,
);
</script>

<template>
  <div>[Parent] injectedColor {{ injectedColor }}</div>
  <button @click="injectedColor = 'lightgreen'">lightgreen</button>
  <ChildDemo />
</template>
vue
<script lang="ts" setup>
import { inject, type Ref, ref } from "vue";
// 并 inject 注入到后代
const injectedColor = inject<Ref<string>>(
  "colorKey",
  ref("unknown-color") /** defaultVal */,
);
</script>

<template>
  <div>[Child] injectedColor {{ injectedColor }}</div>
  <button @click="injectedColor = 'lightblue'">lightblue</button>
</template>

局部组件, 全局组件, 递归组件

局部组件, 全局组件

  • 默认是局部组件
  • 可以在 main.ts 中注册全局组件
ts
// main.ts
import CardComponent from "@/components/global/CardComponent.vue";
const app = createApp(App);
app.component("GlobalCard", CardComponent); // 注册 <GlobalCard /> 全局组件
// .vue 文件可以直接使用 <GlobalCard /> 全局组件, 无需导入

批量注册全局组件

ts
// main.ts
import * as GlobalComponents from "./components/global";

const app = createApp(App);
for (const [key, component] of Object.entries(GlobalComponents)) {
  app.component(key, component);
}

递归组件

父组件 ParentDemo.vue

vue
<script lang="ts" setup>
import { reactive } from "vue";
import RecursiveChild from "./RecursiveChild.vue";

export interface ITreeNode {
  name: string;
  checked: boolean;
  children?: ITreeNode[];
}

const data = reactive<ITreeNode[]>([
  { name: "1", checked: false },
  { name: "2", checked: true, children: [{ name: "2-1", checked: false }] },
  {
    name: "3",
    checked: true,
    children: [
      {
        name: "3-1",
        checked: false,
        children: [{ name: "3-1-1", checked: true }],
      },
    ],
  },
]);
</script>

<template>
  <RecursiveChild :data="data" />
</template>

递归子组件 RecursiveChild.vue

使用递归组件时, 需要阻止事件冒泡 (使用 .stop 修饰符)

vue
<!-- <script lang="ts">
// 可以自定义组件名
// 不能同时使用 defineOptions 宏函数和 export default 默认导出
export default { name: "RecursiveChild" };
</script> -->

<script lang="ts" setup>
import type { ITreeNode } from "./ParentDemo.vue";

defineProps<{ data?: ITreeNode[] }>();
// 可以自定义组件名
// 不能同时使用 defineOptions 宏函数和 export default 默认导出
defineOptions({ name: "RecursiveChild" });

const check = (item: ITreeNode) => console.log(item);
</script>

<template>
  <!-- .stop 修饰符: 阻止事件冒泡 -->
  <div @click.stop="check(item)" v-for="(item, idx) of data" :key="idx">
    <div>
      <input type="checkbox" v-model="item.checked" />
      <span>{{ item.name }}</span>
    </div>
    <!-- 递归组件, 默认组件名等于文件名 -->
    <RecursiveChild v-if="item.children" :data="item.children" />
  </div>
</template>

动态组件 <component />

多个组件使用同一个 <component /> 挂载点, 并可以动态切换

<component :is="componentShallowRef | componentName" />

不要创建组件的 ref 对象, 避免不必要的性能开销, 可以使用 shallowRef 代替 ref, 也可以使用 markRaw 跳过代理

vue
<script lang="ts" setup>
import { markRaw, reactive, shallowRef } from "vue";
import DynamicA from "./DynamicA.vue";
import DynamicB from "./DynamicB.vue";
import DynamicC from "./DynamicC.vue";
type DynamicComp = typeof DynamicA | typeof DynamicB | typeof DynamicC;

const activeComp = shallowRef<DynamicComp>(DynamicA);
const setComp = (comp: DynamicComp) => (activeComp.value = comp);
const options = reactive([
  { name: "compA", handler: () => setComp(DynamicA) },
  { name: "compB", handler: () => setComp(DynamicB) },
  // 不要创建组件的 ref 对象, 避免不必要的性能开销
  // 可以使用 shallowRef 代替 ref
  // 也可以使用 markRaw 跳过代理
  { name: "compC ", handler: () => setComp(markRaw(DynamicC)) },
]);
</script>

<template>
  <div v-for="{ name, handler } of options" :key="name">
    <div @click="handler">{{ name }}</div>
  </div>
  <!-- is 可以是组件的 shallowRef, 也可以是注册的组件名 componentName-->
  <component :is="activeComp" />
</template>
vue
<script lang="ts">
import DynamicA from "./DynamicA.vue";
import DynamicB from "./DynamicB.vue";
import DynamicC from "./DynamicC.vue";

export default {
  // 注册子组件
  components: {
    compA: DynamicA, // 注册的组件名 compA
    compB: DynamicB, // 注册的组件名 compB
    compC: DynamicC, // 注册的组件名 compC
  },
};
</script>

<script lang="ts" setup>
import { reactive, ref } from "vue";

const activeComp = ref<string>("compA");
const setComp = (comp: string) => (activeComp.value = comp);
const options = reactive([
  { name: "compA_", handler: () => setComp("compA") },
  { name: "compB_", handler: () => setComp("compB") },
  { name: "compC_", handler: () => setComp("compC") },
]);
</script>

<template>
  <div v-for="{ name, handler } of options" :key="name">
    <div @click="handler">{{ name }}</div>
  </div>
  <!-- is 可以是组件的 shallowRef, 也可以是注册的组件名 componentName-->
  <component :is="activeComp" />
</template>
vue
<script lang="ts" setup>
import { reactive, ref } from "vue";
import DynamicA from "./DynamicA.vue";
import DynamicB from "./DynamicB.vue";
import DynamicC from "./DynamicC.vue";

defineOptions({
  // 注册子组件
  components: {
    compA: DynamicA, // 注册的组件名 compA
    compB: DynamicB, // 注册的组件名 compB
    compC: DynamicC, // 注册的组件名 compC
  },
});

const activeComp = ref<string>("compA");
const setComp = (comp: string) => (activeComp.value = comp);
const options = reactive([
  { name: "compA_", handler: () => setComp("compA") },
  { name: "compB_", handler: () => setComp("compB") },
  { name: "compC_", handler: () => setComp("compC") },
]);
</script>

<template>
  <div v-for="{ name, handler } of options" :key="name">
    <div @click="handler">{{ name }}</div>
  </div>
  <!-- is 可以是组件的 shallowRef, 也可以是注册的组件名 componentName-->
  <component :is="activeComp" />
</template>

插槽 <slot />

插槽: 子组件提供给父组件的占位符, 可以插入父组件的 template

  1. 匿名插槽 name="default"
  2. 具名插槽
  3. 作用域插槽
  4. 动态插槽
vue
<script lang="ts" setup>
import { reactive } from 'vue'

const users = reactive([
  { name: 'foo', age: 1 },
  { name: 'bar', age: 2 },
  { name: 'baz', age: 3 },
])
</script>

<template>
  <div>
    <header>
      <!-- 匿名插槽 name="default" -->
      <slot>placeholder: 匿名插槽</slot>
    </header>

    <main>
      <div v-for="(item, idx) of users" :key="idx">
        <!-- 作用域插槽 -->
        <slot name="scoped" :item="item" :idx="idx">placeholder: 作用域插槽</slot>
      </div>
    </main>

    <footer>
      <!-- 具名插槽 -->
      <slot name="named">placeholder: 具名插槽</slot>
    </footer>
  </div>
</template>
vue
<script lang="ts" setup>
import ChildDemo from './ChildDemo.vue'
</script>

<template>
  <div>
    <!-- 子组件 -->
    <ChildDemo>
      <!-- <div>默认插入到子组件的匿名插槽</div> -->
      <template v-slot:default>
        <div>插入到子组件的匿名插槽 default</div>
      </template>

      <template v-slot:scoped="{ item, idx }">
        <div>插入到子组件的作用域插槽 scoped</div>
        <div>{{ `idx: ${idx}, name: ${item.name}, age: ${item.age}` }}</div>
      </template>

      <template #named>
        <div>插入到子组件的具名插槽 named, v-slot: 可以简写为 #</div>
      </template>
    </ChildDemo>
  </div>
</template>
vue
<script lang="ts" setup>
import { ref } from "vue";
import ChildDemo from "./ChildDemo.vue";

const slotName = ref("default");
</script>

<template>
  <div>
    <ChildDemo>
      <!-- 动态插槽, 等价于 #[slotName] -->
      <template v-slot:[slotName]="{ item, idx }">
        <div>动态插槽</div>
        <div v-if="item">
          {{ `idx: ${idx}, name: ${item.name}, age: ${item.age}` }}
        </div>
      </template>
    </ChildDemo>
    <button @click="slotName = 'default'">default</button>
    <button @click="slotName = 'scoped'">scoped</button>
    <button @click="slotName = 'named'">named</button>
  </div>
</template>

传送模板 <Teleport />

<Teleport /> 将部分 template 传送到指定 DOM 节点上, 成为该 DOM 节点的直接子元素

vue
<script lang="ts" setup>
import { ref } from "vue";

const popupVisible = ref(false);
</script>

<template>
  <button @click="popupVisible = true">显示弹窗</button>

  <!-- .popup 是 #app 的直接子元素 -->
  <Teleport to="#app" :disabled="false">
    <!-- disable 是否禁用 <Teleport /> -->
    <div class="w-20 h-20 bg-lime-200" v-show="popupVisible">
      我是 #app 的直接子元素
      <button @click="popupVisible = false">隐藏弹窗</button>
    </div>
    <div>我也是 #app 的直接子元素</div>
  </Teleport>
</template>

异步组件 <Suspense />

  1. setup 语法糖中使用顶层 await, 会被编译为 async setup()
  2. 父组件使用 defineAsyncComponent(() => import(...)) 导入异步组件
  3. <Suspense /> 组件有两个插槽: default 和 fallback, 两个插槽都只允许一个直接子节点

xhr.readyState

  • xhr.readyState === 0 [unsent] 未调用 open 方法
  • xhr.readyState === 1 [opened] 已调用 open 方法, 未调用 send 方法
  • xhr.readyState === 2 [headers_received] 已调用 send 方法, 已收到响应头
  • xhr.readyState === 3 [loading] 正在接收响应体
  • xhr.readyState === 4 [done] 请求结束, 数据传输成功或失败
ts
{
  "data": {
    "name": "lark",
    "age": 23,
    "url": "https://161043261.github.io/homepage/",
    "desc": "homepage"
  }
}
ts
// 原生 AJAX
export const myAxios = {
  get<T>(url: string): Promise<T> {
    return new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      xhr.open("GET", url);
      xhr.onreadystatechange = () => {
        // xhr.readyState === 4 [done] 请求结束, 数据传输成功或失败
        if (xhr.readyState === 4 && xhr.status === 200) {
          setTimeout(() => {
            resolve(JSON.parse(xhr.responseText));
          }, 3000);
        }
      };
      xhr.send(null);
    });
  },
};
vue
<script lang="ts" setup>
import { myAxios } from "@/utils/axios.ts";
// setup 语法糖中使用顶层 await, 会被编译为 async setup()
const { data } = await myAxios.get<{ data: unknown }>("/data.json");
</script>

<template>
  <div>ChildAsync</div>
  <div>data: {{ JSON.stringify(data) }}</div>
</template>
vue
<template>
  <div>ChildSkeleton</div>
  <div>请等待...</div>
</template>
vue
<script lang="ts" setup>
import { defineAsyncComponent } from "vue";
import ChildSkeleton from "./ChildSkeleton.vue";
// 父组件使用 defineAsyncComponent(() => import(...)) 导入异步组件
const ChildAsync = defineAsyncComponent(
  () => import("@/components/ChildAsync.vue"),
);
</script>

<template>
  <Suspense>
    <template #default>
      <ChildAsync />
    </template>
    <template v-slot:fallback>
      <ChildSkeleton />
    </template>
  </Suspense>
</template>
  • setup 语法糖中使用顶层 await, 父组件使用 defineAsyncComponent(() => import(...)) 导入的异步组件, vite 会 chunk 分包, 懒加载
  • import(...) 异步导入的路由组件, vite 会 chunk 分包, 懒加载

使用 TSX

  • 支持 v-show, v-model
  • 特有的 v-slots
tsx
import { defineComponent, toRefs, type Ref, type RenderFunction } from "vue";

interface IProps {
  name: Ref<string>;
  age: Ref<number>;
}

const ChildDemo: Component<IProps> = defineComponent({
  name: "ChildDemo", // componentName
  // props: ['name', 'age'], // propNames (defineProps)
  props: {
    name: String,
    age: {
      type: Number,
      required: true,
    },
  },
  emits: [] as string[], // eventNames (defineEmits)
  expose: [] as string[], // exposedKeys (defineExpose)

  setup(props: IProps, { attrs, slots, emit, expose } /** ctx */) {
    console.log("[ChildDemo] props:", props);
    console.log(
      "[ChildDemo] ctx:",
      attrs,
      slots,
      emit.toString(),
      expose.toString(),
    );
    const { name, age } = toRefs(props);

    return (() => (
      <>
        <div>Child name: {name.value}</div>
        <div>Child age: {age.value}</div>
        <button onClick={() => (name.value += "_Child")}>Child setName</button>
        <button onClick={() => (age.value += 1)}>Child setAge</button>
        {slots.default ? slots.default() : <>placeholder: 匿名插槽</>}
      </>
    )) as RenderFunction;
  },
});

export default ChildDemo;
tsx
import { ref, defineComponent } from "vue";
import ChildDemo from "./ChildDemo";

export default defineComponent(
  // setup
  () => {
    const name = ref("lark");
    const age = ref(23);
    const slots = { default: () => <>插槽写法 1</> };
    const slots2 = { default: () => <>插槽写法 2</> };
    // return a renderFunc
    return () => (
      <>
        <div>Parent name: {name.value}</div>
        <div>Parent age: {age.value}</div>
        <button onClick={() => (name.value += "_Parent")}>
          Parent setName
        </button>
        <button onClick={() => (age.value += 1)}>Parent setAge</button>
        <ChildDemo name={name} age={age}>
          {slots}
        </ChildDemo>
        <ChildDemo name={name} age={age} v-slots={slots2} />
      </>
    );
  },
  {
    name: "ParentDemo", // componentName
    props: [] as string[], // propNames (defineProps)
    emits: [] as string[], // eventNames (defineEmits)
  },
);

缓存组件 <KeepAlive />

  1. 默认缓存 <KeepAlive /> 内部的所有组件
  2. include 包含属性: 缓存指定 name 的组件, 支持 string (可以以逗号分隔), RegExp(string | RegExp)[]
  3. exclude 属性: 不缓存指定 name 的组件
  4. max 属性: 最大缓存组件数, 如果实际组件数 > max, 则使用 LRU 算法计算具体缓存哪些组件
vue
<script lang="ts" setup>
import { ref } from "vue";
import BoyDemo from "./BoyDemo.vue";
import GirlDemo from "./GirlDemo.vue";

const flag = ref<boolean>(true);
</script>

<template>
  <KeepAlive>
    <BoyDemo v-if="flag" />
    <GirlDemo v-else />
  </KeepAlive>
  <button @click="flag = !flag">switch</button>
</template>
vue
<script lang="ts" setup>
import { ref } from "vue";

const name = ref("");
const age = ref(0);
</script>

<template>
  <div>Boy</div>
  <input v-model="name" type="text" />
  <input v-model="age" type="number" />
</template>
vue
<script lang="ts" setup>
import { ref } from "vue";

const name = ref("");
const age = ref(0);
</script>

<template>
  <div>Girl</div>
  <input v-model="name" type="text" />
  <input v-model="age" type="number" />
</template>

缓存组件的生命周期

使用 <KeepAlive /> 缓存组件时, 会增加两个生命周期 onActivated 和 onDeactivated

ts
// 这两个生命周期钩子不仅适用于 <KeepAlive /> 缓存的根组件, 也适用于缓存树的后代组件
onActivated(() => {
  // 调用时机为组件挂载时, 和每次读缓存后插入到 DOM 中
});

onDeactivated(() => {
  // 调用时机为组件卸载时, 和每次从 DOM 中移除后写缓存
});

<Transition /> 过渡/动画组件

  • <Transition /> 只允许一个直接子元素; 同时, <Transition /> 包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画
  • <Transition /> 会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载), 显示/隐藏 (v-show) 时应用过渡动画
  • <TransitionGroup /> 允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入, 删除, 移动时应用过渡动画

[enter | leave]-[from | active | to]

对比 CSS 过渡 transition 和动画 animation

过渡 transition动画 animation
触发需要事件触发, 例如 :hover可以自动触发, 例如页面加载后自动播放
状态只有起始状态和结束状态可以使用 @keyframes 定义多个关键帧
自动循环播放不支持支持

前置要求: 安装 tailwindcssanimate.css

vue
<script lang="ts" setup>
import { ref } from "vue";

const flag = ref<boolean>(true);
</script>

<template>
  <button @click="flag = !flag">mount/unMount</button>
  <!-- 默认 name="v" -->
  <Transition name="my-prefix">
    <div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
  </Transition>
</template>

<style lang="css" scoped>
@reference "tailwindcss";

/** 默认 .v-enter-from, .v-leave-to */
.my-prefix-enter-from,
.my-prefix-leave-to {
  @apply w-0 h-0;
}

/** 默认 v-enter-active, .v-leave-active */
.my-prefix-enter-active,
.my-prefix-leave-active {
  @apply transition-all duration-1500;
}

/** 默认 v-enter-to, .v-leave-from */
.my-prefix-enter-to,
.my-prefix-leave-from {
  @apply w-50 h-50 rotate-360;
}
</style>
vue
<script lang="ts" setup>
import { ref } from "vue";

const flag = ref<boolean>(true);
</script>

<template>
  <button @click="flag = !flag">mount/unMount</button>
  <!-- 除了 .[my-prefix]-[enter | leave]-[from | active | to] 约定的类名 -->
  <!-- 也可以自定义类名 [enter | leave]-[from | active | to]-class="your_custom_className" -->

  <!-- :duration="1500" 表示持续时间 1500ms -->
  <!-- 或 :duration="{ enter: 1500, leave: 1500 }" -->
  <Transition
    :duration="{ enter: 1500, leave: 1500 }"
    leaveActiveClass="animate__animated animate__fadeOut"
    enterActiveClass="animate__animated animate__fadeIn"
  >
    <div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
  </Transition>
</template>

<Transition /> 的钩子函数

事件名对应的 CSS 类名
beforeEnterv-enter-from
enterv-enter-active
afterEnterv-enter-to
enterCancelled
beforeLeavev-leave-from
leavev-leave-active
afterLeavev-leave-to
leaveCancelled

示例

vue
<script lang="ts" setup>
import { ref } from "vue";

const flag = ref(true);

const handleEnterActive = (el: Element, done: () => void) => {
  console.log("onEnterActive");
  setTimeout(() => done() /** 过渡结束 */, 3000);
};

const handleLeaveActive = (el: Element, done: () => void) => {
  console.log("onLeaveActive");
  setTimeout(() => done() /** 过渡结束 */, 3000);
};
</script>

<template>
  <div>
    <button type="button" @click="flag = !flag">switch</button>
    <Transition
      class="animate__animated"
      enterActiveClass="animate__fadeIn"
      leaveActiveClass="animate__fadeOut"
      :duration="1000"
      @beforeEnter="(el: Element) => console.log('onBeforeEnter')"
      @enter="handleEnterActive"
      @afterEnter="(el: Element) => console.log('onAfterEnter')"
      @enterCancelled="(el: Element) => console.log('onEnterCancelled')"
      @beforeLeave="(el: Element) => console.log('onBeforeLeave')"
      @leave="handleLeaveActive"
      @afterLeave="(el: Element) => console.log('onAfterLeave')"
      @leaveCancelled="(el: Element) => console.log('onLeaveCancelled')"
    >
      <div class="box" v-if="flag">Transition by animate.css</div>
    </Transition>

    <Transition name="my-prefix">
      <!-- className prefix -->
      <div class="box" v-show="flag" style="background: lightpink">
        Transition by custom CSS
      </div>
    </Transition>
  </div>
</template>

<style lang="scss" scoped>
@mixin wh0 {
  width: 0;
  height: 0;
}

@mixin wh50 {
  width: 200px;
  height: 200px;
}

.box {
  @include wh50;
  background: skyblue;
}

.my-prefix-enter-from {
  @include wh0;
  transform: rotate(360deg);
}

.my-prefix-enter-active {
  transition: all 3s ease;
}

// .my-prefix-enter-to {}
// .my-prefix-leave-from {}

.my-prefix-leave-active {
  transition: all 3s ease;
}

.my-prefix-leave-to {
  @include wh0;
  transform: rotate(360deg);
}
</style>

<Transition /> + GSAP

vue
<!-- pnpm add gsap -->
<script lang="ts" setup>
import gsap from "gsap";
import { ref } from "vue";

const isAlive = ref(true);
const handleBeforeEnter = (el: Element) =>
  gsap.set(el, { width: 0, height: 0 });

const handleEnter = (el: Element, done: () => void) =>
  gsap.to(el, { width: 200, height: 200, onComplete: done });

const handleLeave = (el: Element, done: () => void) =>
  gsap.to(el, { width: 0, height: 0, onComplete: done });
</script>

<template>
  <button type="button" @click="isAlive = !isAlive">switch</button>
  <Transition
    @beforeEnter="handleBeforeEnter"
    @enter="handleEnter"
    @leave="handleLeave"
  >
    <div v-if="isAlive" class="h-50 w-50 bg-lime-200">Transition by GASP</div>
  </Transition>
</template>

appear-[from | active | to]-class

appear-[from | active | to]-class 只在首次渲染时应用 1 次过渡动画

vue
<script lang="ts" setup>
import { ref } from "vue";

const flag = ref<boolean>(true);
</script>

<template>
  <button @click="flag = !flag">mount/unMount</button>
  <Transition
    appear
    appearFromClass="my-appear-from"
    appearActiveClass="my-appear-active"
    appearToClass="my-appear-to"
  >
    <!-- 只在首次渲染时应用 1 次过渡动画 -->
    <div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
  </Transition>
</template>

<style lang="css" scoped>
@reference "tailwindcss";

.my-appear-from {
  @apply w-0 h-0;
}

.my-appear-active {
  @apply transition-all duration-1500;
}

.my-appear-to {
  @apply w-50 h-50;
}
</style>

<TransitionGroup />

  • <Transition /> 只允许一个直接子元素; 同时, <Transition /> 包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画
  • <Transition /> 会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载), 显示/隐藏 (v-show) 时应用过渡动画
  • <TransitionGroup /> 允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入, 删除, 移动时应用过渡动画

<TransitionGroup /> 列表的插入, 删除过渡

vue
<script lang="ts" setup>
import { reactive } from "vue";
import "animate.css";

const list = reactive<number[]>([0, 1, 2]);
</script>

<template>
  <button @click="list.push(list.length)">push</button>
  <button @click="list.pop()">pop</button>
  <!-- tag="htmlTagName" tag 属性为多个列表项包裹一层 htmlTagName 元素 -->
  <div class="wrapper">
    <TransitionGroup
      tag="main"
      class="border-1 flex flex-wrap gap-1"
      enter-active-class="animate__animated animate__bounceIn"
      leave-active-class="animate__animated animate__bounceOut"
    >
      <div class="item" v-for="(item, idx) of list" :key="idx">{{ item }}</div>
    </TransitionGroup>
  </div>
</template>

<TransitionGroup /> 列表的移动过渡

vue
<!-- pnpm i lodash && pnpm i @types/lodash -D -->
<script lang="ts" setup>
import { ref } from "vue";
import { shuffle } from "lodash";

const arr = ref(
  Array.from({ length: 81 }, (_, idx) => ({ key: idx, val: (idx % 9) + 1 })),
);

const shuffleList = () => (arr.value = shuffle(arr.value));
</script>

<template>
  <div>
    <button @click="shuffleList">shuffleList</button>
    <!-- move-class: 平移的过渡动画 -->
    <TransitionGroup moveClass="mv" class="flex flex-wrap w-[378px]" tag="div">
      <!-- v-for 绑定 key 时, 不能使用数组下标, 否则无法应用过渡动画 -->
      <div
        class="w-10 h-10 border-slate-300 border-1 flex justify-center items-center"
        v-for="item of arr"
        :key="item.key"
      >
        {{ item.val }}
      </div>
    </TransitionGroup>
  </div>
</template>

<style lang="css" scoped>
.mv {
  transition: all 1s;
}
</style>

状态过渡 + GASP

示例

vue
<script setup lang="ts">
import gsap from "gsap";

import { reactive, watch } from "vue";
const num = reactive({
  targetVal: 0,
  renderVal: 0,
});

watch(
  () => num.targetVal,
  (newVal, oldVal) => {
    console.log(newVal, "<-", oldVal);
    gsap.to(num, {
      duration: 1, // 1s
      renderVal: newVal,
    });
  },
);
</script>

<template>
  <input v-model="num.targetVal" :step="20" type="number" />
  <div>{{ num.renderVal.toFixed(0) }}</div>
</template>

集成 JSX

tsx
import {
  defineComponent,
  type Component,
  type RenderFunction,
  type VNode,
} from "vue";

interface IProps {
  element: string | VNode;
}

const MyComponent: Component<IProps> = defineComponent<IProps>(
  (props: IProps /** , ctx */) => {
    const { element } = props;
    const vNode: VNode = (
      <div>
        {import.meta.env.DEV ? "My Component" : ""}
        {element}
      </div>
    );
    const renderFunc: RenderFunction = () => vNode;
    return renderFunc;
  },
  {
    props: ["element"],
  },
);

export default MyComponent;

编写 vite 插件解析 JSX

安装依赖

bash
pnpm i @vue/babel-plugin-jsx -D &&              \
pnpm i @babel/core -D &&                        \
pnpm i @babel/plugin-transform-typescript -D && \
pnpm i @babel/plugin-syntax-import-meta -D &&   \
pnpm i @types/babel__core -D
ts
import type { Plugin } from "vite";
import babel from "@babel/core";
import babelPluginJsx from "@vue/babel-plugin-jsx";

function vitePluginVueTsx(): Plugin {
  return {
    name: "vite-plugin-vue-tsx",
    config(/** config */) {
      return {
        esbuild: {
          include: /\.ts$/,
        },
      };
    },
    async transform(code, id) {
      if (/.tsx$/.test(id)) {
        const ts = await import("@babel/plugin-transform-typescript").then(
          (res) => res.default,
        );
        const res = await babel.transformAsync(code, {
          ast: true, // ast 抽象语法树
          babelrc: false, // 没有 .babelrc 文件, 所以是 false
          configFile: false, // 没有 babel.config.json 文件, 所以是 false
          plugins: [
            babelPluginJsx,
            [ts, { isTSX: true, allowExtensions: true }],
          ],
        });
        return res?.code;
      }
      return code;
    },
  };
}

v-model 双向绑定

v-model 本质是语法糖

  • 父组件使用 v-bind 传递 props 给子组件, 预定义的属性名 modelValue
  • 子组件派发预定义事件, 父组件使用 v-on 为预定义事件绑定回调函数, 监听子组件派发的预定义事件, 预定义事件名 update:modelValue
  • 父组件修改值时, 父组件使用 v-bind 传递新的 modelValue 值给子组件
  • 子组件修改值时, 子组件派发 update:modelValue 预定义事件, emit 发射新的 modelValue 值给父组件
  • 支持多个 v-model: v-model 预定义的属性名是 modelValue, 事件名是 update:modelValue, 支持自定义 v-model 的属性名, 事件名
  • v-model 修饰符: .trim, .number, .lazy, 支持自定义修饰符 v-model.customModifier
vue
<script setup lang="ts">
import { ref } from "vue";
import ChildDemo from "./ChildDemo.vue";

const text = ref<string>("Awesome Vue");
</script>

<template>
  ParentDemo
  <div>text: {{ text }}</div>
  <ChildDemo v-model:textVal.myModifier="text" />
  <ChildDemo :textVal="text" @update:textVal="(newVal) => (text = newVal)" />
</template>
vue
<script setup lang="ts">
const props = defineProps<{
  textVal: string;
  // 约定 xxxModifiers
  textValModifiers?: {
    myModifier: boolean; // 修饰符存在则为 true
  };
}>();

const emit = defineEmits(["update:textVal"]);

const handleInput = (ev: Event) => {
  emit("update:textVal", (ev.target as HTMLInputElement).value);
};
</script>

<template>
  ChildDemo
  <div>Has myModifier: {{ props.textValModifiers?.myModifier ?? false }}</div>
  <div>
    textVal: <input type="text" :value="textVal" @input="handleInput" />
  </div>
</template>

自定义指令

自定义指令名: 以 v 开头, vDirectiveName

自定义指令的钩子函数

  • created
  • beforeMount/mounted
  • beforeUpdate/updated
  • beforeUnmount/unmounted
vue
<script setup lang="ts">
import { ref, type Directive, type DirectiveBinding } from "vue";
import ChildDemo from "./ChildDemo.vue";

// 自定义指令名: 以 v 开头, vDirectiveName
const vCustomDirective: Directive = {
  created(...args) {
    console.log("[vCustomDirective] created:", args);
  },

  beforeMount(...args) {
    console.log("[vCustomDirective] beforeMount:", args);
  },

  mounted(
    el: HTMLElement,
    binding: DirectiveBinding<{ background: string; textContent: string }>,
  ) {
    console.log("[vCustomDirective] mounted:", el, binding);
    el.style.background = binding.value.background;
    el.textContent = binding.value.textContent;
  },

  beforeUpdate(...args) {
    console.log("[vCustomDirective] beforeUpdate:", args);
  },

  updated(...args) {
    const el = args[0];
    el.textContent = textContent.value;
    console.log("[vCustomDirective] updated:", args);
  },

  beforeUnmount(...args) {
    console.log("[vCustomDirective] beforeUnmount", args);
  },

  unmounted(...args) {
    console.log("[vCustomDirective] unmounted", args);
  },
};

const isAlive = ref(true);
const textContent = ref("Vue");
const handleUpdate = () => {
  textContent.value += "!";
};
</script>

<template>
  <button @click="isAlive = !isAlive">挂载/卸载</button>
  <button @click="handleUpdate">更新</button>
  <ChildDemo
    v-if="isAlive"
    v-custom-directive:propName.myModifier="{
      background: 'skyblue',
      textContent,
    }"
  />
</template>

自定义指令 v-auth 实现按钮鉴权

vue
<script setup lang="ts">
import type { Directive, DirectiveBinding } from "vue";

const userId = "lark";
const authList = [
  "lark:item:create",
  "lark:item:update" /** 'lark:item:delete' */,
];

const vAuth: Directive<HTMLElement, string> = (el, binding) => {
  if (!authList.includes(userId + ":" + binding.value)) {
    el.style.display = "none"; // 如果没有权限, 则隐藏按钮
  }
};
</script>

<template>
  <button v-auth="'item:create'">创建</button>
  <button v-auth="'item:update'">更新</button>
  <button v-auth="'item:delete'">删除</button>
</template>

自定义指令 v-drag 实现可拖拽窗口

vue
<script lang="ts" setup>
import type { Directive } from "vue";

const vDrag: Directive<HTMLElement> = (el) => {
  const draggableElem = el.firstElementChild as HTMLElement;
  const handleMouseDown = (downEv: MouseEvent) => {
    const dx = downEv.clientX - el.offsetLeft;
    const dy = downEv.clientY - el.offsetTop;

    const handleMouseMove = (moveEv: MouseEvent) => {
      el.style.left = `${moveEv.clientX - dx}px`;
      el.style.top = `${moveEv.clientY - dy}px`;
    };

    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", () =>
      document.removeEventListener("mousemove", handleMouseMove),
    );
  };

  draggableElem.addEventListener("mousedown", handleMouseDown);
};
</script>

<template>
  <!-- fixed 固定定位 -->
  <div v-drag class="fixed">
    <div class="h-20 w-50 bg-lime-100 cursor-pointer" />
    <div class="h-50 w-50 bg-lime-200" />
  </div>
</template>

自定义指令 v-lazy 实现图片懒加载

vue
<script lang="ts" setup>
import { type Directive } from "vue";

// glob 默认懒加载
const images = import.meta.glob(["@/assets/*.jpg", "@/assets/*.png"], {
  eager: true, // 指定立即加载
});
const arr = Object.values(images).map((item) => (item as any).default);
// arr.length = 1
const flattedArr = arr.flatMap((item) => new Array(10).fill(item));
// flattedArr.length = 10
const vLazy: Directive<HTMLImageElement, string> = async (el, binding) => {
  const placeholder = await import("@/assets/vue.svg");
  el.src = placeholder.default;

  // 监听目标元素与祖先元素或视口 viewport 的相交情况
  // 监听目标元素和视口 viewport 的相交情况, 即监听一个元素是否可见
  // entries[0].intersectionRatio 相交的比例, 一个元素可见的比例
  const intersectionObserver = new IntersectionObserver((entries) => {
    const visibleRatio = entries[0].intersectionRatio;
    if (visibleRatio > 0) {
      setTimeout(() => (el.src = binding.value), 1500);
      intersectionObserver.unobserve(el);
    }
  });
  intersectionObserver.observe(el);
};
</script>

<template>
  <div>
    <img
      v-lazy="item"
      width="1000"
      v-for="(item, idx) of flattedArr"
      :key="idx"
    />
  </div>
</template>

自定义 hook

Demo

vue
<script lang="ts" setup>
import { onMounted, ref, type Ref } from "vue";

const useBase64str = (
  el: Ref<HTMLImageElement | null>,
): Promise<{ base64str: string }> => {
  const toBase64str = (img: HTMLImageElement) => {
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
    [canvas.width, canvas.height] = [img.width, img.height];
    if (ctx) {
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const data = imageData.data;
      for (let i = 0; i < data.length; i += 4) {
        const avg =
          (data[i] + // r
            data[i + 1] + // g
            data[i + 2]) / // b
          3;
        data[i] = data[i + 1] = data[i + 2] = avg;
      }
      ctx.putImageData(imageData, 0, 0);
    }
    const base64str = canvas.toDataURL(`image/${getExtName(img.src)}`);
    return base64str;
  };

  const getExtName = (url: string) => {
    const urlObj = new URL(url);
    return urlObj.pathname.split(".").at(-1);
  };

  return new Promise((resolve) => {
    onMounted(() => {
      el.value!.onload = () => {
        const base64str = toBase64str(el.value!);
        resolve({ base64str });
      };
    });
  });
};

const imgRef = ref<HTMLImageElement | null>(null);
useBase64str(imgRef).then((res) => {
  imgRef.value!.src = res.base64str;
});
</script>

<template>
  <img src="@/assets/bg.jpg" id="bg" ref="imgRef" />
</template>

自定义指令 + 自定义 hook 综合示例

  • InterSectionObserver 监听目标元素与祖先元素或视口 viewport 的相交情况
  • MutationObserver 监听整个 DOM 树的改变
  • ResizeObserver 监听元素宽高的改变
ts
import App from "./App.vue";
import { createApp } from "vue";
import type { App as VueApp } from "vue";

// Vue 插件可以是一个有 install 方法的对象
// 也可以直接是一个安装函数
// 也可以是一个有 install 属性的安装函数, install 属性值也是一个函数, 接收一个 App 实例
// useResize 是一个自定义 hook, 也是一个 Vue 插件
export const useResize = (
  el: HTMLElement,
  cb: (contentRect: DOMRectReadOnly) => void,
) => {
  const resizeObserver = new ResizeObserver((entries) => {
    cb(entries[0].contentRect);
  });
  resizeObserver.observe(el);
};

useResize.install = (app: VueApp) => {
  // 注册 v-resize 自定义指令
  app.directive('resize', {
    mounted(el, binding) {
      console.log('[v-resize] mounted:', el, binding)
      // binding.value
      // (rect) => console.log("[v-resize] contentRect:", rect)
      useResize(el, binding.value /** cb */)
    },
  })
}

const app = createApp(App);
app.use(useResize);
app.mount("#app");
vue
<script lang="ts" setup>
import { useResize } from '@/main'
import { onMounted } from 'vue'

onMounted(() => {
  useResize(document.querySelector('#parent') as HTMLElement, (rect) =>
    console.log('[useResize] contentRect:', rect),
  )
})
</script>

<template>
  <textarea
    id="parent"
    v-resize="(rect: DOMRectReadOnly) => console.log('[v-resize] contentRect:', rect)"
  />
</template>

全局变量 app.config.globalProperties

ts
import { createApp } from "vue";
import App from "./App.vue";
import mitt from "mitt";

interface IEncoding {
  jsonMarshal<T extends object>(arg: T): string;
}

const app = createApp(App);
// 类型扩展
declare module "vue" {
  export interface ComponentCustomProperties {
    $env: string;
    $encoding: IEncoding;
    $bus: ReturnType<typeof mitt>;
  }
}

// 全局变量 $bus, $env, $encoding
const emitter = mitt();
app.config.globalProperties.$bus = emitter;
app.config.globalProperties.$env = "DEV";
app.config.globalProperties.$encoding = {
  jsonMarshal<T extends object>(arg: T) {
    return JSON.stringify(arg);
  },
};

app.mount("#app");
vue
<template>
  <div>$env: {{ $env }}</div>
  <div>
    $encoding.jsonMarshal:
    {{ $encoding.jsonMarshal({ name: "lark", age: 23 }) }}
  </div>
</template>

<script lang="ts" setup>
import { getCurrentInstance } from "vue";

const app = getCurrentInstance();
console.log(app?.proxy?.$env);
console.log(app?.proxy?.$encoding.jsonMarshal({ name: "lark", age: 23 }));
</script>

全局变量 + Vue 插件综合示例

  • Vue 插件可以是一个有 install 方法的对象
  • 也可以直接是一个安装函数
vue
<script setup lang="ts">
import { ref } from "vue";
const visible = ref<boolean>(true);

defineExpose({
  visible,
  show: () => (visible.value = true),
  hide: () => (visible.value = false),
});
</script>

<template>
  <Transition
    enter-active-class="animate__animated animate__bounceIn"
    leave-active-class="animate__animated animate__bounceOut"
  >
    <div v-if="visible" class="w-50 h-50 bg-lime-100" />
  </Transition>
</template>
ts
import "animate.css";

import { createApp, createVNode, render } from "vue";
import App from "./App.vue";
import type { Ref, VNode, App as VueApp } from "vue";
import ToastDemo from "./components/ToastDemo.vue";

declare module "vue" {
  export interface ComponentCustomProperties {
    $toast: {
      show: () => void;
      hide: () => void;
      visible: Ref<boolean>;
    };
  }
}

// Vue 插件可以是一个有 install 方法的对象
// 也可以直接是一个安装函数
export const vuePluginToast = {
  install(app: VueApp) {
    const vnode: VNode = createVNode(ToastDemo);
    render(vnode, document.body);
    app.config.globalProperties.$toast = {
      show: vnode.component?.exposed?.show,
      hide: vnode.component?.exposed?.hide,
      visible: vnode.component?.exposed?.visible,
    };
  },
};

const app = createApp(App);
app.use(vuePluginToast);

app.mount("#app");
vue
<template>
  <div class="flex flex-col gap-5">
    <button @click="$toast.show">show</button>
    <button @click="$toast.hide">hide</button>
    <button @click="$toast.visible.value = true">show2</button>
    <button @click="$toast.visible.value = false">hide2</button>
  </div>
</template>

app.use() 源码

ts
import { createApp } from "vue";
import { createPinia } from "pinia";

import App from "./App.vue";
import type { App as VueApp } from "vue";

interface Plugin {
  install: (app: VueApp, ...options: unknown[]) => unknown;
}
const installed = new Set();

function myUse<T extends Plugin>(plugin: T, ...options: Array<unknown>) {
  if (installed.has(plugin)) {
    return;
  }
  plugin.install(this as VueApp /** app */, ...options);
  installed.add(plugin);
  return;
}

const app = createApp(App);

// app.use(createPinia())
myUse.call(app, createPinia());

app.mount("#app");

nextTick

Vue 同步更新数据, 异步更新 DOM

  • Vue 将 DOM 更新加入任务队列, 等到下一个 tick (类似事件循环) 时, 才统一更新 DOM, 避免不必要的重复渲染, 提高性能
  • nextTick 延迟执行 callback, 即等到下一个 tick, DOM 更新后, 再执行 callback

示例

vue
<script setup lang="ts">
import { reactive, ref, useTemplateRef, nextTick } from "vue";

const itemList = reactive([
  { name: "item1", id: 1 },
  { name: "item2", id: 2 },
]);

const inputVal = ref("");
const box = useTemplateRef<HTMLDivElement>("box");

// Vue 同步更新数据, 异步更新 DOM
const addItem = () => {
  itemList.push({ name: inputVal.value, id: itemList.length });
  box.value!.scrollTop = 520_520_520; // 更新滚动位置 (此时 DOM 未更新)
};

const addItem2 = () => {
  itemList.push({ name: inputVal.value, id: itemList.length });
  // nextTick 延迟执行 callback, 即等到下一个 tick, DOM 更新后, 再执行 callback
  nextTick(
    () => (box.value!.scrollTop = 520_520_520), // callback (此时 DOM 已更新)
  );
};

const addItem3 = async () => {
  itemList.push({ name: inputVal.value, id: itemList.length });
  await nextTick(); // 等到下一个 tick, DOM 更新后
  box.value!.scrollTop = 520_520_520; // 更新滚动位置 (此时 DOM 已更新)
};
</script>

<template>
  <div ref="box" class="border-1 h-30 w-50 overflow-auto">
    <div class="border-b-1 truncate" v-for="item in itemList" :key="item.id">
      {{ item }}
    </div>
  </div>
  <div>
    <textarea v-model="inputVal" type="text" class="my-3 border-1" />
    <div class="flex gap-5">
      <button @click="addItem">addItem</button>
      <button @click="addItem2">addItem2</button>
      <button @click="addItem3">addItem3</button>
    </div>
  </div>
</template>

scoped 样式隔离, :deep() 样式穿透

scoped 样式隔离

  1. 通过 PostCSS, 为 DOM 添加唯一的 data-v-[hash:base64:8] 属性
  2. CSS 使用 .selector[data-v-[hash:base64:8]] 属性选择器, 以实现样式隔离

:deep() 样式穿透

vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>

<template>
  <main class="wrap">
    <ChildDemo class="child-bg" />
  </main>
</template>

<style lang="css" scoped>
.wrap {
  width: 10rem;
  height: 10rem;
  background: lightpink;
}

/* .child-bg { // */
:deep(.child-bg) {
  width: 5rem;
  height: 5rem;
  background: lightblue;
}
</style>
vue
<template>
  <div class="child-bg" />
</template>

<style lang="css" scoped>
.child-bg {
  width: 5rem;
  height: 5rem;
  background: lightgreen;
}
</style>
html
<main data-v-[parent-hash:base64:8] class="wrap">
  <div data-v-[child-hash:base64:8] class="child-bg"></div>
</main>
html
<!-- ChildDemo -->
<style type="text/css">
  .child-bg[data-v-<child-hash:base64:8>] {
  }
</style>

<!-- ParentDemo -->
<style type="text/css">
  .wrap[data-v-<parent-hash:base64:8>] {
  }
  .child-bg[data-v-<parent-hash:base64:8>] {
  }
</style>
html
<!-- ChildDemo -->
<style type="text/css">
  /* 类选择器, 交集, 属性选择器; 优先级 (0, 0, 2, 0) */
  .child-bg[data-v-<child-hash:base64:8>] {
    background: lightpink;
  }
</style>

<!-- ParentDemo -->
<style type="text/css">
  .wrap[data-v-<parent-hash:base64:8>] {
  }
  /* 属性选择器, 子代, 类选择器; 优先级 (0, 0, 2, 0) */
  [data-v-<parent-hash:base64:8>] .child-bg {
    background: lightblue;
  }
</style>

:slotted 插槽选择器, :global 全局选择器

:slotted() 插槽选择器

vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>

<template>
  <ChildDemo>
    <div class="parent-bg">插入到子组件的匿名插槽 default</div>
  </ChildDemo>
</template>
vue
<template>
  <!-- 匿名插槽 name="default" -->
  <slot />
</template>

<style lang="css" scoped>
/* .parent-bg { // */
:slotted(.parent-bg) {
  background: lightpink;
}
</style>

:global 全局选择器

  1. 全局选择器: 使用 :global 的选择器, 不会被 vite 编译
  2. <style lang="css"> 中的选择器, 是全局选择器
  3. <style lang="css" scoped> 中, 并使用 :global 的选择器, 也是全局选择器

v-bind 动态 CSS

vue
<script setup lang="ts">
import { ref } from "vue";

const bg = ref("#000");
const text = ref({ color: "#fff" });

setInterval(() => {
  bg.value = bg.value === "#fff" ? "#000" : "#fff";
  text.value.color = text.value.color === "#fff" ? "#000" : "#fff";
}, 1000);
</script>

<template>
  <div class="box w-20 h-20 border-1">v-bind: Dynamic CSS</div>
</template>

<style scoped lang="css">
.box {
  background: v-bind(bg);
  color: v-bind("text.color");
}
</style>

CSS 模块化

vue
<script setup lang="ts">
import { useCssModule } from "vue";

const styles = useCssModule(); // 默认模块 $style
const customStyles = useCssModule("customName"); // 自定义模块名 customName
console.log("styles:", styles);
console.log("customStyles:", customStyles);
</script>

<template>
  <main class="flex flex-col gap-5">
    <!-- 默认模块 $style -->
    <div :class="$style.box">CSS Module</div>
    <div :class="styles.box">CSS Module</div>
    <!-- class 可以绑定数组 -->
    <div :class="[$style.box, styles.border]">CSS Module</div>
    <!-- 可以自定义模块名 -->
    <div :class="[$style.box, customName.bg]">CSS Module</div>
    <div :class="[styles.box, customStyles.bg]">CSS Module</div>
  </main>
</template>

<style module lang="css">
.box {
  width: 5rem;
  height: 5rem;
  background: lightblue;
}

.border {
  border: 1px solid #333;
}
</style>

<!-- 可以自定义模块名 -->
<style module="customName">
.bg {
  background: lightpink;
}
</style>

H5 适配

html
<!-- h5 适配: 设置 meta 标签 -->
<meta name="viewport" content="width=device-width,initial-scale=1" />

圣杯布局 + 全局字体大小

圣杯布局: 两侧盒子宽度固定, 中间盒子宽度自适应的三栏布局

  • rem: 相对 <html> 根元素的字体大小
  • vw/vh: 相对视口 viewport 的宽高, 1vw 是视口宽度的 1%, 1vh 是视口高度的 1%
  • 百分比: 相对父元素的宽高

全局字体大小原理

  • 定义 :root 伪类选择器的全局 CSS 变量, 所有页面都可以使用
  • :root 伪类选择器和 html 元素选择器都选中 <html> 根元素, 但是 :root 伪类选择器的优先级更高
vue
<script setup lang="ts">
import { useCssVar } from "@vueuse/core";

const setGlobalFontSize = (pxVal: number) => {
  const fontSize = useCssVar("--font-size");
  fontSize.value = `${pxVal}px`;
  // 底层: document.documentElement.style.setProperty('--font-size', `${pxVal}px`)
};
</script>

<template>
  <header class="flex">
    <div class="w-[100px] bg-lime-200 my-div">left</div>
    <div class="flex-1 bg-blue-300 my-div">
      center
      <button class="mx-[10px]" @click="setGlobalFontSize(36)">大号字体</button>
      <button class="mx-[10px]" @click="setGlobalFontSize(24)">中号字体</button>
      <button class="mx-[10px]" @click="setGlobalFontSize(12)">小号字体</button>
    </div>
    <div class="w-[100px] bg-lime-200 my-div">right</div>
  </header>
</template>

<style scoped lang="css">
@reference "tailwindcss";

.my-div {
  @apply h-[100px] leading-[100px] text-slate-500 text-center;
  font-size: var(--font-size);
}
</style>

编写 postcss 插件

ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { Plugin } from "postcss";

// pnpm i postcss -D
function postcssPluginPx2viewport(): Plugin {
  return {
    postcssPlugin: "postcss-plugin-px2viewport",
    Declaration(node) {
      if (node.value.includes("px")) {
        // console.log(node.prop, node.value);
        const val = Number.parseFloat(node.value);
        node.value = `${((val / 375) /** 设计稿宽度 375 */ * 100).toFixed(2)}vw`;
      }
    },
  };
}

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  css: {
    postcss: {
      // 自定义 postcss 插件
      plugins: [postcssPluginPx2viewport()],
    },
  },
});

Vue 函数式编程

vue
<script setup lang="ts">
import { h } from "vue";

interface IProps {
  type: "primary" | "danger";
}

// Vue 函数式编程
const Btn = (props: IProps, ctx: any /** { attrs, emit, slots } */) => {
  console.log("[btn] ctx", ctx);
  return h(
    "button", // type
    {
      style: { color: props.type === "primary" ? "lightblue" : "lightcoral" },
      onClick: () => {
        console.log(ctx);
      },
    }, // props
    ctx.slots.default(), // children
  );
};
</script>

<template>
  <Btn type="primary">primary</Btn>
  <Btn type="danger">danger</Btn>
</template>

Vue 宏函数

  • defineProps
  • defineEmits
  • defineOptions
  • defineSlots

defineSlots

vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
const list = [
  { name: "love", age: 1 },
  { name: "you", age: 2 },
];
</script>

<template>
  <main>
    <ChildDemo :defaultList="list" :namedList="list">
      <!-- item 先通过子组件的 props 父传子,
      再通过子组件的 slot 子传父 -->
      <template #default="{ item }">
        <div>defaultSlot {{ `name: ${item.name}, age: ${item.age}` }}</div>
      </template>

      <template #named="{ item }">
        <div>namedSlot {{ `name: ${item.name}, age: ${item.age}` }}</div>
      </template>
    </ChildDemo>
  </main>
</template>
vue
<!-- 泛型支持 -->
<script generic="T extends object" setup lang="ts">
import { toRefs, type RenderFunction } from "vue";

const props = defineProps<{ defaultList: T[]; namedList: T[] }>();
const { defaultList, namedList } = toRefs(props);

defineSlots<{
  default(props: { item: T }): unknown;
  named(props: { item: T }): unknown;
}>();
</script>

<template>
  <main>
    <ul>
      <li v-for="(item, idx) of defaultList" :key="idx">
        <!-- 匿名的作用域插槽 -->
        <slot :item="item" />
      </li>
    </ul>

    <ul>
      <li v-for="(item, idx) of namedList" :key="idx">
        <!-- 具名的作用域插槽 -->
        <slot :item="item" name="named" />
      </li>
    </ul>
  </main>
</template>

环境变量

在项目根目录下创建环境变量文件 .env.development, .env.production, 修改 package.json

bash
VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] development'
bash
VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] production'
json
{
  "scripts": {
    "dev": "vite --mode development",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview"
  }
}
ts
console.log("import.meta.env:", import.meta.env);
// {
//   BASE_URL: '/',
//   DEV: true,
//   MODE: 'development',
//   PROD: false,
//   SSR: false
//   VITE_CUSTOM_ENV: '[VITE_CUSTOM_ENV] development'
// }
ts
console.log("import.meta.env:", import.meta.env);
// {
//   BASE_URL: '/',
//   DEV: false,
//   MODE: 'production',
//   PROD: true,
//   SSR: false
//   VITE_CUSTOM_ENV: '[VITE_CUSTOM_ENV] production'
// }

vite.config.ts 是 node 环境, 无法使用 import.meta.env 读取项目根目录下的环境变量文件

ts
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vite.dev/config/
export default ({ mode }: { mode: string }) => {
  // mode: development
  console.log("mode:", mode);
  // loadEnv: { VITE_CUSTOM_ENV: '[custom_env] development' }
  console.log("loadEnv:", loadEnv(mode, process.cwd()));
  return defineConfig({ plugins: [vue()] });
};

自定义元素

原生 Web Component 自定义元素

优点: CSS, JS 隔离

js
class Btn extends HTMLElement {
  constructor() {
    super();
    const shadowDOM = this.attachShadow({ mode: "open" });
    this.div = this.h("div");
    this.div.innerText = "d2vue-btn";
    this.div.setAttribute(
      "style",
      `width: 100px;
       height: 30px;
       line-height: 30px;
       text-align: center;
       border: 1px solid #ccc;
       border-radius: 15px;
       cursor: pointer;
       `,
    );
    shadowDOM.appendChild(this.div);
  }

  h(el) {
    return document.createElement(el);
  }

  connectedCallback() {
    console.log("[d2vue-btn] Connected");
  }
  disconnectedCallback() {
    console.log("[d2vue-btn] Disconnect");
  }
  adoptedCallback() {
    console.log("[d2vue-btn] Adopted");
  }
  attributeChangedCallback() {
    console.log("[d2vue-btn] Attribute changed");
  }
}

window.customElements.define("d2vue-btn", Btn);
js
class Btn2 extends HTMLElement {
  constructor() {
    super();
    const shadowDOM = this.attachShadow({ mode: "open" });
    this.template = this.h("template");
    this.template.innerHTML = `
      <style>
        .btn {
          width: 100px;
          height: 30px;
          line-height: 30px;
          text-align: center;
          border: 1px solid #ccc;
          border-radius: 15px;
          cursor: pointer;
        }
      </style>
      <div class="btn">d2vue-btn2</div>`;
    shadowDOM.appendChild(this.template.content.cloneNode(true));
  }

  h(el) {
    return document.createElement(el);
  }
  connectedCallback() {
    console.log("[d2vue-btn2] Connected");
  }
  disconnectedCallback() {
    console.log("[d2vue-btn2] Disconnect");
  }
  adoptedCallback() {
    console.log("[d2vue-btn2] Adopted");
  }
  attributeChangedCallback() {
    console.log("[d2vue-btn2] Attribute changed");
  }
}

window.customElements.define("d2vue-btn2", Btn2);
html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="./btn.js"></script>
    <script src="./btn2.js"></script>
  </head>
  <body>
    <d2vue-btn></d2vue-btn>
    <d2vue-btn2></d2vue-btn2>
  </body>
</html>

Vue 中使用 Web Component 自定义元素

ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // 文件名带 - , 文件拓展名 .ce.vue 的单文件组件, 视为 Web Component 自定义元素
          isCustomElement: (tag) => tag.startsWith("d2vue-"),
        },
      },
    }),
  ],
});
vue
<script setup lang="ts">
defineProps<{ item: { name: string; age: number } }>();
</script>

<template>
  <!-- 不能使用 tailwindcss -->
  <div class="btn">name: {{ item.name }}, age: {{ item.age }}</div>
</template>

<style lang="css" scoped>
.btn {
  width: 250px;
  height: 50px;
  line-height: 50px;
  text-align: center;
  border: 1px solid #ccc;
  border-radius: 25px;
  cursor: pointer;
}
</style>
vue
<script setup lang="ts">
import { defineCustomElement } from "vue";
import D2vueBtn from "@/components/d2vue-btn.ce.vue";

// Vue 中使用 Web Component 自定义元素
const Btn = defineCustomElement(D2vueBtn);
window.customElements.define("d2vue-btn", Btn);
const item = { name: "lark", age: 23 };
</script>

<template>
  <d2vue-btn :item="item"></d2vue-btn>
</template>

Proxy 跨域

同源: 主机 (域名), 端口, 协议都相同

JSONP

原理: HTML 文件的 <script> 标签没有跨域限制

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script>
      function jsonp(req /* { url, callback } */) {
        const script = document.createElement("script");
        const url = `${req.url}?callback=${req.callback.name}`;
        script.src = url;
        // 浏览器请求该 <script> 标签的 src
        // 响应: frontendFn({"data":"I love you"})
        document.getElementsByTagName("head")[0].appendChild(script);
      }

      function frontendFn(res) {
        alert(`res.data: ${res.data}`);
      }

      // frontendFn.name: frontendFn
      console.log("frontendFn.name:", frontendFn.name);
      jsonp({ url: "http://localhost:8080", callback: frontendFn });
    </script>
  </head>
  <body></body>
</html>
js
import http from "node:http";
import urllib from "node:url";

const port = 8080;
const cbParams = { data: "I love you" };
http
  .createServer((req, res) => {
    const params = urllib.parse(req.url, true);
    if (params.query.callback) {
      // callback: frontendFn
      console.log("callback:", params.query.callback);
      // JSONP, JSON with Padding
      const jsonWithPadding = `${params.query.callback}(${JSON.stringify(cbParams)})`;
      // jsonWithPadding: frontendFn({"data":"I love you"})
      console.log("jsonWithPadding:", jsonWithPadding);
      res.end(jsonWithPadding);
    } else {
      res.end();
    }
  })
  .listen(port, () => {
    console.log(`http://localhost:${port}`);
  });

Vite 代理

ts
import { fileURLToPath, URL } from "node:url";

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:8080",
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

后端允许跨域

js
function cors(req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Accept, Authorization",
  );
  // res.header("Access-Control-Allow-Credentials", true);
  res.header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
  res.header("Content-type", "application/json;charset=utf-8");
  // 预检 (pre-flight) 请求
  if (req.method.toUpperCase() === "OPTIONS") {
    return res.sendStatus(204);
  }
  next();
}