阅读视图

发现新文章,点击刷新页面。

Message组件和Vue3 进阶:手动挂载组件与 Diff 算法深度解析

ai_image_1768638049154_1.jpg

Vue3 进阶:手动挂载组件与 Diff 算法深度解析

很多 Vue 开发者习惯了在 <template> 里写组件,但在开发 Message(全局提示)、Modal(模态框)或 Notification(通知)这类组件时,我们往往希望通过 JS 函数直接调用,而不是在每个页面都写一个 <MyMessage /> 标签。本文将带你深入 Vue3 底层,看看如何手动渲染组件

1. 为什么需要手动挂载?

想象一下,如果你想弹出一个成功提示,哪种方式更优雅?

方式 A (声明式): 需要在每个页面的 template 里都写一遍组件,还要定义一个变量来控制显示隐藏。

<template>
  <MyMessage :visible="showMsg" message="操作成功" />
  <button @click="showMsg = true">点击</button>
</template>

方式 B (命令式): 直接在 JS 里调用,随用随调,完全解耦。

import { message } from 'my-ui'

function handleClick() {
  message.success('操作成功')
}

显然,方式 B 是组件库的标准做法。但 Vue 的组件通常是渲染在父组件的模板里的,如何把它“凭空”变出来并挂载到 document.body 上呢?

这就需要用到 Vue3 暴露的两个底层 API:createVNoderender

2. 核心 API 解密

createVNode:画图纸

在 Vue 中,一切皆 VNode(虚拟节点)。普通的 .vue 文件只是一个组件定义,它不是 DOM,也不是 VNode。我们需要用 createVNode 把它实例化。

import { createVNode } from 'vue'
import MyComponent from './MyComponent.vue'

// 这就像是拿着图纸 (MyComponent)
// 创建了一个具体的实例化对象 (vm)
// 第二个参数是 props
const vnode = createVNode(MyComponent, { title: 'Hello' })

render:施工队

有了 VNode,它还只是内存里的对象。我们需要 render 函数把它变成真实的 DOM 节点,并挂载到某个容器上。

import { render } from 'vue'

const container = document.createElement('div')
// 把 vnode 渲染到 container 盒子里
render(vnode, container)

// 最后把盒子放到 body 上
document.body.appendChild(container)

3. 实战:手写一个简单的 Message 函数

让我们来看看 packages/components/message/src/method.ts 是如何实现的。

第一步:创建容器与 VNode

import { createVNode, render } from 'vue'
import MessageConstructor from './message.vue'

export function message(options) {
  // 1. 创建一个临时的 div 容器
  const container = document.createElement('div')

  // 2. 创建 VNode,并将 options 作为 props 传入
  // 例如:createVNode(MessageConstructor, { message: '你好', type: 'success' })
  const vnode = createVNode(MessageConstructor, options)

  // 3. 将 VNode 渲染到 container 中
  // 此时 container.firstElementChild 就是组件生成的真实 DOM
  render(vnode, container)

  // 4. 将真实 DOM 追加到 body
  document.body.appendChild(container.firstElementChild!)
}

第二步:处理组件卸载

这就完事了吗?并没有。如果我们不处理销毁逻辑,这些 DOM 节点会一直堆积在 body 里,造成内存泄漏。

我们需要在组件内部发射一个 destroy 事件(比如在动画结束时),然后在外部监听它。

const vnode = createVNode(MessageConstructor, {
  ...options,
  // 监听组件内部 emit('destroy')
  onDestroy: () => {
    // 移除 DOM
    render(null, container) // 这一步会触发组件的 unmounted 钩子
  }
})

4. 源码深潜:createVNode 和 render 到底干了啥?

对于好奇心强的同学,可能想知道:Vue 内部到底是怎么把这几行代码变成页面的?让我们用最通俗的伪代码来拆解一下。

4.1 createVNode:给节点“打标签”

createVNode 的核心任务不仅仅是创建一个对象,更是为了性能优化。它会根据你传入的类型,给 VNode 打上一个二进制标记(ShapeFlag)。

// 伪代码简化版
function createVNode(type, props, children) {
  // 1. 定义 VNode 结构
  const vnode = {
    type, // 组件对象 或 'div' 标签名
    props,
    children,
    component: null, // 稍后挂载组件实例
    el: null, // 稍后挂载真实 DOM
    shapeFlag: 0 // 核心:类型标记
  }

  // 2. 通过位运算打标记
  if (typeof type === 'string') {
    vnode.shapeFlag = ShapeFlags.ELEMENT // 这是一个 HTML 标签 (div, span)
  }
  else if (typeof type === 'object') {
    vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT // 这是一个 Vue 组件
  }

  return vnode
}

为什么要这么做? Vue 的更新过程非常频繁。有了 shapeFlag,在后续的 Diff 过程中,Vue 就不需要每次都去猜“这是个啥”,直接看二进制位就知道怎么处理,速度极快。

4.2 render:万能的包工头

render 函数其实非常简单,它背后真正的干活主力是 patch 函数。

// 伪代码简化版
function render(vnode, container) {
  if (vnode == null) {
    // 如果传 null,说明要销毁
    if (container._vnode) {
      unmount(container._vnode) // 卸载旧节点
    }
  }
  else {
    // 如果有新 VNode,就开始“打补丁”
    // 参数:(旧节点, 新节点, 容器)
    patch(container._vnode || null, vnode, container)
  }

  // 记住这次渲染的 VNode,下次更新时它就是“旧节点”了
  container._vnode = vnode
}

4.3 patch:分发任务

patch 是 Vue 渲染器的核心。它根据我们前面打的 shapeFlag,把任务分发给不同的处理函数。

function patch(n1, n2, container) {
  if (n1 && !isSameVNodeType(n1, n2)) {
    // 如果类型都不一样(比如从 div 变成了 span),直接卸载旧的
    unmount(n1)
    n1 = null
  }

  const { shapeFlag } = n2
  if (shapeFlag & ShapeFlags.ELEMENT) {
    // 这是一个 HTML 标签
    processElement(n1, n2, container)
  }
  else if (shapeFlag & ShapeFlags.COMPONENT) {
    // 这是一个 Vue 组件
    processComponent(n1, n2, container)
  }
}

4.4 深入 processComponent:组件是怎么跑起来的?

patch 发现这是个组件时,它会区分是“初次挂载”还是“更新”。

function processComponent(n1, n2, container) {
  if (n1 == null) {
    // 1. 挂载组件 (Mount)
    mountComponent(n2, container)
  }
  else {
    // 2. 更新组件 (Update)
    updateComponent(n1, n2)
  }
}

mountComponent 做的事情:

  1. 创建实例const instance = createComponentInstance(vnode)
  2. 设置状态:初始化 propsslots,执行 setup() 函数。
  3. 建立副作用:创建一个 effect(响应式副作用),运行组件的 render 函数生成子树(subTree),并监听响应式数据变化。

4.5 深入 processElement:挂载与更新

patch 遇到 HTML 标签时,会根据 n1(旧节点)是否存在来决定是初始化还是更新。

1. 挂载 (Mount)

如果 n1null,说明是初次渲染。Vue 会调用宿主环境的 API(如 document.createElement)创建真实 DOM,并将其插入到容器中。

function mountElement(vnode, container) {
  // 1. 创建真实 DOM
  const el = (vnode.el = hostCreateElement(vnode.type))

  // 2. 处理 Props (Style, Class, Event)
  for (const key in vnode.props) {
    hostPatchProp(el, key, null, vnode.props[key])
  }

  // 3. 处理子节点 (递归 mount)
  mountChildren(vnode.children, el)

  // 4. 插入页面
  hostInsert(el, container)
}
2. 更新 (Patch)

