Vue3
MVVM 架构
MVVM, Model-View-ViewModel
- View 视图层, Vanilla DOM
- ViewModel 视图模型层: Vue
- Model 模型层: Vanilla JavaScript
使用 vscode 调试
// 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 代理对象
优点
- 不需要数据备份
- 可以监听数组的索引和 length 属性
- 可以监听新增属性, 删除属性操作
虚拟 DOM 性能优化
- Vue2 中, 每次使用 diff 算法更新虚拟 DOM 时, 都是全量对比
- Vue3 中, 每次使用 diff 算法更新虚拟 DOM 时, 只对比有 patch 标记的节点
Vue Fragments
Vue 允许组件有多个根节点, 支持 JSX, TSX
<template>
<div>root1</div>
<div>root2</div>
</template>render() {
return (
<>
<div>root1</div>
<div>root2</div>
</>
)
}创建 Vue 项目
Vue 脚手架
pnpm create vue@latest
pnpm dlx create-vue@latest
pnpm create vite@latest
pnpm dlx create-vite@latestVue 项目结构
- 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 函数
<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 语法糖
<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 性能优化, 缓存
<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].stop | ev.stopPropagation(); .stop 指令: 阻止事件冒泡 |
v-on:[evType].prevent | ev.preventDefault(); .prevent 指令: 阻止事件的默认行为 |
v-on:[evType].capture | elem.addEventListener(evType, listener, true /* useCapture */).capture 指令: 事件在捕获阶段触发, 而不是在默认的冒泡阶段触发 |
v-on:[evType].self | .self 指令: 只触发本元素绑定的事件, 不触发从子元素冒泡的事件 |
v-on:[evType].once | elem.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-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>虚拟 DOM 和 diff 算法
vnode: Virtual DOM Node
真实 DOM 的属性过多, 操作真实 DOM 浪费性能, 虚拟 DOM 是一个 JS 对象
diff 算法
- 前序对比: 从头到尾对比 vnode 类型和 key, 相同则复用, 不同则转到 2
- 后序对比: 从尾到头对比 vnode 类型和 key, 相同则复用, 不同则转到 3
- 如果旧节点全部 patch, 有多余的新节点, 则新增 (挂载)
- 如果新节点全部 patch, 有多余的旧节点, 则删除 (卸载)
- 特殊情况: 乱序 (基于最长递增子序列 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
家族成员: ref, shallowRef, isRef, triggerRef, customRef
- ref 深层响应式, 底层会调用 triggerRef 强制收集依赖, 触发深层响应式
- shallowRef 浅层响应式, 只响应
.value的改变 - isRef 判断是否为使用 ref, shallowRef 创建的响应式对象
isRef(refObj) === true,isRef(shallowRefObj) === trueisRef(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>customRef
<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 元素
<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
- React 的 useState 可以接收任意的数据类型
- reactive 只能接收引用数据类型
- React 的 useState 返回一个普通对象 (状态) 和 set 函数, 调用 set 函数更新状态时, 必须修改该普通对象 (状态) 的引用的指向
- reactive 返回一个代理对象, 不能修改该代理对象的引用的指向, 否则会失去响应式!
ref 对比 reactive
const refObj = ref(1);
const refObj2 = ref({ name: "lark", age: 1 });
const reactiveObj = reactive({ name: "lark", age: 1 });- ref 可以接收任意的数据类型; reactive 只能接收引用数据类型
- ref 存取时需要加
.value; reactive 不需要 - ref 更适合简单数据结构; reactive 更适合复杂数据结构
- reactiveObj 是一个 Proxy 对象
- ref 接收基本数据类型时, refObj.value 是一个基本数据类型的值
- ref 接收引用数据类型时, refObj2.value 是一个 Proxy 对象
- 可以直接对 refObj.value 赋值; 不能直接对 reactiveObj 赋值, 否则会失去响应式!
readonly
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"][!important] ref/reactive 深层响应式 使用的 ref/reactive 创建的响应式对象更新时, 会更新整个 template, 类似 React 重新执行整个组件函数
toRef, toRefs, toRaw
- 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 计算属性
- 计算属性
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
);watch 侦听器
数据源
- 一个 ref/reactive 变量
- 一个 getter 函数
() => refObj.value[.prop]或() => reactiveObj.prop - 1,2 的数组
watch
Demo 1
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 },
);watchEffect
不需要指定依赖项, 自动侦听 (自动收集 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
- setup 创建阶段
- onBeforeMount 挂载前, 获取不到 DOM
- onMounted 挂载后, 可以获取到 DOM
- onRenderTriggered 触发更新后, 回调函数接收一个事件对象, 可以同时获取到 newValue 和 oldValue, 调试用 hook, 不属于组件生命周期
- onBeforeUpdate 更新前, 获取的是 oldValue
- onRenderTracked 收集依赖后, 回调函数接收一个事件对象, 只能获取到 newValue, 调试用 hook, 不属于组件生命周期
- onUpdated 更新后, 获取的是 newValue
- onBeforeUnmount 卸载前, 可以获取到 DOM
- onUnmounted 卸载后, 获取不到 DOM
父子组件通信
子组件中使用宏函数 defineProps 定义自定义属性
[!caution] 宏函数
- 宏函数只能在 setup 代码块中使用
- 宏函数不需要显式导入
- 宏函数 defineProps 编译 (Vue -> JS) 时执行, 编译为组件的 props
父传子, defineProps 对比 useAttrs
父组件
<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>子组件
写法 1
<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>Grandparent 传 Child
GrandparentDemo.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>子传父
- 子组件使用 defineEmits 定义自定义事件
- 子组件派发自定义事件, emit 发射参数给父组件
- 父组件为子组件的自定义事件绑定回调函数, 监听子组件派发的自定义事件; 自定义事件派发时, 父组件接收子组件发射的参数, 作为回调函数的参数
子组件
写法 1
<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 暴露接口, 包括属性和方法
ChildDemo.vue
<script lang="ts" setup>
defineExpose({
name: "lark",
getAge() {
return 23;
},
});
</script>
<template>ChildDemo</template>兄弟组件通信
方式 1: 通过父组件转发 (forward)
BoyDemo.vue -> ParentDemo.vue (forward) -> GirlDemo.vue
BoyDemo.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>方式 2: 事件总线 (发布/订阅)
BoyDemo 发布, GirlDemo 订阅, 无需父组件参与
bus.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, onceCb);
};
this.sub(evName, onceCb);
}
}
export default new Bus();mitt 发布/订阅库
<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 注入到后代, 实现祖孙通信
GrandparentDemo.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>局部组件, 全局组件, 递归组件
局部组件, 全局组件
- 默认是局部组件
- 可以在 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 /> 挂载点, 并可以动态切换
<component :is="componentShallowRef | componentName" />
不要创建组件的 ref 对象, 避免不必要的性能开销, 可以使用 shallowRef 代替 ref, 也可以使用 markRaw 跳过代理
写法 1 (推荐)
<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>插槽 <slot />
插槽: 子组件提供给父组件的占位符, 可以插入父组件的 template
- 匿名插槽 name=“default”
- 具名插槽
- 作用域插槽
- 动态插槽
ChildDemo.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>传送模板 <Teleport />
<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>异步组件 <Suspense />
- setup 语法糖中使用顶层 await, 会被编译为
async setup() - 父组件使用
defineAsyncComponent(() => import(...))导入异步组件 <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] 请求结束, 数据传输成功或失败
public/data.json
{
"data": {
"name": "lark",
"age": 23,
"url": "https://161043261.github.io/homepage/",
"desc": "homepage"
}
}- setup 语法糖中使用顶层 await, 父组件使用
defineAsyncComponent(() => import(...))导入的异步组件, vite 会 chunk 分包, 懒加载 import(...)异步导入的路由组件, vite 会 chunk 分包, 懒加载
缓存组件 <KeepAlive />
- 默认缓存
<KeepAlive />内部的所有组件 - include 包含属性: 缓存指定 name 的组件, 支持
string(可以以逗号分隔),RegExp或(string | RegExp)[] - exclude 属性: 不缓存指定 name 的组件
- max 属性: 最大缓存组件数, 如果实际组件数 > max, 则使用 LRU 算法计算具体缓存哪些组件
ParentDemo.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>缓存组件的生命周期
使用 <KeepAlive /> 缓存组件时, 会增加两个生命周期 onActivated 和 onDeactivated
// 这两个生命周期钩子不仅适用于 <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 定义多个关键帧 |
| 自动循环播放 | 不支持 | 支持 |
前置要求: 安装 tailwindcss 和 animate.css
Demo 1
<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><Transition /> 的钩子函数
| 事件名 | 对应的 CSS 类名 |
|---|---|
| beforeEnter | v-enter-from |
| enter | v-enter-active |
| afterEnter | v-enter-to |
| enterCancelled | |
| beforeLeave | v-leave-from |
| leave | v-leave-active |
| afterLeave | v-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><Transition /> + GSAP
<!-- 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 次过渡动画
<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><TransitionGroup />
<Transition />只允许一个直接子元素; 同时,<Transition />包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画<Transition />会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载), 显示/隐藏 (v-show) 时应用过渡动画<TransitionGroup />允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入, 删除, 移动时应用过渡动画
<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><TransitionGroup /> 列表的移动过渡
<!-- 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>状态过渡 + GASP
示例
<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
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
安装依赖
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 -Dimport 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
ParentDemo.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>自定义指令
自定义指令名: 以 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>自定义指令 v-auth 实现按钮鉴权
<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 实现可拖拽窗口
<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 实现图片懒加载
<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
<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 监听元素宽高的改变
main.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");全局变量 app.config.globalProperties
main.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 插件综合示例
- Vue 插件可以是一个有 install 方法的对象
- 也可以直接是一个安装函数
ToastDemo.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="h-50 w-50 bg-lime-100" />
</Transition>
</template>app.use() 源码
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
示例
<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>scoped 样式隔离, :deep() 样式穿透
scoped 样式隔离
- 通过 PostCSS, 为 DOM 添加唯一的
data-v-[hash:base64:8]属性 - CSS 使用
.selector[data-v-[hash:base64:8]]属性选择器, 以实现样式隔离
:deep() 样式穿透
ParentDemo.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 { // [!code --] */
:deep(.child-bg) {
width: 5rem;
height: 5rem;
background: lightblue;
}
</style>:slotted 插槽选择器, :global 全局选择器
:slotted() 插槽选择器
ParentDemo.vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<ChildDemo>
<div class="parent-bg">插入到子组件的匿名插槽 default</div>
</ChildDemo>
</template>:global 全局选择器
- 全局选择器: 使用
:global的选择器, 不会被 vite 编译 <style lang="css">中的选择器, 是全局选择器<style lang="css" scoped>中, 并使用:global的选择器, 也是全局选择器
v-bind 动态 CSS
<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>CSS 模块化
<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 适配
<!-- 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>编写 postcss 插件
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 函数式编程
<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
ParentDemo.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>环境变量
在项目根目录下创建环境变量文件 .env.development, .env.production, 修改 package.json
.env.development
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()] });
};自定义元素
原生 Web Component 自定义元素
优点: CSS, JS 隔离
btn.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 自定义元素
vite.config.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-"),
},
},
}),
],
});Proxy 跨域
同源: 主机 (域名), 端口, 协议都相同
JSONP
原理: HTML 文件的 <script> 标签没有跨域限制
frontend (localhost:5500)
<!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>Vite 代理
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();
}