Vue 高级
缓存组件 <KeepAlive />
- 默认缓存
<KeepAlive />
内部的所有组件 - include 包含属性: 缓存指定 name 的组件, 支持
string
(可以以逗号分隔),RegExp
或(string | RegExp)[]
- exclude 属性: 不缓存指定 name 的组件
- max 属性: 最大缓存组件数, 如果实际组件数 > max, 则使用 LRU 算法计算具体缓存哪些组件
vue
<script lang="ts" setup>
import { ref } from "vue";
import BoyDemo from "./BoyDemo.vue";
import GirlDemo from "./GirlDemo.vue";
const flag = ref<boolean>(true);
</script>
<template>
<KeepAlive>
<BoyDemo v-if="flag" />
<GirlDemo v-else />
</KeepAlive>
<button @click="flag = !flag">switch</button>
</template>
vue
<script lang="ts" setup>
import { ref } from "vue";
const name = ref("");
const age = ref(0);
</script>
<template>
<div>Boy</div>
<input v-model="name" type="text" />
<input v-model="age" type="number" />
</template>
vue
<script lang="ts" setup>
import { ref } from "vue";
const name = ref("");
const age = ref(0);
</script>
<template>
<div>Girl</div>
<input v-model="name" type="text" />
<input v-model="age" type="number" />
</template>
缓存组件的生命周期
使用 <KeepAlive />
缓存组件时, 会增加两个生命周期 onActivated 和 onDeactivated
ts
// 这两个生命周期钩子不仅适用于 <KeepAlive /> 缓存的根组件, 也适用于缓存树的后代组件
onActivated(() => {
// 调用时机为组件挂载时, 和每次读缓存后插入到 DOM 中
});
onDeactivated(() => {
// 调用时机为组件卸载时, 和每次从 DOM 中移除后写缓存
});
<Transition />
过渡/动画组件
<Transition />
只允许一个直接子元素; 同时,<Transition />
包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画<Transition />
会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载)、显示/隐藏 (v-show) 时应用过渡动画<TransitionGroup />
允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入、删除、移动时应用过渡动画
[enter | leave]-[from | active | to]
对比 CSS 过渡 transition 和动画 animation
过渡 transition | 动画 animation | |
---|---|---|
触发 | 需要事件触发, 例如 :hover | 可以自动触发, 例如页面加载后自动播放 |
状态 | 只有起始状态和结束状态 | 可以使用 @keyframes 定义多个关键帧 |
自动循环播放 | 不支持 | 支持 |
前置要求: 安装 tailwindcss 和 animate.css
vue
<script lang="ts" setup>
import { ref } from "vue";
const flag = ref<boolean>(true);
</script>
<template>
<button @click="flag = !flag">mount/unMount</button>
<!-- 默认 name="v" -->
<Transition name="my-prefix">
<div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
</Transition>
</template>
<style lang="css" scoped>
@reference "tailwindcss";
/** 默认 .v-enter-from, .v-leave-to */
.my-prefix-enter-from,
.my-prefix-leave-to {
@apply w-0 h-0;
}
/** 默认 v-enter-active, .v-leave-active */
.my-prefix-enter-active,
.my-prefix-leave-active {
@apply transition-all duration-1500;
}
/** 默认 v-enter-to, .v-leave-from */
.my-prefix-enter-to,
.my-prefix-leave-from {
@apply w-50 h-50 rotate-360;
}
</style>
vue
<script lang="ts" setup>
import { ref } from "vue";
const flag = ref<boolean>(true);
</script>
<template>
<button @click="flag = !flag">mount/unMount</button>
<!-- 除了 .[my-prefix]-[enter | leave]-[from | active | to] 约定的类名 -->
<!-- 也可以自定义类名 [enter | leave]-[from | active | to]-class="your_custom_className" -->
<!-- :duration="1500" 表示持续时间 1500ms -->
<!-- 或 :duration="{ enter: 1500, leave: 1500 }" -->
<Transition
:duration="{ enter: 1500, leave: 1500 }"
leaveActiveClass="animate__animated animate__fadeOut"
enterActiveClass="animate__animated animate__fadeIn"
>
<div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
</Transition>
</template>
<Transition />
的钩子函数
事件名 | 对应的 CSS 类名 |
---|---|
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 |
案例
vue
<script lang="ts" setup>
import { ref } from "vue";
const flag = ref(true);
const handleEnterActive = (el: Element, done: () => void) => {
console.log("onEnterActive");
setTimeout(() => done() /** 过渡结束 */, 3000);
};
const handleLeaveActive = (el: Element, done: () => void) => {
console.log("onLeaveActive");
setTimeout(() => done() /** 过渡结束 */, 3000);
};
</script>
<template>
<div>
<button type="button" @click="flag = !flag">switch</button>
<Transition
class="animate__animated"
enterActiveClass="animate__fadeIn"
leaveActiveClass="animate__fadeOut"
:duration="1000"
@beforeEnter="(el: Element) => console.log('onBeforeEnter')"
@enter="handleEnterActive"
@afterEnter="(el: Element) => console.log('onAfterEnter')"
@enterCancelled="(el: Element) => console.log('onEnterCancelled')"
@beforeLeave="(el: Element) => console.log('onBeforeLeave')"
@leave="handleLeaveActive"
@afterLeave="(el: Element) => console.log('onAfterLeave')"
@leaveCancelled="(el: Element) => console.log('onLeaveCancelled')"
>
<div class="box" v-if="flag">Transition by animate.css</div>
</Transition>
<Transition name="my-prefix">
<!-- className prefix -->
<div class="box" v-show="flag" style="background: lightpink">
Transition by custom CSS
</div>
</Transition>
</div>
</template>
<style lang="scss" scoped>
@mixin wh0 {
width: 0;
height: 0;
}
@mixin wh50 {
width: 200px;
height: 200px;
}
.box {
@include wh50;
background: skyblue;
}
.my-prefix-enter-from {
@include wh0;
transform: rotate(360deg);
}
.my-prefix-enter-active {
transition: all 3s ease;
}
// .my-prefix-enter-to {}
// .my-prefix-leave-from {}
.my-prefix-leave-active {
transition: all 3s ease;
}
.my-prefix-leave-to {
@include wh0;
transform: rotate(360deg);
}
</style>
<Transition />
+ GSAP
vue
<!-- pnpm add gsap -->
<script lang="ts" setup>
import gsap from "gsap";
import { ref } from "vue";
const isAlive = ref(true);
const handleBeforeEnter = (el: Element) =>
gsap.set(el, { width: 0, height: 0 });
const handleEnter = (el: Element, done: () => void) =>
gsap.to(el, { width: 200, height: 200, onComplete: done });
const handleLeave = (el: Element, done: () => void) =>
gsap.to(el, { width: 0, height: 0, onComplete: done });
</script>
<template>
<button type="button" @click="isAlive = !isAlive">switch</button>
<Transition
@beforeEnter="handleBeforeEnter"
@enter="handleEnter"
@leave="handleLeave"
>
<div v-if="isAlive" class="h-50 w-50 bg-lime-200">Transition by GASP</div>
</Transition>
</template>
appear-[from | active | to]-class
appear-[from | active | to]-class 只在首次渲染时应用 1 次过渡动画
vue
<script lang="ts" setup>
import { ref } from "vue";
const flag = ref<boolean>(true);
</script>
<template>
<button @click="flag = !flag">mount/unMount</button>
<Transition
appear
appearFromClass="my-appear-from"
appearActiveClass="my-appear-active"
appearToClass="my-appear-to"
>
<!-- 只在首次渲染时应用 1 次过渡动画 -->
<div v-if="flag" className="w-50 h-50 bg-lime-200">TransitionDemo</div>
</Transition>
</template>
<style lang="css" scoped>
@reference "tailwindcss";
.my-appear-from {
@apply w-0 h-0;
}
.my-appear-active {
@apply transition-all duration-1500;
}
.my-appear-to {
@apply w-50 h-50;
}
</style>
<TransitionGroup />
<Transition />
只允许一个直接子元素; 同时,<Transition />
包裹组件时, 组件必须有唯一的根元素, 否则无法应用过渡动画<Transition />
会在一个元素或组件插入/移除 DOM (v-if 挂载/卸载)、显示/隐藏 (v-show) 时应用过渡动画<TransitionGroup />
允许多个直接子元素, 会在一个 v-for 列表中的元素或组件插入、删除、移动时应用过渡动画
<TransitionGroup />
列表的插入、删除过渡
vue
<script lang="ts" setup>
import { reactive } from "vue";
import "animate.css";
const list = reactive<number[]>([0, 1, 2]);
</script>
<template>
<button @click="list.push(list.length)">push</button>
<button @click="list.pop()">pop</button>
<!-- tag="htmlTagName" tag 属性为多个列表项包裹一层 htmlTagName 元素 -->
<div class="wrapper">
<TransitionGroup
tag="main"
class="border-1 flex flex-wrap gap-1"
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOut"
>
<div class="item" v-for="(item, idx) of list" :key="idx">{{ item }}</div>
</TransitionGroup>
</div>
</template>
<TransitionGroup />
列表的移动过渡
vue
<!-- pnpm i lodash && pnpm i @types/lodash -D -->
<script lang="ts" setup>
import { ref } from "vue";
import { shuffle } from "lodash";
const arr = ref(
Array.from({ length: 81 }, (_, idx) => ({ key: idx, val: (idx % 9) + 1 })),
);
const shuffleList = () => (arr.value = shuffle(arr.value));
</script>
<template>
<div>
<button @click="shuffleList">shuffleList</button>
<!-- move-class: 平移的过渡动画 -->
<TransitionGroup moveClass="mv" class="flex flex-wrap w-[378px]" tag="div">
<!-- v-for 绑定 key 时, 不能使用数组下标, 否则无法应用过渡动画 -->
<div
class="w-10 h-10 border-slate-300 border-1 flex justify-center items-center"
v-for="item of arr"
:key="item.key"
>
{{ item.val }}
</div>
</TransitionGroup>
</div>
</template>
<style lang="css" scoped>
.mv {
transition: all 1s;
}
</style>
状态过渡 + GASP
案例
vue
<script setup lang="ts">
import gsap from "gsap";
import { reactive, watch } from "vue";
const num = reactive({
targetVal: 0,
renderVal: 0,
});
watch(
() => num.targetVal,
(newVal, oldVal) => {
console.log(newVal, "<-", oldVal);
gsap.to(num, {
duration: 1, // 1s
renderVal: newVal,
});
},
);
</script>
<template>
<input v-model="num.targetVal" :step="20" type="number" />
<div>{{ num.renderVal.toFixed(0) }}</div>
</template>
编写 vite 插件解析 JSX
安装依赖
bash
pnpm i @vue/babel-plugin-jsx -D && \
pnpm i @babel/core -D && \
pnpm i @babel/plugin-transform-typescript -D && \
pnpm i @babel/plugin-syntax-import-meta -D && \
pnpm i @types/babel__core -D
ts
import type { Plugin } from "vite";
import babel from "@babel/core";
import babelPluginJsx from "@vue/babel-plugin-jsx";
function vitePluginVueTsx(): Plugin {
return {
name: "vite-plugin-vue-tsx",
config(/** config */) {
return {
esbuild: {
include: /\.ts$/,
},
};
},
async transform(code, id) {
if (/.tsx$/.test(id)) {
const ts = await import("@babel/plugin-transform-typescript").then(
(res) => res.default,
);
const res = await babel.transformAsync(code, {
ast: true, // ast 抽象语法树
babelrc: false, // 没有 .babelrc 文件, 所以是 false
configFile: false, // 没有 babel.config.json 文件, 所以是 false
plugins: [
babelPluginJsx,
[ts, { isTSX: true, allowExtensions: true }],
],
});
return res?.code;
}
return code;
},
};
}
v-model 双向绑定
v-model 本质是语法糖
- 父组件使用
v-bind
传递 props 给子组件, 预定义的属性名modelValue
- 子组件派发预定义事件, 父组件使用
v-on
为预定义事件绑定回调函数, 监听子组件派发的预定义事件, 预定义事件名update:modelValue
- 父组件修改值时, 父组件使用
v-bind
传递新的modelValue
值给子组件 - 子组件修改值时, 子组件派发
update:modelValue
预定义事件, emit 发射新的modelValue
值给父组件 - 支持多个 v-model: v-model 预定义的属性名是
modelValue
, 事件名是update:modelValue
, 支持自定义 v-model 的属性名、事件名 - v-model 修饰符:
.trim
,.number
,.lazy
, 支持自定义修饰符v-model.customModifier
vue
<script setup lang="ts">
import { ref } from "vue";
import ChildDemo from "./ChildDemo.vue";
const text = ref<string>("Awesome Vue");
</script>
<template>
ParentDemo
<div>text: {{ text }}</div>
<ChildDemo v-model:textVal.myModifier="text" />
<ChildDemo :textVal="text" @update:textVal="(newVal) => (text = newVal)" />
</template>
vue
<script setup lang="ts">
const props = defineProps<{
textVal: string;
// 约定 xxxModifiers
textValModifiers?: {
myModifier: boolean; // 修饰符存在则为 true
};
}>();
const emit = defineEmits(["update:textVal"]);
const handleInput = (ev: Event) => {
emit("update:textVal", (ev.target as HTMLInputElement).value);
};
</script>
<template>
ChildDemo
<div>Has myModifier: {{ props.textValModifiers?.myModifier ?? false }}</div>
<div>
textVal: <input type="text" :value="textVal" @input="handleInput" />
</div>
</template>
自定义指令
自定义指令名: 以 v 开头, vDirectiveName
自定义指令的钩子函数
- created
- beforeMount/mounted
- beforeUpdate/updated
- beforeUnmount/unmounted
vue
<script setup lang="ts">
import { ref, type Directive, type DirectiveBinding } from "vue";
import ChildDemo from "./ChildDemo.vue";
// 自定义指令名: 以 v 开头, vDirectiveName
const vCustomDirective: Directive = {
created(...args) {
console.log("[vCustomDirective] created:", args);
},
beforeMount(...args) {
console.log("[vCustomDirective] beforeMount:", args);
},
mounted(
el: HTMLElement,
binding: DirectiveBinding<{ background: string; textContent: string }>,
) {
console.log("[vCustomDirective] mounted:", el, binding);
el.style.background = binding.value.background;
el.textContent = binding.value.textContent;
},
beforeUpdate(...args) {
console.log("[vCustomDirective] beforeUpdate:", args);
},
updated(...args) {
const el = args[0];
el.textContent = textContent.value;
console.log("[vCustomDirective] updated:", args);
},
beforeUnmount(...args) {
console.log("[vCustomDirective] beforeUnmount", args);
},
unmounted(...args) {
console.log("[vCustomDirective] unmounted", args);
},
};
const isAlive = ref(true);
const textContent = ref("Vue");
const handleUpdate = () => {
textContent.value += "!";
};
</script>
<template>
<button @click="isAlive = !isAlive">挂载/卸载</button>
<button @click="handleUpdate">更新</button>
<ChildDemo
v-if="isAlive"
v-custom-directive:propName.myModifier="{
background: 'skyblue',
textContent,
}"
/>
</template>
自定义指令 v-auth
实现按钮鉴权
vue
<script setup lang="ts">
import type { Directive, DirectiveBinding } from "vue";
const userId = "whoami";
const authList = [
"whoami:item:create",
"whoami:item:update" /** 'whoami:item:delete' */,
];
const vAuth: Directive<HTMLElement, string> = (el, binding) => {
if (!authList.includes(userId + ":" + binding.value)) {
el.style.display = "none"; // 如果没有权限, 则隐藏按钮
}
};
</script>
<template>
<button v-auth="'item:create'">创建</button>
<button v-auth="'item:update'">更新</button>
<button v-auth="'item:delete'">删除</button>
</template>
自定义指令 v-drag
实现可拖拽窗口
vue
<script lang="ts" setup>
import type { Directive } from "vue";
const vDrag: Directive<HTMLElement> = (el) => {
const draggableElem = el.firstElementChild as HTMLElement;
const handleMouseDown = (downEv: MouseEvent) => {
const dx = downEv.clientX - el.offsetLeft;
const dy = downEv.clientY - el.offsetTop;
const handleMouseMove = (moveEv: MouseEvent) => {
el.style.left = `${moveEv.clientX - dx}px`;
el.style.top = `${moveEv.clientY - dy}px`;
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", () =>
document.removeEventListener("mousemove", handleMouseMove),
);
};
draggableElem.addEventListener("mousedown", handleMouseDown);
};
</script>
<template>
<!-- fixed 固定定位 -->
<div v-drag class="fixed">
<div class="h-20 w-50 bg-lime-100 cursor-pointer" />
<div class="h-50 w-50 bg-lime-200" />
</div>
</template>
自定义指令 v-lazy
实现图片懒加载
vue
<script lang="ts" setup>
import { type Directive } from "vue";
// glob 默认懒加载
const images = import.meta.glob(["@/assets/*.jpg", "@/assets/*.png"], {
eager: true, // 指定立即加载
});
const arr = Object.values(images).map((item) => (item as any).default);
// arr.length = 1
const flattedArr = arr.flatMap((item) => new Array(10).fill(item));
// flattedArr.length = 10
const vLazy: Directive<HTMLImageElement, string> = async (el, binding) => {
const placeholder = await import("@/assets/vue.svg");
el.src = placeholder.default;
// 监听目标元素与祖先元素或视口 viewport 的相交情况
// 监听目标元素和视口 viewport 的相交情况, 即监听一个元素是否可见
// entries[0].intersectionRatio 相交的比例、一个元素可见的比例
const intersectionObserver = new IntersectionObserver((entries) => {
const visibleRatio = entries[0].intersectionRatio;
if (visibleRatio > 0) {
setTimeout(() => (el.src = binding.value), 1500);
intersectionObserver.unobserve(el);
}
});
intersectionObserver.observe(el);
};
</script>
<template>
<div>
<img
v-lazy="item"
width="1000"
v-for="(item, idx) of flattedArr"
:key="idx"
/>
</div>
</template>
自定义 hook
Demo
vue
<script lang="ts" setup>
import { onMounted, ref, type Ref } from "vue";
const useBase64str = (
el: Ref<HTMLImageElement | null>,
): Promise<{ base64str: string }> => {
const toBase64str = (img: HTMLImageElement) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
[canvas.width, canvas.height] = [img.width, img.height];
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const avg =
(data[i] + // r
data[i + 1] + // g
data[i + 2]) / // b
3;
data[i] = data[i + 1] = data[i + 2] = avg;
}
ctx.putImageData(imageData, 0, 0);
}
const base64str = canvas.toDataURL(`image/${getExtName(img.src)}`);
return base64str;
};
const getExtName = (url: string) => {
const urlObj = new URL(url);
return urlObj.pathname.split(".").at(-1);
};
return new Promise((resolve) => {
onMounted(() => {
el.value!.onload = () => {
const base64str = toBase64str(el.value!);
resolve({ base64str });
};
});
});
};
const imgRef = ref<HTMLImageElement | null>(null);
useBase64str(imgRef).then((res) => {
imgRef.value!.src = res.base64str;
});
</script>
<template>
<img src="@/assets/bg.jpg" id="bg" ref="imgRef" />
</template>
自定义指令 + 自定义 hook 综合案例
- InterSectionObserver 监听目标元素与祖先元素或视口 viewport 的相交情况
- MutationObserver 监听整个 DOM 树的改变
- ResizeObserver 监听元素宽高的改变
ts
import App from "./App.vue";
import { createApp } from "vue";
import type { App as VueApp } from "vue";
// Vue 插件可以是一个有 install 方法的对象
// 也可以直接是一个安装函数
// 也可以是一个有 install 属性的安装函数, install 属性值也是一个函数, 接收一个 App 实例
// useResize 是一个自定义 hook, 也是一个 Vue 插件
export const useResize = (
el: HTMLElement,
cb: (contentRect: DOMRectReadOnly) => void,
) => {
const resizeObserver = new ResizeObserver((entries) => {
cb(entries[0].contentRect);
});
resizeObserver.observe(el);
};
useResize.install = (app: VueApp) => {
// 注册 v-resize 自定义指令
app.directive('resize', {
mounted(el, binding) {
console.log('[v-resize] mounted:', el, binding)
// binding.value
// (rect) => console.log("[v-resize] contentRect:", rect)
useResize(el, binding.value /** cb */)
},
})
}
const app = createApp(App);
app.use(useResize);
app.mount("#app");
vue
<script lang="ts" setup>
import { useResize } from '@/main'
import { onMounted } from 'vue'
onMounted(() => {
useResize(document.querySelector('#parent') as HTMLElement, (rect) =>
console.log('[useResize] contentRect:', rect),
)
})
</script>
<template>
<textarea
id="parent"
v-resize="(rect: DOMRectReadOnly) => console.log('[v-resize] contentRect:', rect)"
/>
</template>
全局变量 app.config.globalProperties
ts
import { createApp } from "vue";
import App from "./App.vue";
import mitt from "mitt";
interface IEncoding {
jsonMarshal<T extends object>(arg: T): string;
}
const app = createApp(App);
// 类型扩展
declare module "vue" {
export interface ComponentCustomProperties {
$env: string;
$encoding: IEncoding;
$bus: ReturnType<typeof mitt>;
}
}
// 全局变量 $bus, $env, $encoding
const emitter = mitt();
app.config.globalProperties.$bus = emitter;
app.config.globalProperties.$env = "DEV";
app.config.globalProperties.$encoding = {
jsonMarshal<T extends object>(arg: T) {
return JSON.stringify(arg);
},
};
app.mount("#app");
vue
<template>
<div>$env: {{ $env }}</div>
<div>
$encoding.jsonMarshal:
{{ $encoding.jsonMarshal({ name: "whoami", 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: "whoami", age: 23 }));
</script>
全局变量 + Vue 插件综合案例
- Vue 插件可以是一个有 install 方法的对象
- 也可以直接是一个安装函数
vue
<script setup lang="ts">
import { ref } from "vue";
const visible = ref<boolean>(true);
defineExpose({
visible,
show: () => (visible.value = true),
hide: () => (visible.value = false),
});
</script>
<template>
<Transition
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOut"
>
<div v-if="visible" class="w-50 h-50 bg-lime-100" />
</Transition>
</template>
ts
import "animate.css";
import { createApp, createVNode, render } from "vue";
import App from "./App.vue";
import type { Ref, VNode, App as VueApp } from "vue";
import ToastDemo from "./components/ToastDemo.vue";
declare module "vue" {
export interface ComponentCustomProperties {
$toast: {
show: () => void;
hide: () => void;
visible: Ref<boolean>;
};
}
}
// Vue 插件可以是一个有 install 方法的对象
// 也可以直接是一个安装函数
export const vuePluginToast = {
install(app: VueApp) {
const vnode: VNode = createVNode(ToastDemo);
render(vnode, document.body);
app.config.globalProperties.$toast = {
show: vnode.component?.exposed?.show,
hide: vnode.component?.exposed?.hide,
visible: vnode.component?.exposed?.visible,
};
},
};
const app = createApp(App);
app.use(vuePluginToast);
app.mount("#app");
vue
<template>
<div class="flex flex-col gap-5">
<button @click="$toast.show">show</button>
<button @click="$toast.hide">hide</button>
<button @click="$toast.visible.value = true">show2</button>
<button @click="$toast.visible.value = false">hide2</button>
</div>
</template>
app.use()
源码
ts
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import type { App as VueApp } from "vue";
interface Plugin {
install: (app: VueApp, ...options: unknown[]) => unknown;
}
const installed = new Set();
function myUse<T extends Plugin>(plugin: T, ...options: Array<unknown>) {
if (installed.has(plugin)) {
return;
}
plugin.install(this as VueApp /** app */, ...options);
installed.add(plugin);
return;
}
const app = createApp(App);
// app.use(createPinia())
myUse.call(app, createPinia());
app.mount("#app");
nextTick
Vue 同步更新数据, 异步更新 DOM
- Vue 将 DOM 更新加入任务队列, 等到下一个 tick (类似事件循环) 时, 才统一更新 DOM, 避免多次重复渲染, 提高性能
- nextTick 延迟执行 callback, 即等到下一个 tick, DOM 更新后, 再执行 callback
案例
vue
<script setup lang="ts">
import { reactive, ref, useTemplateRef, nextTick } from "vue";
const itemList = reactive([
{ name: "item1", id: 1 },
{ name: "item2", id: 2 },
]);
const inputVal = ref("");
const box = useTemplateRef<HTMLDivElement>("box");
// Vue 同步更新数据, 异步更新 DOM
const addItem = () => {
itemList.push({ name: inputVal.value, id: itemList.length });
box.value!.scrollTop = 520_520_520; // 更新滚动位置 (此时 DOM 未更新)
};
const addItem2 = () => {
itemList.push({ name: inputVal.value, id: itemList.length });
// nextTick 延迟执行 callback, 即等到下一个 tick, DOM 更新后, 再执行 callback
nextTick(
() => (box.value!.scrollTop = 520_520_520), // callback (此时 DOM 已更新)
);
};
const addItem3 = async () => {
itemList.push({ name: inputVal.value, id: itemList.length });
await nextTick(); // 等到下一个 tick, DOM 更新后
box.value!.scrollTop = 520_520_520; // 更新滚动位置 (此时 DOM 已更新)
};
</script>
<template>
<div ref="box" class="border-1 h-30 w-50 overflow-auto">
<div class="border-b-1 truncate" v-for="item in itemList" :key="item.id">
{{ item }}
</div>
</div>
<div>
<textarea v-model="inputVal" type="text" class="my-3 border-1" />
<div class="flex gap-5">
<button @click="addItem">addItem</button>
<button @click="addItem2">addItem2</button>
<button @click="addItem3">addItem3</button>
</div>
</div>
</template>
scoped
样式隔离, :deep()
样式穿透
scoped
样式隔离
- 通过 PostCSS, 为 DOM 添加唯一的
data-v-<hash>
属性 - CSS 使用
selector[data-v-<hash>]
选择器, 以实现样式隔离
vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<main class="box wrap">
<ChildDemo class="box" />
</main>
<div class="box my-rounded"></div>
</template>
<style lang="css" scoped>
.box {
width: 10rem;
height: 10rem;
}
.wrap {
margin: 2rem;
}
.my-rounded {
background: #ecfcca;
border-radius: 5rem;
}
</style>
vue
<template>
<section>
<div class="bg-lime-100 h-[100%]" />
</section>
</template>
html
<!-- @prettier-ignore -->
<main data-v-0123abcd class="box wrap">
<section data-v-0123abcd class="box">
<div class="bg-lime-100 h-[100%]"></div>
</section>
</main>
<div data-v-0123abcd class="box my-rounded"></div>
css
.box[data-v-0123abcd] {
width: 10rem;
height: 10rem;
}
.wrap[data-v-0123abcd] {
margin: 2rem;
}
.my-rounded[data-v-0123abcd] {
background: #ecfcca;
border-radius: 5rem;
}
:deep()
样式穿透
vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<main class="box wrap">
<ChildDemo class="box child-bg" />
</main>
</template>
<style lang="css" scoped>
.box {
width: 10rem;
height: 10rem;
}
.wrap {
margin: 2rem;
}
/* .wrap .child-bg {
.wrap :deep(.child-bg) {
background: lightblue;
}
</style>
vue
<template>
<section>
<div class="h-[100%] child-bg" />
</section>
</template>
<style lang="css" scoped>
.child-bg {
background: lightpink;
}
</style>
html
<!-- @prettier-ignore -->
<main data-v-0123abcd class="box wrap">
<section data-v-4567efgh data-v-0123abcd class="box child-bg">
<div data-v-4567efgh class="h-[100%] child-bg"></div>
</section>
</main>
html
<!-- ParentDemo -->
<style type="text/css">
.box[data-v-0123abcd] {
width: 10rem;
height: 10rem;
}
.wrap[data-v-0123abcd] {
margin: 2rem;
}
.wrap .child-bg[data-v-0123abcd] {
background: lightblue;
}
</style>
<!-- ChildDemo -->
<style type="text/css">
.child-bg[data-v-4567efgh] {
background: lightpink;
}
</style>
html
<!-- ParentDemo -->
<style type="text/css">
.box[data-v-0123abcd] {
width: 10rem;
height: 10rem;
}
.wrap[data-v-0123abcd] {
margin: 2rem;
}
.wrap[data-v-0123abcd] .child-bg {
background: lightblue;
}
</style>
<!-- ChildDemo -->
<style type="text/css">
.child-bg[data-v-4567efgh] {
background: lightpink;
}
</style>
:slotted
插槽选择器, :global
全局选择器
:slotted()
插槽选择器
vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
</script>
<template>
<ChildDemo>
<div class="parent-bg">插入到子组件的匿名插槽 default</div>
</ChildDemo>
</template>
vue
<template>
<!-- 匿名插槽 name="default" -->
<slot />
</template>
<style lang="css" scoped>
/* .parent-bg {
:slotted(.parent-bg) {
background: lightpink;
}
</style>
:global
全局选择器
- 全局选择器: 使用
:global
的选择器, 不会被 vite 编译 <style lang="css">
中的选择器, 是全局选择器<style lang="css" scoped>
中, 并使用:global
的选择器, 也是全局选择器
v-bind
动态 CSS
vue
<script setup lang="ts">
import { ref } from "vue";
const bg = ref("#000");
const text = ref({ color: "#fff" });
setInterval(() => {
bg.value = bg.value === "#fff" ? "#000" : "#fff";
text.value.color = text.value.color === "#fff" ? "#000" : "#fff";
}, 1000);
</script>
<template>
<div class="box w-20 h-20 border-1">v-bind: Dynamic CSS</div>
</template>
<style scoped lang="css">
.box {
background: v-bind(bg);
color: v-bind("text.color");
}
</style>
CSS 模块化
vue
<script setup lang="ts">
import { useCssModule } from "vue";
const styles = useCssModule(); // 默认模块 $style
const customStyles = useCssModule("customName"); // 自定义模块名 customName
console.log("styles:", styles);
console.log("customStyles:", customStyles);
</script>
<template>
<main class="flex flex-col gap-5">
<!-- 默认模块 $style -->
<div :class="$style.box">CSS Module</div>
<div :class="styles.box">CSS Module</div>
<!-- class 可以绑定数组 -->
<div :class="[$style.box, styles.border]">CSS Module</div>
<!-- 可以自定义模块名 -->
<div :class="[$style.box, customName.bg]">CSS Module</div>
<div :class="[styles.box, customStyles.bg]">CSS Module</div>
</main>
</template>
<style module lang="css">
.box {
width: 5rem;
height: 5rem;
background: lightblue;
}
.border {
border: 1px solid #333;
}
</style>
<!-- 可以自定义模块名 -->
<style module="customName">
.bg {
background: lightpink;
}
</style>
H5 适配
html
<!-- h5 适配: 设置 meta 标签 -->
<meta name="viewport" content="width=device-width,initial-scale=1" />
圣杯布局 + 全局字体大小
圣杯布局: 两侧盒子宽度固定, 中间盒子宽度自适应的三栏布局
- rem: 相对
<html>
根元素的字体大小 - vw/vh: 相对视口 viewport 的宽高, 1vw 是视口宽度的 1%, 1vh 是视口高度的 1%
- 百分比: 相对父元素的宽高
全局字体大小原理
- 定义 :root 伪类选择器的全局 CSS 变量, 所有页面都可以使用
- :root 伪类选择器和 html 元素选择器都选中
<html>
根元素, 但是 :root 伪类选择器的优先级更高
vue
<script setup lang="ts">
import { useCssVar } from "@vueuse/core";
const setGlobalFontSize = (pxVal: number) => {
const fontSize = useCssVar("--font-size");
fontSize.value = `${pxVal}px`;
// 底层: document.documentElement.style.setProperty('--font-size', `${pxVal}px`)
};
</script>
<template>
<header class="flex">
<div class="w-[100px] bg-lime-200 my-div">left</div>
<div class="flex-1 bg-blue-300 my-div">
center
<button class="mx-[10px]" @click="setGlobalFontSize(36)">大号字体</button>
<button class="mx-[10px]" @click="setGlobalFontSize(24)">中号字体</button>
<button class="mx-[10px]" @click="setGlobalFontSize(12)">小号字体</button>
</div>
<div class="w-[100px] bg-lime-200 my-div">right</div>
</header>
</template>
<style scoped lang="css">
@reference "tailwindcss";
.my-div {
@apply h-[100px] leading-[100px] text-slate-500 text-center;
font-size: var(--font-size);
}
</style>
编写 postcss 插件
ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { Plugin } from "postcss";
// pnpm i postcss -D
function postcssPluginPx2viewport(): Plugin {
return {
postcssPlugin: "postcss-plugin-px2viewport",
Declaration(node) {
if (node.value.includes("px")) {
// console.log(node.prop, node.value);
const val = Number.parseFloat(node.value);
node.value = `${((val / 375) /** 设计稿宽度 375 */ * 100).toFixed(2)}vw`;
}
},
};
}
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
css: {
postcss: {
// 自定义 postcss 插件
plugins: [postcssPluginPx2viewport()],
},
},
});
Vue 函数式编程
vue
<script setup lang="ts">
import { h } from "vue";
interface IProps {
type: "primary" | "danger";
}
// Vue 函数式编程
const Btn = (props: IProps, ctx: any /** { attrs, emit, slots } */) => {
console.log("[btn] ctx", ctx);
return h(
"button", // type
{
style: { color: props.type === "primary" ? "lightblue" : "lightcoral" },
onClick: () => {
console.log(ctx);
},
}, // props
ctx.slots.default(), // children
);
};
</script>
<template>
<Btn type="primary">primary</Btn>
<Btn type="danger">danger</Btn>
</template>
Vue 宏函数
- defineProps
- defineEmits
- defineOptions
- defineSlots
defineSlots
vue
<script setup lang="ts">
import ChildDemo from "./ChildDemo.vue";
const list = [
{ name: "love", age: 1 },
{ name: "you", age: 2 },
];
</script>
<template>
<main>
<ChildDemo :defaultList="list" :namedList="list">
<!-- item 先通过子组件的 props 父传子,
再通过子组件的 slot 子传父 -->
<template #default="{ item }">
<div>defaultSlot {{ `name: ${item.name}, age: ${item.age}` }}</div>
</template>
<template #named="{ item }">
<div>namedSlot {{ `name: ${item.name}, age: ${item.age}` }}</div>
</template>
</ChildDemo>
</main>
</template>
vue
<!-- 泛型支持 -->
<script generic="T extends object" setup lang="ts">
import { toRefs, type RenderFunction } from "vue";
const props = defineProps<{ defaultList: T[]; namedList: T[] }>();
const { defaultList, namedList } = toRefs(props);
defineSlots<{
default(props: { item: T }): unknown;
named(props: { item: T }): unknown;
}>();
</script>
<template>
<main>
<ul>
<li v-for="(item, idx) of defaultList" :key="idx">
<!-- 匿名的作用域插槽 -->
<slot :item="item" />
</li>
</ul>
<ul>
<li v-for="(item, idx) of namedList" :key="idx">
<!-- 具名的作用域插槽 -->
<slot :item="item" name="named" />
</li>
</ul>
</main>
</template>
环境变量
在项目根目录下创建环境变量文件 .env.development
, .env.production
, 修改 package.json
bash
VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] development'
bash
VITE_CUSTOM_ENV = '[VITE_CUSTOM_ENV] production'
json
{
"scripts": {
"dev": "vite --mode development",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview"
}
}
ts
console.log("import.meta.env:", import.meta.env);
// {
// BASE_URL: '/',
// DEV: true,
// MODE: 'development',
// PROD: false,
// SSR: false
// VITE_CUSTOM_ENV: '[VITE_CUSTOM_ENV] development'
// }
ts
console.log("import.meta.env:", import.meta.env);
// {
// BASE_URL: '/',
// DEV: false,
// MODE: 'production',
// PROD: true,
// SSR: false
// VITE_CUSTOM_ENV: '[VITE_CUSTOM_ENV] production'
// }
vite.config.ts
是 node 环境, 无法使用 import.meta.env
读取项目根目录下的环境变量文件
ts
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vite.dev/config/
export default ({ mode }: { mode: string }) => {
// mode: development
console.log("mode:", mode);
// loadEnv: { VITE_CUSTOM_ENV: '[custom_env] development' }
console.log("loadEnv:", loadEnv(mode, process.cwd()));
return defineConfig({ plugins: [vue()] });
};
Vue 性能优化
lighthouse
- 首次内容绘制 FCP, First Contentful Paint: 从页面开始加载到浏览器首次渲染出内容的时间 (用户首次看到内容的时间, 内容可以是首段文本或首张图片)
- 最大内容绘制 LCP, Largest Contentful Paint 视口内最大的内容元素完成渲染的时间
- 速度指数 SI, Speed Index: 页面的各个可视区域的平均渲染时间, 页面等待后端响应数据时, 会影响到 Speed Index
- 首次可交互时间 TTI, Time to Interactive: 从页面开始加载到用户可以与页面交互的时间, 此时页面渲染已完成, 交互元素绑定的事件已注册
- 总阻塞时间 TBT, Total Blocking Time: 从页面开始加载到首次可交互时间 (TTI) 期间, 主线程被阻塞的总时间
- 累积布局偏移 CLS, Cumulative Layout Shift: 比较两次渲染的布局偏移情况, 数值越小越好
分析打包产物
ts
// pnpm install rollup-plugin-visualizer -D
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { visualizer } from "rollup-plugin-visualizer";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), visualizer({ open: true })],
build: {
// 代码块 (chunk) 大小 > 2000KB 时警告
chunkSizeWarningLimit: 2000,
cssCodeSplit: true, // 开启 CSS 拆分
sourcemap: false, // 不生成源代码映射文件 source-map
minify: "esbuild", // JS 最小化混淆
cssMinify: "esbuild", // CSS 最小化混淆
assetsInlineLimit: 5000, // 静态资源大小 < 5000B 时, 将内联为 base64
},
});
自定义元素
原生 Web Component 自定义元素
优点: CSS, JS 隔离
js
class Btn extends HTMLElement {
constructor() {
super();
const shadowDOM = this.attachShadow({ mode: "open" });
this.div = this.h("div");
this.div.innerText = "d2vue-btn";
this.div.setAttribute(
"style",
`width: 100px;
height: 30px;
line-height: 30px;
text-align: center;
border: 1px solid #ccc;
border-radius: 15px;
cursor: pointer;
`,
);
shadowDOM.appendChild(this.div);
}
h(el) {
return document.createElement(el);
}
connectedCallback() {
console.log("[d2vue-btn] Connected");
}
disconnectedCallback() {
console.log("[d2vue-btn] Disconnect");
}
adoptedCallback() {
console.log("[d2vue-btn] Adopted");
}
attributeChangedCallback() {
console.log("[d2vue-btn] Attribute changed");
}
}
window.customElements.define("d2vue-btn", Btn);
js
class Btn2 extends HTMLElement {
constructor() {
super();
const shadowDOM = this.attachShadow({ mode: "open" });
this.template = this.h("template");
this.template.innerHTML = `
<style>
.btn {
width: 100px;
height: 30px;
line-height: 30px;
text-align: center;
border: 1px solid #ccc;
border-radius: 15px;
cursor: pointer;
}
</style>
<div class="btn">d2vue-btn2</div>`;
shadowDOM.appendChild(this.template.content.cloneNode(true));
}
h(el) {
return document.createElement(el);
}
connectedCallback() {
console.log("[d2vue-btn2] Connected");
}
disconnectedCallback() {
console.log("[d2vue-btn2] Disconnect");
}
adoptedCallback() {
console.log("[d2vue-btn2] Adopted");
}
attributeChangedCallback() {
console.log("[d2vue-btn2] Attribute changed");
}
}
window.customElements.define("d2vue-btn2", Btn2);
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./btn.js"></script>
<script src="./btn2.js"></script>
</head>
<body>
<d2vue-btn></d2vue-btn>
<d2vue-btn2></d2vue-btn2>
</body>
</html>
Vue 中使用 Web Component 自定义元素
ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// 文件名带 - , 文件拓展名 .ce.vue 的单文件组件, 视为 Web Component 自定义元素
isCustomElement: (tag) => tag.startsWith("d2vue-"),
},
},
}),
],
});
vue
<script setup lang="ts">
defineProps<{ item: { name: string; age: number } }>();
</script>
<template>
<!-- 不能使用 tailwindcss -->
<div class="btn">name: {{ item.name }}, age: {{ item.age }}</div>
</template>
<style lang="css" scoped>
.btn {
width: 250px;
height: 50px;
line-height: 50px;
text-align: center;
border: 1px solid #ccc;
border-radius: 25px;
cursor: pointer;
}
</style>
vue
<script setup lang="ts">
import { defineCustomElement } from "vue";
import D2vueBtn from "@/components/d2vue-btn.ce.vue";
// Vue 中使用 Web Component 自定义元素
const Btn = defineCustomElement(D2vueBtn);
window.customElements.define("d2vue-btn", Btn);
const item = { name: "whoami", age: 23 };
</script>
<template>
<d2vue-btn :item="item"></d2vue-btn>
</template>
Proxy 跨域
同源: 主机 (域名), 端口, 协议都相同
JSONP
原理: HTML 文件的 <script>
标签没有跨域限制
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script>
function jsonp(req /* { url, callback } */) {
const script = document.createElement("script");
const url = `${req.url}?callback=${req.callback.name}`;
script.src = url;
// 浏览器请求该 <script> 标签的 src
// 响应: frontendFn({"data":"I love you"})
document.getElementsByTagName("head")[0].appendChild(script);
}
function frontendFn(res) {
alert(`res.data: ${res.data}`);
}
// frontendFn.name: frontendFn
console.log("frontendFn.name:", frontendFn.name);
jsonp({ url: "http://localhost:8080", callback: frontendFn });
</script>
</head>
<body></body>
</html>
js
import http from "node:http";
import urllib from "node:url";
const port = 8080;
const cbParams = { data: "I love you" };
http
.createServer((req, res) => {
const params = urllib.parse(req.url, true);
if (params.query.callback) {
// callback: frontendFn
console.log("callback:", params.query.callback);
// JSONP, JSON with Padding
const jsonWithPadding = `${params.query.callback}(${JSON.stringify(cbParams)})`;
// jsonWithPadding: frontendFn({"data":"I love you"})
console.log("jsonWithPadding:", jsonWithPadding);
res.end(jsonWithPadding);
} else {
res.end();
}
})
.listen(port, () => {
console.log(`http://localhost:${port}`);
});
Vite 代理
ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
后端允许跨域
js
function cors(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, Authorization",
);
// res.header("Access-Control-Allow-Credentials", true);
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.header("Content-type", "application/json;charset=utf-8");
// 预检 (pre-flight) 请求
if (req.method.toUpperCase() === "OPTIONS") {
return res.sendStatus(204);
}
next();
}