如果 n1 存在,Vue 就需要对比新旧节点,做最小量的 DOM 操作。

  1. 更新 Props:对比新旧 Props,修改变动的 Class、Style 或事件监听器。
  2. 更新 Children (核心 Diff):这是最复杂的部分。

4.6 核心 Diff 算法:Vue3 是如何“增删改移”的?

Vue3 采用的是快速 Diff 算法 (Quick Diff)。它的核心思想是:先处理两端容易对比的节点,最后再处理中间复杂的乱序部分

我们通过一个具体的代码示例来模拟这个过程。

假设场景:

// 旧列表 (n1)
const oldChildren = [
  { key: 'A' }, { key: 'B' }, // 头
  { key: 'C' }, { key: 'D' }, { key: 'E' }, // 中间
  { key: 'F' }, { key: 'G' }  // 尾
]

// 新列表 (n2)
const newChildren = [
  { key: 'A' }, { key: 'B' }, // 头 (不变)
  { key: 'E' }, { key: 'C' }, { key: 'D' }, { key: 'H' }, // 中间 (乱序 + 新增)
  { key: 'F' }, { key: 'G' }  // 尾 (不变)
]
第一步:掐头(Sync from start)

Vue 会维护一个索引 i = 0,从头部开始向后遍历,如果 key 相同,直接 patch(更新属性),然后 i++

let i = 0
const e1 = oldChildren.length - 1 // 旧列表尾部索引
const e2 = newChildren.length - 1 // 新列表尾部索引

// 1. 从头往后比
while (i <= e1 && i <= e2) {
  const n1 = oldChildren[i]
  const n2 = newChildren[i]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container) // 复用节点,更新 Props
    i++
  } else {
    break // 遇到不同的 (C vs E),停下来
  }
}
// 此时 i = 2,指向 C 和 E
第二步:去尾(Sync from end)

同样的逻辑,从尾部开始向前遍历。

// 2. 从尾往前比
while (i <= e1 && i <= e2) {
  const n1 = oldChildren[e1]
  const n2 = newChildren[e2]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container)
    e1--
    e2--
  } else {
    break // 遇到不同的 (E vs H),停下来
  }
}
// 此时 e1 = 4 (指向 E), e2 = 5 (指向 H)

此时的状态:

  • 头部 A, B 已处理。
  • 尾部 F, G 已处理。
  • 剩下的烂摊子
    • 旧:[C, D, E] (索引 2 到 4)
    • 新:[E, C, D, H] (索引 2 到 5)
第三步:处理新增与删除(简单情况)

如果预处理后,旧列表没了(i > e1),新列表还剩,说明全是新增。 如果新列表没了(i > e2),旧列表还剩,说明全是删除

if (i > e1) {
  if (i <= e2) {
    // 旧的没了,新的还有 -> 挂载剩余的新节点
    while (i <= e2) patch(null, newChildren[i++], container)
  }
} 
else if (i > e2) {
  // 新的没了,旧的还有 -> 卸载剩余的旧节点
  while (i <= e1) unmount(oldChildren[i++])
}
第四步:处理乱序(Unknown Sequence)

这是最复杂的情况(如我们的例子)。Vue 需要判断哪些节点移动了,哪些需要新建。

1. 构建新节点映射表与初始化

// 1. 构建新节点的 key 映射表
const keyToNewIndexMap = new Map()
for (let k = i; k <= e2; k++) {
  keyToNewIndexMap.set(newChildren[k].key, k)
}

// 2. 待处理新节点数量
const count = e2 - i + 1
// 3. 记录新节点在旧列表中的位置(用于计算最长递增子序列)
const newIndexToOldIndexMap = new Array(count).fill(0)

2. 遍历旧节点:复用与删除

// 4. 遍历旧节点,寻找可复用的节点
for (let k = i; k <= e1; k++) {
  const oldChild = oldChildren[k]
  const newIndex = keyToNewIndexMap.get(oldChild.key)

  if (newIndex === undefined) {
    // 旧节点在新列表中找不到了 -> 删除
    unmount(oldChild)
  } else {
    // 找到了!记录旧索引 + 1(防止 0 索引冲突)
    // newIndex - i 是为了映射到从 0 开始的 count 数组中
    newIndexToOldIndexMap[newIndex - i] = k + 1
    // 进行递归比对
    patch(oldChild, newChildren[newIndex], container)
  }
}
/**
 * 此时产生的映射关系图例:
 * 
 * 索引 (i):      0    1    2    3   (对应新列表中的位置)
 * 新节点 key:   [E]  [C]  [D]  [H]
 * 旧索引 + 1:   [5]  [3]  [4]  [0]  (对应 newIndexToOldIndexMap)
 * 
 * 其中:
 * - 0 代表 H 是新来的,需要挂载 (Mount)
 * - 3, 4 是递增的序列 -> 这就是 LIS (最长递增子序列)
 * - 5 打破了递增性 -> 说明 E 发生了移动
 */

💡 小白专属解释:

你可以把 newIndexToOldIndexMap 想象成一张 “寻人启事表”。 表格的长度就是新列表里乱序的人数(这里是 4 个:E, C, D, H)。

  • 第 0 格 (E):表里写着 5。意思是:“我是旧列表里的第 4 号(5-1)人”。
  • 第 1 格 (C):表里写着 3。意思是:“我是旧列表里的第 2 号(3-1)人”。
  • 第 2 格 (D):表里写着 4。意思是:“我是旧列表里的第 3 号(4-1)人”。
  • 第 3 格 (H):表里写着 0。意思是:“查无此人,我是新来的”。

Vue 看到这张表,就知道谁是从哪儿来的,谁是新来的。然后只要算出哪个序列是递增的(3 -> 4),就说明这几个人(C 和 D)的相对站位没变,可以不用动,省力气!

3. 计算最长递增子序列 (LIS)

Vue 使用一个算法算出 newIndexToOldIndexMap 中的最长递增子序列。这个序列里的节点,在旧列表和新列表里的相对顺序是一样的,所以不需要移动

// 获取最长递增子序列的相对索引
// 传入: [5, 3, 4, 0] (忽略 0)
// 返回: [1, 2] (对应值 3, 4,即 C 和 D,它们在 newIndexToOldIndexMap 中的下标)
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)

// j 指向 LIS 数组的末尾 (即最大索引)
let j = increasingNewIndexSequence.length - 1

4. 倒序遍历与移动 (Moving)

最后,我们从后往前遍历需要处理的新节点。 为什么倒序?因为 insert 操作需要一个参照节点 (Anchor)。从后往前遍历时,当前节点的后一个节点一定已经处理好了(要么是刚移动完的,要么是末尾固定的),可以放心地作为 Anchor。

// 遍历待处理的新节点 (倒序)
// k: 当前处理节点在乱序区间内的相对索引 (0 ~ count-1)
// i: 乱序区间的起始索引 (全局索引)
for (let k = count - 1; k >= 0; k--) {
  // 1. 计算该节点在新列表中的真实全局索引
  const nextIndex = i + k
  const nextChild = newChildren[nextIndex]
  
  // 2. 找锚点 (Anchor):就是它后面那个节点
  // 如果 nextIndex + 1 超过了长度,说明它是最后一个,锚点是 null (插到容器末尾)
  const anchor = nextIndex + 1 < newChildren.length ? newChildren[nextIndex + 1].el : null

  if (newIndexToOldIndexMap[k] === 0) {
    // ------------------------------------
    // 情况 1: 标记是 0 -> 新增节点
    // ------------------------------------
    patch(null, nextChild, container, anchor)
  
  } else if (j < 0 || k !== increasingNewIndexSequence[j]) {
    // ------------------------------------
    // 情况 2: 需要移动
    // ------------------------------------
    // 这里的 k 不在 LIS 里,说明位置不对,需要搬家
    move(nextChild, container, anchor) 
  
  } else {
    // ------------------------------------
    // 情况 3: 命中 LIS -> 原地不动
    // ------------------------------------
    // k === seq[j]: 恭喜,这个节点在最长递增序列里
    // 它的相对位置没变,不需要动 DOM,只需要让 LIS 指针前移
    j--
  }
}

