Skip to content

Vue3

MVVM, Model-View-ViewModel

  1. View 视图层, Vanilla DOM
  2. ViewModel 视图模型层: Vue
  3. Model 模型层: Vanilla JavaScript
launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Vue: chrome",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src"
}
]
}
  • Vue2 的双向数据绑定基于 Object.defineProperty(); 创建一个 Vue 实例时, for…in 遍历 data 字段中的所有属性, 使用 Object.defineProperty() 将属性转换为 getter 和 setter
  • Vue3 的双向数据绑定基于 Proxy 代理对象

优点

  1. 不需要数据备份
  2. 可以监听数组的索引和 length 属性
  3. 可以监听新增属性, 删除属性操作
  • Vue2 中, 每次使用 diff 算法更新虚拟 DOM 时, 都是全量对比
  • Vue3 中, 每次使用 diff 算法更新虚拟 DOM 时, 只对比有 patch 标记的节点

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

<template>
<div>root1</div>
<div>root2</div>
</template>
render() {
return (
<>
<div>root1</div>
<div>root2</div>
</>
)
}
Terminal window
pnpm create vue@latest
pnpm dlx create-vue@latest
pnpm create vite@latest
pnpm dlx create-vite@latest
  • 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, Single File Component 单文件组件

对于 .vue 文件

  • script 标签: setup 只能有一个, 非 setup 可以有多个
  • template 标签: 只能有一个
  • style 标签: 可以有多个
<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 指令, 常用于输入框
<script lang="ts" setup>
import { ref } from "vue";
const cnt = ref(1);
const addCnt = () => {
cnt.value++;
};
</script>
  • 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 性能优化, 缓存
<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 的语法糖
<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>
<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 键并点击
<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-once, 该元素只会渲染一次
<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>

vnode: Virtual DOM Node

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

  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
<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, 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)

<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>
<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>
<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, shallowReactive

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

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

  1. React 的 useState 可以接收任意的数据类型
  2. reactive 只能接收引用数据类型
  3. React 的 useState 返回一个普通对象 (状态) 和 set 函数, 调用 set 函数更新状态时, 必须修改该普通对象 (状态) 的引用的指向
  4. reactive 返回一个代理对象, 不能修改该代理对象的引用的指向, 否则会失去响应式!
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 赋值, 否则会失去响应式!
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"]
  • toRef: 将 ref/reactive 创建的响应式对象上的属性值, 转换为响应式对象 (值是绑定的)
  • toRefs: 将 ref/reactive 创建的响应式对象上的属性值, 批量解构为响应式对象 (值是绑定的)
  • toRaw: 将代理对象 refObj.value, reactiveObj 转换为普通对象
  • toRef/toRefs 作用于普通对象时, 视图不会更新 (没有 track, trigger)
// 实现 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({ getter, setter })
  • 只读的计算属性 computed(getter)
  • 计算属性会缓存计算结果, 只有当依赖项改变时, 才会重新计算 (基于脏值检测)
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
);

数据源

  1. 一个 ref/reactive 变量
  2. 一个 getter 函数 () => refObj.value[.prop]() => reactiveObj.prop
  3. 1,2 的数组
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 },
);

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

// 返回停止侦听的函数
// 调用 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 定义自定义属性

父组件

<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>

子组件

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

子组件

<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>

父组件

<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 暴露接口, 包括属性和方法

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

BoyDemo.vue -> ParentDemo.vue (forward) -> GirlDemo.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>

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

// 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, onceCb);
};
this.sub(evName, onceCb);
}
}
export default new Bus();
<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>

类似的技术: IoC/DI

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

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

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

批量注册全局组件

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

<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 修饰符)

<!-- <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 :is="componentShallowRef | componentName" />

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

<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>

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

  1. 匿名插槽 name=“default”
  2. 具名插槽
  3. 作用域插槽
  4. 动态插槽
<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>

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

<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="h-20 w-20 bg-lime-200" v-show="popupVisible">
我是 #app 的直接子元素
<button @click="popupVisible = false">隐藏弹窗</button>
</div>
<div>我也是 #app 的直接子元素</div>
</Teleport>
</template>
  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] 请求结束, 数据传输成功或失败
{
"data": {
"name": "lark",
"age": 23,
"url": "https://hangtiancheng.github.io/homepage/",
"desc": "homepage"
}
}
  • setup 语法糖中使用顶层 await, 父组件使用 defineAsyncComponent(() => import(...)) 导入的异步组件, vite 会 chunk 分包, 懒加载
  • import(...) 异步导入的路由组件, vite 会 chunk 分包, 懒加载
  1. 默认缓存 <KeepAlive /> 内部的所有组件
  2. include 包含属性: 缓存指定 name 的组件, 支持 string (可以以逗号分隔), RegExp(string | RegExp)[]
  3. exclude 属性: 不缓存指定 name 的组件
  4. max 属性: 最大缓存组件数, 如果实际组件数 > max, 则使用 LRU 算法计算具体缓存哪些组件
<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>

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

