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"]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
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 },
);const reactiveObj = reactive(
/* deep = 0 */ {
// deep = 1
foo: {
// deep = 2
bar: {
// deep = 3
type: "reactive",
},
},
},
);
// reactive 创建的响应式对象, 默认深层侦听 deep: true
watch(
reactiveObj,
(newVal, oldVal) => {
console.log("[watch2] newVal", newVal);
console.log("[watch2] oldVal", oldVal);
},
{ deep: 3 },
);const name = ref("lark");
// 返回停止侦听的函数
// 调用 watchHandle() 或 watchHandle.stop() 停止侦听
const watchHandle = watch(
[refObj, reactiveObj, name],
(newVal, oldVal, onCleanup) => {
console.log("[watch3] newVal:", newVal);
console.log("[watch3] oldVal:", oldVal);
} /** watchCallback */,
{
// deep: true, // 默认 false, 深层侦听
immediate: false,
// 是否立即执行 watchCallback
// 默认 false, 即默认懒执行 watchCallback
flush: "pre", // "pre" | "post" | "sync", 默认 pre
// pre: 组件挂载, 更新前调用 watchCallback
// post: 组件挂载, 更新后调用 watchCallback
// sync: 同步调用 watchCallback
once: false, // 一次性侦听, watchCallback 只调用一次
} /** options */,
);
// 可以传递一个 getter, 侦听响应式对象中指定的属性
watch(
[() => refObj.value.foo.bar.type, () => reactiveObj.foo.bar.type],
(newVal, oldVal) => {
console.log("[watch4] newVal:", newVal);
console.log("[watch4] oldVal:", oldVal);
},
);watchEffect
不需要指定依赖项, 自动侦听 (自动收集 watchEffectCallback 中的响应式依赖), 默认立即执行 watchEffectCallback
// 返回停止侦听的函数
// 调用 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 定义自定义属性
宏函数
- 宏函数只能在 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>子组件
<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>import { toRefs, useAttrs } from 'vue'
const props = defineProps({
str: {
type: String,
default: 'str_default',
},
refStr: {
type: String,
default: 'refStr_default',
},
reactiveArr: {
type: Array<number>, // Array
default: () => [5, 2, 8], // 引用类型必须转换为箭头函数
},
})
const { str, refStr, reactiveArr } = toRefs(props)
console.log('[ChildDemo] props:', str, refStr, reactiveArr)const props = defineProps<{
str?: string;
refStr?: string;
reactiveArr?: number[];
}>();
console.log("[ChildDemo] props:", props.str, props.refStr, props.reactiveArr);const props = withDefaults(
defineProps<{
str?: string;
refStr?: string;
reactiveArr?: number[];
}>(),
{
str: "str_default",
refStr: "refStr_default",
reactiveArr: () => [5, 2, 8], // 引用类型必须转换为箭头函数
},
);
console.log("[ChildDemo] props:", props.str, props.refStr, props.reactiveArr);Grandparent 传 Child
<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><script lang="ts" setup>
import { useAttrs } from "vue";
import ChildDemo from "./ChildDemo.vue";
const props = defineProps(["a", "b", "addA"]);
// {a: 1, b: Proxy(Object), addA: ƒ}
console.log("[ParentDemo] props:", props);
const attrs = useAttrs();
// {p1: 'v1', p2: 'v2'}
console.log("[ParentDemo] attrs:", attrs);
</script>
<template>
<div>
<div>[ParentDemo] a={{ a }} b={{ b }} attrs={{ attrs }}</div>
<ChildDemo :a="a" :b="b" :addA="addA" :="attrs" />
</div>
</template><script lang="ts" setup>
import { useAttrs } from "vue";
const props = defineProps(["p1", "p2"]);
// {p1: 'v1', p2: 'v2'}
console.log("[ChildDemo] props:", props);
const attrs = useAttrs();
// {a: 1, b: Proxy(Object)}
console.log("[ChildDemo] attrs:", attrs);
</script>
<template>
<div>
<p>[ChildDemo] p1={{ p1 }} p2={{ p2 }} attrs={{ attrs }}</p>
<button @click="(attrs.addA as Function)(1)">Add grandparent's a</button>
</div>
</template>子传父
- 子组件使用 defineEmits 定义自定义事件
- 子组件派发自定义事件, emit 发射参数给父组件
- 父组件为子组件的自定义事件绑定回调函数, 监听子组件派发的自定义事件; 自定义事件派发时, 父组件接收子组件发射的参数, 作为回调函数的参数
子组件
<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>// 子组件使用 defineEmits 定义自定义事件
// 自定义事件名 evName, evName2
const emit = defineEmits<{
(e: 'evName', arg: Event): void,
(e: 'evName2', arg: string, arg2: string): void
}>()
const emitToParent = (ev: Event) => {
// 子组件派发自定义事件, emit 发射参数给父组件
emit('evName', ev)
}
const emitToParent2 = () => {
emit('evName2', 'foo', 'bar')
}// 子组件使用 defineEmits 定义自定义事件
// 自定义事件名 evName, evName2
const emit = defineEmits<{
evName: [arg: Event], // 具名元组
evName2: [arg: string, arg2: string] // 具名元组
}>()
const emitToParent = (ev: Event) => {
// 子组件派发自定义事件, emit 发射参数给父组件
emit('evName', ev)
}
const emitToParent2 = () => {
emit('evName2', 'foo', 'bar')
}父组件
<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><script lang="ts" setup>
import { onMounted, ref } from "vue";
import ChildDemo from "./ChildDemo.vue";
const sameName = ref<InstanceType<typeof ChildDemo>>();
onMounted(() => {
console.log(
"[ParentDemo] Child expose:",
sameName.value?.name,
sameName.value?.getAge(),
);
});
</script>
<template>
<ChildDemo ref="sameName" />
</template>兄弟组件通信
方式 1: 通过父组件转发 (forward)
BoyDemo.vue -> ParentDemo.vue (forward) -> GirlDemo.vue
<!-- 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><!-- 父组件为子组件的自定义事件绑定回调函数
自定义事件发生时, 父组件接收子组件发射的参数, 作为回调函数的参数 -->
<script lang="ts" setup>
import { ref } from "vue";
import BoyDemo from "./BoyDemo.vue";
import GirlDemo from "./GirlDemo.vue";
const flag = ref<boolean>(false);
const timestamp = ref<string>("");
const receiveArgs = (flag_: boolean, timestamp_: string) => {
flag.value = flag_;
timestamp.value = timestamp_;
};
</script>
<template>
<div>
<BoyDemo
@custom-event="
(flag: boolean, timestamp: string) => receiveArgs(flag, timestamp)
"
/>
<!-- 转发: 将父组件接收的 BoyDemo 子组件发射的参数, 传递给 GirlDemo 子组件 -->
<GirlDemo :flag="flag" :timestamp="timestamp" />
</div>
</template><script lang="ts" setup>
defineProps<{
flag: boolean;
timestamp: string;
}>();
</script>
<template>
<div>[GirlDemo] flag: {{ flag }}</div>
<div>[GirlDemo] timestamp: {{ timestamp }}</div>
</template>方式 2: 事件总线 (发布/订阅)
BoyDemo 发布, GirlDemo 订阅, 无需父组件参与
// type TCallback = (...args: any[]) => void | Promise<void>
interface ICallback {
(...args: any[]): void | Promise<void>;
}
class Bus {
evName2cbs: Map<string, Set<ICallback>> = new Map();
// 发布
pub(evName: string, ...args: unknown[]) {
const cbs = this.evName2cbs.get(evName);
if (!cbs) {
return;
}
for (const cb of cbs) {
cb.apply(this, args);
}
}
// 订阅
sub(evName: string, cb: ICallback) {
const cbs = this.evName2cbs.get(evName);
if (!cbs) {
this.evName2cbs.set(evName, new Set([cb]));
return;
}
cbs.add(cb);
}
// 取消订阅
off(evName: string, cb: ICallback) {
const cbs = this.evName2cbs.get(evName);
if (!cbs) {
return;
}
cbs.delete(cb);
if (cbs.size === 0) {
this.evName2cbs.delete(evName);
}
}
// 订阅一次
once(evName: string, cb: ICallback) {
const onceCb = (...args: Parameters<typeof cb>) => {
cb.apply(this, args);
this.off(evName, cb);
};
this.sub(evName, onceCb);
}
}
export default new Bus();<script lang="ts" setup>
import bus from "./bus";
let flag = false;
const emitArgs = () => {
flag = !flag;
// 发布
bus.pub("customEvent", flag, new Date().toLocaleTimeString()); // 发布
};
</script>
<template>
<button @click="emitArgs">emitArgs</button>
</template><script lang="ts" setup>
import { ref } from "vue";
import bus from "./bus";
const flag = ref(false);
const timestamp = ref("");
// 订阅
bus.sub("customEvent", (flag_: boolean, timestamp_: string) => {
flag.value = flag_;
timestamp.value = timestamp_;
});
</script>
<template>
<div>[GirlDemo] flag: {{ flag }}</div>
<div>[GirlDemo] timestamp: {{ timestamp }}</div>
</template>mitt 发布/订阅库
<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 注入到后代, 实现祖孙通信
<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><script lang="ts" setup>
import { inject, ref, type Ref } from "vue";
import ChildDemo from "./ChildDemo.vue";
// 并 inject 注入到后代
const injectedColor = inject<Ref<string>>(
"colorKey",
ref("unknown-color") /** defaultVal */,
);
</script>
<template>
<div>[Parent] injectedColor {{ injectedColor }}</div>
<button @click="injectedColor = 'lightgreen'">lightgreen</button>
<ChildDemo />
</template><script lang="ts" setup>
import { inject, type Ref, ref } from "vue";
// 并 inject 注入到后代
const injectedColor = inject<Ref<string>>(
"colorKey",
ref("unknown-color") /** defaultVal */,
);
</script>
<template>
<div>[Child] injectedColor {{ injectedColor }}</div>
<button @click="injectedColor = 'lightblue'">lightblue</button>
</template>局部组件, 全局组件, 递归组件
局部组件, 全局组件
- 默认是局部组件
- 可以在 main.ts 中注册全局组件
// 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 跳过代理
<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><script lang="ts">
import DynamicA from "./DynamicA.vue";
import DynamicB from "./DynamicB.vue";
import DynamicC from "./DynamicC.vue";
export default {
// 注册子组件
components: {
compA: DynamicA, // 注册的组件名 compA
compB: DynamicB, // 注册的组件名 compB
compC: DynamicC, // 注册的组件名 compC
},
};
</script>
<script lang="ts" setup>
import { reactive, ref } from "vue";
const activeComp = ref<string>("compA");
const setComp = (comp: string) => (activeComp.value = comp);
const options = reactive([
{ name: "compA_", handler: () => setComp("compA") },
{ name: "compB_", handler: () => setComp("compB") },
{ name: "compC_", handler: () => setComp("compC") },
]);
</script>
<template>
<div v-for="{ name, handler } of options" :key="name">
<div @click="handler">{{ name }}</div>
</div>
<!-- is 可以是组件的 shallowRef, 也可以是注册的组件名 componentName-->
<component :is="activeComp" />
</template><script lang="ts" setup>
import { reactive, ref } from "vue";
import DynamicA from "./DynamicA.vue";
import DynamicB from "./DynamicB.vue";
import DynamicC from "./DynamicC.vue";
defineOptions({
// 注册子组件
components: {
compA: DynamicA, // 注册的组件名 compA
compB: DynamicB, // 注册的组件名 compB
compC: DynamicC, // 注册的组件名 compC
},
});
const activeComp = ref<string>("compA");
const setComp = (comp: string) => (activeComp.value = comp);
const options = reactive([
{ name: "compA_", handler: () => setComp("compA") },
{ name: "compB_", handler: () => setComp("compB") },
{ name: "compC_", handler: () => setComp("compC") },
]);
</script>
<template>
<div v-for="{ name, handler } of options" :key="name">
<div @click="handler">{{ name }}</div>
</div>
<!-- is 可以是组件的 shallowRef, 也可以是注册的组件名 componentName-->
<component :is="activeComp" />
</template>插槽 <slot />
插槽: 子组件提供给父组件的占位符, 可以插入父组件的 template
- 匿名插槽 name="default"
- 具名插槽
- 作用域插槽
- 动态插槽
<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><script lang="ts" setup>
import ChildDemo from './ChildDemo.vue'
</script>
<template>
<div>
<!-- 子组件 -->
<ChildDemo>
<!-- <div>默认插入到子组件的匿名插槽</div> -->
<template v-slot:default>
<div>插入到子组件的匿名插槽 default</div>
</template>
<template v-slot:scoped="{ item, idx }">
<div>插入到子组件的作用域插槽 scoped</div>
<div>{{ `idx: ${idx}, name: ${item.name}, age: ${item.age}` }}</div>
</template>
<template #named>
<div>插入到子组件的具名插槽 named, v-slot: 可以简写为 #</div>
</template>
</ChildDemo>
</div>
</template><script lang="ts" setup>
import { ref } from "vue";
import ChildDemo from "./ChildDemo.vue";
const slotName = ref("default");
</script>
<template>
<div>
<ChildDemo>
<!-- 动态插槽, 等价于 #[slotName] -->
<template v-slot:[slotName]="{ item, idx }">
<div>动态插槽</div>
<div v-if="item">
{{ `idx: ${idx}, name: ${item.name}, age: ${item.age}` }}
</div>
</template>
</ChildDemo>
<button @click="slotName = 'default'">default</button>
<button @click="slotName = 'scoped'">scoped</button>
<button @click="slotName = 'named'">named</button>
</div>
</template>传送模板 <Teleport />
<Teleport /> 将部分 template 传送到指定 DOM 节点上, 成为该 DOM 节点的直接子元素
<script lang="ts" setup>
import { ref } from "vue";
const popupVisible = ref(false);
</script>
<template>
<button @click="popupVisible = true">显示弹窗</button>
<!-- .popup 是 #app 的直接子元素 -->
<Teleport to="#app" :disabled="false">
<!-- disable 是否禁用 <Teleport /> -->
<div class="w-20 h-20 bg-lime-200" v-show="popupVisible">
我是 #app 的直接子元素
<button @click="popupVisible = false">隐藏弹窗</button>
</div>
<div>我也是 #app 的直接子元素</div>
</Teleport>
</template>异步组件 <Suspense />
- 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] 请求结束, 数据传输成功或失败
{
"data": {
"name": "lark",
"age": 23,
"url": "https://161043261.github.io/homepage/",
"desc": "homepage"
}
}// 原生 AJAX
export const myAxios = {
get<T>(url: string): Promise<T> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.onreadystatechange = () => {
// xhr.readyState === 4 [done] 请求结束, 数据传输成功或失败
if (xhr.readyState === 4 && xhr.status === 200) {
setTimeout(() => {
resolve(JSON.parse(xhr.responseText));
}, 3000);
}
};
xhr.send(null);
});
},
};<script lang="ts" setup>
import { myAxios } from "@/utils/axios.ts";
// setup 语法糖中使用顶层 await, 会被编译为 async setup()
const { data } = await myAxios.get<{ data: unknown }>("/data.json");
</script>
<template>
<div>ChildAsync</div>
<div>data: {{ JSON.stringify(data) }}</div>
</template><template>
<div>ChildSkeleton</div>
<div>请等待...</div>
</template><script lang="ts" setup>
import { defineAsyncComponent } from "vue";
import ChildSkeleton from "./ChildSkeleton.vue";
// 父组件使用 defineAsyncComponent(() => import(...)) 导入异步组件
const ChildAsync = defineAsyncComponent(
() => import("@/components/ChildAsync.vue"),
);
</script>
<template>
<Suspense>
<template #default>
<ChildAsync />
</template>
<template v-slot:fallback>
<ChildSkeleton />
</template>
</Suspense>
</template>- setup 语法糖中使用顶层 await, 父组件使用
defineAsyncComponent(() => import(...))导入的异步组件, vite 会 chunk 分包, 懒加载 import(...)异步导入的路由组件, vite 会 chunk 分包, 懒加载
使用 TSX
- 支持 v-show, v-model
- 特有的 v-slots
import { defineComponent, toRefs, type Ref, type RenderFunction } from "vue";
interface IProps {
name: Ref<string>;
age: Ref<number>;
}
const ChildDemo: Component<IProps> = defineComponent({
name: "ChildDemo", // componentName
// props: ['name', 'age'], // propNames (defineProps)
props: {
name: String,
age: {
type: Number,
required: true,
},
},
emits: [] as string[], // eventNames (defineEmits)
expose: [] as string[], // exposedKeys (defineExpose)
setup(props: IProps, { attrs, slots, emit, expose } /** ctx */) {
console.log("[ChildDemo] props:", props);
console.log(
"[ChildDemo] ctx:",
attrs,
slots,
emit.toString(),
expose.toString(),
);
const { name, age } = toRefs(props);
return (() => (
<>
<div>Child name: {name.value}</div>
<div>Child age: {age.value}</div>
<button onClick={() => (name.value += "_Child")}>Child setName</button>
<button onClick={() => (age.value += 1)}>Child setAge</button>
{slots.default ? slots.default() : <>placeholder: 匿名插槽</>}
</>
)) as RenderFunction;
},
});
export default ChildDemo;import { ref, defineComponent } from "vue";
import ChildDemo from "./ChildDemo";
export default defineComponent(
// setup
() => {
const name = ref("lark");
const age = ref(23);
const slots = { default: () => <>插槽写法 1</> };
const slots2 = { default: () => <>插槽写法 2</> };
// return a renderFunc
return () => (
<>
<div>Parent name: {name.value}</div>
<div>Parent age: {age.value}</div>
<button onClick={() => (name.value += "_Parent")}>
Parent setName
</button>
<button onClick={() => (age.value += 1)}>Parent setAge</button>
<ChildDemo name={name} age={age}>
{slots}
</ChildDemo>
<ChildDemo name={name} age={age} v-slots={slots2} />
</>
);
},
{
name: "ParentDemo", // componentName
props: [] as string[], // propNames (defineProps)
emits: [] as string[], // eventNames (defineEmits)
},
);缓存组件 <KeepAlive />
- 默认缓存
<KeepAlive />内部的所有组件 - include 包含属性: 缓存指定 name 的组件, 支持
string(可以以逗号分隔),RegExp或(string | RegExp)[] - exclude 属性: 不缓存指定 name 的组件
- 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><script lang="ts" setup>
import { ref } from "vue";
const name = ref("");
const age = ref(0);
</script>
<template>
<div>Boy</div>
<input v-model="name" type="text" />
<input v-model="age" type="number" />
</template><script lang="ts" setup>
import { ref } from "vue";
const name = ref("");
const age = ref(0);
</script>
<template>
<div>Girl</div>
<input v-model="name" type="text" />
<input v-model="age" type="number" />
</template>缓存组件的生命周期
使用 <KeepAlive /> 缓存组件时, 会增加两个生命周期 onActivated 和 onDeactivated
// 这两个生命周期钩子不仅适用于 <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
<script lang="ts" setup>
import { ref } from "vue";
const flag = ref<boolean>(true);
</script>
<template>
<button @click="flag = !flag">mount/unMount</button>
<!-- 默认 name="v" -->
<Transition name="my-prefix">
<div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
</Transition>
</template>
<style lang="css" scoped>
@reference "tailwindcss";
/** 默认 .v-enter-from, .v-leave-to */
.my-prefix-enter-from,
.my-prefix-leave-to {
@apply w-0 h-0;
}
/** 默认 v-enter-active, .v-leave-active */
.my-prefix-enter-active,
.my-prefix-leave-active {
@apply transition-all duration-1500;
}
/** 默认 v-enter-to, .v-leave-from */
.my-prefix-enter-to,
.my-prefix-leave-from {
@apply w-50 h-50 rotate-360;
}
</style><script lang="ts" setup>
import { ref } from "vue";
const flag = ref<boolean>(true);
</script>
<template>
<button @click="flag = !flag">mount/unMount</button>
<!-- 除了 .[my-prefix]-[enter | leave]-[from | active | to] 约定的类名 -->
<!-- 也可以自定义类名 [enter | leave]-[from | active | to]-class="your_custom_className" -->
<!-- :duration="1500" 表示持续时间 1500ms -->
<!-- 或 :duration="{ enter: 1500, leave: 1500 }" -->
<Transition
:duration="{ enter: 1500, leave: 1500 }"
leaveActiveClass="animate__animated animate__fadeOut"
enterActiveClass="animate__animated animate__fadeIn"
>
<div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
</Transition>
</template><Transition /> 的钩子函数
| 事件名 | 对应的 CSS 类名 |
|---|---|
| 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 w-0 h-0;
}
.my-appear-active {
@apply transition-all duration-1500;
}
.my-appear-to {
@apply w-50 h-50;
}
</style><TransitionGroup />
<Transition />只允许一个直接子元素; 同时,<Transition />包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画<Transition />会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载), 显示/隐藏 (v-show) 时应用过渡动画<TransitionGroup />允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入, 删除, 移动时应用过渡动画
<TransitionGroup /> 列表的插入, 删除过渡
<script lang="ts" setup>
import { reactive } from "vue";
import "animate.css";
const list = reactive<number[]>([0, 1, 2]);
</script>
<template>
<button @click="list.push(list.length)">push</button>
<button @click="list.pop()">pop</button>
<!-- tag="htmlTagName" tag 属性为多个列表项包裹一层 htmlTagName 元素 -->
<div class="wrapper">
<TransitionGroup
tag="main"
class="border-1 flex flex-wrap gap-1"
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOut"
>
<div class="item" v-for="(item, idx) of list" :key="idx">{{ item }}</div>
</TransitionGroup>
</div>
</template><TransitionGroup /> 列表的移动过渡
<!-- pnpm i lodash && pnpm i @types/lodash -D -->
<script lang="ts" setup>
import { ref } from "vue";
import { shuffle } from "lodash";
const arr = ref(
Array.from({ length: 81 }, (_, idx) => ({ key: idx, val: (idx % 9) + 1 })),
);
const shuffleList = () => (arr.value = shuffle(arr.value));
</script>
<template>
<div>
<button @click="shuffleList">shuffleList</button>
<!-- move-class: 平移的过渡动画 -->
<TransitionGroup moveClass="mv" class="flex flex-wrap w-[378px]" tag="div">
<!-- v-for 绑定 key 时, 不能使用数组下标, 否则无法应用过渡动画 -->
<div
class="w-10 h-10 border-slate-300 border-1 flex justify-center items-center"
v-for="item of arr"
:key="item.key"
>
{{ item.val }}
</div>
</TransitionGroup>
</div>
</template>
<style lang="css" scoped>
.mv {
transition: all 1s;
}
</style>状态过渡 + GASP
示例
<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
<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><script setup lang="ts">
const props = defineProps<{
textVal: string;
// 约定 xxxModifiers
textValModifiers?: {
myModifier: boolean; // 修饰符存在则为 true
};
}>();
const emit = defineEmits(["update:textVal"]);
const handleInput = (ev: Event) => {
emit("update:textVal", (ev.target as HTMLInputElement).value);
};
</script>
<template>
ChildDemo
<div>Has myModifier: {{ props.textValModifiers?.myModifier ?? false }}</div>
<div>
textVal: <input type="text" :value="textVal" @input="handleInput" />
</div>
</template>自定义指令
自定义指令名: 以 v 开头, vDirectiveName
自定义指令的钩子函数
- created
- beforeMount/mounted
- beforeUpdate/updated
- beforeUnmount/unmounted
<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 bg-lime-100 cursor-pointer" />
<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 监听元素宽高的改变
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");<script lang="ts" setup>
import { useResize } from '@/main'
import { onMounted } from 'vue'
onMounted(() => {
useResize(document.querySelector('#parent') as HTMLElement, (rect) =>
console.log('[useResize] contentRect:', rect),
)
})
</script>
<template>
<textarea
id="parent"
v-resize="(rect: DOMRectReadOnly) => console.log('[v-resize] contentRect:', rect)"
/>
</template>全局变量 app.config.globalProperties
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");<template>
<div>$env: {{ $env }}</div>
<div>
$encoding.jsonMarshal:
{{ $encoding.jsonMarshal({ name: "lark", age: 23 }) }}
</div>
</template>
<script lang="ts" setup>
import { getCurrentInstance } from "vue";
const app = getCurrentInstance();
console.log(app?.proxy?.$env);
console.log(app?.proxy?.$encoding.jsonMarshal({ name: "lark", age: 23 }));
</script>全局变量 + Vue 插件综合示例
- Vue 插件可以是一个有 install 方法的对象
- 也可以直接是一个安装函数
<script setup lang="ts">
import { ref } from "vue";
const visible = ref<boolean>(true);
defineExpose({
visible,
show: () => (visible.value = true),
hide: () => (visible.value = false),
});
</script>
<template>
<Transition
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOut"
>
<div v-if="visible" class="w-50 h-50 bg-lime-100" />
</Transition>
</template>import "animate.css";
import { createApp, createVNode, render } from "vue";
import App from "./App.vue";
import type { Ref, VNode, App as VueApp } from "vue";
import ToastDemo from "./components/ToastDemo.vue";
declare module "vue" {
export interface ComponentCustomProperties {
$toast: {
show: () => void;
hide: () => void;
visible: Ref<boolean>;
};
}
}
// Vue 插件可以是一个有 install 方法的对象
// 也可以直接是一个安装函数
export const vuePluginToast = {
install(app: VueApp) {
const vnode: VNode = createVNode(ToastDemo);
render(vnode, document.body);
app.config.globalProperties.$toast = {
show: vnode.component?.exposed?.show,
hide: vnode.component?.exposed?.hide,
visible: vnode.component?.exposed?.visible,
};
},
};
const app = createApp(App);
app.use(vuePluginToast);
app.mount("#app");<template>
<div class="flex flex-col gap-5">
<button @click="$toast.show">show</button>
<button @click="$toast.hide">hide</button>
<button @click="$toast.visible.value = true">show2</button>
<button @click="$toast.visible.value = false">hide2</button>
</div>
</template>app.use() 源码
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="border-1 h-30 w-50 overflow-auto">
<div class="border-b-1 truncate" v-for="item in itemList" :key="item.id">
{{ item }}
</div>
</div>
<div>
<textarea v-model="inputVal" type="text" class="my-3 border-1" />
<div class="flex gap-5">
<button @click="addItem">addItem</button>
<button @click="addItem2">addItem2</button>
<button @click="addItem3">addItem3</button>
</div>
</div>
</template>scoped 样式隔离, :deep() 样式穿透
scoped 样式隔离
- 通过 PostCSS, 为 DOM 添加唯一的
data-v-[hash:base64:8]属性 - CSS 使用
.selector[data-v-[hash:base64:8]]属性选择器, 以实现样式隔离
:deep() 样式穿透
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<main class="wrap">
<ChildDemo class="child-bg" />
</main>
</template>
<style lang="css" scoped>
.wrap {
width: 10rem;
height: 10rem;
background: lightpink;
}
/* .child-bg { // */
:deep(.child-bg) {
width: 5rem;
height: 5rem;
background: lightblue;
}
</style><template>
<div class="child-bg" />
</template>
<style lang="css" scoped>
.child-bg {
width: 5rem;
height: 5rem;
background: lightgreen;
}
</style><main data-v-[parent-hash:base64:8] class="wrap">
<div data-v-[child-hash:base64:8] class="child-bg"></div>
</main><!-- ChildDemo -->
<style type="text/css">
.child-bg[data-v-<child-hash:base64:8>] {
}
</style>
<!-- ParentDemo -->
<style type="text/css">
.wrap[data-v-<parent-hash:base64:8>] {
}
.child-bg[data-v-<parent-hash:base64:8>] {
}
</style><!-- ChildDemo -->
<style type="text/css">
/* 类选择器, 交集, 属性选择器; 优先级 (0, 0, 2, 0) */
.child-bg[data-v-<child-hash:base64:8>] {
background: lightpink;
}
</style>
<!-- ParentDemo -->
<style type="text/css">
.wrap[data-v-<parent-hash:base64:8>] {
}
/* 属性选择器, 子代, 类选择器; 优先级 (0, 0, 2, 0) */
[data-v-<parent-hash:base64:8>] .child-bg {
background: lightblue;
}
</style>:slotted 插槽选择器, :global 全局选择器
:slotted() 插槽选择器
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<ChildDemo>
<div class="parent-bg">插入到子组件的匿名插槽 default</div>
</ChildDemo>
</template><template>
<!-- 匿名插槽 name="default" -->
<slot />
</template>
<style lang="css" scoped>
/* .parent-bg { // */
:slotted(.parent-bg) {
background: lightpink;
}
</style>: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 w-20 h-20 border-1">v-bind: Dynamic CSS</div>
</template>
<style scoped lang="css">
.box {
background: v-bind(bg);
color: v-bind("text.color");
}
</style>CSS 模块化
<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="w-[100px] bg-lime-200 my-div">left</div>
<div class="flex-1 bg-blue-300 my-div">
center
<button class="mx-[10px]" @click="setGlobalFontSize(36)">大号字体</button>
<button class="mx-[10px]" @click="setGlobalFontSize(24)">中号字体</button>
<button class="mx-[10px]" @click="setGlobalFontSize(12)">小号字体</button>
</div>
<div class="w-[100px] bg-lime-200 my-div">right</div>
</header>
</template>
<style scoped lang="css">
@reference "tailwindcss";
.my-div {
@apply h-[100px] leading-[100px] text-slate-500 text-center;
font-size: var(--font-size);
}
</style>编写 postcss 插件
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
<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><!-- 泛型支持 -->
<script generic="T extends object" setup lang="ts">
import { toRefs, type RenderFunction } from "vue";
const props = defineProps<{ defaultList: T[]; namedList: T[] }>();
const { defaultList, namedList } = toRefs(props);
defineSlots<{
default(props: { item: T }): unknown;
named(props: { item: T }): unknown;
}>();
</script>
<template>
<main>
<ul>
<li v-for="(item, idx) of defaultList" :key="idx">
<!-- 匿名的作用域插槽 -->
<slot :item="item" />
</li>
</ul>
<ul>
<li v-for="(item, idx) of namedList" :key="idx">
<!-- 具名的作用域插槽 -->
<slot :item="item" name="named" />
</li>
</ul>
</main>
</template>环境变量
在项目根目录下创建环境变量文件 .env.development, .env.production, 修改 package.json
VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] development'VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] production'{
"scripts": {
"dev": "vite --mode development",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview"
}
}console.log("import.meta.env:", import.meta.env);
// {
// BASE_URL: '/',
// DEV: true,
// MODE: 'development',
// PROD: false,
// SSR: false
// VITE_CUSTOM_ENV: '[VITE_CUSTOM_ENV] development'
// }console.log("import.meta.env:", import.meta.env);
// {
// BASE_URL: '/',
// DEV: false,
// MODE: 'production',
// PROD: true,
// SSR: false
// VITE_CUSTOM_ENV: '[VITE_CUSTOM_ENV] production'
// }vite.config.ts 是 node 环境, 无法使用 import.meta.env 读取项目根目录下的环境变量文件
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 隔离
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);class Btn2 extends HTMLElement {
constructor() {
super();
const shadowDOM = this.attachShadow({ mode: "open" });
this.template = this.h("template");
this.template.innerHTML = `
<style>
.btn {
width: 100px;
height: 30px;
line-height: 30px;
text-align: center;
border: 1px solid #ccc;
border-radius: 15px;
cursor: pointer;
}
</style>
<div class="btn">d2vue-btn2</div>`;
shadowDOM.appendChild(this.template.content.cloneNode(true));
}
h(el) {
return document.createElement(el);
}
connectedCallback() {
console.log("[d2vue-btn2] Connected");
}
disconnectedCallback() {
console.log("[d2vue-btn2] Disconnect");
}
adoptedCallback() {
console.log("[d2vue-btn2] Adopted");
}
attributeChangedCallback() {
console.log("[d2vue-btn2] Attribute changed");
}
}
window.customElements.define("d2vue-btn2", Btn2);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./btn.js"></script>
<script src="./btn2.js"></script>
</head>
<body>
<d2vue-btn></d2vue-btn>
<d2vue-btn2></d2vue-btn2>
</body>
</html>Vue 中使用 Web Component 自定义元素
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-"),
},
},
}),
],
});<script setup lang="ts">
defineProps<{ item: { name: string; age: number } }>();
</script>
<template>
<!-- 不能使用 tailwindcss -->
<div class="btn">name: {{ item.name }}, age: {{ item.age }}</div>
</template>
<style lang="css" scoped>
.btn {
width: 250px;
height: 50px;
line-height: 50px;
text-align: center;
border: 1px solid #ccc;
border-radius: 25px;
cursor: pointer;
}
</style><script setup lang="ts">
import { defineCustomElement } from "vue";
import D2vueBtn from "@/components/d2vue-btn.ce.vue";
// Vue 中使用 Web Component 自定义元素
const Btn = defineCustomElement(D2vueBtn);
window.customElements.define("d2vue-btn", Btn);
const item = { name: "lark", age: 23 };
</script>
<template>
<d2vue-btn :item="item"></d2vue-btn>
</template>Proxy 跨域
同源: 主机 (域名), 端口, 协议都相同
JSONP
原理: HTML 文件的 <script> 标签没有跨域限制
<!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 http from "node:http";
import urllib from "node:url";
const port = 8080;
const cbParams = { data: "I love you" };
http
.createServer((req, res) => {
const params = urllib.parse(req.url, true);
if (params.query.callback) {
// callback: frontendFn
console.log("callback:", params.query.callback);
// JSONP, JSON with Padding
const jsonWithPadding = `${params.query.callback}(${JSON.stringify(cbParams)})`;
// jsonWithPadding: frontendFn({"data":"I love you"})
console.log("jsonWithPadding:", jsonWithPadding);
res.end(jsonWithPadding);
} else {
res.end();
}
})
.listen(port, () => {
console.log(`http://localhost:${port}`);
});Vite 代理
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();
}