💡 核心逻辑图解:

  1. H (i=3): Map 值为 0 -> 新建,插到末尾。
  2. D (i=2): 命中 LIS (seq[j]=2) -> 不动j--
  3. C (i=1): 命中 LIS (seq[j]=1) -> 不动j--
  4. E (i=0): 不在 LIS 里 -> 移动,插到 C 前面。

5. 源码级细节:为什么需要 Context?

你可能会发现我们的源码里有这么一行:

vnode.appContext = context || null

这是为了让动态挂载的组件能继承当前 App 的上下文。比如,你在主 App 里注册了 vue-routeri18n,如果不把 appContext 赋值给新的 VNode,那么在这个 Message 组件里就无法使用 useRouter()$t()

6. 总结

通过手动使用 createVNoderender,我们突破了 Vue 模板的限制,实现了能够动态创建、挂载、销毁的命令式组件。

这也是开发高级组件(如弹窗、抽屉、通知)的必经之路。

关键点复习

  1. createVNode(Component, props) 创建虚拟节点。
  2. render(vnode, container) 将虚拟节点转化为真实 DOM。
  3. render(null, container) 销毁组件,释放内存。

Vue3响应式API全指南:ref/reactive及衍生API的区别与最佳实践

Vue3基于Proxy重构了响应式系统,提供了一套灵活的API矩阵——核心的ref与reactive、浅响应式的shallowRef/shallowReactive、只读封装的readonly/shallowReadonly。这些API看似功能重叠,实则各有适配场景,误用易导致响应式失效或性能冗余。本文将从特性本质、核心区别、代码示例、适用场景四个维度,系统拆解六大API,帮你精准选型、规避踩坑。

一、核心基础:ref 与 reactive

ref和reactive是Vue3响应式开发的基石,均用于创建响应式数据,但针对的数据类型、访问方式有明确边界,是后续衍生API的设计基础。

1. 核心特性与区别

维度 ref reactive
支持类型 基本类型(string/number/boolean等)+ 引用类型 仅支持引用类型(对象/数组),基本类型传入无响应式效果
实现原理 封装为Ref对象(含.value属性),基本类型靠Object.defineProperty拦截.value,引用类型内部调用reactive 直接通过Proxy拦截对象的属性读取/修改,天然支持嵌套属性响应式
操作方式 脚本中需通过.value访问/修改,模板中自动解包(无需.value) 脚本、模板中均直接操作属性(无.value冗余)
解构特性 解构后丢失响应式,需用toRefs/toRef转换保留 直接解构失效,通过toRefs可将属性转为Ref对象维持响应式
响应式深度 默认深响应式(嵌套对象属性变化触发更新) 默认深响应式(嵌套对象属性变化触发更新)

2. 代码示例

import { ref, reactive, toRefs } from 'vue';

// ref使用:基本类型+引用类型
const count = ref(0);
count.value++; // 脚本中必须用.value
console.log(count.value); // 1

const user = ref({ name: '张三', age: 20 });
user.value.age = 21; // 嵌套属性修改,触发响应式

// reactive使用:仅引用类型
const person = reactive({ name: '李四', info: { height: 180 } });
person.name = '王五'; // 直接操作属性
person.info.height = 185; // 嵌套属性深响应式

// 解构处理
const { name, age } = toRefs(user.value); // 保留响应式
name.value = '赵六'; // 触发更新

3. 适用场景

ref:优先用于基本类型响应式(如计数器、开关状态、输入框值);单独维护单个引用类型数据(无需复杂嵌套解构);组合式API中作为默认选择,灵活性更高。

reactive:适用于复杂引用类型(如用户信息、列表数据、表单聚合状态);希望避免.value冗余,追求更直观的属性操作;组件内部状态聚合管理(相关属性封装为一个对象,可读性更强)。

二、性能优化:shallowRef 与 shallowReactive

ref和reactive的深响应式会递归处理所有嵌套属性,对大型对象/第三方实例而言,可能产生不必要的性能开销。浅响应式API仅拦截顶层数据变化,专为性能优化场景设计。

1. 核心特性与区别

维度 shallowRef shallowReactive
支持类型 基本类型 + 引用类型(同ref) 仅引用类型(同reactive)
响应式深度 仅拦截.value的引用替换,嵌套属性变化不触发更新 仅拦截顶层属性变化,嵌套属性变化无响应式效果
更新触发 需替换.value引用(如shallowRef.value = 新对象);嵌套修改需用triggerRef手动触发更新 仅修改顶层属性触发更新,嵌套属性修改完全不拦截
使用成本 嵌套修改需手动触发更新,有额外编码成本 无需手动触发,但需牢记仅顶层响应式,易踩坑

2. 代码示例

import { shallowRef, shallowReactive, triggerRef } from 'vue';

// shallowRef示例
const shallowUser = shallowRef({ name: '张三', info: { age: 20 } });
shallowUser.value.info.age = 21; // 嵌套修改,无响应式
shallowUser.value = { name: '李四', info: { age: 22 } }; // 替换引用,触发更新
triggerRef(shallowUser); // 手动触发更新(嵌套修改后强制同步)

// shallowReactive示例
const shallowPerson = shallowReactive({
  name: '王五',
  info: { height: 180 }
});
shallowPerson.name = '赵六'; // 顶层修改,触发更新
shallowPerson.info.height = 185; // 嵌套修改,无响应式

3. 适用场景

shallowRef:引用类型数据仅需整体替换(如大型图表配置、第三方库实例、不可变数据);明确不需要嵌套属性响应式,追求极致性能(避免递归Proxy开销)。

shallowReactive:复杂对象仅需顶层属性响应式(如表单顶层状态、静态嵌套数据的配置对象);大型对象场景下,规避深响应式的性能损耗,且无需频繁修改嵌套属性。

注意:浅响应式API并非“银弹”,仅在明确不需要深层响应式时使用,否则易导致响应式失效问题,增加调试成本。

三、只读防护:readonly 与 shallowReadonly

在父子组件通信、全局常量管理等场景,需禁止数据被修改,此时可使用只读API。它们会拦截修改操作(开发环境抛警告),同时保留原数据的响应式特性(原数据变化时,只读数据同步更新)。

1. 核心特性与区别

维度 readonly shallowReadonly
支持类型 引用类型为主(基本类型只读无实际意义) 引用类型为主(基本类型只读无实际意义)
只读深度 深只读:顶层+所有嵌套属性均不可修改 浅只读:仅顶层属性不可修改,嵌套属性可正常修改
修改拦截 任何层级修改均被拦截,开发环境抛警告 仅顶层修改被拦截,嵌套修改无拦截、无警告
响应式保留 保留深响应式:原数据任意层级变化,只读数据同步更新 保留浅响应式:原数据变化(无论层级),只读数据同步更新

2. 代码示例

import { readonly, shallowReadonly, reactive } from 'vue';

// 原始响应式数据
const original = reactive({
  name: '张三',
  info: { age: 20 }
});