// 这两个生命周期钩子不仅适用于 <KeepAlive /> 缓存的根组件, 也适用于缓存树的后代组件
onActivated(() => {
// 调用时机为组件挂载时, 和每次读缓存后插入到 DOM 中
});
onDeactivated(() => {
// 调用时机为组件卸载时, 和每次从 DOM 中移除后写缓存
});
  • <Transition /> 只允许一个直接子元素; 同时, <Transition /> 包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画
  • <Transition /> 会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载), 显示/隐藏 (v-show) 时应用过渡动画
  • <TransitionGroup /> 允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入, 删除, 移动时应用过渡动画

对比 CSS 过渡 transition 和动画 animation

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

前置要求: 安装 tailwindcssanimate.css

<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 h-0 w-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 h-50 w-50 rotate-360;
}
</style>
事件名对应的 CSS 类名
beforeEnterv-enter-from
enterv-enter-active
afterEnterv-enter-to
enterCancelled
beforeLeavev-leave-from
leavev-leave-active
afterLeavev-leave-to
leaveCancelled

示例

<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>
<!-- 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 只在首次渲染时应用 1 次过渡动画

<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 h-0 w-0;
}
.my-appear-active {
@apply transition-all duration-1500;
}
.my-appear-to {
@apply h-50 w-50;
}
</style>
  • <Transition /> 只允许一个直接子元素; 同时, <Transition /> 包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画
  • <Transition /> 会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载), 显示/隐藏 (v-show) 时应用过渡动画
  • <TransitionGroup /> 允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入, 删除, 移动时应用过渡动画

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

Section titled “<TransitionGroup /> 列表的插入, 删除过渡”
<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="flex flex-wrap gap-1 border-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>
<!-- 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 w-[378px] flex-wrap" tag="div">
<!-- v-for 绑定 key 时, 不能使用数组下标, 否则无法应用过渡动画 -->
<div
class="flex h-10 w-10 items-center justify-center border-1 border-slate-300"
v-for="item of arr"
:key="item.key"
>
{{ item.val }}
</div>
</TransitionGroup>
</div>
</template>
<style lang="css" scoped>
.mv {
transition: all 1s;
}
</style>

示例

<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>
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;

安装依赖

Terminal window
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
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-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
<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>

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

自定义指令的钩子函数

  • created
  • beforeMount/mounted
  • beforeUpdate/updated
  • beforeUnmount/unmounted
<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>
<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 实现可拖拽窗口

Section titled “自定义指令 v-drag 实现可拖拽窗口”
<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 cursor-pointer bg-lime-100" />
<div class="h-50 w-50 bg-lime-200" />
</div>
</template>

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

Section titled “自定义指令 v-lazy 实现图片懒加载”
<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>
<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 综合示例

Section titled “自定义指令 + 自定义 hook 综合示例”
  • InterSectionObserver 监听目标元素与祖先元素或视口 viewport 的相交情况
  • MutationObserver 监听整个 DOM 树的改变
  • ResizeObserver 监听元素宽高的改变
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");
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 插件可以是一个有 install 方法的对象
  • 也可以直接是一个安装函数
<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="h-50 w-50 bg-lime-100" />
</Transition>
</template>
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");

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

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

示例

<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="h-30 w-50 overflow-auto border-1">
<div class="truncate border-b-1" 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>
  1. 通过 PostCSS, 为 DOM 添加唯一的 data-v-[hash:base64:8] 属性
  2. CSS 使用 .selector[data-v-[hash:base64:8]] 属性选择器, 以实现样式隔离
<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 { // [!code --] */
:deep(.child-bg) {
width: 5rem;
height: 5rem;
background: lightblue;
}
</style>

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

Section titled “:slotted 插槽选择器, :global 全局选择器”
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<ChildDemo>
<div class="parent-bg">插入到子组件的匿名插槽 default</div>
</ChildDemo>
</template>
  1. 全局选择器: 使用 :global 的选择器, 不会被 vite 编译
  2. <style lang="css"> 中的选择器, 是全局选择器
  3. <style lang="css" scoped> 中, 并使用 :global 的选择器, 也是全局选择器
<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 h-20 w-20 border-1">v-bind: Dynamic CSS</div>
</template>
<style scoped lang="css">
.box {
background: v-bind(bg);
color: v-bind("text.color");
}
</style>
<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 适配: 设置 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 伪类选择器的优先级更高
<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="my-div w-[100px] bg-lime-200">left</div>
<div class="my-div flex-1 bg-blue-300">
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="my-div w-[100px] bg-lime-200">right</div>
</header>
</template>
<style scoped lang="css">
@reference "tailwindcss";
.my-div {
@apply h-[100px] text-center leading-[100px] text-slate-500;
font-size: var(--font-size);
}
</style>
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()],
},
},
});
<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>
  • defineProps
  • defineEmits
  • defineOptions
  • defineSlots
<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>

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

Terminal window
VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] development'

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

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()] });
};

优点: CSS, 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);

Vue 中使用 Web Component 自定义元素

Section titled “Vue 中使用 Web Component 自定义元素”
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-"),
},
},
}),
],
});

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

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

<!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>
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/, ""),
},
},
},
});
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();
}