// readonly示例
const readOnlyData = readonly(original);
readOnlyData.name = '李四'; // 顶层修改,被拦截(抛警告)
readOnlyData.info.age = 21; // 嵌套修改,被拦截(抛警告)
original.name = '李四'; // 原数据变化,只读数据同步更新
console.log(readOnlyData.name); // 李四

// shallowReadonly示例
const shallowReadOnlyData = shallowReadonly(original);
shallowReadOnlyData.name = '王五'; // 顶层修改,被拦截(抛警告)
shallowReadOnlyData.info.age = 22; // 嵌套修改,正常执行(无警告)
console.log(shallowReadOnlyData.info.age); // 22

3. 适用场景

readonly:完全禁止修改的响应式数据(如全局常量配置、接口返回的不可变数据);父子组件通信的Props(Vue内部默认对Props做readonly处理,防止子组件修改父组件状态);需要严格防护数据完整性的场景。

shallowReadonly:仅需禁止顶层属性修改,嵌套属性允许微调(如父组件传递给子组件的复杂对象,子组件可修改嵌套细节但不能替换整体);追求性能优化,避免深只读的递归拦截开销(大型对象场景更明显)。

四、API选型总指南与避坑要点

1. 快速选型流程图

  1. 明确需求:是否需要响应式?→ 不需要则直接用普通变量;需要则进入下一步。
  2. 数据类型:基本类型→只能用ref;引用类型→进入下一步。
  3. 修改权限:需要禁止修改→readonly(深防护)/shallowReadonly(浅防护);允许修改→进入下一步。
  4. 响应式深度:仅需顶层响应式→shallowRef/shallowReactive;需要深层响应式→ref/reactive。
  5. 操作习惯:避免.value→reactive;接受.value或基本类型→ref。

2. 常见坑点规避

  • ref解构丢失响应式:务必用toRefs/toRef转换,而非直接解构。
  • reactive传入基本类型:无响应式效果,需改用ref。
  • 浅响应式嵌套修改失效:shallowRef需用triggerRef手动触发,shallowReactive避免依赖嵌套属性更新。
  • readonly修改原数据:只读API仅拦截对自身的修改,原数据仍可修改,需注意数据溯源。
  • ref嵌套对象修改:无需额外处理,内部已转为reactive,直接修改.value.属性即可。

五、总结

Vue3的响应式API设计围绕“灵活性”与“性能”两大核心:ref/reactive构建基础响应式能力,适配绝大多数日常场景;shallow系列API针对性优化性能,降低大型数据的响应式开销;readonly系列API保障数据安全性,适配只读场景。

核心原则是“按需选型”——无需为简单场景引入复杂API,也无需为性能牺牲开发效率。掌握各API的响应式深度、修改权限、操作方式,就能在项目中精准运用,打造高效、健壮的响应式系统。

Vue3 多主题/明暗模式切换:CSS 变量 + class 覆盖的完整工程方案(附开源代码)

文章简介

之前逛 V 站的时候刷到一个讲 JSON 格式化工具信息泄漏的帖子,有条评论说:“V 站不是人手一个工具站吗?”受此感召,我给自己做了一个工具站。

在搭建工具站的时候有做多主题、亮/暗主题切换,于是有了这篇文章。

备注:工具站当前支持的工具还不多,但已开源,也有部署在 Github page 中,文中介绍的主题切换源码也在其中,感兴趣的朋友可随意取用,后续我也会将自己要用的、感兴趣的工具集成进去。

再备注:此处介绍的多主题、模式切换是在 vue3 中实现,其他环境请感兴趣的朋友自行实现。

工具站源码地址

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

实现原理

主题切换使用了 CSS 变量和 class 覆盖两种特性。

  • class 覆盖特性,后加载的 class 样式会覆盖之前加载的 class 样式,变量也会被覆盖。
  • CSS 变量定义时以 -- 开头,如下:
:root {
  /* ========== 品牌主色调 ========== */
  --brand-primary: #4f46e5; /* 主色:靛蓝 */
  --brand-secondary: #0ea5e9; /* 次要色:天蓝 */
  --brand-accent: #8b5cf6; /* 强调色:紫色 */
}

实现思路

  1. 首先在 :root 伪 class 下定义所有需要用到的变量,然后定义拥有相同变量的不同主题 class
  2. 切换主题时通过 document 直接设置对应主题的 class
  3. 跟随系统主题可以通过监听 (prefers-color-scheme: dark) 来切换

:root 伪 class 定义

源码在 src/themes/index.css 文件内,此处只贴出部分变量

:root {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题明亮模式 class 定义

源码在 src/themes/default/light.css 文件内,此处只贴出部分变量

html.theme-default {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题暗夜模式 class 定义

源码在 src/themes/default/dark.css 文件内,此处只贴出部分变量

html.theme-default.dark {
  /* 背景与表面色 */
  --bg-primary: #0f172a; /* 主背景 */
  --bg-secondary: #1e293b; /* 次级背景/卡片 */
  --bg-tertiary: #334155; /* 工具栏/三级背景 */
  --bg-sidebar: #1e293b; /* 侧边栏背景 */
}

主题切换源码

源码位置:src/themes/theme.ts

切换主题后会将当前主题保存至本地,下次打开站点时会自动加载上次设置的主题

  • 对象定义
    • Theme:用来定义主题信息
    • ThemeModel:用来定义当前模式(明亮/暗夜),以及是否跟随系统
    • ThemeConfig:用来定义当前主题与模式
  • 函数定义
    • isDarkMode:用来判断当前系统是否为暗夜模式
    • applyTheme:用来应用主题与模式
    • initializeTheme:初始化主题,用来加载之前设置的主题与模式
    • getCurrentThemeConfig:获取当前主题配置(主题与模式)
    • addDarkListener:添加暗夜模式监听
    • removeDarkListener:移除暗夜模式监听
    • changeThemeMode:切换主题模式(亮/暗模式)
    • changeTheme:切换主题,默认主题、星空主题、海洋主题等
    • getThemeList:获取支持的主题列表 备注:主题初始化、暗夜模式监听/移除监听函数需要在主页面加载时调用、设置
// 存储主题配置的键
const THEME_STORAGE_KEY = "custom-theme";

// 主题
export interface Theme {
  name: string; // 主题名称
  className: string; // 对应的 CSS 类名
}

// 模式
export interface ThemeModel {
  name: string; // 模式名称
  followSystem: boolean; // 是否跟随系统
  value: "light" | "dark"; // 模式值
}

// 主题配置
export interface ThemeConfig {
  theme: Theme; // 主题
  model: ThemeModel; // 默认主题模式
}

/**
 * 检测当前系统是否启用暗黑模式
 */
function isDarkMode() {
  return (
    window.matchMedia &&
    window.matchMedia("(prefers-color-scheme: dark)").matches
  );
}

/**
 * 应用主题
 * @param themeConfig 主题配置
 */
function applyTheme(themeConfig: ThemeConfig) {
  const className = themeConfig.theme.className;
  const mode = themeConfig.model;

  // 移除旧的主题类
  const classes = document.documentElement.className.split(" ");
  const themeClasses = classes.filter(
    (c) => !c.includes("theme-") && c !== "dark"
  );
  document.documentElement.className = themeClasses.join(" ");

  // 添加新的主题类
  document.documentElement.classList.add(className);
  // 判断是否启用暗黑模式
  if (mode.value === "dark") {
    document.documentElement.classList.add("dark");
  }

  // 存储当前主题配置
  localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeConfig));
}

/**
 * 初始化主题
 */
export function initializeTheme() {
  // 获取当前主题配置并应用
  const themeConfig = getCurrentThemeConfig();
  // 初始化当前主题类型
  if (themeConfig.model.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 获取当前主题配置
 * @returns 主题配置
 */
export function getCurrentThemeConfig(): ThemeConfig {
  let theme: any = localStorage.getItem(THEME_STORAGE_KEY);
  return theme
    ? JSON.parse(theme)
    : {
        theme: getThemeList()[0], // 默认主题
        model: {
          name: "跟随系统",
          followSystem: true,
          value: isDarkMode() ? "dark" : "light",
        },
      };
}

/**
 * 添加暗黑模式监听
 */
export function addDarkListener() {
  // 监听暗黑模式变化, auto 模式动态切换主题
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      const themeConfig = getCurrentThemeConfig();
      if (!themeConfig.model.followSystem) return;
      changeThemeMode(themeConfig.model);
    });
}

/**
 * 移除暗黑模式监听
 */
export function removeDarkListener() {
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .removeEventListener("change", () => {});
}

/**
 * 切换主题模式
 * @param mode 模式
 */
export function changeThemeMode(themeModel: ThemeModel) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.model = themeModel;
  if (themeModel.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 切换主题
 * @param theme 主题
 */
export function changeTheme(theme: Theme) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.theme = theme;
  applyTheme(themeConfig);
}

/**
 * 获取主题列表
 * @returns 主题列表
 */
export function getThemeList(): Theme[] {
  return [
    {
      name: "默认",
      className: "theme-default",
    },
    {
      name: "星空",
      className: "theme-starry",
    },
    {
      name: "海洋",
      className: "theme-ocean",
    },
  ];
}

主题、模式手动切换组件

源码位置:src/themes/Theme.vue

组件内会自动加载站点支持的主题与模式,也会根据系统模式变化自动切换状态信息,源码内有注释,此处不赘述

<script setup lang="ts">
import { SettingOutlined, BulbFilled } from "@ant-design/icons-vue";
import { onMounted, onUnmounted, ref } from "vue";
import {
  Theme,
  getThemeList,
  getCurrentThemeConfig,
  changeTheme,
  changeThemeMode,
} from "./theme";

const themeList = ref<Theme[]>(getThemeList());
const currentTheme = ref<Theme>(getCurrentThemeConfig().theme);
const followSystem = ref<boolean>(getCurrentThemeConfig().model.followSystem);
const isLightModel = ref<boolean>(
  getCurrentThemeConfig().model.value == "light"
);

// 切换主题
function onChangeTheme(theme: Theme) {
  currentTheme.value = theme;
  changeTheme(theme);
}

// 切换跟随系统
function onFollowSystemChange() {
  followSystem.value = !followSystem.value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.followSystem = followSystem.value;
  changeThemeMode(themeConfig.model);
}

// 切换主题模式
function onChangeThemeModel(value: boolean) {
  isLightModel.value = value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.value = value ? "light" : "dark";
  changeThemeMode(themeConfig.model);
}

// 添加主题模式监听
let interval: NodeJS.Timeout | null = null;
onMounted(() => {
  // 定时更新主题信息
  interval = setInterval(() => {
    const themeConfig = getCurrentThemeConfig();
    currentTheme.value = themeConfig.theme;
    followSystem.value = themeConfig.model.followSystem;
    isLightModel.value = themeConfig.model.value == "light";
  }, 200);
});

onUnmounted(() => {
  // 移除定时更新主题信息
  interval && clearInterval(interval);
});
</script>

<template>
  <div class="theme-root center">
    <a-dropdown placement="bottom">
      <div class="theme-btn center">
        <SettingOutlined />
      </div>
      <template #overlay>
        <a-menu>
          <div
            class="theme-item"
            v-for="theme in themeList"
            :key="theme.className"
            @click="onChangeTheme(theme)"
          >
            <div class="row">
              <div
                style="width: var(--space-xl); font-size: var(--font-size-sm)"
              >
                <BulbFilled
                  class="sign"
                  v-if="theme.className == currentTheme.className"
                />
              </div>
              <div>{{ theme.name }}-主题</div>
            </div>
          </div>
          <div class="theme-model-item row">
            <a-radio
              v-model:checked="followSystem"
              @click="onFollowSystemChange()"
              >🖥️</a-radio
            >
            <a-switch
              checked-children="☀️"
              un-checked-children="🌑"
              v-model:checked="isLightModel"
              :disabled="followSystem"
              @change="onChangeThemeModel"
            />
          </div>
        </a-menu>
      </template>
    </a-dropdown>
  </div>
</template>

<style scoped>
.theme-root {
  padding: var(--space-lg);
}
.theme-btn {
  padding: var(--space-xs) var(--space-lg);
  font-size: var(--font-size-2xl);
  color: var(--brand-primary);
}
.theme-item {
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  user-select: none;
  cursor: pointer;

  .sign {
    color: var(--brand-accent);
  }

  &:hover {
    background: var(--brand-secondary);
    color: var(--text-inverse);
  }

  &:active {
    background: var(--brand-primary);
    color: var(--text-inverse);
    .sign {
      color: var(--text-inverse);
    }
  }
}
.theme-model-item {
  padding: var(--space-sm) var(--space-md);
  color: var(--text-primary);
  user-select: none;
}
</style>

vue main.js 文件内容

源码位置:src/main.js

该文件内需引入 "src/themes/index.css" 文件,如下

import { createApp } from "vue";
import Antd from "ant-design-vue";
import "./themes/index.css";
import App from "./App.vue";

createApp(App).use(Antd).mount("#app");

主题初始化、模式监听

源码位置:src/App.vue

src/App.vue 文件是 vue 所有的页面基础,在此处初始化主题信息、监听模式变化比较合适。

  • 初始化主题样式只需要调用 src/themes/theme.ts 内的 initializeTheme() 函数即可
  • 监听模式变化需要在组件挂载之后,在 onMounted 函数内调用 addDarkListener() 函数即可
  • 移除监听需要在组件卸载之后,在 onUnmounted 函数内调用 removeDarkListener() 函数即可

src/App.vue 文件内 script 块部分源码如下

function initialize() {
  // 初始化主题样式
  initializeTheme();
}
initialize();
// 组件生命周期钩子
onMounted(() => {
  initialize();
  // 添加暗黑模式监听器
  addDarkListener();
});
onUnmounted(() => {
  // 移除暗黑模式监听器
  removeDarkListener();
});

仓库地址:

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上

今天来分享 10 个 Vue3 的性能优化技巧。

核心原则
减少不必要的响应式追踪
避免无谓的 DOM 操作
按需加载资源

咱也不要为了优化而优化!小项目用默认写法完全没问题,优化应在性能瓶颈出现后进行。

这些技巧不难,但都非常关键。 看完你会发现:原来 Vue3 还能这么写。


1. 使用 shallowReactive 替代 reactive

问题
reactive 会让对象里每一层都变得“敏感”——哪怕你只改了最里面的某个小字段,Vue 也会花力气去追踪它。数据一大,性能就变慢。

解决方案
对不需要深层响应的数据,使用 shallowReactive,只让最外层变成响应式的。

示例

import { shallowReactive } from 'vue';

const data = shallowReactive({
  list: [],
  meta: { total: 0 }
});

适用场景
当你从后端拿到一大坨只读数据(比如表格列表、API 响应),且不会修改嵌套属性时。


2. 用 toRefs 解构响应式对象

问题
如果你直接从 reactive 对象里解构变量(如 const { name } = state),这个 name 就变成普通变量了,修改它不会触发页面更新。

解决方案
使用 toRefs 解构,保持每个属性的响应性。

示例

const state = reactive({ name: 'Vue', age: 3 });
const { name, age } = toRefs(state); // name 和 age 依然是响应式的!

好处
在模板中可以直接写 {{ name }},不用写 {{ state.name }},代码更清爽。


3. 优先使用 watchEffect 而非 watch

区别

  • watch:你要手动指定监听谁(比如 watch(count, ...))。
  • watchEffect:你只写逻辑,Vue 自动分析里面用了哪些响应式变量,并监听它们。

示例

watchEffect(() => {
  // Vue 自动发现 count.value 被用了 → 只要 count 变,这段就执行
  localStorage.setItem('count', count.value);
});

适合场景
保存用户输入到本地缓存、根据筛选条件自动请求数据、同步状态到 URL 等。


4. 利用 <Suspense> 优雅处理异步组件

问题
动态加载组件(如通过 import())时,页面可能白屏几秒,用户体验差。

解决方案
<Suspense> 包裹异步组件,显示 loading 提示。

示例

<Suspense>
  <template #default>
    <UserProfile /> <!-- 必须是异步组件 -->
  </template>
  <template #fallback>
    <div>加载中,请稍候…</div>
  </template>
</Suspense>

注意
仅适用于异步组件(即用 defineAsyncComponent() => import(...) 定义的组件)。


5. 使用 <Teleport> 解决模态框层级问题

问题
弹窗写在组件内部,可能被父级的 overflow: hiddenz-index 限制,导致显示不全或盖不住其他内容。

解决方案
<Teleport> 把组件“传送”到 <body> 底部,脱离当前 DOM 树。

示例

<Teleport to="body">
  <Modal v-if="show" />
</Teleport>

类比
就像你在客厅写了个气球,但它实际飘到了天空——不受房间天花板限制。

常用目标to="body" 是最常见用法。


6. 自定义指令封装高频操作(如复制)

问题
复制文本、防抖点击、自动聚焦……这些功能到处都要用,每次都写一堆代码很麻烦。

解决方案
写一个自定义指令,一次定义,处处使用。

示例

app.directive('copy', {
  mounted(el, binding) {
    el.addEventListener('click', () => {
      navigator.clipboard.writeText(binding.value);
    });
  }
});

使用

<button v-copy="'要复制的内容'">点我复制</button>

好处:逻辑集中、复用性强、模板干净。


7. 用 Pinia 插件扩展 store 能力

问题
每个 store 都想加个“重置”功能?手动一个个写太重复。

解决方案
通过 Pinia 插件,一次性给所有 store 添加 $reset() 方法。

正确实现

pinia.use(({ store }) => {
  // 保存初始状态快照(深拷贝)
  const initialState = JSON.parse(JSON.stringify(store.$state));
  store.$reset = () => {
    store.$state = initialState;
  };
});

使用

const userStore = useUserStore();
userStore.$reset(); // 恢复初始状态

适用场景:表单重置、清除缓存、统一日志等。

注意:不能直接用 store.$patch(store.$state),因为 $state 是当前状态,不是初始状态!


8. v-memo 优化大型列表渲染

问题
列表有上千项,哪怕只改了一行的状态,Vue 默认会重新比对整张表,浪费性能。

解决方案
v-memo 告诉 Vue:“只有这些值变了,才需要重新渲染这一行”。

示例

<li v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.name }} —— 状态:{{ item.status }}
</li>

注意事项

  • 适合内容稳定、更新频率低的大列表。
  • 不要和 <transition-group> 一起用(会失效)。
  • 高频变动的列表慎用,可能适得其反。

v-memo 是 Vue 3.2+ 的功能。


9. 虚拟滚动(Virtual Scrolling)

问题
渲染 10,000 条消息?浏览器直接卡死!

解决方案
只渲染“当前可见区域”的内容,滑动时动态替换,内存和性能都省下来。

推荐库(Vue 3 兼容)

安装 & 示例(以 vueuc 为例)

npm install vueuc
<script setup>
import { VirtualList } from 'vueuc';
</script>

<template>
  <VirtualList :items="messages" :item-height="60" :bench="10">
    <template #default="{ item }">
      <MessageItem :msg="item" />
    </template>
  </VirtualList>
</template>

类比
就像微信聊天记录——你往上滑,旧消息才加载;不滑的时候,几千条其实没真画出来。


10. 路由与组件懒加载 + 图片优化

组件懒加载

原理:不是一打开网页就加载所有页面,而是“用到哪个才加载哪个”。

写法

{ path: '/about', component: () => import('./views/About.vue') }

好处:首屏加载更快,节省流量和内存。

图片优化

  • 用 WebP 格式:比 JPG/PNG 小 30%~50%,清晰度不变(现代浏览器都支持)。
  • 图片懒加载:屏幕外的图先不加载,滑到附近再加载。
  • 关键图预加载:首页 Banner 图提前加载,避免白块。

简单懒加载(原生支持)

<img src="image.jpg" loading="lazy" alt="示例图" />

兼容性提示loading="lazy" 在 Chrome/Firefox/Edge 支持良好,但 Safari 15.4 以下和 IE 不支持。若需兼容旧环境,建议搭配 IntersectionObserver 或第三方库(如 lazysizes)。


总结

技巧 解决什么问题 关键词
shallowReactive 大对象响应式开销大 浅响应
toRefs 解构丢失响应性 保持链接
watchEffect 手动监听麻烦 自动追踪
<Suspense> 异步组件白屏 加载提示
<Teleport> 弹窗被遮挡 脱离 DOM
自定义指令 重复逻辑多 一键复用
Pinia 插件 store 功能重复 全局增强
v-memo 大列表重渲染 按需更新
虚拟滚动 上万条卡顿 只渲染可见
懒加载 + 图片优化 首屏慢、流量大 按需加载

先写出清晰可维护的代码,再根据实际性能问题选择合适的优化手段!

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

解锁Vue新姿势:5种定义全局方法的实用技巧,让你的代码更优雅!

解锁Vue新姿势:5种定义全局方法的实用技巧,让你的代码更优雅!

无论你是Vue新手还是有一定经验的开发者,相信在工作中都遇到过这样的场景:多个组件需要用到同一个工具函数,比如格式化日期、权限验证、HTTP请求等。如果每个组件都单独引入,不仅代码冗余,维护起来也让人头疼。

今天我就为大家分享5种定义全局方法的实用方案,让你轻松解决这个问题!

🤔 为什么需要全局方法?

先来看一个真实的例子。假设你的项目中有三个组件都需要格式化日期:

// UserProfile.vue
methods: {
  formatDate(date) {
    return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  }
}

// OrderList.vue  
methods: {
  formatDate(date) {
    return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  }
}

// Dashboard.vue
methods: {
  formatDate(date) {
    return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  }
}

发现了问题吗?同样的代码写了三遍!  这就是我们需要全局方法的原因。

📝 方案一:Vue.prototype(最经典的方式)

这是Vue 2时代最常用的方法,直接扩展Vue的原型链:

// main.js 或 plugins/global.js
import Vue from 'vue'

// 定义全局方法
Vue.prototype.$formatDate = function(date) {
  const dayjs = require('dayjs')
  return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}

Vue.prototype.$checkPermission = function(permission) {
  const user = this.$store.state.user
  return user.permissions.includes(permission)
}

// 在组件中使用
export default {
  mounted() {
    console.log(this.$formatDate(new Date()))
    if (this.$checkPermission('admin')) {
      // 执行管理员操作
    }
  }
}

优点:

  • • 使用简单,直接通过 this 调用
  • • 广泛支持,兼容性好

缺点:

  • • 污染Vue原型链
  • • 方法多了难以管理
  • • TypeScript支持需要额外声明

🎯 方案二:全局混入(适合通用逻辑)

如果你有一组相关的全局方法,可以考虑使用混入:

// mixins/globalMethods.js
export default {
  methods: {
    $showSuccess(message) {
      this.$message.success(message)
    },
    $showError(error) {
      this.$message.error(error.message || '操作失败')
    },
    $confirmAction(title, content) {
      return this.$confirm(content, title, {
        type'warning'
      })
    }
  }
}

// main.js
import Vue from 'vue'
import GlobalMixin from './mixins/globalMethods'

Vue.mixin(GlobalMixin)

// 组件中使用
export default {
  methods: {
    async deleteItem() {
      try {
        await this.$confirmAction('确认删除''确定删除该记录吗?')
        await api.deleteItem(this.id)
        this.$showSuccess('删除成功')
      } catch (error) {
        this.$showError(error)
      }
    }
  }
}

适合场景:  UI反馈、确认对话框等通用交互逻辑。

🏗️ 方案三:独立模块 + Provide/Inject(Vue 3推荐)

Vue 3提供了更优雅的解决方案:

// utils/globalMethods.js
export const globalMethods = {
  // 防抖函数
  debounce(fn, delay = 300) {
    let timer = null
    return function(...args) {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => {
        fn.apply(this, args)
      }, delay)
    }
  },
  
  // 深度拷贝
  deepClone(obj) {
    return JSON.parse(JSON.stringify(obj))
  },
  
  // 生成唯一ID
  generateId() {
    return Math.random().toString(36).substr(29)
  }
}

// main.js
import { createApp } from 'vue'
import { globalMethods } from './utils/globalMethods'

const app = createApp(App)

// 通过provide提供给所有组件
app.provide('$global', globalMethods)

// 组件中使用
import { inject } from 'vue'

export default {
  setup() {
    const $global = inject('$global')
    
    const handleInput = $global.debounce((value) => {
      console.log('搜索:', value)
    }, 500)
    
    return { handleInput }
  }
}

这是Vue 3的推荐方式,保持了良好的类型推断和代码组织。

📦 方案四:插件化封装(企业级方案)

对于大型项目,建议采用插件化的方式:

// plugins/globalMethods.js
const GlobalMethodsPlugin = {
  install(app, options) {
    // 添加全局方法
    app.config.globalProperties.$http = async (url, config) => {
      try {
        const response = await fetch(url, config)
        return await response.json()
      } catch (error) {
        console.error('请求失败:', error)
        throw error
      }
    }
    
    app.config.globalProperties.$validate = {
      email(email) {
        return /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email)
      },
      phone(phone) {
        return /^1[3-9]\d{9}$/.test(phone)
      }
    }
    
    // 添加全局属性
    app.config.globalProperties.$appName = options?.appName || 'My App'
    
    // 添加自定义指令
    app.directive('focus', {
      mounted(el) {
        el.focus()
      }
    })
  }
}

// main.js
import { createApp } from 'vue'
import GlobalMethodsPlugin from './plugins/globalMethods'

const app = createApp(App)
app.use(GlobalMethodsPlugin, {
  appName'企业管理系统'
})

// 组件中使用
export default {
  mounted() {
    // 使用全局方法
    this.$http('/api/users')
    
    // 使用验证
    if (this.$validate.email(this.email)) {
      // 邮箱有效
    }
    
    // 访问全局属性
    console.log('应用名称:'this.$appName)
  }
}

🌟 方案五:Composition API方式(最现代)

如果你使用Vue 3的Composition API,可以这样组织:

// composables/useGlobalMethods.js
import { readonly } from 'vue'

export function useGlobalMethods() {
  // 定义所有全局方法
  const methods = {
    // 金额格式化
    formatCurrency(amount) {
      return '¥' + Number(amount).toFixed(2)
    },
    
    // 文件大小格式化
    formatFileSize(bytes) {
      const units = ['B''KB''MB''GB']
      let size = bytes
      let unitIndex = 0
      
      while (size >= 1024 && unitIndex < units.length - 1) {
        size /= 1024
        unitIndex++
      }
      
      return `${size.toFixed(1)} ${units[unitIndex]}`
    },
    
    // 复制到剪贴板
    async copyToClipboard(text) {
      try {
        await navigator.clipboard.writeText(text)
        return true
      } catch {
        // 降级方案
        const textArea = document.createElement('textarea')
        textArea.value = text
        document.body.appendChild(textArea)
        textArea.select()
        document.execCommand('copy')
        document.body.removeChild(textArea)
        return true
      }
    }
  }
  
  return readonly(methods)
}

// main.js
import { createApp } from 'vue'
import { useGlobalMethods } from './composables/useGlobalMethods'

const app = createApp(App)

// 挂载到全局
app.config.globalProperties.$globalMethods = useGlobalMethods()

// 组件中使用
import { getCurrentInstance } from 'vue'

export default {
  setup() {
    const instance = getCurrentInstance()
    const $global = instance?.appContext.config.globalProperties.$globalMethods
    
    // 或者在setup中直接引入
    // const $global = useGlobalMethods()
    
    return { $global }
  },
  mounted() {
    console.log(this.$global.formatCurrency(1234.56))
  }
}

📊 5种方案对比总结

方案 适用版本 优点 缺点 推荐指数
Vue.prototype Vue 2 简单直接 污染原型链 ⭐⭐⭐
全局混入 Vue 2/3 逻辑分组 可能造成冲突 ⭐⭐⭐
Provide/Inject Vue 3 类型安全 使用稍复杂 ⭐⭐⭐⭐
插件封装 Vue 2/3 功能完整 配置复杂 ⭐⭐⭐⭐⭐
Composition API Vue 3 现代灵活 需要Vue 3 ⭐⭐⭐⭐⭐

💡 最佳实践建议

  1. 1. 按功能分类组织
// 不推荐:把所有方法堆在一个文件
// 推荐:按功能模块拆分
utils/
  ├── formatters/    # 格式化相关
  ├── validators/    # 验证相关  
  ├── http/         # 请求相关
  └── ui/           # UI交互相关
  1. 2. 添加TypeScript支持
// global.d.ts
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $formatDate(date: Date) => string
    $checkPermission(permission: string) => boolean
  }
}
  1. 3. 注意性能影响
  • • 避免在全局方法中执行重逻辑
  • • 考虑使用懒加载
  • • 及时清理不再使用的方法
  1. 4. 保持方法纯净
  • • 一个方法只做一件事
  • • 做好错误处理
  • • 添加详细的JSDoc注释

🎁 福利:一个实用的全局方法库

我整理了一些常用的全局方法,你可以直接使用:

// utils/essentials.js
export const essentials = {
  // 下载文件
  downloadFile(url, filename) {
    const link = document.createElement('a')
    link.href = url
    link.download = filename
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
  },
  
  // 获取URL参数
  getUrlParam(name) {
    const params = new URLSearchParams(window.location.search)
    return params.get(name)
  },
  
  // 休眠函数
  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms))
  },
  
  // 对象转FormData
  objectToFormData(obj) {
    const formData = new FormData()
    Object.keys(obj).forEach(key => {
      formData.append(key, obj[key])
    })
    return formData
  }
}

✨ 结语

掌握全局方法的定义和使用,能够让你的Vue项目更加模块化、可维护、高效。不同的方案适用于不同的场景和需求,关键是要根据项目实际情况选择最合适的方式。

记住:好的代码不是写出来的,而是设计出来的。

希望今天的分享对你有帮助!如果你有更好的方案或实践经验,欢迎在评论区留言分享。

vscode 中找settings.json 配置

在VSCode中查找和配置settings.json,最快捷的方式是通过命令面板直接打开,具体操作如下:

一、快速打开settings.json的方法

方法1:命令面板(推荐)

  1. Ctrl + Shift + P(Windows/Linux)或 Cmd + Shift + P(macOS)
  2. 输入"Preferences: Open Settings (JSON)"并回车
  3. 系统会直接打开当前生效的settings.json文件(通常是用户全局设置)

方法2:设置界面跳转

  1. Ctrl + ,打开设置UI界面
  2. 点击右上角的"打开设置(JSON)"图标(文件图标)
  3. 自动跳转到对应的JSON文件

方法3:文件路径访问

  • 用户全局设置:在资源管理器中输入对应路径(Windows:%APPDATA%\Code\User\settings.json
  • 工作区设置:项目根目录下的.vscode/settings.json(需先创建文件夹)

二、配置示例(针对"不换行整理"需求)

在打开的settings.json文件中,添加或修改以下配置:

{
  // 全局格式化设置
  "editor.formatOnSave": true,
  "editor.wordWrap": "off",
  
  // Prettier配置(如使用Prettier)
  "prettier.printWidth": 200,
  "prettier.proseWrap": "never",
  
  // 各语言默认格式化器
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.defaultFormatter": "vscode.html-language-features",
    "html.format.wrapLineLength": 0
  }
}

三、配置注意事项

  1. 保存生效:修改后按Ctrl + S保存,配置立即生效
  2. JSON格式:确保文件是合法的JSON格式(逗号、引号正确)
  3. 优先级:工作区设置(项目内)会覆盖用户全局设置
  4. 扩展依赖:如果配置了Prettier等格式化器,需确保已安装对应扩展

四、验证配置是否生效

  1. 打开一个代码文件
  2. Ctrl + S保存,观察是否按预期格式化(不自动换行)
  3. 或手动按Shift + Alt + F格式化,检查效果

如果配置后仍自动换行,可能是其他扩展或配置冲突,建议检查:

  • 是否安装了多个格式化扩展
  • 通过命令面板"Format Document With..."查看当前使用的格式化器
  • 在状态栏右下角查看当前文件使用的格式化工具

核心提示:日常使用建议通过命令面板(Ctrl+Shift+P)快速打开,这是最直接且不易出错的方式。配置时注意JSON语法正确性,保存后即可生效。

Vue项目中使用xlsx库解析Excel文件

项目中有个需求是上传Excel实现批量导入,但是解析Excel的需要前端来实现,所以用到了xlsx库

xlsx 库是一个强大的 JavaScript 库,用于处理 Excel 文件,支持:

  • 读取 .xls.xlsx 格式
  • 写入 Excel 文件
  • 解析工作表数据
  • 支持多种数据格式转换

在项目中安装 xlsx 库:

npm install xlsx
# 或者使用 yarn
yarn add xlsx
# 或者使用 pnpm
pnpm add xlsx

核心 API

import * as XLSX from 'xlsx';

// 主要方法
XLSX.read(data, options)      // 读取 Excel 数据
XLSX.readFile(filename)       // 从文件读取
XLSX.utils.sheet_to_json()    // 工作表转 JSON
XLSX.utils.sheet_to_csv()     // 工作表转 CSV
XLSX.utils.sheet_to_html()    // 工作表转 HTML

Excel 文件读取与解析

1. 使用 FileReader 读取文件

在浏览器环境中,我们需要使用 FileReader API 来读取用户上传的文件:

const readExcelFile = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    
    reader.onload = (e) => {
      try {
        // 读取文件内容
        const data = new Uint8Array(e.target.result);
        resolve(data);
      } catch (error) {
        reject(new Error('文件读取失败'));
      }
    };
    
    reader.onerror = () => {
      reject(new Error('文件读取失败'));
    };
    
    // 以 ArrayBuffer 格式读取文件
    reader.readAsArrayBuffer(file);
  });
};

2. 解析 Excel 文件

使用 XLSX.read() 方法解析 Excel 数据:

const parseExcelData = (data) => {
  // 读取 Excel 工作簿
  const workbook = XLSX.read(data, { type: 'array' });
  
  // 获取所有工作表名称
  const sheetNames = workbook.SheetNames;
  console.log('工作表名称:', sheetNames);
  
  // 获取第一个工作表
  const firstSheetName = sheetNames[0];
  const worksheet = workbook.Sheets[firstSheetName];
  
  // 将工作表转换为 JSON
  const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
  
  return {
    workbook,
    worksheet,
    jsonData,
    sheetNames
  };
};

3. 不同数据格式的转换

// 转换为 JSON 对象(带表头)
const jsonWithHeaders = XLSX.utils.sheet_to_json(worksheet);

// 转换为 JSON 数组(不带表头)
const jsonArray = XLSX.utils.sheet_to_json(worksheet, { header: 1 });

// 转换为 CSV 字符串
const csvString = XLSX.utils.sheet_to_csv(worksheet);

// 转换为 HTML 表格
const htmlString = XLSX.utils.sheet_to_html(worksheet);

表头验证与数据提取

1. 验证表头格式

在实际应用中,我们通常需要验证 Excel 文件的表头是否符合预期格式:

const validateExcelHeaders = (jsonData, requiredHeaders) => {
  if (jsonData.length === 0) {
    throw new Error('Excel文件为空');
  }
  
  // 获取表头行(第一行)
  const headers = jsonData[0].map(header => 
    header ? header.toString().trim() : ''
  );
  
  // 检查必需表头
  const missingHeaders = requiredHeaders.filter(header =>
    !headers.includes(header)
  );
  
  if (missingHeaders.length > 0) {
    throw new Error(`缺少必需表头: ${missingHeaders.join(', ')}`);
  }
  
  return headers;
};

2. 提取数据行

const extractDataRows = (jsonData, headers) => {
  // 跳过表头行(第一行)
  const dataRows = jsonData.slice(1);
  
  return dataRows.map((row, rowIndex) => {
    const rowData = {};
    
    headers.forEach((header, colIndex) => {
      rowData[header] = row[colIndex] || '';
    });
    
    return {
      ...rowData,
      _rowNumber: rowIndex + 2 // Excel 行号(从1开始,表头为第1行)
    };
  }).filter(row => {
    // 过滤空行(所有单元格都为空)
    return Object.values(row).some(value => 
      value !== '' && value !== undefined && value !== null
    );
  });
};

3. 数据验证与清洗

const validateAndCleanData = (dataRows, validationRules) => {
  const errors = [];
  const cleanedData = [];
  
  dataRows.forEach((row, index) => {
    const rowErrors = [];
    
    // 检查每个字段
    Object.keys(validationRules).forEach(field => {
      const value = row[field];
      const rules = validationRules[field];
      
      // 必填验证
      if (rules.required && (!value || value.toString().trim() === '')) {
        rowErrors.push(`${field} 不能为空`);
      }
      
      // 类型验证
      if (value && rules.type) {
        if (rules.type === 'number' && isNaN(Number(value))) {
          rowErrors.push(`${field} 必须是数字`);
        }
        if (rules.type === 'email' && !isValidEmail(value)) {
          rowErrors.push(`${field} 格式不正确`);
        }
      }
      
      // 枚举值验证
      if (value && rules.enum && !rules.enum.includes(value)) {
        rowErrors.push(`${field} 必须是以下值之一: ${rules.enum.join(', ')}`);
      }
    });
    
    if (rowErrors.length === 0) {
      cleanedData.push(row);
    } else {
      errors.push({
        row: row._rowNumber,
        errors: rowErrors
      });
    }
  });
  
  return { cleanedData, errors };
};
❌