阅读视图

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

让 Vant 弹出层适配 Uniapp Webview 返回键

问题背景

在 UniApp Webview 中使用 Vant 组件库时,返回键的行为往往不符合用户预期:

  • 当 Popup、Dialog、ActionSheet 等弹出层打开时,用户按下返回键会直接返回上一页,而不是关闭弹出层
  • 多层弹出层叠加时,无法按层级顺序依次关闭
  • Vant 内置的 closeOnPopstate 仅会在页面回退时自动关闭弹窗,而不会阻止页面回退

这导致用户体验与原生应用存在明显差距。

解决方案

@vue-spark/back-handler 提供了基于栈的返回键处理机制,可以与 Vant 组件无缝集成,让弹出层正确响应 UniApp 的返回键事件。

核心思路:将每个需要响应返回键的弹出层注册到全局栈中,按后进先出的顺序处理返回事件。

使用方式

1. 安装依赖

npm install @vue-spark/back-handler

2. 初始化插件(UniApp 适配)

// main.ts
import { BackHandler } from '@vue-spark/back-handler'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App)
  .use((app) => {
    const routerHistory = router.options.history

    let initialPosition = 0
    const hasRouteHistory = () => {
      // 当 vue-router 内部记录的位置不是初始位置时认为还存在历史记录
      return routerHistory.state.position !== initialPosition
    }

    router.isReady().then(() => {
      // 记录初始位置
      initialPosition = routerHistory.state.position as number

      router.afterEach(() => {
        // 每次页面变更后通知 uniapp 是否需要阻止返回键默认行为
        uni.postMessage({
          type: 'preventBackPress',
          data: hasRouteHistory(),
        })
      })
    })

    // 注册插件
    BackHandler.install(app, {
      // 每次增加栈记录时通知 uniapp 阻止返回键默认行为
      onPush() {
        uni.postMessage({
          type: 'preventBackPress',
          data: true,
        })
      },

      // 每次移除栈记录时通知 uniapp 是否阻止返回键默认行为
      onRemove() {
        uni.postMessage({
          type: 'preventBackPress',
          data: BackHandler.stack.length > 0 || hasRouteHistory(),
        })
      },

      // 栈为空时尝试页面回退
      fallback() {
        hasRouteHistory() && router.back()
      },

      // 这里绑定 uniapp webview 触发的 backbutton 事件
      bind(handler) {
        window.addEventListener('uni:backbutton', handler)
      },
    })
  })

  // 在这里注册 router
  .use(router)

  // 挂载应用
  .mount('#app')

3. 适配 Vant Popup

通过扩展 Popup 的 setup 函数,让所有基于 Popup 的组件(ActionSheet、ShareSheet、Picker 等)都支持返回键关闭:

import { useBackHandler } from '@vue-spark/back-handler'
import { Dialog, Popup } from 'vant'
import { callInterceptor } from 'vant/es/utils'
import { getCurrentInstance, watch } from 'vue'

const { setup } = Popup

// 变更 closeOnPopstate 默认值为 true
Popup.props.closeOnPopstate = {
  type: Boolean,
  default: true,
}

Popup.setup = (props, ctx) => {
  const { emit } = ctx
  const vm = getCurrentInstance()!

  // Dialog 组件基于 Popup,这里需要排除,否则会重复注册
  if (vm.parent?.type !== Dialog) {
    const close = () => {
      return new Promise<void>((resolve, reject) => {
        if (!props.show) {
          return resolve()
        }

        callInterceptor(props.beforeClose, {
          done() {
            emit('close')
            emit('update:show', false)
            resolve()
          },
          canceled() {
            reject(new Error('canceled'))
          },
        })
      })
    }

    const { push, remove } = useBackHandler(
      () => props.show,
      // closeOnPopstate 用于控制是否响应返回键
      () => !!props.closeOnPopstate && close(),
    )

    watch(
      () => props.show,
      (value) => (value ? push() : remove()),
      { immediate: true, flush: 'sync' },
    )
  }

  return setup!(props, ctx)
}

4. 适配 Vant Dialog

Dialog 需要单独适配,因为它基于 Popup 但有独立的关闭逻辑:

import { useBackHandler } from '@vue-spark/back-handler'
import { Dialog, showLoadingToast } from 'vant'
import { callInterceptor } from 'vant/es/utils'
import { watch } from 'vue'

// Dialog 的 closeOnPopstate 默认为 true,可以不修改默认值
const { setup } = Dialog

Dialog.setup = (props, ctx) => {
  const { emit } = ctx
  const updateShow = (value: boolean) => emit('update:show', value)

  const close = (action: 'cancel') => {
    updateShow(false)
    props.callback?.(action)
  }

  const getActionHandler = (action: 'cancel') => () => {
    return new Promise<void>((resolve, reject) => {
      if (!props.show) {
        return resolve()
      }

      emit(action)

      if (props.beforeClose) {
        const toast = showLoadingToast({})
        callInterceptor(props.beforeClose, {
          args: [action],
          done() {
            close(action)
            toast.close()
            resolve()
          },
          canceled() {
            toast.close()
            reject(new Error('canceled'))
          },
        })
      } else {
        close(action)
        resolve()
      }
    })
  }

  const { push, remove } = useBackHandler(
    () => props.show,
    // closeOnPopstate 用于控制是否响应返回键
    () => !!props.closeOnPopstate && getActionHandler('cancel')(),
  )

  watch(
    () => props.show,
    (value) => (value ? push() : remove()),
    { immediate: true, flush: 'sync' },
  )

  return setup!(props, ctx)
}

效果

完成上述配置后:

  • Popup、ActionSheet、ShareSheet、Picker、Dialog 等弹出层在打开时,按返回键会关闭弹出层而不是退出页面
  • 多层弹出层会按打开顺序的逆序依次关闭
  • 支持 beforeClose 拦截器进行异步确认

相关链接

手写简易Vue响应式:基于Proxy + effect的核心实现

Vue的响应式系统是其核心特性之一,从Vue2到Vue3,响应式的实现方案从Object.defineProperty演进为Proxy。相比前者,Proxy能原生支持数组、对象新增属性等场景,且对对象的拦截更全面。本文将从核心原理出发,手把手教你实现一个基于Proxy + effect的简易Vue响应式系统,帮你彻底搞懂响应式的底层逻辑。

一、响应式的核心原理是什么?

响应式的本质是“数据变化驱动视图更新”,其核心逻辑可拆解为三个关键步骤:

  1. 依赖收集:当组件渲染(或effect执行)时,会访问响应式数据,此时记录“数据-依赖(effect)”的映射关系;
  2. 数据拦截:通过Proxy拦截响应式数据的读取(get)和修改(set)操作——读取时触发依赖收集,修改时触发依赖更新;
  3. 依赖触发:当响应式数据被修改时,找到之前收集的所有依赖(effect),并重新执行这些依赖,从而实现视图更新或其他副作用触发。

其中,Proxy负责“数据拦截”,effect负责封装“依赖(副作用函数)”,再配合一个“依赖映射表”完成整个响应式闭环。

二、核心模块拆解与实现

我们将分三步实现简易响应式系统:先实现effect模块封装副作用,再实现reactive模块基于Proxy拦截数据,最后通过依赖映射表关联两者,完成依赖收集与触发。

1. 第一步:实现effect——副作用函数封装

effect的作用是包裹需要响应式触发的副作用函数(比如组件渲染函数、watch回调等)。当effect执行时,会主动触发响应式数据的get操作,进而触发依赖收集;当数据变化时,effect会被重新执行。

核心逻辑:

  • 定义一个全局变量(activeEffect),用于标记当前正在执行的effect;
  • effect函数接收一个副作用函数(fn),执行fn前将其赋值给activeEffect,执行后清空activeEffect(避免非响应式数据访问时误收集依赖)。

代码实现:

// 全局变量:标记当前活跃的effect(正在执行的副作用函数)
let activeEffect = null;

/**
 * 副作用函数封装
 * @param {Function} fn - 需要响应式触发的副作用函数
 */
function effect(fn) {
  // 定义一个包装函数,便于后续扩展(如错误处理、调度执行等)
  const effectFn = () => {
    // 执行副作用函数前,先标记当前活跃的effect
    activeEffect = effectFn;
    // 执行副作用函数(此时会访问响应式数据,触发get拦截,进而收集依赖)
    fn();
    // 执行完成后,清空标记(避免后续非响应式数据访问时误收集)
    activeEffect = null;
  };

  // 立即执行一次副作用函数,触发初始的依赖收集
  effectFn();
}

2. 第二步:实现依赖映射表——track与trigger

我们需要一个数据结构来存储“数据-属性-effect”的映射关系,这里采用WeakMap(数据)→ Map(属性)→ Set(effect)的结构:

  • WeakMap:key为响应式对象(target),value为Map(属性映射表),弱引用特性可避免内存泄漏;
  • Map:key为对象的属性名(key),value为Set(存储该属性对应的所有effect);
  • Set:存储effect,保证effect不重复(避免多次执行同一副作用)。

基于这个结构,实现两个核心函数:

  • track:在响应式数据被读取时调用,收集依赖(将activeEffect存入映射表);
  • trigger:在响应式数据被修改时调用,触发依赖(从映射表中取出effect并执行)。

代码实现:

// 依赖映射表:WeakMap(target) → Map(key) → Set(effect)
const targetMap = new WeakMap();

/**
 * 收集依赖(响应式数据读取时触发)
 * @param {Object} target - 响应式对象
 * @param {string} key - 被读取的属性名
 */
function track(target, key) {
  // 1. 若当前无活跃的effect,无需收集依赖,直接返回
  if (!activeEffect) return;

  // 2. 从targetMap中获取当前对象的属性映射表(Map)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    // 若不存在,创建新的Map并存入targetMap
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  // 3. 从depsMap中获取当前属性的effect集合(Set)
  let deps = depsMap.get(key);
  if (!deps) {
    // 若不存在,创建新的Set并存入depsMap
    deps = new Set();
    depsMap.set(key, deps);
  }

  // 4. 将当前活跃的effect存入Set(保证不重复)
  deps.add(activeEffect);
}

/**
 * 触发依赖(响应式数据修改时触发)
 * @param {Object} target - 响应式对象
 * @param {string} key - 被修改的属性名
 */
function trigger(target, key) {
  // 1. 从targetMap中获取当前对象的属性映射表
  const depsMap = targetMap.get(target);
  if (!depsMap) return; // 若没有收集过依赖,直接返回

  // 2. 从depsMap中获取当前属性的effect集合
  const deps = depsMap.get(key);
  if (deps) {
    // 3. 遍历effect集合,执行每个effect(触发副作用更新)
    deps.forEach(effect => effect());
  }
}

3. 第三步:实现reactive——基于Proxy的响应式数据拦截

reactive函数的作用是将普通对象转为响应式对象,核心是通过Proxy拦截对象的get(读取)和set(修改)操作:

  • get拦截:当读取响应式对象的属性时,调用track函数收集依赖;
  • set拦截:当修改响应式对象的属性时,先更新属性值,再调用trigger函数触发依赖。

代码实现:

/**
 * 将普通对象转为响应式对象(基于Proxy)
 * @param {Object} target - 普通对象
 * @returns {Proxy} 响应式对象
 */
function reactive(target) {
  return new Proxy(target, {
    // 拦截属性读取操作
    get(target, key) {
      // 1. 读取原始属性值
      const value = Reflect.get(target, key);
      // 2. 收集依赖(关联target、key和当前activeEffect)
      track(target, key);
      // 3. 返回属性值(若value是对象,可递归转为响应式,这里简化实现)
      return value;
    },

    // 拦截属性修改操作
    set(target, key, value) {
      // 1. 修改原始属性值
      const result = Reflect.set(target, key, value);
      // 2. 触发依赖(执行该属性关联的所有effect)
      trigger(target, key);
      // 3. 返回修改结果(符合Proxy规范)
      return result;
    }
  });
}

这里使用Reflect而非直接操作target,是为了保证操作的规范性(比如Reflect.set会返回布尔值表示修改成功,而直接赋值不会),同时与Proxy的拦截行为更匹配。

三、完整测试:验证响应式效果

我们已经实现了effect、track、trigger、reactive四个核心模块,现在编写测试代码验证响应式是否生效:

// 1. 创建普通对象并转为响应式对象
const user = reactive({ name: "张三", age: 20 });

// 2. 定义副作用函数(模拟组件渲染:依赖user.name和user.age)
effect(() => {
  console.log(`姓名:${user.name},年龄:${user.age}`);
});

// 3. 修改响应式数据,观察副作用是否触发
user.name = "李四"; // 输出:姓名:李四,年龄:20(触发effect重新执行)
user.age = 21;      // 输出:姓名:李四,年龄:21(再次触发effect)
user.gender = "男"; // 新增属性(Proxy天然支持,若有依赖该属性的effect也会触发)

运行结果:

  • effect首次执行时,输出“姓名:张三,年龄:20”(初始渲染);
  • 修改user.name时,触发set拦截→trigger→effect重新执行,输出更新后的内容;
  • 修改user.age时,同样触发effect更新;
  • 新增user.gender时,若后续有effect依赖该属性,修改时也会触发更新(本测试中无依赖,故无输出)。

四、核心细节补充与简化点说明

上面的实现是简化版响应式,Vue3的真实响应式系统更复杂,这里补充几个关键细节和简化点:

1. 简化点:未处理嵌套对象

当前reactive函数仅对顶层对象进行Proxy拦截,若对象属性是嵌套对象(如user = { info: { age: 20 } }),修改user.info.age不会触发响应式。解决方法是在get拦截时,对返回的value进行判断,若为对象则递归调用reactive:

// 优化reactive的get拦截
get(target, key) {
  const value = Reflect.get(target, key);
  track(target, key);
  // 递归处理嵌套对象
  return typeof value === 'object' && value !== null ? reactive(value) : value;
}

2. 简化点:未处理数组

Proxy天然支持数组拦截,比如修改数组的push、splice、索引等操作。只需在set拦截时,对数组的特殊操作(如push会新增索引)进行处理,确保trigger能正确触发。当前简化实现已支持数组的索引修改,比如:

const list = reactive([1, 2, 3]);
effect(() => {
  console.log("数组:", list.join(','));
});
list[0] = 10; // 输出:数组:10,2,3(触发effect)
list.push(4); // 输出:数组:10,2,3,4(触发effect)

3. 真实Vue3的扩展:调度执行、computed、watch等

我们的实现仅覆盖了核心响应式逻辑,Vue3还在此基础上扩展了:

  • 调度执行:effect支持传入scheduler选项,实现副作用的延迟执行、防抖、节流等;
  • computed:基于effect实现缓存机制,只有依赖变化时才重新计算;
  • watch:监听响应式数据变化,触发回调函数(支持立即执行、深度监听等);
  • Ref:处理基本类型的响应式(通过封装对象实现,核心还是Proxy)。

五、总结

本文通过“effect封装副作用 → track/trigger管理依赖 → reactive基于Proxy拦截数据”的步骤,实现了一个简易的Vue响应式系统。核心逻辑可概括为:

effect执行时标记活跃状态,访问响应式数据触发get拦截,通过track收集“数据-属性-effect”依赖;修改数据触发set拦截,通过trigger找到对应依赖并重新执行effect,最终实现响应式更新。

理解这个核心逻辑后,再去学习Vue3的computed、watch等API的实现原理,就会变得非常轻松。建议你动手敲一遍代码,尝试修改和扩展(比如添加嵌套对象支持、调度执行),加深对响应式原理的理解。

想提升专注力?我做了一个web端的训练工具

用 Vue 3 打造一款专注力训练神器

舒尔特方格(Schulte Table)是一种经典的注意力训练工具,被广泛应用于飞行员选拔、运动员训练等专业领域。本文将分享如何将这一传统训练方法打造成一款现代化的 Web 应用。

什么是舒尔特方格?

舒尔特方格是由德国心理学家舒尔特发明的一种注意力训练方法。标准的舒尔特方格是一个 5×5 的表格,其中随机排列着 1-25 这 25 个数字。训练者需要按照从 1 到 25 的顺序,依次用眼睛找到并点击每个数字。

这种训练的核心价值在于:

  • 扩大视觉注意范围:从"逐个搜索"逐渐过渡到"整体感知"
  • 提高眼球运动效率:减少不必要的眼动,提升信息捕捉速度
  • 锻炼专注力持续性:在限定时间内完成任务需要高度集中

效果预览

image.png

2.png

3.png

4.png

5.png

核心架构设计

1. 游戏逻辑层:单例状态模式

游戏的核心逻辑被封装在 useGameLogic 这个组合式函数中。x采用了模块级单例的设计模式:

// composables/useGameLogic.js
import { ref, computed, onUnmounted } from 'vue'

// Singleton state - 在模块级别创建,确保全局唯一
const gridNumbers = ref([])
const nextNumber = ref(1)
const isPlaying = ref(false)
const timeRemaining = ref(0)
const elapsedTime = ref(0)
const selectedMode = ref('30')

const GAME_SIZE = 5
const TOTAL_NUMBERS = GAME_SIZE * GAME_SIZE

// Fisher-Yates 洗牌算法
const shuffleArray = (array) => {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[array[i], array[j]] = [array[j], array[i]]
  }
}

const generateGrid = () => {
  const numbers = Array.from({ length: TOTAL_NUMBERS }, (_, i) => i + 1)
  shuffleArray(numbers)
  gridNumbers.value = numbers
  return numbers
}

这种设计的优势在于:

  • 状态共享:不同组件可以访问同一份游戏状态
  • 逻辑复用:计时器、点击处理等逻辑只需编写一次
  • 测试友好:游戏逻辑与 UI 解耦,便于单元测试

2. 双模式计时系统

应用支持两种训练模式:限时模式和不限时模式。这通过计算属性和条件分支实现:

// 检查是否为不限时模式
const isUnlimitedMode = computed(() => selectedMode.value === 'unlimited')

const startGame = () => {
  generateGrid()
  nextNumber.value = 1
  elapsedTime.value = 0
  isPlaying.value = true

  if (isUnlimitedMode.value) {
    // 不限时模式:正计时
    timeRemaining.value = 0
    timerInterval.value = setInterval(() => {
      elapsedTime.value += 0.1
    }, 100)
  } else {
    // 限时模式:倒计时
    timeRemaining.value = timedTotal.value
    timerInterval.value = setInterval(() => {
      timeRemaining.value -= 0.1
      if (timeRemaining.value <= 0) {
        const record = endGame('timeout')
        if (onTimeoutCallback.value) {
          onTimeoutCallback.value(record)
        }
      }
    }, 100)
  }
}

限时模式增加了时间压力,更适合进阶训练;不限时模式则适合初学者熟悉规则。

3. IndexedDB 数据持久化

// services/indexedDB.js
const DB_NAME = 'shuerte_db'
const DB_VERSION = 1
const STORE_NAME = 'records'

export function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION)

    request.onupgradeneeded = (event) => {
      const db = event.target.result

      if (!db.objectStoreNames.contains(STORE_NAME)) {
        const store = db.createObjectStore(STORE_NAME, {
          keyPath: 'id',
          autoIncrement: true,
        })

        // 创建索引以支持高效查询
        store.createIndex('timestamp', 'timestamp', { unique: false })
        store.createIndex('result', 'result', { unique: false })
        store.createIndex('replayOf', 'replayOf', { unique: false })
        store.createIndex('mode', 'mode', { unique: false })
      }
    }

    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

选择 IndexedDB 的理由:

  • 存储容量大:localStorage 只有 5MB,IndexedDB 可存储 GB 级数据
  • 自增主键:自动生成唯一 ID,便于管理记录
  • 索引支持:可以按时间戳、结果等字段快速查询
  • 异步操作:不会阻塞主线程

4. 重练功能的实现

一个独特的功能是"重练"——用户可以选择历史记录中的任意一次训练,使用相同的数字排列重新挑战:

// 重新训练:使用指定的 gridNumbers 开始游戏
const replayGame = (gridNumbersArray, mode) => {
  selectedMode.value = mode
  gridNumbers.value = gridNumbersArray // 复用原有排列
  nextNumber.value = 1
  elapsedTime.value = 0
  isPlaying.value = true

  // 启动对应模式的计时器...
}

这个功能的价值在于:

  • 用户可以针对"困难"的排列反复练习
  • 可以比较同一排列下不同次的成绩变化
  • 增加了训练的趣味性和挑战性

用户体验设计

1. 视觉反馈系统

应用提供了丰富的视觉反馈:

// 根据结果类型显示不同边框颜色
.history-item {
  &.result-success {
    border-left: 4px solid #4ade80; // 绿色 - 成功
  }
  &.result-fail {
    border-left: 4px solid #ff2d55; // 红色 - 失败
  }
  &.result-timeout {
    border-left: 4px solid #f59e0b; // 橙色 - 超时
  }
}

2. 成绩评级系统

基于完成时间给出 S/A/B/C/D 五个等级的评价:

const getRating = (record) => {
  const time = record.timeUsed

  if (record.isUnlimited) {
    // 不限时模式:宽松标准
    if (time <= 15) return 'S'
    if (time <= 25) return 'A'
    if (time <= 35) return 'B'
    if (time <= 50) return 'C'
    return 'D'
  } else {
    // 限时模式:严格标准
    if (time <= 8) return 'S'
    if (time <= 15) return 'A'
    if (time <= 20) return 'B'
    if (time <= 25) return 'C'
    return 'D'
  }
}

不同模式采用不同的评级标准,确保公平性。

3. 庆祝动画

成功完成训练时,使用 canvas-confetti 库触发彩带效果:

import confetti from 'canvas-confetti'

const fireConfetti = () => {
  const duration = 3000
  const animationEnd = Date.now() + duration
  const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 2000 }

  const interval = setInterval(() => {
    const timeLeft = animationEnd - Date.now()
    if (timeLeft <= 0) return clearInterval(interval)

    const particleCount = 50 * (timeLeft / duration)
    confetti({
      ...defaults,
      particleCount,
      origin: { x: Math.random() * 0.4 + 0.1, y: Math.random() - 0.2 },
    })
  }, 250)
}

这种即时的正向反馈能够显著提升用户的成就感和继续训练的动力。

统计与可视化

应用集成了 Chart.js 来展示训练数据:

<template>
  <StatsChart
    type="bar"
    :data="resultChartData"
    title="训练结果分布"
    subtitle="成功 / 失败 / 超时"
  />

  <StatsChart
    type="line"
    :data="timeChartData"
    title="成绩趋势"
    subtitle="最近 20 次训练"
  />
</template>

通过可视化图表,用户可以直观地看到:

  • 成功率的变化
  • 完成时间的趋势
  • 不同模式的表现对比

性能优化要点

1. 精确的方格尺寸

为了还原标准舒尔特方格(1cm × 1cm),我使用了固定像素值而非相对单位:

.grid-cell {
  width: 38px;  // 约 1cm
  height: 38px;
  font-family: 'SimSun', 'STSong', 'Songti SC', serif;
  font-size: 16px;
}

2. 动画性能

所有动画都使用 CSS transformopacity 属性,确保 GPU 加速:

@keyframes slideUp {
  from {
    transform: translateY(20px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

3. 组件懒加载

通过 Vue Router 的动态导入实现页面级代码分割:

const routes = [
  {
    path: '/training',
    component: () => import('./pages/TrainingPage.vue')
  },
  {
    path: '/stats',
    component: () => import('./pages/StatsPage.vue')
  }
]

本项目源码已开源,欢迎 Star 和 PR。技术栈:Vue 3 + Vite + TailwindCSS + IndexedDB 在线体验:younglina.wang/shulte 源码地址:github.com/younglina/s…

Vue 3 的 Proxy 革命:为什么必须放弃 defineProperty?

大家好!今天我们来深入探讨 Vue 3 中最重大的技术变革之一:为什么用 Proxy 全面替代 Object.defineProperty。这不仅仅是简单的 API 替换,而是一次响应式系统的彻底革命!

一、defineProperty 的先天局限

1. 无法检测属性添加/删除

这是 defineProperty 最致命的缺陷:

// Vue 2 中使用 defineProperty
const data = { name: '张三' }
Object.defineProperty(data, 'name', {
  get() {
    console.log('读取name')
    return this._name
  },
  set(newVal) {
    console.log('设置name')
    this._name = newVal
  }
})

// 问题来了!
data.age = 25  // ⚠️ 静默失败!无法被检测到!
delete data.name  // ⚠️ 静默失败!无法被检测到!

// Vue 2 的补救方案:$set/$delete
this.$set(this.data, 'age', 25)  // 必须使用特殊API
this.$delete(this.data, 'name')  // 必须使用特殊API

现实影响:

  • 开发者需要时刻记住使用 $set/$delete
  • 新手极易踩坑,代码难以维护
  • 框架失去"透明性",API 变得复杂

2. 数组监控的尴尬实现

const arr = [1, 2, 3]

// Vue 2 的数组劫持方案
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)

;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
  .forEach(method => {
    const original = arrayProto[method]
    Object.defineProperty(arrayMethods, method, {
      value: function mutator(...args) {
        const result = original.apply(this, args)
        notifyUpdate()  // 手动触发更新
        return result
      }
    })
  })

// 但这种方式依然有问题:
arr[0] = 100  // ⚠️ 通过索引直接赋值,无法被检测!
arr.length = 0  // ⚠️ 修改length属性,无法被检测!

3. 性能瓶颈

// defineProperty 需要递归遍历所有属性
function observe(data) {
  if (typeof data !== 'object' || data === null) {
    return
  }
  
  // 递归劫持每个属性
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key])
    
    // 如果是对象,继续递归
    if (typeof data[key] === 'object') {
      observe(data[key])  // 深度递归,性能消耗大!
    }
  })
}

// 初始化1000个属性的对象
const largeObj = {}
for (let i = 0; i < 1000; i++) {
  largeObj[`key${i}`] = { value: i }
}

// defineProperty: 需要定义2000个getter/setter(1000个属性×2)
// Proxy: 只需要1个代理!

二、Proxy 的降维打击

1. 一网打尽所有操作

const data = { name: '张三', hobbies: ['篮球', '游泳'] }

const proxy = new Proxy(data, {
  // 拦截所有读取操作
  get(target, key, receiver) {
    console.log(`读取属性:${key}`)
    track(target, key)  // 收集依赖
    return Reflect.get(target, key, receiver)
  },
  
  // 拦截所有设置操作
  set(target, key, value, receiver) {
    console.log(`设置属性:${key} = ${value}`)
    const result = Reflect.set(target, key, value, receiver)
    trigger(target, key)  // 触发更新
    return result
  },
  
  // 拦截删除操作
  deleteProperty(target, key) {
    console.log(`删除属性:${key}`)
    const result = Reflect.deleteProperty(target, key)
    trigger(target, key)
    return result
  },
  
  // 拦截 in 操作符
  has(target, key) {
    console.log(`检查属性是否存在:${key}`)
    return Reflect.has(target, key)
  },
  
  // 拦截 Object.keys()
  ownKeys(target) {
    console.log('获取所有属性键')
    track(target, 'iterate')  // 收集迭代依赖
    return Reflect.ownKeys(target)
  }
})

// 所有操作都能被拦截!
proxy.age = 25  // ✅ 正常拦截
delete proxy.name  // ✅ 正常拦截
'age' in proxy  // ✅ 正常拦截
Object.keys(proxy)  // ✅ 正常拦截

2. 完美的数组支持

const arr = [1, 2, 3]
const proxyArray = new Proxy(arr, {
  set(target, key, value, receiver) {
    console.log(`设置数组[${key}] = ${value}`)
    
    // 自动检测数组索引操作
    const oldLength = target.length
    const result = Reflect.set(target, key, value, receiver)
    
    // 如果是索引赋值
    if (key !== 'length' && Number(key) >= 0) {
      trigger(target, key)
    }
    
    // 如果length变化
    if (key === 'length' || oldLength !== target.length) {
      trigger(target, 'length')
    }
    
    return result
  }
})

// 所有数组操作都能完美监控!
proxyArray[0] = 100  // ✅ 索引赋值,正常拦截
proxyArray.push(4)   // ✅ push操作,正常拦截
proxyArray.length = 0 // ✅ length修改,正常拦截

3. 支持新数据类型

// defineProperty 无法支持这些
const map = new Map([['name', '张三']])
const set = new Set([1, 2, 3])
const weakMap = new WeakMap()
const weakSet = new WeakSet()

// Proxy 可以完美代理
const proxyMap = new Proxy(map, {
  get(target, key, receiver) {
    // Map的get、set、has等方法都能被拦截
    const value = Reflect.get(target, key, receiver)
    return typeof value === 'function' 
      ? value.bind(target)  // 保持方法上下文
      : value
  }
})

proxyMap.set('age', 25)  // ✅ 正常拦截
proxyMap.has('name')     // ✅ 正常拦截

三、性能对比实测

1. 初始化性能

// 测试代码
const testData = {}
for (let i = 0; i < 10000; i++) {
  testData[`key${i}`] = i
}

// defineProperty 版本
console.time('defineProperty')
Object.keys(testData).forEach(key => {
  Object.defineProperty(testData, key, {
    get() { /* ... */ },
    set() { /* ... */ }
  })
})
console.timeEnd('defineProperty')  // ~120ms

// Proxy 版本
console.time('Proxy')
const proxy = new Proxy(testData, {
  get() { /* ... */ },
  set() { /* ... */ }
})
console.timeEnd('Proxy')  // ~2ms

// 结果:Proxy 快 60 倍!

2. 内存占用对比

// defineProperty: 每个属性都需要定义descriptor
// 1000个属性 = 1000个getter + 1000个setter函数

// Proxy: 只有一个handler对象
// 无论对象有多少属性,都只需要一个代理

// 内存节省:约50%+!

3. 惰性访问优化

// Proxy 的惰性拦截
const deepObj = {
  level1: {
    level2: {
      level3: {
        value: 'deep value'
      }
    }
  }
}

const proxy = new Proxy(deepObj, {
  get(target, key, receiver) {
    const value = Reflect.get(target, key, receiver)
    
    // 惰性代理:只有访问到时才创建子代理
    if (value && typeof value === 'object') {
      return reactive(value)  // 按需代理
    }
    return value
  }
})

// 只有访问 level1.level2.level3 时才会逐层创建代理
// defineProperty 则必须在初始化时递归所有层级

四、开发体验的质变

1. 更直观的 API

// Vue 2 的复杂操作
export default {
  data() {
    return {
      user: { name: '张三' }
    }
  },
  methods: {
    addProperty() {
      // 必须使用 $set
      this.$set(this.user, 'age', 25)
    },
    deleteProperty() {
      // 必须使用 $delete
      this.$delete(this.user, 'name')
    }
  }
}

// Vue 3 的直观操作
setup() {
  const user = reactive({ name: '张三' })
  
  const addProperty = () => {
    user.age = 25  // ✅ 直接赋值!
  }
  
  const deleteProperty = () => {
    delete user.name  // ✅ 直接删除!
  }
  
  return { user, addProperty, deleteProperty }
}

2. 更好的 TypeScript 支持

// defineProperty 会破坏类型推断
interface User {
  name: string
  age?: number
}

const user: User = { name: '张三' }
Object.defineProperty(user, 'age', { 
  value: 25,
  writable: true
})
// TypeScript: ❌ 不能将类型“number”分配给类型“undefined”

// Proxy 保持类型安全
const user = reactive<User>({ name: '张三' })
user.age = 25  // ✅ TypeScript 能正确推断

五、技术实现细节

1. Vue 3 的响应式系统架构

// 核心响应式模块
function reactive(target) {
  // 如果已经是响应式对象,直接返回
  if (target && target.__v_isReactive) {
    return target
  }
  
  // 创建代理
  return createReactiveObject(
    target,
    mutableHandlers,  // 可变对象的处理器
    reactiveMap       // 缓存映射,避免重复代理
  )
}

function createReactiveObject(target, baseHandlers, proxyMap) {
  // 检查缓存
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  
  // 创建代理
  const proxy = new Proxy(target, baseHandlers)
  
  // 标记为响应式
  proxy.__v_isReactive = true
  
  // 加入缓存
  proxyMap.set(target, proxy)
  
  return proxy
}

2. 依赖收集系统

// 简化的依赖收集系统
const targetMap = new WeakMap()  // 目标对象 → 键 → 依赖集合

function track(target, key) {
  if (!activeEffect) return
  
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  
  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }
  
  dep.add(activeEffect)  // 收集当前活动的effect
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  
  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => effect())  // 触发所有相关effect
  }
}

六、Proxy 的注意事项

1. 浏览器兼容性

// Proxy 的兼容性考虑
if (typeof Proxy !== 'undefined') {
  // 使用 Proxy 实现
  return new Proxy(target, handlers)
} else {
  // 降级方案:Vue 3 提供了兼容版本
  // 但强烈建议使用现代浏览器或polyfill
}

// 实际支持情况:
// - Chrome 49+ ✅
// - Firefox 18+ ✅  
// - Safari 10+ ✅
// - Edge 79+ ✅
// - IE 11 ❌(需要polyfill)

2. this 绑定问题

const data = {
  name: '张三',
  getName() {
    return this.name
  }
}

const proxy = new Proxy(data, {
  get(target, key, receiver) {
    // receiver 参数很重要!
    const value = Reflect.get(target, key, receiver)
    
    // 如果是方法,确保正确的 this 指向
    if (typeof value === 'function') {
      return value.bind(receiver)  // 绑定到代理对象
    }
    
    return value
  }
})

console.log(proxy.getName())  // ✅ 正确输出"张三"

总结:为什么必须用 Proxy?

特性 Object.defineProperty Proxy
属性增删 无法检测,需要 set/set/delete 完美支持
数组监控 需要hack,索引赋值无效 完美支持
新数据类型 不支持 Map、Set 等 完美支持
性能 递归遍历,O(n) 初始化 惰性代理,O(1) 初始化
内存 每个属性都需要描述符 整个对象一个代理
API透明性 需要特殊API 完全透明
TypeScript 类型推断困难 完美支持

Vue 3 选择 Proxy 的根本原因:

  1. 完整性:Proxy 提供了完整的对象操作拦截能力
  2. 性能:大幅提升初始化速度和内存效率
  3. 开发体验:让响应式 API 对开发者透明
  4. 未来性:支持现代 JavaScript 特性,为未来发展铺路

Vue 3 性能革命:比闪电还快的秘密,全在这里了!

各位前端开发者们,大家好!今天我们来聊聊Vue 3带来的性能革命——这不仅仅是“快了一点”,而是架构级的全面升级!

一、响应式系统的彻底重构

1. Proxy替代Object.defineProperty

Vue 2的响应式系统有个“先天缺陷”——无法检测到对象属性的添加和删除。Vue 3使用Proxy API彻底解决了这个问题:

// Vue 3响应式原理简化版
function reactive(target) {
  return new Proxy(target, {
    get(obj, key) {
      track(obj, key) // 收集依赖
      return obj[key]
    },
    set(obj, key, value) {
      obj[key] = value
      trigger(obj, key) // 触发更新
      return true
    }
  })
}

实际收益:

  • • 初始化速度提升100%+
  • • 内存占用减少50%+
  • • 支持Map、Set等新数据类型

2. 静态树提升(Static Tree Hoisting)

Vue 3编译器能识别静态节点,将它们“提升”到渲染函数之外:

// 编译前
<template>
  <div>
    <h1>Hello World</h1>  <!-- 静态节点 -->
    <p>{{ dynamicContent }}</p>
  </div>
</template>

// 编译后
const _hoisted_1 = /*#__PURE__*/_createVNode("h1"null"Hello World")

function render() {
  return (_openBlock(), _createBlock("div"null, [
    _hoisted_1,  // 直接引用,无需重新创建
    _createVNode("p"null_toDisplayString(dynamicContent))
  ]))
}

二、编译时优化:快到飞起

1. Patch Flag标记系统

Vue 3为每个虚拟节点添加“补丁标志”,告诉运行时哪些部分需要更新:

// 编译时生成的优化代码
export function render() {
  return (_openBlock(), _createBlock("div", null, [
    _createVNode("div", { 
      classnormalizeClass({ active: isActive })
    }, null, 2 /* CLASS */),  // 只有class可能变化
    
    _createVNode("div", {
      id: props.id,
      onClick: handleClick
    }, null, 9 /* PROPS, HYDRATE_EVENTS */)  // id和事件可能变化
  ]))
}

支持的Patch Flag类型:

  • • 1:文本动态
  • • 2:class动态
  • • 4:style动态
  • • 8:props动态
  • • 16:需要完整props diff

2. 树结构拍平(Tree Flattening)

Vue 3自动“拍平”静态子树,大幅减少虚拟节点数量:

// 编译优化前:15个vnode
<div>
  <h1>标题</h1>
  <div>
    <p>静态段落1</p>
    <p>静态段落2</p>
    <p>静态段落3</p>
  </div>
  <span>{{ dynamicText }}</span>
</div>

// 编译优化后:只需追踪1个动态节点
const _hoisted_1 = /* 整个静态子树被打包成一个vnode */

三、组合式API带来的运行时优化

1. 更精准的依赖追踪

// Vue 2选项式API - 整个组件重新计算
export default {
  computed: {
    fullName() {
      return this.firstName + ' ' + this.lastName
    },
    // 即使只改firstName,所有计算属性都要重新计算
  }
}

// Vue 3组合式API - 精准更新
setup() {
  const firstName = ref('张')
  const lastName = ref('三')
  
  const fullName = computed(() => {
    return firstName.value + ' ' + lastName.value
  })
  // 只有相关的ref变化时才会重新计算
}

2. 更好的Tree-shaking支持

Vue 3的模块化架构让打包体积大幅减少:

// 只引入需要的API
import { ref, computed, watch } from 'vue'
// 而不是 import Vue from 'vue'(包含所有内容)

// 结果:生产环境打包体积减少41%!

四、真实场景性能对比

大型表格渲染测试

// 测试条件:1000行 x 10列数据表
Vue 2: 初始渲染 245ms,更新 156ms
Vue 3: 初始渲染 112ms,更新 47ms
// 性能提升:渲染快2.2倍,更新快3.3倍!

组件更新性能

// 深层嵌套组件更新
Vue 2: 需要遍历整个组件树
Vue 3: 通过静态分析跳过静态子树
// 更新速度提升最高可达6倍!

五、内存优化:更智能的缓存策略

Vue 3引入了cacheHandlers事件缓存:

// 内联事件处理函数会被自动缓存
<button @click="count++">点击</button>

// 编译为:
function render() {
  return _createVNode("button", {
    onClick: _cache[0] || (_cache[0] = ($event) => (count.value++))
  }, "点击")
}

六、服务端渲染(SSR)性能飞跃

Vue 3的SSR性能提升尤为显著:

// Vue 3的流式SSR
const { renderToStream } = require('@vue/server-renderer')

app.get('*'async (req, res) => {
  const stream = renderToStream(app, req.url)
  
  // 流式传输,TTFB(首字节时间)大幅减少
  res.write('<!DOCTYPE html>')
  stream.pipe(res)
})

// 对比结果:
// Vue 2 SSR: 首屏时间 220ms
// Vue 3 SSR: 首屏时间 85ms(提升2.6倍!)

七、实战升级建议

1. 渐进式迁移

// 可以在Vue 2项目中逐步使用Vue 3特性
import { createApp } from 'vue'
import { Vue2Components } from './legacy'

const app = createApp(App)
// 逐步替换,平滑迁移

2. 性能监控

// 使用Vue 3的性能标记API
import { mark, measure } from 'vue'

mark('component-start')
// 组件渲染逻辑
measure('component-render''component-start')

结语

Vue 3的性能提升不是某个单一优化,而是编译器、运行时、响应式系统三位一体的全面升级:

  • • 🚀 编译时优化让初始渲染快2倍
  • • ⚡ 运行时优化让更新快3-6倍
  • • 📦 打包体积减少41%
  • • 🧠 内存占用减少50%

更重要的是,这些优化都是自动的——你几乎不需要修改代码就能享受性能红利!

最后送给大家一句话: “性能不是功能,但它是所有功能的基础。”  Vue 3正是这句话的最佳实践。


Nuxt 4 学习文档

Nuxt 4 学习文档(案例驱动)

目标:以大功能点为章节、每个知识点配套详细案例与代码,帮助具备 Vue 3 基础的工程师系统掌握 Nuxt 4(思路兼容 Nuxt 3)。

目录

    1. 项目初始化与目录约定
    1. 路由与导航
    1. 数据获取与渲染模式
    1. 组件与布局
    1. 状态管理(Pinia)
    1. 组合式 API 与可复用逻辑
    1. 插件与模块生态
    1. 服务端开发(Nitro)
    1. 运行时配置与环境变量
    1. SEO 与元信息
    1. 内容系统(Nuxt Content)
    1. 国际化(i18n)
    1. 静态资源与图片优化
    1. 样式与构建工具
    1. 安全与权限
    1. 测试与质量保障
    1. 部署与运维
    1. 性能优化
    1. 开发者工具与调试

1. 项目初始化与目录约定

1.1 使用 nuxi 创建项目与启动开发

知识点:使用官方脚手架创建 Nuxt 4 项目,了解基础命令。

案例:从零创建 nuxt4-app 并运行

步骤:

  1. 安装最新 nuxi(或使用 npx 直接调用)
  2. 创建项目并选择 TypeScript
  3. 启动开发服务器,访问本地地址
# 使用 npx(无需全局安装)
npx nuxi@latest init nuxt4-app
cd nuxt4-app
# 推荐使用 pnpm,也可用 npm 或 yarn
pnpm install
pnpm dev
# 终端输出本地预览地址,例如 http://localhost:3000

项目创建后,默认文件结构示例:

nuxt4-app/
├─ app.vue
├─ nuxt.config.ts
├─ pages/
├─ components/
├─ composables/
├─ server/
├─ plugins/
├─ middleware/
├─ assets/
├─ public/
└─ package.json

验证:

  • 打开浏览器访问本地地址,看到默认欢迎页

常见坑:

  • Node 版本过低导致依赖安装失败;建议 Node 18+。

1.2 nuxt.config 基础与类型提示

知识点:掌握 nuxt.config.ts 的基本配置项与类型提示。

案例:配置站点标题与图标

nuxt.config.ts 中设置 app.head

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      title: 'Nuxt4 学习文档示例站',
      meta: [{ name: 'description', content: '案例驱动的 Nuxt4 学习文档' }],
      link: [{ rel: 'icon', type: 'image/png', href: '/favicon.png' }]
    }
  },
  typescript: {
    strict: true
  }
})

验证:

  • 启动项目后查看页面标题与 Favicon 是否生效。

最佳实践:

  • 使用 TypeScript,开启 typescript.strict 获得更好类型提示。

1.3 约定式目录与最小博客骨架

知识点:理解 Nuxt 的约定式目录与页面、组件、服务端的组织方式。

案例:搭建最小博客骨架(首页/文章页)

创建首页与文章详情页:

<!-- pages/index.vue -->
<template>
  <section class="container">
    <h1>我的博客</h1>
    <ul>
      <li v-for="post in posts" :key="post.id">
        <NuxtLink :to="`/posts/${post.id}`">{{ post.title }}</NuxtLink>
      </li>
    </ul>
  </section>
</template>

<script setup lang="ts">
const posts = [
  { id: 1, title: 'Nuxt4 入门与目录约定' },
  { id: 2, title: '路由与导航详解' }
]
</script>

<style scoped>
.container { max-width: 720px; margin: 40px auto; }
</style>
<!-- pages/posts/[id].vue -->
<template>
  <article class="container">
    <NuxtLink to="/">← 返回首页</NuxtLink>
    <h1>{{ post?.title }}</h1>
    <p>文章 ID:{{ id }}</p>
    <p>这里是文章内容示例……</p>
  </article>
</template>

<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
const post = computed(() => {
  const map: Record<string, { title: string }> = {
    '1': { title: 'Nuxt4 入门与目录约定' },
    '2': { title: '路由与导航详解' }
  }
  return map[id.value as string]
})
</script>

<style scoped>
.container { max-width: 720px; margin: 40px auto; }
</style>

添加基础组件与样式:

<!-- components/BaseHeader.vue -->
<template>
  <header class="header">
    <NuxtLink to="/" class="logo">Nuxt4 Docs</NuxtLink>
    <nav>
      <NuxtLink to="/" class="nav">首页</NuxtLink>
      <NuxtLink to="/about" class="nav">关于</NuxtLink>
    </nav>
  </header>
</template>

<style scoped>
.header { display:flex; align-items:center; gap:16px; padding:16px; border-bottom:1px solid #eee; }
.logo { font-weight:bold; }
.nav { margin-right: 8px; }
</style>

将组件挂到应用根:

<!-- app.vue -->
<template>
  <BaseHeader />
  <NuxtPage />
</template>

验证:

  • 首页展示文章列表,可点击进入详情页
  • 顶部导航与样式正常

最佳实践:

  • pages 用于路由页面,components 存放可复用视图组件
  • 将公共结构放入 app.vue 或布局(layouts)

下一章将从“路由与导航”开始,深入讲解动态/嵌套路由与中间件,配合完整示例持续扩展本示例站点。


2. 路由与导航

2.1 约定式路由:动态/可选/捕获/嵌套

知识点:Nuxt 的 pages 目录根据文件命名自动生成路由。

案例:动态、可选与捕获路由

pages/
├─ users/
│  ├─ [id].vue         # 动态参数 /users/123
│  ├─ [[tab]].vue      # 可选参数 /users 或 /users/profile
│  └─ [...slug].vue    # 捕获所有 /users/a/b/c
└─ users/[id]/settings.vue  # 动态 + 嵌套路由 /users/123/settings

示例页面:

<!-- pages/users/[id].vue -->
<template>
  <section class="container">
    <h2>用户:{{ id }}</h2>
    <NuxtLink :to="`/users/${id}/settings`">进入设置</NuxtLink>
  </section>
</template>
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
</script>
<!-- pages/users/[id]/settings.vue -->
<template>
  <section class="container">
    <NuxtLink :to="`/users/${id}`">← 返回</NuxtLink>
    <h3>设置中心</h3>
    <p>这里是用户 {{ id }} 的设置页</p>
  </section>
</template>
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
</script>

验证:

  • 访问 /users/1/users/1/settings,路由与参数正常

2.2 页面导航与编程式跳转

知识点:使用 <NuxtLink>useRouter() 进行导航。

案例:分页列表导航与编程式跳转

<!-- pages/list.vue -->
<template>
  <section class="container">
    <h2>文章列表 - 第 {{ page }} 页</h2>
    <nav class="pager">
      <NuxtLink :to="`/list?page=${Number(page)-1}`" v-if="Number(page)>1">上一页</NuxtLink>
      <NuxtLink :to="`/list?page=${Number(page)+1}`">下一页</NuxtLink>
      <button @click="goDetail(42)">编程式跳转到文章 42</button>
    </nav>
  </section>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const page = computed(() => route.query.page ?? '1')
function goDetail(id: number) {
  router.push(`/posts/${id}`)
}
</script>
<style scoped>
.pager { display:flex; gap:12px; align-items:center; }
</style>

验证:

  • 切换分页链接正常
  • 点击按钮编程式跳转到 /posts/42

2.3 路由中间件与重定向

知识点:路由中间件在进入页面前执行,可用于权限校验或重定向。

案例:需要登录的受保护页面与 302 重定向

创建路由中间件:

// middleware/auth.global.ts  全局中间件(文件名以 .global)
export default defineNuxtRouteMiddleware((to, from) => {
  const isLoggedIn = useCookie('logged_in').value === '1'
  if (!isLoggedIn && to.path.startsWith('/admin')) {
    return navigateTo('/login', { redirectCode: 302 })
  }
})

受保护路由示例:

<!-- pages/admin/index.vue -->
<template>
  <section class="container">
    <h2>后台管理</h2>
    <p>只有登录用户可访问</p>
  </section>
</template>

登录页简单实现:

<!-- pages/login.vue -->
<template>
  <section class="container">
    <h2>登录</h2>
    <button @click="login">点击登录并跳转后台</button>
  </section>
</template>
<script setup lang="ts">
function login() {
  const cookie = useCookie('logged_in')
  cookie.value = '1'
  navigateTo('/admin')
}
</script>

验证:

  • 未登录访问 /admin 自动重定向到 /login
  • 登录后访问 /admin 正常进入

最佳实践:

  • 使用 middleware/*.global.ts 处理全局策略
  • 需要仅针对某页面的策略可在页面 definePageMeta({ middleware: 'xxx' })

3. 数据获取与渲染模式

3.1 useFetch / useAsyncData / $fetch

知识点:数据获取的三种常用方式与差异。

案例:SSR 获取文章列表 + 客户端增量刷新

服务端接口(模拟):

// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
  // 模拟数据库查询
  return [
    { id: 1, title: 'Nuxt4 入门与目录约定' },
    { id: 2, title: '路由与导航详解' },
    { id: 3, title: '数据获取与渲染模式' }
  ]
})

页面使用 useAsyncData

<!-- pages/fetch.vue -->
<template>
  <section class="container">
    <h2>文章列表(SSR 首屏)</h2>
    <ul v-if="data">
      <li v-for="p in data" :key="p.id">
        <NuxtLink :to="`/posts/${p.id}`">{{ p.title }}</NuxtLink>
      </li>
    </ul>
    <p v-else>加载中...</p>
    <button @click="refresh">客户端刷新</button>
  </section>
</template>
<script setup lang="ts">
const { data, pending, error, refresh } = await useAsyncData('posts', () => $fetch('/api/posts'))
</script>

在某组件中使用 useFetch(自动处理 SSR/CSR):

<!-- components/PostCounter.vue -->
<template>
  <div>当前文章总数:{{ count ?? '-' }}</div>
</template>
<script setup lang="ts">
const { data } = await useFetch('/api/posts')
const count = computed(() => data.value?.length)
</script>

验证:

  • 首次访问 pages/fetch.vue SSR 渲染列表
  • 点击“客户端刷新”会重新请求数据并更新视图

3.2 渲染模式:SSR、CSR、混合、预渲染

知识点:理解不同渲染模式的取舍与 Nuxt 支持。

案例:对比同页面在不同模式下的表现

<!-- pages/modes.vue -->
<template>
  <section class="container">
    <h2>渲染模式实验</h2>
    <p>当前时间(服务端或客户端):{{ now }}</p>
  </section>
</template>
<script setup lang="ts">
const now = ref<string>('')
if (process.server) {
  now.value = `SSR: ${new Date().toISOString()}`
} else {
  now.value = `CSR: ${new Date().toISOString()}`
}
</script>

注:

  • 预渲染(静态生成)可通过 nuxi build + nuxi generate(视具体版本命令)生成静态 HTML
  • 混合渲染常见于部分页面 SSR、部分纯客户端

3.3 缓存与错误处理

知识点:给数据获取设置缓存键、处理错误与加载态。

案例:带缓存键的 useAsyncData 与骨架屏

<!-- pages/cache.vue -->
<template>
  <section class="container">
    <h2>缓存示例</h2>
    <div v-if="pending" class="skeleton">加载中(骨架屏)...</div>
    <ul v-else-if="data">
      <li v-for="p in data" :key="p.id">{{ p.title }}</li>
    </ul>
    <p v-else-if="error">发生错误:{{ error.message }}</p>
    <button @click="refresh">重新拉取</button>
  </section>
</template>
<script setup lang="ts">
const { data, pending, error, refresh } = await useAsyncData(
  // 缓存键
  'posts-cache',
  // 获取函数
  () => $fetch('/api/posts'),
  // 可选配置
  { default: () => [], server: true, lazy: false }
)
</script>
<style scoped>
.skeleton { height: 120px; background: #f5f5f5; animation: pulse 1.2s infinite; }
@keyframes pulse { 0%{opacity:.6} 50%{opacity:1} 100%{opacity:.6} }
</style>

最佳实践:

  • useAsyncData 设置合理的 key 以启用缓存与避免重复请求
  • 统一处理 pending/error,提供良好的用户体验

4. 组件与布局

4.1 自动导入组件与目录组织

知识点:Nuxt 自动导入 components/ 下的组件,无需手动注册。

案例:建立 BaseButton 并在多页面复用

<!-- components/BaseButton.vue -->
<template>
  <button class="btn" :class="variant">
    <slot />
  </button>
</template>
<script setup lang="ts">
defineProps<{ variant?: 'primary' | 'secondary' }>()
</script>
<style scoped>
.btn { padding:8px 12px; border-radius:6px; }
.primary { background:#0ea5e9; color:#fff; }
.secondary { background:#eee; color:#333; }
</style>

在任意页面直接使用:

<!-- pages/about.vue -->
<template>
  <section class="container">
    <h2>关于页面</h2>
    <BaseButton variant="primary">立即体验</BaseButton>
  </section>
</template>

4.2 布局(layouts)与错误页(error.vue)

知识点:使用布局统一页面框架与导航;使用错误页统一异常展示。

案例:默认布局与自定义错误页

<!-- layouts/default.vue -->
<template>
  <div>
    <BaseHeader />
    <main class="main">
      <slot />
    </main>
    <footer class="footer">© 2026 Nuxt4 Docs</footer>
  </div>
</template>
<style scoped>
.main { max-width: 960px; margin: 24px auto; min-height: 60vh; }
.footer { border-top: 1px solid #eee; padding: 16px; text-align: center; color:#666; }
</style>

错误页:

<!-- error.vue -->
<template>
  <section class="container">
    <h2>发生错误</h2>
    <p>{{ error.message }}</p>
    <NuxtLink to="/">返回首页</NuxtLink>
  </section>
</template>
<script setup lang="ts">
const props = defineProps<{ error: { message: string } }>()
const error = toRef(props, 'error')
</script>

验证:

  • 页面自动套用默认布局
  • 抛出错误时统一由 error.vue 捕获展示

4.3 插槽与跨布局状态

知识点:通过插槽构建可扩展布局;使用 useState 保持跨页面/布局状态。

案例:跨页公告栏与可插槽的主布局

// composables/useBanner.ts
export const useBanner = () => useState<string>('global-banner', () => '')
<!-- layouts/default.vue(片段,加入公告栏插槽) -->
<template>
  <div>
    <BaseHeader />
    <div v-if="banner" class="banner">{{ banner }}</div>
    <main class="main"><slot /></main>
    <footer class="footer">© 2026 Nuxt4 Docs</footer>
  </div>
</template>
<script setup lang="ts">
const banner = useBanner()
</script>
<style scoped>
.banner { background:#fff0c2; padding:8px 12px; border-bottom:1px solid #ffe28a; }
</style>

在某页面设定公告:

<!-- pages/announcement.vue -->
<template>
  <section class="container">
    <h2>设置公告</h2>
    <BaseButton variant="secondary" @click="setBanner">显示公告</BaseButton>
    <BaseButton variant="secondary" @click="clearBanner">清除公告</BaseButton>
  </section>
</template>
<script setup lang="ts">
const banner = useBanner()
function setBanner() { banner.value = '这是一个跨页公告,所有页面顶部可见。' }
function clearBanner() { banner.value = '' }
</script>

最佳实践:

  • 将全局 UI 与状态放入布局与 composables
  • 使用命名 useState 以共享跨页面状态

5. 状态管理(Pinia)

5.1 安装与集成 Pinia

知识点:在 Nuxt 中使用 Pinia 进行状态管理。

案例:通过模块集成 @pinia/nuxt

pnpm add @pinia/nuxt pinia

nuxt.config.ts 中开启模块:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
  pinia: {
    autoImports: ['defineStore', 'storeToRefs']
  }
})

5.2 购物车 Store 的增删改查

知识点:定义 Store、派发动作、从组件读取状态。

案例:stores/cart.ts 与页面使用

// stores/cart.ts
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as { id: number; title: string; qty: number; price: number }[]
  }),
  getters: {
    total: (s) => s.items.reduce((sum, i) => sum + i.qty * i.price, 0)
  },
  actions: {
    add(item: { id: number; title: string; price: number }) {
      const found = this.items.find(i => i.id === item.id)
      if (found) found.qty += 1
      else this.items.push({ ...item, qty: 1 })
    },
    remove(id: number) {
      this.items = this.items.filter(i => i.id !== id)
    },
    clear() { this.items = [] }
  }
})

在页面中使用:

<!-- pages/shop.vue -->
<template>
  <section class="container">
    <h2>商品列表</h2>
    <ul>
      <li v-for="p in products" :key="p.id">
        {{ p.title }} - ¥{{ p.price }}
        <BaseButton variant="primary" @click="add(p)">加入购物车</BaseButton>
      </li>
    </ul>
    <h3>购物车(总计:¥{{ total }})</h3>
    <ul>
      <li v-for="i in items" :key="i.id">
        {{ i.title }} × {{ i.qty }} = ¥{{ i.qty * i.price }}
        <BaseButton variant="secondary" @click="remove(i.id)">移除</BaseButton>
      </li>
    </ul>
    <BaseButton variant="secondary" @click="clear">清空</BaseButton>
  </section>
</template>
<script setup lang="ts">
const products = [
  { id: 1, title: '书籍 A', price: 30 },
  { id: 2, title: '书籍 B', price: 50 }
]
const cart = useCartStore()
const { items, total } = storeToRefs(cart)
const { add, remove, clear } = cart
</script>

验证:

  • 加入/移除商品;总价实时更新

5.3 服务端初始化与持久化

知识点:在 SSR 中恢复状态;在客户端持久化(cookies/localStorage)。

案例:登录态在 SSR/CSR 的保持与恢复

// middleware/auth.global.ts(片段)
export default defineNuxtRouteMiddleware((to) => {
  const logged = useCookie('logged_in').value === '1'
  if (!logged && to.path.startsWith('/admin')) return navigateTo('/login')
})

在页面挂载时将购物车持久化:

// plugins/persist.client.ts
export default defineNuxtPlugin(() => {
  const cart = useCartStore()
  const saved = localStorage.getItem('cart')
  if (saved) cart.items = JSON.parse(saved)
  watch(() => cart.items, (val) => {
    localStorage.setItem('cart', JSON.stringify(val))
  }, { deep: true })
})

最佳实践:

  • 通过 .client.ts 插件确保仅在客户端访问 localStorage
  • SSR 依赖 cookies 传递会话;敏感信息使用 HTTPOnly Cookie(见安全章节)

6. 组合式 API 与可复用逻辑

6.1 auto-import composables 与目录组织

知识点:Nuxt 会自动导入 composables/ 下的函数。

案例:封装分页与搜索逻辑

// composables/usePagination.ts
export function usePagination(initial = 1) {
  const page = useState<number>('page', () => initial)
  function next() { page.value += 1 }
  function prev() { page.value = Math.max(1, page.value - 1) }
  return { page, next, prev }
}
// composables/useSearch.ts
export function useSearch() {
  const q = useState<string>('q', () => '')
  const set = (val: string) => { q.value = val }
  return { q, set }
}

在页面中使用:

<!-- pages/search.vue -->
<template>
  <section class="container">
    <h2>搜索与分页</h2>
    <input v-model="q" placeholder="输入关键字" />
    <div class="pager">
      <BaseButton variant="secondary" @click="prev">上一页</BaseButton>
      <span>第 {{ page }} 页</span>
      <BaseButton variant="secondary" @click="next">下一页</BaseButton>
    </div>
    <p>当前搜索:{{ q }}</p>
  </section>
</template>
<script setup lang="ts">
const { q, set } = useSearch()
const { page, next, prev } = usePagination()
</script>
<style scoped>
.pager { display:flex; gap:12px; align-items:center; margin-top:12px; }
</style>

6.2 类型安全的自定义组合式

知识点:在组合式中使用 TypeScript 声明输入/输出。

案例:表单校验 composable

// composables/useForm.ts
export interface LoginForm {
  username: string
  password: string
}

export function useForm() {
  const form = reactive<LoginForm>({ username: '', password: '' })
  const errors = reactive<{ username?: string; password?: string }>({})

  function validate(): boolean {
    errors.username = form.username ? undefined : '用户名必填'
    errors.password = form.password.length >= 6 ? undefined : '密码至少 6 位'
    return !errors.username && !errors.password
  }

  return { form, errors, validate }
}

在登录页使用:

<!-- pages/login.vue(片段,表单校验) -->
<script setup lang="ts">
const { form, errors, validate } = useForm()
async function onSubmit() {
  if (!validate()) return
  // 调用后端登录 API
}
</script>

最佳实践:

  • 将可复用业务逻辑沉淀为 composables,统一类型与校验
  • 通过 useState 或传参控制状态作用域与持久化策略

7. 插件与模块生态

7.1 Plugins:注入客户端/服务端能力

知识点:通过插件向应用注入全局对象或方法(nuxtApp.provide)。

案例:注册 axios 插件与请求拦截器,注入 $api

pnpm add axios
// plugins/api.ts
import axios from 'axios'

export default defineNuxtPlugin((nuxtApp) => {
  const instance = axios.create({
    baseURL: useRuntimeConfig().public.apiBase || 'https://api.example.com'
  })
  // 请求拦截器
  instance.interceptors.request.use((config) => {
    const token = useCookie('token').value
    if (token) config.headers.Authorization = `Bearer ${token}`
    return config
  })
  // 响应拦截器
  instance.interceptors.response.use(
    (resp) => resp,
    (err) => {
      // 统一错误处理
      console.error('API Error:', err.message)
      return Promise.reject(err)
    }
  )
  nuxtApp.provide('api', instance)
})

在组件中使用:

<!-- pages/api-demo.vue -->
<template>
  <section class="container">
    <h2>Axios 插件示例</h2>
    <p v-if="error">请求失败:{{ error }}</p>
    <ul v-else>
      <li v-for="u in users" :key="u.id">{{ u.name }}</li>
    </ul>
  </section>
</template>
<script setup lang="ts">
const { $api } = useNuxtApp()
const users = ref<{ id:number; name:string }[]>([])
const error = ref<string>('')
try {
  const resp = await $api.get('/users')
  users.value = resp.data
} catch (e: any) {
  error.value = e.message
}
</script>

7.2 Modules:官方与第三方模块

知识点:通过模块扩展 Nuxt 能力,如内容、图片、国际化等。

案例:接入 @nuxt/image 优化产品图

pnpm add @nuxt/image
// nuxt.config.ts(片段)
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    // 可根据实际 CDN 或静态资源配置
    domains: ['images.example.com']
  }
})

使用图片组件:

<!-- pages/image-demo.vue -->
<template>
  <section class="container">
    <h2>图片优化示例</h2>
    <NuxtImg src="https://images.example.com/product.jpg" width="600" height="400" format="webp" />
  </section>
</template>

最佳实践:

  • 将通用功能封装为插件,便于在任意组件使用
  • 优先使用官方模块(content/image/i18n/devtools 等),提升开发效率与质量

8. 服务端开发(Nitro)

8.1 server/api 路由与事件处理器

知识点:在 server/api 下新增文件即为 API 路由,使用事件处理器读取请求。

案例:RESTful 文章 API

// server/api/posts.get.ts
export default defineEventHandler(() => {
  return [{ id: 1, title: '文章 A' }, { id: 2, title: '文章 B' }]
})
// server/api/posts/[id].get.ts
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  return { id, title: `文章 ${id}` }
})
// server/api/posts.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event) // { title: string }
  return { id: Date.now(), ...body }
})

客户端调用:

// composables/usePosts.ts
export function usePosts() {
  const list = () => $fetch('/api/posts')
  const detail = (id: number) => $fetch(`/api/posts/${id}`)
  const create = (title: string) => $fetch('/api/posts', { method: 'POST', body: { title } })
  return { list, detail, create }
}

8.2 server/routes 与中间件(认证/限速)

知识点:自定义服务端路由与中间件,适合非 API 的服务端响应或特殊处理。

案例:简单限流中间件与认证校验

// server/middleware/rate-limit.ts
const hits = new Map<string, { count: number; ts: number }>()
export default defineEventHandler((event) => {
  const ip = getHeader(event, 'x-forwarded-for') || event.node.req.socket.remoteAddress || 'unknown'
  const record = hits.get(ip) || { count: 0, ts: Date.now() }
  const now = Date.now()
  if (now - record.ts > 60_000) { record.count = 0; record.ts = now }
  record.count += 1
  hits.set(ip, record)
  if (record.count > 60) { // 每分钟 60 次
    throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
  }
})
// server/routes/secure.get.ts
export default defineEventHandler((event) => {
  const token = getCookie(event, 'token')
  if (!token) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  return { ok: true }
})

8.3 JWT 登录与 RBAC 权限

知识点:基于 JWT 的登录态与角色权限控制。

案例:登录颁发令牌与角色检查

// server/api/auth/login.post.ts
import jwt from 'jsonwebtoken'

export default defineEventHandler(async (event) => {
  const { username, password } = await readBody(event)
  if (username !== 'admin' || password !== '123456') {
    throw createError({ statusCode: 401, statusMessage: 'Bad credentials' })
  }
  const token = jwt.sign({ sub: username, role: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '1h' })
  setCookie(event, 'token', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' })
  return { ok: true }
})
// server/middleware/rbac.ts
import jwt from 'jsonwebtoken'
export default defineEventHandler((event) => {
  const token = getCookie(event, 'token')
  if (!token) throw createError({ statusCode: 401 })
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as { role: string }
    if (payload.role !== 'admin') throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
  } catch {
    throw createError({ statusCode: 401 })
  }
})

说明:

  • 切勿将 JWT_SECRET 等密钥硬编码到代码(见运行时配置章节)

8.4 文件上传(multipart)

知识点:处理表单文件上传。

案例:接收图片并保存到临时目录

// server/api/upload.post.ts
import { readMultipartFormData } from 'h3'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'

export default defineEventHandler(async (event) => {
  const parts = await readMultipartFormData(event)
  const file = parts?.find(p => p.type === 'file')
  if (!file) throw createError({ statusCode: 400, statusMessage: 'No file' })
  const tmp = join('/tmp', file.filename || `upload-${Date.now()}`)
  await fs.writeFile(tmp, file.data)
  return { ok: true, path: tmp }
})

最佳实践:

  • 使用中间件统一认证与限速
  • 密钥通过运行时配置与环境变量管理

9. 运行时配置与环境变量

9.1 runtimeConfig(public/private)与多环境切换

知识点:在 nuxt.config.ts 声明运行时配置,区分私有与公开字段。

案例:配置 API 域名与密钥(私有)

// nuxt.config.ts(片段)
export default defineNuxtConfig({
  runtimeConfig: {
    // 仅服务器可读
    secretKey: process.env.SECRET_KEY,
    // 客户端也可读
    public: {
      apiBase: process.env.PUBLIC_API_BASE || 'http://localhost:3000'
    }
  }
})

在客户端/服务端读取:

// composables/useApiBase.ts
export function useApiBase() {
  const { public: { apiBase } } = useRuntimeConfig()
  return apiBase
}
// server/utils/keys.ts
export function getSecret() {
  const { secretKey } = useRuntimeConfig()
  if (!secretKey) throw new Error('SECRET_KEY 未配置')
  return secretKey
}

9.2 .env 管理与类型安全校验

知识点:通过 .env 配置环境变量并进行类型校验。

案例:使用 zod 校验环境变量

pnpm add zod
// server/plugins/env-check.ts
import { z } from 'zod'

const EnvSchema = z.object({
  SECRET_KEY: z.string().min(16),
  PUBLIC_API_BASE: z.string().url()
})

export default defineNitroPlugin(() => {
  const parsed = EnvSchema.safeParse(process.env)
  if (!parsed.success) {
    console.error('环境变量校验失败:', parsed.error.format())
    // 在生产环境中建议直接退出或抛错
  }
})

最佳实践:

  • 私有配置放在 runtimeConfig 顶层,公开配置放 runtimeConfig.public
  • 使用 schema 校验环境变量,避免因缺失或格式错误导致线上事故

10. SEO 与元信息

10.1 useHead/useSeoMeta 与 OG/Meta 标签

知识点:在页面层面设置标题、描述、OG 等信息。

案例:文章详情页设置 SEO 与社交分享卡片

<!-- pages/posts/[id].vue(片段:SEO) -->
<script setup lang="ts">
const route = useRoute()
const id = route.params.id as string
const title = `文章 ${id} 的标题`
const description = `这是文章 ${id} 的摘要描述。`
useSeoMeta({
  title,
  description,
  ogTitle: title,
  ogDescription: description,
  ogType: 'article',
  ogUrl: `https://example.com/posts/${id}`,
  ogImage: 'https://images.example.com/og-default.jpg',
  twitterCard: 'summary_large_image'
})
</script>

10.2 sitemap/robots 与 canonical

知识点:为搜索引擎提供索引提示与规范化链接。

案例:配置 canonical 与 robots

<!-- pages/index.vue(片段) -->
<script setup lang="ts">
useHead({
  link: [{ rel: 'canonical', href: 'https://example.com/' }],
  meta: [{ name: 'robots', content: 'index,follow' }]
})
</script>

注:

  • sitemap 可使用社区模块或自行在构建阶段生成

10.3 结构化数据(JSON-LD)

知识点:通过 JSON-LD 增强搜索展示(如文章、产品)。

案例:BlogPosting 注入

<!-- pages/posts/[id].vue(片段:JSON-LD) -->
<script setup lang="ts">
const id = useRoute().params.id as string
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  'headline': `文章 ${id} 的标题`,
  'datePublished': new Date().toISOString(),
  'author': { '@type': 'Person', 'name': '作者姓名' }
}
useHead({ script: [{ type: 'application/ld+json', children: JSON.stringify(jsonLd) }] })
</script>

最佳实践:

  • 为关键页面设置 title/description/og 完整信息
  • 使用 canonical 避免重复内容带来的权重分散
  • 合理注入结构化数据提升搜索结果展示质量

11. 内容系统(Nuxt Content)

11.1 安装与基本使用

知识点:通过 Content 模块在 Nuxt 中渲染 Markdown/MDX 内容。

案例:安装并渲染 Markdown 文档

pnpm add @nuxt/content
// nuxt.config.ts(片段)
export default defineNuxtConfig({
  modules: ['@nuxt/content']
})

创建内容文件:

content/
└─ guide/
   └─ intro.md
<!-- content/guide/intro.md -->
# 入门指南

欢迎使用 Nuxt Content,这里是第一篇文档。

渲染页面:

<!-- pages/guide.vue -->
<template>
  <section class="container">
    <h2>文档</h2>
    <ContentDoc path="/guide/intro" />
  </section>
</template>

11.2 目录驱动与搜索/高亮

知识点:根据目录结构生成导航,支持代码高亮与搜索。

案例:生成侧边目录与正文

<!-- pages/docs.vue -->
<template>
  <div class="layout">
    <aside class="sidebar">
      <ContentNavigation v-slot="{ navigation }">
        <ul>
          <li v-for="item in navigation" :key="item._path">
            <NuxtLink :to="item._path">{{ item.title }}</NuxtLink>
          </li>
        </ul>
      </ContentNavigation>
    </aside>
    <main class="main">
      <ContentDoc />
    </main>
  </div>
</template>
<style scoped>
.layout { display:flex; }
.sidebar { width: 240px; border-right: 1px solid #eee; padding: 12px; }
.main { flex:1; padding: 16px; }
</style>

11.3 在 Markdown 中嵌入交互式组件

知识点:在 Content 渲染的 MD 中插入自定义 Vue 组件。

案例:嵌入 Demo 组件

<!-- components/DemoCounter.vue -->
<template>
  <div>
    <p>计数:{{ n }}</p>
    <BaseButton variant="primary" @click="n++">+</BaseButton>
  </div>
</template>
<script setup lang="ts">
const n = ref(0)
</script>
<!-- content/demo.md -->
# 交互式 Demo

这是一个嵌入组件的例子:

::DemoCounter
::

渲染:

<!-- pages/demo.vue -->
<template>
  <ContentDoc path="/demo" />
</template>

最佳实践:

  • 通过 Content 快速搭建文档站与博客系统
  • 使用目录导航与组件插入提升可读性与交互性

12. 国际化(i18n)

12.1 安装与路由策略

知识点:使用 i18n 模块实现多语言与路由前缀策略。

案例:中英文站点的路径与切换

pnpm add @nuxtjs/i18n
// nuxt.config.ts(片段)
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'zh', name: '中文', file: 'zh.json' },
      { code: 'en', name: 'English', file: 'en.json' }
    ],
    defaultLocale: 'zh',
    lazy: true,
    langDir: 'locales',
    strategy: 'prefix', // /zh, /en
  }
})

语言文件:

locales/
├─ zh.json
└─ en.json
// locales/zh.json
{ "home": "首页", "welcome": "欢迎使用 Nuxt4" }
// locales/en.json
{ "home": "Home", "welcome": "Welcome to Nuxt4" }

在页面中使用:

<!-- pages/i18n.vue -->
<template>
  <section class="container">
    <h2>{{ t('welcome') }}</h2>
    <NuxtLink to="/zh">中文</NuxtLink>
    <NuxtLink to="/en">English</NuxtLink>
  </section>
</template>
<script setup lang="ts">
const { t } = useI18n()
</script>

12.2 服务端翻译加载与 SEO

知识点:在 SSR 中加载翻译并设置 hreflang。

案例:设置多语言的 hreflang 链接

<!-- app.vue(片段) -->
<script setup lang="ts">
useHead({
  link: [
    { rel: 'alternate', href: 'https://example.com/zh', hreflang: 'zh' },
    { rel: 'alternate', href: 'https://example.com/en', hreflang: 'en' }
  ]
})
</script>

最佳实践:

  • 为多语言配置路由前缀与默认语言,避免重复内容冲突
  • 设置 hreflang 提示搜索引擎不同语言版本

13. 静态资源与图片优化

13.1 assets 与 public 的区别

知识点assets/ 走构建管线(可被处理/打包),public/ 原样公开。

案例:组织静态文件与图标

assets/
└─ styles/
   └─ main.css
public/
└─ favicon.png

app.vue 引入样式与图标:

<!-- app.vue(片段) -->
<script setup>
import '~/assets/styles/main.css'
</script>

13.2 Nuxt Image 的懒加载与裁剪

知识点:通过 NuxtImg 进行图片优化与懒加载。

案例:不同视口下自适应图片与格式转换

<!-- pages/image-advanced.vue -->
<template>
  <section class="container">
    <h2>图片优化</h2>
    <NuxtImg
      src="https://images.example.com/product.jpg"
      sizes="sm:320px md:640px lg:960px"
      format="webp"
      class="img"
    />
  </section>
</template>
<style scoped>
.img { width: 100%; border-radius: 8px; }
</style>

13.3 图片 CDN 集成

知识点:将图片托管到 CDN 并在 Nuxt 中统一配置。

案例:在 nuxt.config.ts 中设置域名

// nuxt.config.ts(片段)
export default defineNuxtConfig({
  image: {
    domains: ['images.example.com'],
    format: ['webp', 'png', 'jpg']
  }
})

最佳实践:

  • 体积大的静态资产放 CDN
  • 使用 Nuxt Image 统一图片优化策略(懒加载、裁剪、格式转换)

14. 样式与构建工具

14.1 Tailwind/UnoCSS 集成与暗色模式

知识点:快速集成现代样式方案与暗色模式。

案例:接入 Tailwind 并实现暗色模式切换

pnpm add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
// tailwind.config.js
module.exports = {
  darkMode: 'class',
  content: ['components/**/*.{vue,js}', 'pages/**/*.{vue,js}', 'app.vue'],
  theme: { extend: {} }
}
/* assets/styles/main.css(新增 Tailwind 指令) */
@tailwind base;
@tailwind components;
@tailwind utilities;
<!-- components/DarkToggle.vue -->
<template>
  <BaseButton variant="secondary" @click="toggle">
    切换暗色模式
  </BaseButton>
</template>
<script setup lang="ts">
function toggle() {
  document.documentElement.classList.toggle('dark')
}
</script>

14.2 Vite 配置扩展(别名/预处理器)

知识点:在 Nuxt 中扩展 Vite 配置以支持别名与全局样式变量。

案例:SCSS 全局变量与路径别名

// nuxt.config.ts(片段)
export default defineNuxtConfig({
  vite: {
    resolve: {
      alias: {
        '@': '/src' // 示例,按需调整
      }
    },
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: '@use "@/styles/vars.scss" as *;'
        }
      }
    }
  }
})

最佳实践:

  • 使用原子化 CSS(如 Tailwind/UnoCSS)提升开发效率
  • 通过 Vite 预处理器统一全局样式变量与主题

15. 安全与权限

15.1 XSS/CSRF 基础与安全配置

知识点:防止跨站脚本与跨站请求伪造。

案例:HTTPOnly Cookie + CSRF Token 双重防护

// server/api/auth/login.post.ts(片段)
import { randomBytes } from 'node:crypto'
export default defineEventHandler(async (event) => {
  // 认证通过后:
  setCookie(event, 'token', 'jwt-token', { httpOnly: true, secure: true, sameSite: 'lax', path: '/' })
  const csrf = randomBytes(16).toString('hex')
  setCookie(event, 'csrf', csrf, { httpOnly: false, secure: true, sameSite: 'lax', path: '/' })
  return { ok: true }
})
// server/middleware/csrf.ts
export default defineEventHandler((event) => {
  if (event.method === 'GET') return
  const csrfCookie = getCookie(event, 'csrf')
  const csrfHeader = getHeader(event, 'x-csrf-token')
  if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
    throw createError({ statusCode: 403, statusMessage: 'CSRF verification failed' })
  }
})

客户端在提交时带上 CSRF 头:

// plugins/api.ts(片段)
instance.interceptors.request.use((config) => {
  const csrf = useCookie('csrf').value
  if (csrf) config.headers['x-csrf-token'] = csrf
  return config
})

15.2 角色权限中间件与路由保护

知识点:前端路由守卫结合服务端 RBAC。

案例:页面级路由守卫

// middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
  const role = useCookie('role').value
  if (role !== 'admin') return navigateTo('/', { redirectCode: 302 })
})
<!-- pages/admin/settings.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
</script>

最佳实践:

  • 敏感信息只放 HTTPOnly Cookie
  • 写操作强制 CSRF 校验;前后端共同防护
  • 前端路由守卫仅用于提升体验,真正授权在服务端校验

16. 测试与质量保障

16.1 单元测试(Vitest)

知识点:为 Store/组件编写单元测试。

案例:测试购物车 Store

pnpm add -D vitest
// tests/cart.test.ts
import { describe, it, expect } from 'vitest'
import { useCartStore } from '../stores/cart'

describe('cart store', () => {
  it('add and remove items', () => {
    const cart = useCartStore()
    cart.clear()
    cart.add({ id: 1, title: 'A', price: 10 })
    cart.add({ id: 1, title: 'A', price: 10 })
    expect(cart.items[0].qty).toBe(2)
    cart.remove(1)
    expect(cart.items.length).toBe(0)
  })
})

16.2 端到端测试(Playwright)

知识点:编写 E2E 测试覆盖登录与路由守卫。

案例:登录流程与访问受保护路由

pnpm add -D @playwright/test
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test('redirect to login when not authenticated', async ({ page }) => {
  await page.goto('http://localhost:3000/admin')
  await expect(page).toHaveURL(/login/)
})

test('login then access admin', async ({ page }) => {
  await page.goto('http://localhost:3000/login')
  await page.getByRole('button', { name: '点击登录并跳转后台' }).click()
  await expect(page).toHaveURL(/admin/)
})

16.3 Lint/TypeScript 与 CI

知识点:在 CI 中集成 Lint、类型检查与测试。

案例:简单 CI 步骤(伪代码)

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with: { node-version: 18 }
      - run: pnpm install
      - run: pnpm typecheck
      - run: pnpm lint
      - run: pnpm test

最佳实践:

  • 单元测试聚焦纯逻辑;E2E 测试覆盖关键业务流
  • 在 CI 强制类型检查与测试,保障主干稳定

17. 部署与运维

17.1 目标平台与构建

知识点:Nuxt 可部署至 Node、Edge、Serverless,也可静态导出。

案例:不同平台部署要点

  • Node:传统服务器,pnpm build 后运行 Nitro 服务器
  • Edge/Workers:要求无 Node 专属 API,注意 Bundling 与 KV 存储
  • Serverless:每个 API 作为函数,需冷启动优化
  • 静态导出:内容/纯前端页面预渲染为静态 HTML

17.2 环境变量注入与密钥管理

知识点:生产环境注入 .env 并在运行时读取。

案例:在平台配置面板中注入 SECRET_KEYPUBLIC_API_BASE

  • 平台侧设置环境变量,避免写入代码仓库
  • 使用 runtimeConfig 获取并对敏感值仅在服务器端使用

17.3 日志与监控(Sentry)

知识点:集成错误上报与性能监控。

案例:服务端错误上报(示意)

// server/plugins/sentry.ts(示意)
import * as Sentry from '@sentry/node'
export default defineNitroPlugin(() => {
  Sentry.init({ dsn: process.env.SENTRY_DSN })
})

在 API 中捕获并上报:

// server/api/example.get.ts(示意)
import * as Sentry from '@sentry/node'
export default defineEventHandler(() => {
  try {
    // ...
  } catch (e) {
    Sentry.captureException(e)
    throw e
  }
})

17.4 缓存策略与 headers

知识点:为静态与动态资源设置合理缓存策略。

案例:为图片与静态文件设置长期缓存

// server/middleware/cache.ts
export default defineEventHandler((event) => {
  const url = event.path
  if (url.startsWith('/_nuxt/') || url.startsWith('/public/')) {
    setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable')
  }
})

最佳实践:

  • 优先选择平台的原生集成(Vercel/Netlify/Cloudflare)简化部署流程
  • 使用监控与日志定位线上问题,配合错误上报
  • 针对不同类型资源设计差异化缓存策略

18. 性能优化

18.1 路由级代码分割与预取

知识点:Nuxt 自动按页面代码分割;可预取提升导航速度。

案例:启用 link prefetch

// nuxt.config.ts(片段)
export default defineNuxtConfig({
  app: {
    // 在视口可见的链接上自动进行预取
    pageTransition: { name: 'page', mode: 'out-in' }
  },
  experimental: {
    // 不同 Nuxt 版本选项可能不同,此处为示意
  }
})

在页面中使用 <NuxtLink> 默认即可享受预取(可在 DevTools 中观察网络请求)。


18.2 useLazyAsyncData 与请求优化

知识点:惰性数据获取、去抖/节流减少不必要请求。

案例:搜索建议的去抖优化

<!-- pages/search-optimized.vue -->
<template>
  <section class="container">
    <input v-model="q" placeholder="输入关键字(300ms 去抖)" />
    <ul>
      <li v-for="s in suggestions" :key="s">{{ s }}</li>
    </ul>
  </section>
</template>
<script setup lang="ts">
const q = ref('')
const debounced = ref('')
let timer: any
watch(q, (val) => {
  clearTimeout(timer)
  timer = setTimeout(() => debounced.value = val, 300)
})
const { data: suggestions } = await useLazyAsyncData(
  () => `sugg-${debounced.value}`,
  () => $fetch('/api/suggest', { query: { q: debounced.value } }),
  { immediate: true }
)
</script>

18.3 图片优化与 HTTP 压缩

知识点:webp/avif 与 Gzip/Brotli 压缩提升加载性能。

案例:服务端开启压缩(示意)

// server/middleware/compress.ts(示意)
export default defineEventHandler((event) => {
  // 依赖平台/运行时开启压缩,此处仅示意设置头
  setHeader(event, 'Content-Encoding', 'br')
})

最佳实践:

  • 使用 Lighthouse 评估并逐项优化(图片、脚本体积、缓存策略)
  • 结合 DevTools 与 Vite Inspect 分析依赖并按需拆分

19. 开发者工具与调试

19.1 Nuxt DevTools

知识点:使用 DevTools 查看路由、组件树、数据来源与性能。

案例:定位慢路由

  • 打开 DevTools(开发模式自动可用)
  • 进入路由面板,观察该页面数据获取时间与组件渲染耗时
  • 结合网络面板检查是否有重复请求或大型资源

19.2 Vite Inspect 与依赖分析

知识点:分析打包产物与依赖体积来源。

案例:发现大体积依赖并按需优化

  • 启用 Inspect 插件(Nuxt 内置集成或按需配置)
  • 查看页面对应 chunk,确认是否引入了不必要的第三方库
  • 通过动态导入或替换轻量库减少体积

最佳实践:

  • 在开发阶段持续使用 DevTools/Inspect 发现问题
  • 优先移除不必要依赖、减少全局引入,采用按需与懒加载

Vue 的 nextTick:破解异步更新的玄机

一、先看现象:为什么数据变了,DOM 却没更新?

<template>
  <div>
    <div ref="message">{{ msg }}</div>
    <button @click="changeMessage">点击我</button>
  </div>
</template>

<script>
export default {
  data() {
    return { msg: '初始消息' }
  },
  methods: {
    changeMessage() {
      this.msg = '新消息'
      console.log('数据已更新:', this.msg)
      console.log('DOM内容:', this.$refs.message?.textContent) // 还是'初始消息'!
    }
  }
}
</script>

执行结果:

数据已更新: 新消息
DOM内容: 初始消息  ← 问题在这里!

数据明明已经改了,为什么 DOM 还是旧值?这就是 nextTick 要解决的问题。

二、核心原理:Vue 的异步更新队列

Vue 的 DOM 更新是异步的。当你修改数据时,Vue 不会立即更新 DOM,而是:

  1. 开启一个队列,缓冲同一事件循环中的所有数据变更
  2. 移除重复的 watcher,避免不必要的计算
  3. 下一个事件循环中,刷新队列并执行实际 DOM 更新
// Vue 内部的简化逻辑
let queue = []
let waiting = false

function queueWatcher(watcher) {
  // 1. 去重
  if (!queue.includes(watcher)) {
    queue.push(watcher)
  }
  
  // 2. 异步执行
  if (!waiting) {
    waiting = true
    nextTick(flushQueue)
  }
}

function flushQueue() {
  queue.forEach(watcher => watcher.run())
  queue = []
  waiting = false
}

三、nextTick 的本质:微任务调度器

nextTick 的核心任务:在 DOM 更新完成后执行回调

// Vue 2.x 中的 nextTick 实现(简化版)
let callbacks = []
let pending = false

function nextTick(cb) {
  callbacks.push(cb)
  
  if (!pending) {
    pending = true
    
    // 优先级:Promise > MutationObserver > setImmediate > setTimeout
    if (typeof Promise !== 'undefined') {
      Promise.resolve().then(flushCallbacks)
    } else if (typeof MutationObserver !== 'undefined') {
      // 用 MutationObserver 模拟微任务
    } else {
      setTimeout(flushCallbacks, 0)
    }
  }
}

function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  copies.forEach(cb => cb())
}

四、四大核心使用场景

场景1:获取更新后的 DOM

<script>
export default {
  methods: {
    async updateAndLog() {
      this.msg = '更新后的消息'
      
      // ❌ 错误:此时 DOM 还未更新
      console.log('同步获取:', this.$refs.message.textContent)
      
      // ✅ 正确:使用 nextTick
      this.$nextTick(() => {
        console.log('nextTick获取:', this.$refs.message.textContent)
      })
      
      // ✅ 更优雅:async/await 版本
      this.msg = '另一个消息'
      await this.$nextTick()
      console.log('await获取:', this.$refs.message.textContent)
    }
  }
}
</script>

场景2:操作第三方 DOM 库

<template>
  <div ref="chartContainer"></div>
</template>

<script>
import echarts from 'echarts'

export default {
  data() {
    return { data: [] }
  },
  
  async mounted() {
    // ❌ 错误:容器可能还未渲染
    // this.chart = echarts.init(this.$refs.chartContainer)
    
    // ✅ 正确:确保 DOM 就绪
    this.$nextTick(() => {
      this.chart = echarts.init(this.$refs.chartContainer)
      this.renderChart()
    })
  },
  
  methods: {
    async updateChart(newData) {
      this.data = newData
      
      // 等待 Vue 更新 DOM 和图表数据
      await this.$nextTick()
      
      // 此时可以安全操作图表实例
      this.chart.setOption({
        series: [{ data: this.data }]
      })
    }
  }
}
</script>

场景3:解决计算属性依赖问题

<script>
export default {
  data() {
    return {
      list: [1, 2, 3],
      newItem: ''
    }
  },
  
  computed: {
    filteredList() {
      // 依赖 list 的变化
      return this.list.filter(item => item > 1)
    }
  },
  
  methods: {
    async addItem(item) {
      this.list.push(item)
      
      // ❌ filteredList 可能还未计算完成
      console.log('列表长度:', this.filteredList.length)
      
      // ✅ 确保计算属性已更新
      this.$nextTick(() => {
        console.log('正确的长度:', this.filteredList.length)
      })
    }
  }
}
</script>

场景4:优化批量更新性能

// 批量操作示例
async function batchUpdate(items) {
  // 开始批量更新
  this.updating = true
  
  // 所有数据变更都在同一个事件循环中
  items.forEach(item => {
    this.dataList.push(processItem(item))
  })
  
  // 只触发一次 DOM 更新
  await this.$nextTick()
  
  // 此时 DOM 已更新完成
  this.updating = false
  this.showCompletionMessage()
  
  // 继续其他操作
  await this.$nextTick()
  this.triggerAnimation()
}

五、性能陷阱与最佳实践

陷阱1:嵌套的 nextTick

// ❌ 性能浪费:创建多个微任务
this.$nextTick(() => {
  // 操作1
  this.$nextTick(() => {
    // 操作2
    this.$nextTick(() => {
      // 操作3
    })
  })
})

// ✅ 优化:合并到同一个回调中
this.$nextTick(() => {
  // 操作1
  // 操作2  
  // 操作3
})

陷阱2:与宏任务混用

// ❌ 顺序不可控
this.msg = '更新'
setTimeout(() => {
  console.log(this.$refs.message.textContent)
}, 0)

// ✅ 明确使用 nextTick
this.msg = '更新'
this.$nextTick(() => {
  console.log(this.$refs.message.textContent)
})

最佳实践:使用 async/await

methods: {
  async reliableUpdate() {
    // 1. 更新数据
    this.data = await fetchData()
    
    // 2. 等待 DOM 更新
    await this.$nextTick()
    
    // 3. 操作更新后的 DOM
    this.scrollToBottom()
    
    // 4. 如果需要,再次等待
    await this.$nextTick()
    this.triggerAnimation()
    
    return '更新完成'
  }
}

六、Vue 3 的变化与优化

Vue 3 的 nextTick 更加精简高效:

// Vue 3 中的使用
import { nextTick } from 'vue'

// 方式1:回调函数
nextTick(() => {
  console.log('DOM 已更新')
})

// 方式2:Promise
await nextTick()
console.log('DOM 已更新')

// 方式3:Composition API
setup() {
  const handleClick = async () => {
    state.value = '新值'
    await nextTick()
    // 操作 DOM
  }
  
  return { handleClick }
}

Vue 3 的优化:

  • 使用 Promise.resolve().then() 作为默认策略
  • 移除兼容性代码,更小的体积
  • 更好的 TypeScript 支持

七、源码级理解

// Vue 2.x nextTick 核心逻辑
export function nextTick(cb, ctx) {
  let _resolve
  
  // 1. 将回调推入队列
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  
  // 2. 如果未在等待,开始异步执行
  if (!pending) {
    pending = true
    timerFunc() // 触发异步更新
  }
  
  // 3. 支持 Promise 链式调用
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

八、实战:手写简易 nextTick

class MyVue {
  constructor() {
    this.callbacks = []
    this.pending = false
  }
  
  $nextTick(cb) {
    // 返回 Promise 支持 async/await
    return new Promise(resolve => {
      const wrappedCallback = () => {
        if (cb) cb()
        resolve()
      }
      
      this.callbacks.push(wrappedCallback)
      
      if (!this.pending) {
        this.pending = true
        
        // 优先使用微任务
        if (typeof Promise !== 'undefined') {
          Promise.resolve().then(() => this.flushCallbacks())
        } else {
          setTimeout(() => this.flushCallbacks(), 0)
        }
      }
    })
  }
  
  flushCallbacks() {
    this.pending = false
    const copies = this.callbacks.slice(0)
    this.callbacks.length = 0
    copies.forEach(cb => cb())
  }
  
  // 模拟数据更新
  async setData(key, value) {
    this[key] = value
    await this.$nextTick()
    console.log(`DOM 已更新: ${key} = ${value}`)
  }
}

总结

nextTick 的三层理解:

  1. 表象层:在 DOM 更新后执行代码
  2. 原理层:Vue 异步更新队列的调度器
  3. 实现层:基于 JavaScript 事件循环的微任务管理器

使用原则:

  1. 需要访问更新后的 DOM 时,必须用 nextTick
  2. 操作第三方库前,先等 Vue 更新完成
  3. 批量操作后,用 nextTick 统一处理副作用
  4. 优先使用 async/await 语法,更清晰直观

一句话概括:

nextTick 是 Vue 给你的一个承诺:"等我把 DOM 更新完,再执行你的代码"。

记住这个承诺,你就能完美掌控 Vue 的更新时机。


思考题: 如果连续修改同一个数据 1000 次,Vue 会触发多少次 DOM 更新? (答案:得益于 nextTick 的队列机制,只会触发 1 次)

vue使用h函数封装dialog组件,以命令的形式使用dialog组件

场景

有些时候我们的页面是有很多的弹窗
如果我们把这些弹窗都写html中会有一大坨
因此:我们需要把弹窗封装成命令式的形式

命令式弹窗

// 使用弹窗的组件
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
function openMask(){
  // 第1个参数:表示的是组件,你写弹窗中的组件
  // 第2个参数:表示的组件属性,比如:确认按钮的名称等
  // 第3个参数:表示的模态框的属性。比如:模态宽的宽度,标题名称,是否可移动
  renderDialog(childTest,{},{title:'测试弹窗'})
}
</script>
// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
export function renderDialog(component:any,props:any, modalProps:any){
 const dialog  = h(
    ElDialog,   // 模态框组件
    {
      ...modalProps, // 模态框属性
      modelValue:true, // 模态框是否显示
    }, // 因为是模态框组件,肯定是模态框的属性
    {
      default:()=>h(component, props ) // 插槽,el-dialog下的内容
    }
  )
 console.log(dialog)
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
//childTest.vue 组件
<template>
  <div>
    <span>It's a modal Dialog</span>
    <el-form :model="form" label-width="auto" style="max-width: 600px">
    <el-form-item label="Activity name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="Activity zone">
      <el-select v-model="form.region" placeholder="please select your zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
  </el-form>
  </div>
</template>
<script setup lang="ts">
import { ref,reactive } from 'vue'
const dialogVisible = ref(true)
const form = reactive({
  name: '',
  region: '',
})
const onSubmit = () => {
  console.log('submit!')
}
</script>

01

为啥弹窗中的表单不能够正常展示呢?

在控制台会有下面的提示信息:
Failed to resolve component:
el-form If this is a native custom element,
make sure to exclude it from component resolution via compilerOptions.isCustomElement
翻译过来就是
无法解析组件:el-form如果这是一个原生自定义元素,
请确保通过 compilerOptions.isCustomElement 将其从组件解析中排除

02

其实就是说:我重新创建了一个新的app,这个app中没有注册组件。
因此会警告,页面渲染不出来。

// 我重新创建了一个app,这个app中没有注册 element-plus 组件。
const app = createApp(dialog)

现在我们重新注册element-plus组件。
准确的说:我们要注册 childTest.vue 组件使用到的东西

给新创建的app应用注册childTest组件使用到的东西

我们将会在这个命令式弹窗中重新注册需要使用到的组件

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
// 引入组件和样式
import ElementPlus from "element-plus";
// import "element-plus/dist/index.css";
export function renderDialog(component:any,props:any, modalProps:any){
 const dialog  = h(
    ElDialog,   // 模态框组件
    {
      ...modalProps, // 模态框属性
      modelValue:true, // 模态框显示
    }, // 因为是模态框组件,肯定是模态框的属性
    {
      default:()=>h(component, props ) // 插槽,el-dialog下的内容
    }
  )
 console.log(dialog)
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

03

现在我们发现可以正常展示弹窗中的表单了。因为我们注册了element-plus组件。
但是我们发现又发现了另外一个问题。
弹窗底部没有取消和确认按钮。
需要我们再次通过h函数来创建

关于使用createApp创建新的应用实例

在Vue 3中,我们可以使用 createApp 来创建新的应用实例
但是这样会创建一个完全独立的应用
它不会共享主应用的组件、插件等。
因此我们需要重新注册

弹窗底部新增取消和确认按钮

我们将会使用h函数中的插槽来创建底部的取消按钮

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
    },
    {
      // 主要内容插槽
      default: () => h(component, props),
      // 底部插槽
      footer:() =>h(
        'div',
        { class: 'dialog-footer' },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('取消')
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

04

点击关闭弹窗时,需要移除之前创建的div

卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div。
2个地方需要移除:1,点击确认按钮。 2,点击其他地方的关闭
05

关闭弹窗正确销毁相关组件

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  console.log('111')
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽
      default: () => h(component, props),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

06

点击确认按钮时验证规则

有些时候,我们弹窗中的表单是需要进行规则校验的。
我们下面来实现这个功能点
传递的组件

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
  >
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Activity zone" prop="region">
      <el-select v-model="ruleForm.region" placeholder="Activity zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    
    <el-form-item label="Activity time" required>
      <el-col :span="11">
        <el-form-item prop="date1">
          <el-date-picker
            v-model="ruleForm.date1"
            type="date"
            aria-label="Pick a date"
            placeholder="Pick a date"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col class="text-center" :span="2">
        <span class="text-gray-500">-</span>
      </el-col>
      <el-col :span="11">
        <el-form-item prop="date2">
          <el-time-picker
            v-model="ruleForm.date2"
            aria-label="Pick a time"
            placeholder="Pick a time"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-form-item>

    <el-form-item label="Resources" prop="resource">
      <el-radio-group v-model="ruleForm.resource">
        <el-radio value="Sponsorship">Sponsorship</el-radio>
        <el-radio value="Venue">Venue</el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="Activity form" prop="desc">
      <el-input v-model="ruleForm.desc" type="textarea" />
    </el-form-item>

  </el-form>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'

import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
  region: string
  date1: string
  date2: string
  resource: string
  desc: string
}
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
  region: '',
  date1: '',
  date2: '',
  resource: '',
  desc: '',
})
const rules = reactive<FormRules<RuleForm>>({
  name: [
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ],
  region: [
    {
      required: true,
      message: 'Please select Activity zone',
      trigger: 'change',
    },
  ],
  date1: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a date',
      trigger: 'change',
    },
  ],
  date2: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a time',
      trigger: 'change',
    },
  ],
  resource: [
    {
      required: true,
      message: 'Please select activity resource',
      trigger: 'change',
    },
  ],
  desc: [
    { required: true, message: 'Please input activity form', trigger: 'blur' },
  ],
})

const submitForm = async () => {
  if (!ruleFormRef.value) {
    console.error('ruleFormRef is not initialized')
    return false
  }
  try {
    const valid = await ruleFormRef.value.validate()
    if (valid) {
      console.log('表单校验通过', ruleForm)
      return Promise.resolve(ruleForm)
    }
  } catch (error) {
    // 为啥submitForm中,valid的值是false会执行catch ?
    // el-form 组件的 validate 方法的工作机制导致的。 validate 方法在表单验证失败时会抛出异常
    console.error('err', error)
    return false
    /**
     * 下面这样写为啥界面会报错呢?
     * return Promise.reject(error)
     * 当表单验证失败时,ruleFormRef.value.validate() 会抛出一个异常。
     * 虽然你用了 try...catch 捕获这个异常,并且在 catch 块中通过 return Promise.reject(error) 返回了一个被拒绝的 Promise
     * 但如果调用 submitForm 的地方没有正确地处理这个被拒绝的 Promise(即没有使用 .catch() 或者 await 来接收错误),
     * 那么浏览器控制台就会显示一个 "Uncaught (in promise)" 错误。
     * 在 catch 中再次 return Promise.reject(error) 是多余的, 直接return false
     * */ 
    /**
     * 如果你这样写
     * throw error 直接抛出错误即可
     * 那么就需要再调用submitForm的地方捕获异常
     * */  
  }
}

defineExpose({
  submitForm:submitForm
})
</script>
// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                })
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

07 关键的点:通过ref拿到childTest组件中的方法,childTest要暴露需要的方法

如何把表单中的数据暴露出去

可以通过回调函数的方式把数据暴露出去哈。

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据, 如验证失败,res 的值有可能是一个false。
                  onConfirm(res)
                  // 怎么把这个事件传递出去,让使用的时候知道点击了确认并且知道验证通过了
                }).catch((error: any) => {
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
  })
}
</script>

08

点击确定时,业务完成后关闭弹窗

现在想要点击确定,等业务处理完成之后,才关闭弹窗。 需要在使用完成业务的时候返回一个promise,让封装的弹窗调用这个promise 这样就可以知道什么时候关闭弹窗了

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                    // 注意这里的finally,这样写在服务出现异常的时候会有问题,这里是有问题的,需要优化
                    // 注意这里的finally,这样写在服务出现异常的时候会有问题,这里是有问题的,需要优化
                    callbackResult.finally(() => { 
                      // 弹窗关闭逻辑
                      app.unmount()
                      document.body.removeChild(div)
                    });
                  } else {
                    // 如果不是 Promise,立即关闭弹窗
                    app.unmount()
                    document.body.removeChild(div)
                  }
                }).catch((error: any) => {
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
    // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
    return fetch("https://dog.ceo/api/breed/pembroke/images/random")
     .then((res) => {
       return res.json();
     })
     .then((res) => {
        console.log('获取的图片地址为:', res.message);
     });
  })
}
</script>

09

优化业务组件

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 关闭弹窗,避免重复代码
  const closeDialog = () => {
    // 成功时关闭弹窗
    app.unmount();
    // 检查div是否仍然存在且为body的子元素,否者可能出现异常
    if (div && div.parentNode) {
      document.body.removeChild(div)
    }
  }
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                   
                     callbackResult.then(() => {
                      if(res){
                        console.log('111')
                        closeDialog()
                      }
                    }).catch(error=>{
                      console.log('222')
                      console.error('回调函数执行出错,如:网络错误', error);
                      // 错误情况下也关闭弹窗
                      closeDialog()
                    });
                  } else {
                    // 如果不是 Promise,并且验证时通过了的。立即关闭弹窗
                    console.log('333', res)
                    if(res){
                      closeDialog()
                    }
                  }
                }).catch((error: any) => {
                  console.log('44444')
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>
<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
      // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
      return fetch("https://dog.ceo/api/breed/pembroke/images/random")
      .then((res) => {
        return res.json();
      })
      .then((res) => {
          console.log('获取的图片地址为:', res.message);
      });
  })
}
</script>

眼尖的小伙伴可能已经发现了这一段代码。 1,验证不通过会也会触发卸载弹窗 2,callbackResult.finally是不合适的

image

10

最终的代码

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 关闭弹窗,避免重复代码
  const closeDialog = () => {
    // 成功时关闭弹窗
    app.unmount();
    // 检查div是否仍然存在且为body的子元素,否者可能出现异常
    if (div && div.parentNode) {
      document.body.removeChild(div)
    }
  }
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  const isLoading = ref(false)
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        isLoading.value = false
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽,noShowFooterBool是true,不显示; false的显示底部 
      footer: props.noShowFooterBool ? null : () =>h(
        'div',
        { 
          class: 'dialog-footer',
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => props.cancelText || '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              loading: isLoading.value,
              onClick: () => {
                isLoading.value = true
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  if(!res){
                    isLoading.value = false
                  }
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                     callbackResult.then(() => {
                      if(res){
                        console.log('111')
                        closeDialog()
                      }else{
                        isLoading.value = false
                      }
                    }).catch(error=>{
                      console.log('222')
                      console.error('回调函数执行出错,如:网络错误', error);
                      // 错误情况下也关闭弹窗
                      closeDialog()
                    });
                  } else {
                    // 如果不是 Promise,并且验证时通过了的。立即关闭弹窗
                    console.log('333', res)
                    if(res){
                      closeDialog()
                    }else{
                      isLoading.value = false
                    }
                  }
                }).catch((error: any) => {
                  console.log('44444')
                   isLoading.value = false
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => props.confirmText ||  '确定'
          )
        ]
      ) 
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  const otherProps =  {cancelText:'取消哈', confirmText: '确认哈',showFooterBool:true }
  const dialogSetObject = {title:'测试弹窗哈', width: '700', draggable: true}
  renderDialog(childTest,otherProps,dialogSetObject, (res)=>{
    console.log('通过回调函数返回值', res)
    // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
    return fetch("https://dog.ceo/api/breed/pembroke/images/random")
    .then((res) => {
      return res.json();
    })
    .then((res) => {
        console.log('获取的图片地址为:', res.message);
    });
  })
}
</script>

<style lang="scss" scoped>

</style>
<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
  >
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Activity zone" prop="region">
      <el-select v-model="ruleForm.region" placeholder="Activity zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    
    <el-form-item label="Activity time" required>
      <el-col :span="11">
        <el-form-item prop="date1">
          <el-date-picker
            v-model="ruleForm.date1"
            type="date"
            aria-label="Pick a date"
            placeholder="Pick a date"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col class="text-center" :span="2">
        <span class="text-gray-500">-</span>
      </el-col>
      <el-col :span="11">
        <el-form-item prop="date2">
          <el-time-picker
            v-model="ruleForm.date2"
            aria-label="Pick a time"
            placeholder="Pick a time"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-form-item>

  
    <el-form-item label="Resources" prop="resource">
      <el-radio-group v-model="ruleForm.resource">
        <el-radio value="Sponsorship">Sponsorship</el-radio>
        <el-radio value="Venue">Venue</el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="Activity form" prop="desc">
      <el-input v-model="ruleForm.desc" type="textarea" />
    </el-form-item>

  </el-form>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'

import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
  region: string

  date1: string
  date2: string


  resource: string
  desc: string
}


const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
  region: '',
  date1: '',
  date2: '',
  resource: '',
  desc: '',
})



const rules = reactive<FormRules<RuleForm>>({
  name: [
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ],
  region: [
    {
      required: true,
      message: 'Please select Activity zone',
      trigger: 'change',
    },
  ],
  date1: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a date',
      trigger: 'change',
    },
  ],
  date2: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a time',
      trigger: 'change',
    },
  ],
  resource: [
    {
      required: true,
      message: 'Please select activity resource',
      trigger: 'change',
    },
  ],
  desc: [
    { required: true, message: 'Please input activity form', trigger: 'blur' },
  ],
})

const submitForm = async () => {
  if (!ruleFormRef.value) {
    console.error('ruleFormRef is not initialized')
    return false
  }
  try {
    const valid = await ruleFormRef.value.validate()
    if (valid) {
      // 验证通过后,就会可以把你需要的数据暴露出去
      return Promise.resolve(ruleForm)
    }
  } catch (error) {
    // 为啥submitForm中,valid的值是false会执行catch ?
    // el-form 组件的 validate 方法的工作机制导致的。 validate 方法在表单验证失败时会抛出异常
    console.error('err', error)
    return false
    /**
     * 下面这样写为啥界面会报错呢?
     * return Promise.reject(error)
     * 当表单验证失败时,ruleFormRef.value.validate() 会抛出一个异常。
     * 虽然你用了 try...catch 捕获这个异常,并且在 catch 块中通过 return Promise.reject(error) 返回了一个被拒绝的 Promise
     * 但如果调用 submitForm 的地方没有正确地处理这个被拒绝的 Promise(即没有使用 .catch() 或者 await 来接收错误),
     * 那么浏览器控制台就会显示一个 "Uncaught (in promise)" 错误。
     * 在 catch 中再次 return Promise.reject(error) 是多余的, 直接return false
     * */ 
    /**
     * 如果你这样写
     * throw error 直接抛出错误即可
     * 那么就需要再调用submitForm的地方捕获异常
     * */  
  }
}

defineExpose({
  submitForm:submitForm
})
</script>

深度解析Vue3响应式原理:Proxy + Reflect + effect 三叉戟

响应式系统是Vue框架的核心基石,它实现了“数据驱动视图”的核心思想——当数据发生变化时,依赖该数据的视图会自动更新,无需手动操作DOM。Vue3相较于Vue2,彻底重构了响应式系统,放弃了Object.defineProperty,转而采用Proxy + Reflect + effect的组合方案,解决了Vue2响应式的诸多缺陷(如无法监听对象新增属性、数组索引变化等)。本文将从核心概念入手,层层拆解三者的协作机制,深入剖析Vue3响应式系统的实现原理与核心细节。

一、核心目标:什么是“响应式”?

在Vue中,“响应式”的核心目标可概括为:建立数据与依赖(如组件渲染函数、watch回调)之间的关联,当数据发生变化时,自动触发所有依赖的重新执行

举个直观的例子:

<script setup>
import { ref } from 'vue';
const count = ref(0); // 响应式数据

// 依赖count的逻辑(组件渲染函数)
const render = () => {
  document.body.innerHTML = `count: ${count.value}`;
};

// 初始执行渲染
render();

// 1秒后修改数据,视图自动更新
setTimeout(() => {
  count.value = 1;
}, 1000);
</script>

上述代码中,count是响应式数据,render函数是依赖count的“副作用”。当count.value修改时,render函数会自动重新执行,视图随之更新。Vue3响应式系统的核心任务,就是自动完成“依赖收集”(识别render依赖count)和“依赖触发”(count变化时触发render重新执行)。

二、核心三要素:Proxy + Reflect + effect 各司其职

Vue3响应式系统的实现依赖三个核心要素,它们分工明确、协同工作:

  • Proxy:作为响应式数据的“代理层”,拦截数据的读取(get)、修改(set)等操作,为依赖收集和依赖触发提供“钩子”。
  • Reflect:配合Proxy完成数据操作的“反射层”,确保在拦截操作时,能正确保留原对象的行为(如原型链、属性描述符等),同时简化拦截逻辑。
  • effect:封装“副作用”逻辑(如组件渲染函数、watch回调),负责触发依赖收集(记录数据与副作用的关联)和在数据变化时重新执行副作用。

三者的协作流程可简化为:

  1. effect执行副作用函数,触发数据的读取操作。
  2. Proxy拦截数据读取,通过Reflect完成原始读取操作,同时触发依赖收集(将当前effect与数据关联)。
  3. 当数据被修改时,Proxy拦截数据修改,通过Reflect完成原始修改操作,同时触发依赖触发(找到所有关联的effect并重新执行)。

三、逐个拆解:核心要素的作用与实现

3.1 Proxy:响应式数据的“拦截器”

Proxy是ES6新增的对象,用于创建一个对象的代理,从而实现对目标对象的属性读取、修改、删除等操作的拦截和自定义处理。Vue3正是利用Proxy的拦截能力,为响应式数据提供了“监听”机制。

3.1.1 Proxy的核心优势(对比Vue2的Object.defineProperty)

  • 支持监听对象新增属性:Object.defineProperty只能监听已存在的属性,无法监听新增属性;Proxy的set拦截可以捕获对象新增属性的操作。
  • 支持监听数组索引/长度变化:Object.defineProperty难以监听数组通过索引修改元素、修改length属性的操作;Proxy可以轻松拦截数组的这些变化。
  • 支持监听对象删除操作:Proxy的deleteProperty拦截可以捕获属性删除操作。
  • 非侵入式拦截:Proxy无需像Object.defineProperty那样遍历对象属性并重新定义,直接代理目标对象,更高效、更简洁。

3.1.2 Proxy在响应式中的核心拦截操作

在Vue3响应式系统中,主要拦截以下两个核心操作:

  1. get拦截:当读取响应式对象的属性时触发,核心作用是“依赖收集”——记录当前正在执行的effect与该属性的关联。
  2. set拦截:当修改响应式对象的属性时触发,核心作用是“依赖触发”——找到所有与该属性关联的effect,重新执行它们。

简单实现一个基础的响应式Proxy:

// 目标对象
const target = { count: 0 };

// 创建Proxy代理
const reactiveTarget = new Proxy(target, {
  // 拦截属性读取操作
  get(target, key, receiver) {
    console.log(`读取属性 ${key}${target[key]}`);
    // 此处会触发依赖收集逻辑(后续补充)
    return target[key];
  },
  // 拦截属性修改/新增操作
  set(target, key, value, receiver) {
    console.log(`修改属性 ${key}${value}`);
    target[key] = value;
    // 此处会触发依赖触发逻辑(后续补充)
    return true; // 表示修改成功
  }
});

// 测试拦截效果
reactiveTarget.count; // 输出:读取属性 count:0
reactiveTarget.count = 1; // 输出:修改属性 count:1
reactiveTarget.name = "Vue3"; // 输出:修改属性 name:Vue3(支持新增属性拦截)

3.2 Reflect:拦截操作的“反射器”

Reflect也是ES6新增的内置对象,它提供了一系列方法,用于执行对象的原始操作(如读取属性、修改属性、删除属性等),这些方法与Proxy的拦截方法一一对应。Vue3在Proxy的拦截器中,通过Reflect执行原始数据操作,而非直接操作目标对象。

3.2.1 为什么需要Reflect?

  • 确保原始操作的正确性:Reflect的方法会严格遵循ECMAScript规范,正确处理对象的原型链、属性描述符等细节。例如,当目标对象的属性不可写时,Reflect.set会返回false,而直接赋值会抛出错误。
  • 简化拦截逻辑:Reflect的方法会自动传递receiver(Proxy实例),确保在操作中正确绑定this。例如,当目标对象的属性是访问器属性(getter/setter)时,receiver可以确保this指向Proxy实例,而非目标对象。
  • 统一的返回值逻辑:Reflect的方法都会返回一个布尔值,表示操作是否成功,便于拦截器中判断操作结果。

3.2.2 Reflect在响应式中的应用

修改上述Proxy示例,使用Reflect执行原始操作:

const target = { count: 0 };

const reactiveTarget = new Proxy(target, {
  get(target, key, receiver) {
    console.log(`读取属性 ${key}`);
    // 使用Reflect.get执行原始读取操作,传递receiver
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`修改属性 ${key}${value}`);
    // 使用Reflect.set执行原始修改操作,返回操作结果
    const success = Reflect.set(target, key, value, receiver);
    if (success) {
      // 操作成功后触发依赖
      console.log("依赖触发成功");
    }
    return success;
  }
});

reactiveTarget.count; // 输出:读取属性 count
reactiveTarget.count = 1; // 输出:修改属性 count:1 → 依赖触发成功

3.3 effect:副作用的“管理器”

effect是Vue3响应式系统中封装“副作用”的核心函数。所谓“副作用”,是指会依赖响应式数据、且当响应式数据变化时需要重新执行的逻辑(如组件渲染函数、watch回调函数、computed计算函数等)。

3.3.1 effect的核心作用

  • 触发依赖收集:当effect执行时,会将自身设为“当前活跃的effect”,然后执行副作用函数。副作用函数中读取响应式数据时,会触发Proxy的get拦截,此时将“当前活跃的effect”与该数据属性关联起来(依赖收集)。
  • 响应数据变化:当响应式数据变化时,会触发Proxy的set拦截,此时找到所有与该数据属性关联的effect,重新执行它们(依赖触发)。

3.3.2 effect的简单实现

要实现effect,需要解决两个核心问题:

  1. 如何记录“当前活跃的effect”?
  2. 如何存储“数据属性与effect的关联关系”?

解决方案:

  • 用一个全局变量(如activeEffect)存储当前正在执行的effect。
  • 用一个“依赖映射表”(如targetMap)存储关联关系,结构为:targetMap → target → key → effects(Set集合)。

具体实现代码:

// 1. 全局变量:存储当前活跃的effect
let activeEffect = null;

// 2. 依赖映射表:target → key → effects
const targetMap = new WeakMap();

// 3. 依赖收集函数:建立数据属性与effect的关联
function track(target, key) {
  // 若没有活跃的effect,无需收集依赖
  if (!activeEffect) return;

  // 从targetMap中获取当前target的依赖表(没有则创建)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  // 从depsMap中获取当前key的effect集合(没有则创建)
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }

  // 将当前活跃的effect添加到集合中(Set自动去重)
  deps.add(activeEffect);
}

// 4. 依赖触发函数:数据变化时,执行关联的effect
function trigger(target, key) {
  // 从targetMap中获取当前target的依赖表
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  // 从depsMap中获取当前key的effect集合
  const deps = depsMap.get(key);
  if (deps) {
    // 执行所有关联的effect
    deps.forEach(effect => effect());
  }
}

// 5. effect核心函数:封装副作用
function effect(callback) {
  // 定义effect函数
  const effectFn = () => {
    // 执行副作用前,先清除当前effect的关联(避免重复收集)
    cleanup(effectFn);
    // 将当前effect设为活跃状态
    activeEffect = effectFn;
    // 执行副作用函数(会触发响应式数据的get拦截,进而触发track收集依赖)
    callback();
    // 副作用执行完毕,重置活跃effect
    activeEffect = null;
  };

  // 存储当前effect关联的依赖集合(用于cleanup清除)
  effectFn.deps = [];

  // 初始执行一次effect,触发依赖收集
  effectFn();
}

// 6. 清除依赖函数:避免effect重复执行
function cleanup(effectFn) {
  // 遍历effect关联的所有依赖集合,移除当前effect
  for (const deps of effectFn.deps) {
    deps.delete(effectFn);
  }
  // 清空deps数组
  effectFn.deps.length = 0;
}

// 7. 响应式函数:创建Proxy代理
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 执行原始读取操作
      const result = Reflect.get(target, key, receiver);
      // 触发依赖收集
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      // 执行原始修改操作
      const success = Reflect.set(target, key, value, receiver);
      // 触发依赖触发
      trigger(target, key);
      return success;
    }
  });
}

3.3.3 effect的工作流程演示

结合上述实现,演示effect与响应式数据的协作流程:

// 1. 创建响应式数据
const state = reactive({ count: 0 });

// 2. 定义副作用(组件渲染逻辑模拟)
effect(() => {
  console.log(`count: ${state.count}`);
});
// 初始执行effect,输出:count: 0
// 执行过程中读取state.count,触发get拦截 → 调用track收集依赖(effect与state.count关联)

// 3. 修改响应式数据
state.count = 1;
// 触发set拦截 → 调用trigger → 执行关联的effect → 输出:count: 1

// 4. 新增属性(Proxy支持)
state.name = "Vue3";
// 触发set拦截 → 调用trigger(无关联effect,无输出)

// 5. 定义依赖name的副作用
effect(() => {
  console.log(`name: ${state.name}`);
});
// 初始执行effect,输出:name: Vue3
// 收集name与该effect的关联

// 6. 修改name
state.name = "Vue3 Reactivity";
// 触发set拦截 → 执行关联的effect → 输出:name: Vue3 Reactivity

四、核心协作流程:完整响应式链路拆解

结合上述实现,我们可以梳理出Vue3响应式系统的完整协作流程,分为“依赖收集阶段”和“依赖触发阶段”两个核心环节。

4.1 依赖收集阶段(数据与effect关联)

  1. 调用effect函数,传入副作用回调(如渲染函数)。
  2. effect函数内部创建effectFn,执行effectFn。
  3. effectFn中先执行cleanup清除旧依赖,再将自身设为activeEffect(当前活跃effect)。
  4. 执行副作用回调,回调中读取响应式数据的属性(如state.count)。
  5. 触发响应式数据的Proxy.get拦截。
  6. get拦截中调用Reflect.get执行原始读取操作。
  7. 调用track函数,在targetMap中建立“target(state)→ key(count)→ effectFn”的关联。
  8. 副作用回调执行完毕,重置activeEffect为null。

4.2 依赖触发阶段(数据变化触发effect重新执行)

  1. 修改响应式数据的属性(如state.count = 1)。
  2. 触发响应式数据的Proxy.set拦截。
  3. set拦截中调用Reflect.set执行原始修改操作。
  4. 调用trigger函数,从targetMap中查找“target(state)→ key(count)”关联的所有effectFn。
  5. 遍历执行所有关联的effectFn,副作用逻辑(如渲染函数)重新执行,视图更新。

五、进阶细节:Vue3响应式系统的优化与扩展

5.1 对Ref的支持:基本类型的响应式

Proxy只能代理对象类型,无法直接代理基本类型(string、number、boolean等)。Vue3通过Ref解决了基本类型的响应式问题:

  • Ref将基本类型包装成一个“具有value属性的对象”(如{ value: 0 })。
  • 对Ref对象的value属性进行Proxy代理,从而实现基本类型的响应式。
  • 在模板中使用Ref时,Vue3会自动解包(无需手动写.value),在组合式API的setup中则需要手动使用.value。

5.2 对computed的支持:缓存型副作用

computed本质是一个“缓存型effect”,它具有以下特性:

  • computed的回调函数是一个副作用,依赖响应式数据。
  • computed会缓存计算结果,只有当依赖的响应式数据变化时,才会重新计算。
  • computed内部通过effect的调度器(scheduler)实现缓存逻辑:当依赖变化时,不立即执行effect,而是标记为“脏数据”,等到下次读取computed值时再重新计算。

5.3 对watch的支持:监听数据变化的副作用

watch的核心是“监听指定响应式数据的变化,触发自定义副作用”,其实现基于effect:

  • watch内部创建一个effect,副作用函数中读取要监听的响应式数据(触发依赖收集)。
  • 当监听的数据变化时,触发effect重新执行,此时调用watch的回调函数,并传入新旧值。
  • watch支持“深度监听”(通过deep选项)和“立即执行”(通过immediate选项),本质是通过调整effect的执行时机和依赖收集范围实现。

5.4 调度器(scheduler):控制effect的执行时机

Vue3的effect支持传入调度器函数(scheduler),用于控制effect的执行时机和方式。调度器是实现computed缓存、watch延迟执行、批量更新的核心:

  • 当effect触发时,若存在调度器,会执行调度器而非直接执行effect。
  • 例如,Vue3的批量更新机制:将多个effect的执行延迟到下一个微任务中,避免多次DOM更新,提升性能。

六、实战避坑:响应式系统的常见问题

6.1 响应式数据的“丢失”问题

问题描述:将响应式对象的属性解构赋值给普通变量,普通变量会失去响应式。

import { reactive } from 'vue';

const state = reactive({ count: 0 });
const { count } = state; // 解构出普通变量count,失去响应式

count = 1; // 不会触发响应式更新

解决方案:

  • 避免直接解构响应式对象,若需解构,可使用toRefs将响应式对象的属性转为Ref。
  • 使用Ref包裹基本类型,避免解构导致的响应式丢失。
import { reactive, toRefs } from 'vue';

const state = reactive({ count: 0 });
const { count } = toRefs(state); // count是Ref对象,保留响应式

count.value = 1; // 触发响应式更新

6.2 数组响应式的特殊情况

问题描述:通过数组的某些方法(如push、pop)修改数组时,Vue3能正常监听,但直接修改数组索引或length时,需注意响应式触发。

import { reactive } from 'vue';

const arr = reactive([1, 2, 3]);

arr[0] = 10; // 能触发响应式更新
arr.length = 0; // 能触发响应式更新
arr.push(4); // 能触发响应式更新

注意:Vue3对数组的响应式支持已非常完善,大部分数组操作都能正常触发响应式,但仍建议优先使用数组的内置方法(push、splice等)修改数组,更符合直觉。

6.3 深层对象的响应式问题

问题描述:响应式对象的深层属性变化时,是否能正常触发响应式?

答案:能。因为Proxy的get拦截会递归触发深层属性的依赖收集。例如:

import { reactive } from 'vue';

const state = reactive({ a: { b: 1 } });

effect(() => {
  console.log(state.a.b); // 读取深层属性,收集依赖
});

state.a.b = 2; // 能触发响应式更新,输出2

注意:若深层对象是后来新增的,需确保新增的对象也是响应式的(Vue3的reactive会自动处理新增属性的响应式)。

七、总结:Vue3响应式系统的核心价值

Vue3响应式系统通过Proxy + Reflect + effect的组合,构建了一个高效、灵活、功能完善的响应式机制,其核心价值在于:

  • 彻底解决了Vue2响应式的缺陷:支持对象新增属性、数组索引/长度变化、属性删除等操作的监听。
  • 非侵入式设计:通过Proxy代理目标对象,无需修改原始对象的结构,更符合JavaScript的语言特性。
  • 灵活的扩展能力:通过effect的调度器、Ref、computed、watch等扩展,支持各种复杂的业务场景。
  • 高效的性能:通过批量更新、缓存机制(computed)等优化,减少不必要的副作用执行,提升应用性能。

理解Vue3响应式原理,不仅能帮助我们更好地使用Vue3的API(如reactive、ref、computed、watch),还能让我们在遇到响应式相关问题时快速定位并解决。Proxy + Reflect + effect的组合设计,也为我们编写高效的JavaScript代码提供了优秀的思路借鉴。

2026 年,只会写 div 和 css 的前端将彻底失业

引言:当“手写”成为一种昂贵的低效

如果把时间拨回2023年,听到“只会写 HTML 和 CSS 的前端要失业”这种话,大多数人可能只会把它当作制造焦虑的标题党,甚至会嗤之以鼻地反驳:“AI 懂什么叫像素级还原吗?”

但在 2026 年的今天,站在新年的路口,我们必须诚实地面对现状:这不再是一个预测,而是正在发生的残酷事实。

现在的开发环境是怎样的?打开 IDE,你用自然语言描述一个“带有毛玻璃效果、响应式布局、暗黑模式切换的 Dashboard 侧边栏”,AI Copilot 在 3 秒内生成的代码,不仅符合 Tailwind CSS 最佳实践,甚至连 Accessibility(无障碍访问)属性都配齐了。Figma 的设计稿一键转出的 React/Vue 代码,其质量已经超过了 3 年经验的中级工程师。

在这种生产力下,如果你所谓的工作产出仅仅是“把设计图转换成代码”,那么你的价值已经被压缩到了无限接近于零。

并不是前端死了,而是“切图(Slicing)”这个曾养活了无数人的工种,彻底完成了它的历史使命,退出了舞台。


一、 认清现实:UI 层的“去技能化”

在 2026 年,UI 构建的门槛已经发生了本质的变化。我们必须接受一个现实:基础 UI 构建已经不再是核心竞争力,而是基础设施。

  • 从 Write 到 Generate: 过去我们以“手写 CSS 选择器熟练度”为荣,现在这变成了 AI 的基本功。对于静态布局,AI 的准确率和速度是人类的百倍。
  • Design-to-Code 的闭环: 设计工具与代码仓库的壁垒已被打通。中间不再需要一个人类作为“翻译官”。
  • 组件库的极端成熟: 各类 Headless UI 配合 AI,让构建复杂交互组件变得像搭积木一样简单。

结论很残酷: 如果你的技能树依然停留在 display: flexv-if/v-else 的排列组合上,那么你面对的竞争对手不是更便宜的实习生,而是成本几乎为零的算力。


二、 幸存者偏差:2026 年,什么样的人依然不可替代?

既然 div + css 甚至基础的业务逻辑都能被自动生成,那么现在的企业到底愿意花高薪聘请什么样的前端工程师?答案在于 AI 目前无法轻易跨越的深水区

真正的护城河,建立在架构设计、底层原理与工程化之上。

1. 复杂状态管理与业务架构师

AI 擅长写片段(Snippets),擅长解决局部问题,但在处理几十万行代码的巨型应用时,它依然缺乏全局观,甚至会产生严重的“幻觉”。

  • 你需要做的: 不是纠结用 Pinia 还是 Redux,而是**领域驱动设计(DDD)**在前端的落地。如何设计一个高内聚、低耦合的 Store?如何在微前端(Micro-frontends)架构下保证子应用间的通信而不导致内存泄漏?
  • 核心价值: 你是设计“骨架”的人,AI 只是帮你填充“血肉”。

2. 性能优化的深层专家

AI 可以写出跑得通的代码,但很难写出跑得“极快”的代码。在 2026 年,用户对体验的阈值被无限拔高,卡顿零容忍。

  • 你需要做的: 深入浏览器渲染原理。
  • • 如何利用 OffscreenCanvasWeb Worker 将繁重的计算(如图像处理、大屏数据清洗)移出主线程?
  • • 深入理解 Chrome Performance 面板,解决由大量 DOM 操作引起的 Layout Thrashing(强制重排)。
  • • 精通 HTTP/3 协议与边缘缓存策略。
  • 核心价值: 当应用卡顿影响用户留存时,你是那个能切开血管(底层代码)做精密手术的人,而不是只会问 AI “怎么优化 Vue” 的人。

3. 图形学与互动技术的掌控者

随着 WebGPU 的普及和空间计算设备的迭代,Web 不再局限于 2D 平面。

  • 你需要做的: 掌握 WebGL / WebGPU。只会写 div 是不够的,你需要理解着色器(Shaders)、矩阵变换、光照模型。利用 Three.js 构建 3D 场景,甚至利用 WASM 将 C++ 图形引擎搬到浏览器。
  • 核心价值: 创造 AI 难以凭空想象的、具有沉浸感的交互体验。

4. AI 工程化(AI Engineering)

这是 2026 年最新的“前端”分支。前端不再只是面向用户,而是面向模型。

  • 你需要做的: 探索如何在浏览器端运行小模型(Small Language Models)以保护隐私?如何利用 RAG 技术在前端处理向量数据?如何设计适应流式输出(Streaming UI)的新一代交互界面?
  • 核心价值: 你是连接虽然强大但不可控的 LLM 与最终用户体验之间的桥梁。

三、 生存指南:从“搬砖”到“设计图纸”

对于现在的开发者,我的建议非常直接:

    1. 放弃对“语法记忆”的执念: 以前我们背诵 CSS 属性,现在请把这些外包给 AI。不要因为 AI 写出了代码而感到羞耻,要学会 Review AI 的代码,你需要比 AI 更懂代码的好坏
    1. 深入计算机科学基础: 算法、数据结构、编译原理、网络协议。这些是 AI 经常犯错的地方,也是你能体现 Debug 能力的地方。
    1. 拥抱全栈思维: 2026 年的前端不再局限于浏览器。Server Component 早已成为主流,你必须懂数据库、懂 Serverless、懂后端逻辑。只有打通前后端,你才能设计出完整的系统。
    1. 培养“产品力”: 当技术实现的门槛降低,决定产品生死的往往是对用户需求的洞察。能不能用现有的技术栈最快地解决业务痛点?这才是王道。

结语

“只会写 div 和 css 的前端彻底失业”这句话,本质上不是一种诅咒,而是一种解放

它意味着我们终于可以从繁琐、重复的体力劳动中解脱出来,去思考架构、去优化体验、去创造真正的价值。在这个时代, “前端”的定义正在被重写。 我们不再是浏览器的排版工,我们是数字体验的架构师,是连接算力与人心的工程师。

如果你还在担心失业,请停止焦虑,开始学习那些 AI 此刻还看不懂的“复杂系统”吧。


💬 互动时刻

看到这里,我想邀请大家做一个名为**“断网测试”**的小实验:

打开你最近负责的一个项目代码库,找一段你认为最复杂的逻辑。
如果现在切断所有 AI 辅助工具(Copilot、ChatGPT 等),只给你官方文档:

    1. 你还能独立理解并重构这段代码吗?
    1. 其中的性能瓶颈和边界情况,你能凭直觉发现吗?
    1. 如果它是 AI 生成的,你能确信它 100% 没有隐患吗?

欢迎在评论区留下你的答案。是“毫无压力”,还是“冷汗直流”?

让我们聊聊,剥离了 AI 的外衣后,作为工程师的我们,到底还剩下什么。

Vue 路由信息获取全攻略:8 种方法深度解析

Vue 路由信息获取全攻略:8 种方法深度解析

在 Vue 应用中,获取当前路由信息是开发中的常见需求。本文将全面解析从基础到高级的各种获取方法,并帮助你选择最佳实践。

一、路由信息全景图

在深入具体方法前,先了解 Vue Router 提供的完整路由信息结构:

// 路由信息对象结构
{
  path: '/user/123/profile?tab=info',    // 完整路径
  fullPath: '/user/123/profile?tab=info&token=abc',
  name: 'user-profile',                   // 命名路由名称
  params: {                               // 动态路径参数
    id: '123'
  },
  query: {                                // 查询参数
    tab: 'info',
    token: 'abc'
  },
  hash: '#section-2',                     // 哈希片段
  meta: {                                 // 路由元信息
    requiresAuth: true,
    title: '用户资料'
  },
  matched: [                              // 匹配的路由记录数组
    { path: '/user', component: UserLayout, meta: {...} },
    { path: '/user/:id', component: UserContainer, meta: {...} },
    { path: '/user/:id/profile', component: UserProfile, meta: {...} }
  ]
}

二、8 种获取路由信息的方法

方法 1:$route 对象(最常用)

<template>
  <div>
    <h1>用户详情页</h1>
    <p>用户ID: {{ $route.params.id }}</p>
    <p>当前标签: {{ $route.query.tab || 'default' }}</p>
    <p>需要认证: {{ $route.meta.requiresAuth ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  created() {
    // 访问路由信息
    console.log('路径:', this.$route.path)
    console.log('参数:', this.$route.params)
    console.log('查询:', this.$route.query)
    console.log('哈希:', this.$route.hash)
    console.log('元信息:', this.$route.meta)
    
    // 获取完整的匹配记录
    const matchedRoutes = this.$route.matched
    matchedRoutes.forEach(route => {
      console.log('匹配的路由:', route.path, route.meta)
    })
  }
}
</script>

特点:

  • ✅ 简单直接,无需导入
  • ✅ 响应式变化(路由变化时自动更新)
  • ✅ 在模板和脚本中都能使用

方法 2:useRoute Hook(Vue 3 Composition API)

<script setup>
import { useRoute } from 'vue-router'
import { watch, computed } from 'vue'

// 获取路由实例
const route = useRoute()

// 直接使用
console.log('当前路由路径:', route.path)
console.log('路由参数:', route.params)

// 计算属性基于路由
const userId = computed(() => route.params.id)
const isEditMode = computed(() => route.query.mode === 'edit')

// 监听路由变化
watch(
  () => route.params.id,
  (newId, oldId) => {
    console.log(`用户ID从 ${oldId} 变为 ${newId}`)
    loadUserData(newId)
  }
)

// 监听多个路由属性
watch(
  () => ({
    id: route.params.id,
    tab: route.query.tab
  }),
  ({ id, tab }) => {
    console.log(`ID: ${id}, Tab: ${tab}`)
  },
  { deep: true }
)
</script>

<template>
  <div>
    <h1>用户 {{ userId }} 的资料</h1>
    <nav>
      <router-link :to="{ query: { tab: 'info' } }" 
                   :class="{ active: route.query.tab === 'info' }">
        基本信息
      </router-link>
      <router-link :to="{ query: { tab: 'posts' } }"
                   :class="{ active: route.query.tab === 'posts' }">
        动态
      </router-link>
    </nav>
  </div>
</template>

方法 3:路由守卫中获取

// 全局守卫
router.beforeEach((to, from, next) => {
  // to: 即将进入的路由
  // from: 当前导航正要离开的路由
  
  console.log('前往:', to.path)
  console.log('来自:', from.path)
  console.log('需要认证:', to.meta.requiresAuth)
  
  // 权限检查
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next({
      path: '/login',
      query: { redirect: to.fullPath } // 保存目标路径
    })
  } else {
    next()
  }
})

// 组件内守卫
export default {
  beforeRouteEnter(to, from, next) {
    // 不能访问 this,因为组件实例还没创建
    console.log('进入前:', to.params.id)
    
    // 可以通过 next 回调访问实例
    next(vm => {
      vm.initialize(to.params.id)
    })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 可以访问 this
    console.log('路由更新:', to.params.id)
    this.loadData(to.params.id)
    next()
  },
  
  beforeRouteLeave(to, from, next) {
    // 离开前的确认
    if (this.hasUnsavedChanges) {
      const answer = window.confirm('有未保存的更改,确定离开吗?')
      if (!answer) {
        next(false) // 取消导航
        return
      }
    }
    next()
  }
}

方法 4:$router 对象获取当前路由

export default {
  methods: {
    getCurrentRouteInfo() {
      // 获取当前路由信息(非响应式)
      const currentRoute = this.$router.currentRoute
      
      // Vue Router 4 中的变化
      // const currentRoute = this.$router.currentRoute.value
      
      console.log('当前路由对象:', currentRoute)
      
      // 编程式导航时获取
      this.$router.push({
        path: '/user/456',
        query: { from: currentRoute.fullPath } // 携带来源信息
      })
    },
    
    // 检查是否在特定路由
    isActiveRoute(routeName) {
      return this.$route.name === routeName
    },
    
    // 检查路径匹配
    isPathMatch(pattern) {
      return this.$route.path.startsWith(pattern)
    }
  },
  
  computed: {
    // 基于当前路由的复杂计算
    breadcrumbs() {
      return this.$route.matched.map(route => ({
        name: route.meta?.breadcrumb || route.name,
        path: route.path
      }))
    },
    
    // 获取嵌套路由参数
    nestedParams() {
      const params = {}
      this.$route.matched.forEach(route => {
        Object.assign(params, route.params)
      })
      return params
    }
  }
}

方法 5:通过 Props 传递路由参数(推荐)

// 路由配置
const routes = [
  {
    path: '/user/:id',
    component: UserDetail,
    props: true // 将 params 作为 props 传递
  },
  {
    path: '/search',
    component: SearchResults,
    props: route => ({ // 自定义 props 函数
      query: route.query.q,
      page: parseInt(route.query.page) || 1,
      sort: route.query.sort || 'relevance'
    })
  }
]

// 组件中使用
export default {
  props: {
    // 从路由 params 自动注入
    id: {
      type: [String, Number],
      required: true
    },
    // 从自定义 props 函数注入
    query: String,
    page: Number,
    sort: String
  },
  
  watch: {
    // props 变化时响应
    id(newId) {
      this.loadUser(newId)
    },
    query(newQuery) {
      this.performSearch(newQuery)
    }
  },
  
  created() {
    // 直接使用 props,无需访问 $route
    console.log('用户ID:', this.id)
    console.log('搜索词:', this.query)
  }
}

方法 6:使用 Vuex/Pinia 管理路由状态

// store/modules/route.js (Vuex)
const state = {
  currentRoute: null,
  previousRoute: null
}

const mutations = {
  SET_CURRENT_ROUTE(state, route) {
    state.previousRoute = state.currentRoute
    state.currentRoute = {
      path: route.path,
      name: route.name,
      params: { ...route.params },
      query: { ...route.query },
      meta: { ...route.meta }
    }
  }
}

// 在全局守卫中同步
router.afterEach((to, from) => {
  store.commit('SET_CURRENT_ROUTE', to)
})

// 组件中使用
export default {
  computed: {
    ...mapState({
      currentRoute: state => state.route.currentRoute,
      previousRoute: state => state.route.previousRoute
    }),
    
    // 基于路由状态的衍生数据
    pageTitle() {
      const route = this.currentRoute
      return route?.meta?.title || '默认标题'
    }
  }
}
// Pinia 版本(Vue 3)
import { defineStore } from 'pinia'

export const useRouteStore = defineStore('route', {
  state: () => ({
    current: null,
    history: []
  }),
  
  actions: {
    updateRoute(route) {
      this.history.push({
        ...this.current,
        timestamp: new Date().toISOString()
      })
      
      // 只保留最近10条记录
      if (this.history.length > 10) {
        this.history = this.history.slice(-10)
      }
      
      this.current = {
        path: route.path,
        fullPath: route.fullPath,
        name: route.name,
        params: { ...route.params },
        query: { ...route.query },
        meta: { ...route.meta }
      }
    }
  },
  
  getters: {
    // 获取路由参数
    routeParam: (state) => (key) => {
      return state.current?.params?.[key]
    },
    
    // 获取查询参数
    routeQuery: (state) => (key) => {
      return state.current?.query?.[key]
    },
    
    // 检查是否在特定路由
    isRoute: (state) => (routeName) => {
      return state.current?.name === routeName
    }
  }
})

方法 7:自定义路由混合/组合函数

// 自定义混合(Vue 2)
export const routeMixin = {
  computed: {
    // 便捷访问器
    $routeParams() {
      return this.$route.params || {}
    },
    
    $routeQuery() {
      return this.$route.query || {}
    },
    
    $routeMeta() {
      return this.$route.meta || {}
    },
    
    // 常用路由检查
    $isHomePage() {
      return this.$route.path === '/'
    },
    
    $hasRouteParam(param) {
      return param in this.$route.params
    },
    
    $getRouteParam(param, defaultValue = null) {
      return this.$route.params[param] || defaultValue
    }
  },
  
  methods: {
    // 路由操作辅助方法
    $updateQuery(newQuery) {
      this.$router.push({
        ...this.$route,
        query: {
          ...this.$route.query,
          ...newQuery
        }
      })
    },
    
    $removeQueryParam(key) {
      const query = { ...this.$route.query }
      delete query[key]
      this.$router.push({ query })
    }
  }
}

// 在组件中使用
export default {
  mixins: [routeMixin],
  
  created() {
    console.log('用户ID:', this.$getRouteParam('id', 'default'))
    console.log('是否首页:', this.$isHomePage)
    
    // 更新查询参数
    this.$updateQuery({ page: 2, sort: 'name' })
  }
}
// Vue 3 Composition API 版本
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useRouteHelpers() {
  const route = useRoute()
  const router = useRouter()
  
  const routeParams = computed(() => route.params || {})
  const routeQuery = computed(() => route.query || {})
  const routeMeta = computed(() => route.meta || {})
  
  const isHomePage = computed(() => route.path === '/')
  
  function getRouteParam(param, defaultValue = null) {
    return route.params[param] || defaultValue
  }
  
  function updateQuery(newQuery) {
    router.push({
      ...route,
      query: {
        ...route.query,
        ...newQuery
      }
    })
  }
  
  function removeQueryParam(key) {
    const query = { ...route.query }
    delete query[key]
    router.push({ query })
  }
  
  return {
    routeParams,
    routeQuery,
    routeMeta,
    isHomePage,
    getRouteParam,
    updateQuery,
    removeQueryParam
  }
}

// 在组件中使用
<script setup>
const {
  routeParams,
  routeQuery,
  getRouteParam,
  updateQuery
} = useRouteHelpers()

const userId = getRouteParam('id')
const currentTab = computed(() => routeQuery.tab || 'info')

function changeTab(tab) {
  updateQuery({ tab })
}
</script>

方法 8:访问 Router 实例的匹配器

export default {
  methods: {
    // 获取所有路由配置
    getAllRoutes() {
      return this.$router.options.routes
    },
    
    // 通过名称查找路由
    findRouteByName(name) {
      return this.$router.options.routes.find(route => route.name === name)
    },
    
    // 检查路径是否匹配路由
    matchRoute(path) {
      // Vue Router 3
      const matched = this.$router.match(path)
      return matched.matched.length > 0
      
      // Vue Router 4
      // const matched = this.$router.resolve(path)
      // return matched.matched.length > 0
    },
    
    // 生成路径
    generatePath(routeName, params = {}) {
      const route = this.findRouteByName(routeName)
      if (!route) return null
      
      // 简单的路径生成(实际项目建议使用 path-to-regexp)
      let path = route.path
      Object.keys(params).forEach(key => {
        path = path.replace(`:${key}`, params[key])
      })
      return path
    }
  }
}

三、不同场景的推荐方案

场景决策表

场景 推荐方案 理由
简单组件中获取参数 $route.params.id 最简单直接
Vue 3 Composition API useRoute() Hook 响应式、类型安全
组件复用/测试友好 Props 传递 解耦路由依赖
复杂应用状态管理 Vuex/Pinia 存储 全局访问、历史记录
多个组件共享逻辑 自定义混合/组合函数 代码复用
路由守卫/拦截器 守卫参数 (to, from) 官方标准方式
需要路由配置信息 $router.options.routes 访问完整配置

性能优化建议

// ❌ 避免在模板中频繁访问深层属性
<template>
  <div>
    <!-- 每次渲染都会计算 -->
    {{ $route.params.user.details.profile.name }}
  </div>
</template>

// ✅ 使用计算属性缓存
<template>
  <div>{{ userName }}</div>
</template>

<script>
export default {
  computed: {
    userName() {
      return this.$route.params.user?.details?.profile?.name || '未知'
    },
    
    // 批量提取路由信息
    routeInfo() {
      const { params, query, meta } = this.$route
      return {
        userId: params.id,
        tab: query.tab,
        requiresAuth: meta.requiresAuth
      }
    }
  }
}
</script>

响应式监听最佳实践

export default {
  watch: {
    // 监听特定参数变化
    '$route.params.id': {
      handler(newId, oldId) {
        if (newId !== oldId) {
          this.loadUserData(newId)
        }
      },
      immediate: true
    },
    
    // 监听查询参数变化
    '$route.query': {
      handler(newQuery) {
        this.applyFilters(newQuery)
      },
      deep: true // 深度监听对象变化
    }
  },
  
  // 或者使用 beforeRouteUpdate 守卫
  beforeRouteUpdate(to, from, next) {
    // 只处理需要的变化
    if (to.params.id !== from.params.id) {
      this.loadUserData(to.params.id)
    }
    next()
  }
}

四、实战案例:用户管理系统

<template>
  <div class="user-management">
    <!-- 面包屑导航 -->
    <nav class="breadcrumbs">
      <router-link v-for="item in breadcrumbs" 
                   :key="item.path"
                   :to="item.path">
        {{ item.title }}
      </router-link>
    </nav>
    
    <!-- 用户详情 -->
    <div v-if="$route.name === 'user-detail'">
      <h2>用户详情 - {{ userName }}</h2>
      <UserTabs :active-tab="activeTab" @change-tab="changeTab" />
      <router-view />
    </div>
    
    <!-- 用户列表 -->
    <div v-else-if="$route.name === 'user-list'">
      <UserList :filters="routeFilters" />
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState(['currentUser']),
    
    // 从路由获取信息
    userId() {
      return this.$route.params.userId
    },
    
    activeTab() {
      return this.$route.query.tab || 'profile'
    },
    
    routeFilters() {
      return {
        department: this.$route.query.dept,
        role: this.$route.query.role,
        status: this.$route.query.status || 'active'
      }
    },
    
    // 面包屑导航
    breadcrumbs() {
      const crumbs = []
      const { matched } = this.$route
      
      matched.forEach((route, index) => {
        const { meta, path } = route
        
        // 生成面包屑项
        if (meta?.breadcrumb) {
          crumbs.push({
            title: meta.breadcrumb,
            path: this.generateBreadcrumbPath(matched.slice(0, index + 1))
          })
        }
      })
      
      return crumbs
    },
    
    // 用户名(需要根据ID查找)
    userName() {
      const user = this.$store.getters.getUserById(this.userId)
      return user ? user.name : '加载中...'
    }
  },
  
  watch: {
    // 监听用户ID变化
    userId(newId) {
      if (newId) {
        this.$store.dispatch('fetchUser', newId)
      }
    },
    
    // 监听标签页变化
    activeTab(newTab) {
      this.updateDocumentTitle(newTab)
    }
  },
  
  created() {
    // 初始化加载
    if (this.userId) {
      this.$store.dispatch('fetchUser', this.userId)
    }
    
    // 设置页面标题
    this.updateDocumentTitle()
    
    // 记录页面访问
    this.logPageView()
  },
  
  methods: {
    changeTab(tab) {
      // 更新查询参数
      this.$router.push({
        ...this.$route,
        query: { ...this.$route.query, tab }
      })
    },
    
    generateBreadcrumbPath(routes) {
      // 生成完整路径
      return routes.map(r => r.path).join('')
    },
    
    updateDocumentTitle(tab = null) {
      const tabName = tab || this.activeTab
      const title = this.$route.meta.title || '用户管理'
      document.title = `${title} - ${this.getTabDisplayName(tabName)}`
    },
    
    logPageView() {
      // 发送分析数据
      analytics.track('page_view', {
        path: this.$route.path,
        name: this.$route.name,
        params: this.$route.params
      })
    }
  }
}
</script>

五、常见问题与解决方案

问题1:路由信息延迟获取

// ❌ 可能在 created 中获取不到完整的 $route
created() {
  console.log(this.$route.params.id) // 可能为 undefined
}

// ✅ 使用 nextTick 确保 DOM 和路由都就绪
created() {
  this.$nextTick(() => {
    console.log('路由信息:', this.$route)
    this.loadData(this.$route.params.id)
  })
}

// ✅ 或者使用 watch + immediate
watch: {
  '$route.params.id': {
    handler(id) {
      if (id) this.loadData(id)
    },
    immediate: true
  }
}

问题2:路由变化时组件不更新

// 对于复用组件,需要监听路由变化
export default {
  // 使用 beforeRouteUpdate 守卫
  beforeRouteUpdate(to, from, next) {
    this.userId = to.params.id
    this.loadUserData()
    next()
  },
  
  // 或者使用 watch
  watch: {
    '$route.params.id'(newId) {
      this.userId = newId
      this.loadUserData()
    }
  }
}

问题3:TypeScript 类型支持

// Vue 3 + TypeScript
import { RouteLocationNormalized } from 'vue-router'

// 定义路由参数类型
interface UserRouteParams {
  id: string
}

interface UserRouteQuery {
  tab?: 'info' | 'posts' | 'settings'
  edit?: string
}

export default defineComponent({
  setup() {
    const route = useRoute()
    
    // 类型安全的参数访问
    const userId = computed(() => {
      const params = route.params as UserRouteParams
      return params.id
    })
    
    const currentTab = computed(() => {
      const query = route.query as UserRouteQuery
      return query.tab || 'info'
    })
    
    // 类型安全的路由跳转
    const router = useRouter()
    function goToEdit() {
      router.push({
        name: 'user-edit',
        params: { id: userId.value },
        query: { from: route.fullPath }
      })
    }
    
    return { userId, currentTab, goToEdit }
  }
})

六、总结:最佳实践指南

  1. 优先使用 Props 传递 - 提高组件可测试性和复用性
  2. 复杂逻辑使用组合函数 - Vue 3 推荐方式,逻辑更清晰
  3. 适当使用状态管理 - 需要跨组件共享路由状态时
  4. 性能优化 - 避免频繁访问深层属性,使用计算属性缓存
  5. 类型安全 - TypeScript 项目一定要定义路由类型

快速选择流程图:

graph TD
    A[需要获取路由信息] --> B{使用场景}
    
    B -->|简单访问参数| C[使用 $route.params]
    B -->|Vue 3 项目| D[使用 useRoute Hook]
    B -->|组件需要复用/测试| E[使用 Props 传递]
    B -->|多个组件共享状态| F[使用 Pinia/Vuex 存储]
    B -->|通用工具函数| G[自定义组合函数]
    
    C --> H[完成]
    D --> H
    E --> H
    F --> H
    G --> H

记住黄金法则:优先考虑组件独立性,只在必要时直接访问路由对象。


思考题:在你的 Vue 项目中,最常使用哪种方式获取路由信息?遇到过哪些有趣的问题?欢迎分享你的实战经验!

Vue Watch 立即执行:5 种初始化调用方案全解析

Vue Watch 立即执行:5 种初始化调用方案全解析

你是否遇到过在组件初始化时就需要立即执行 watch 逻辑的场景?本文将深入探讨 Vue 中 watch 的立即执行机制,并提供 5 种实用方案。

一、问题背景:为什么需要立即执行 watch?

在 Vue 开发中,我们经常遇到这样的需求:

export default {
  data() {
    return {
      userId: null,
      userData: null,
      filters: {
        status: 'active',
        sortBy: 'name'
      },
      filteredUsers: []
    }
  },
  
  watch: {
    // 需要组件初始化时就执行一次
    'filters.status'() {
      this.loadUsers()
    },
    
    'filters.sortBy'() {
      this.sortUsers()
    }
  },
  
  created() {
    // 我们期望:初始化时自动应用 filters 的默认值
    // 但默认的 watch 不会立即执行
  }
}

二、解决方案对比表

方案 适用场景 优点 缺点 Vue 版本
1. immediate 选项 简单监听 原生支持,最简洁 无法复用逻辑 2+
2. 提取为方法 复杂逻辑复用 逻辑可复用,清晰 需要手动调用 2+
3. 计算属性 派生数据 响应式,自动更新 不适合副作用 2+
4. 自定义 Hook 复杂业务逻辑 高度复用,可组合 需要额外封装 2+ (Vue 3 最佳)
5. 侦听器工厂 多个相似监听 减少重复代码 有一定复杂度 2+

三、5 种解决方案详解

方案 1:使用 immediate: true(最常用)

export default {
  data() {
    return {
      searchQuery: '',
      searchResults: [],
      loading: false
    }
  },
  
  watch: {
    // 基础用法:立即执行 + 深度监听
    searchQuery: {
      handler(newVal, oldVal) {
        this.performSearch(newVal)
      },
      immediate: true,    // ✅ 组件创建时立即执行
      deep: false         // 默认值,可根据需要开启
    },
    
    // 监听对象属性
    'filters.status': {
      handler(newStatus) {
        this.applyFilter(newStatus)
      },
      immediate: true
    },
    
    // 监听多个源(Vue 2.6+)
    '$route.query': {
      handler(query) {
        // 路由变化时初始化数据
        this.initFromQuery(query)
      },
      immediate: true
    }
  },
  
  methods: {
    async performSearch(query) {
      this.loading = true
      try {
        this.searchResults = await api.search(query)
      } catch (error) {
        console.error('搜索失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    initFromQuery(query) {
      // 从 URL 参数初始化状态
      if (query.search) {
        this.searchQuery = query.search
      }
    }
  }
}

进阶技巧:动态 immediate

export default {
  data() {
    return {
      shouldWatchImmediately: true,
      value: ''
    }
  },
  
  watch: {
    value: {
      handler(newVal) {
        this.handleValueChange(newVal)
      },
      // 动态决定是否立即执行
      immediate() {
        return this.shouldWatchImmediately
      }
    }
  }
}

方案 2:提取为方法并手动调用(最灵活)

export default {
  data() {
    return {
      pagination: {
        page: 1,
        pageSize: 20,
        total: 0
      },
      items: []
    }
  },
  
  created() {
    // ✅ 立即调用一次
    this.handlePaginationChange(this.pagination)
    
    // 同时设置 watch
    this.$watch(
      () => ({ ...this.pagination }),
      this.handlePaginationChange,
      { deep: true }
    )
  },
  
  methods: {
    async handlePaginationChange(newPagination, oldPagination) {
      // 避免初始化时重复调用(如果 created 中已调用)
      if (oldPagination === undefined) {
        // 这是初始化调用
        console.log('初始化加载数据')
      }
      
      // 防抖处理
      if (this.loadDebounce) {
        clearTimeout(this.loadDebounce)
      }
      
      this.loadDebounce = setTimeout(async () => {
        this.loading = true
        try {
          const response = await api.getItems({
            page: newPagination.page,
            pageSize: newPagination.pageSize
          })
          this.items = response.data
          this.pagination.total = response.total
        } catch (error) {
          console.error('加载失败:', error)
        } finally {
          this.loading = false
        }
      }, 300)
    }
  }
}

优势对比:

// ❌ 重复逻辑
watch: {
  pagination: {
    handler() { this.loadData() },
    immediate: true,
    deep: true
  },
  filters: {
    handler() { this.loadData() },  // 重复的 loadData 调用
    immediate: true,
    deep: true
  }
}

// ✅ 提取方法,复用逻辑
created() {
  this.loadData()  // 初始化调用
  
  // 多个监听复用同一方法
  this.$watch(() => this.pagination, this.loadData, { deep: true })
  this.$watch(() => this.filters, this.loadData, { deep: true })
}

方案 3:计算属性替代(适合派生数据)

export default {
  data() {
    return {
      basePrice: 100,
      taxRate: 0.08,
      discount: 10
    }
  },
  
  computed: {
    // 计算属性自动响应依赖变化
    finalPrice() {
      const priceWithTax = this.basePrice * (1 + this.taxRate)
      return Math.max(0, priceWithTax - this.discount)
    },
    
    // 复杂计算场景
    formattedReport() {
      // 这里会立即执行,并自动响应 basePrice、taxRate、discount 的变化
      return {
        base: this.basePrice,
        tax: this.basePrice * this.taxRate,
        discount: this.discount,
        total: this.finalPrice,
        timestamp: new Date().toISOString()
      }
    }
  },
  
  created() {
    // 计算属性在 created 中已可用
    console.log('初始价格:', this.finalPrice)
    console.log('初始报告:', this.formattedReport)
    
    // 如果需要执行副作用(如 API 调用),仍需要 watch
    this.$watch(
      () => this.finalPrice,
      (newPrice) => {
        this.logPriceChange(newPrice)
      },
      { immediate: true }
    )
  }
}

方案 4:自定义 Hook/Composable(Vue 3 最佳实践)

// composables/useWatcher.js
import { watch, ref, onMounted } from 'vue'

export function useImmediateWatcher(source, callback, options = {}) {
  const { immediate = true, ...watchOptions } = options
  
  // 立即执行一次
  if (immediate) {
    callback(source.value, undefined)
  }
  
  // 设置监听
  watch(source, callback, watchOptions)
  
  // 返回清理函数
  return () => {
    // 如果需要,可以返回清理逻辑
  }
}

// 在组件中使用
import { ref } from 'vue'
import { useImmediateWatcher } from '@/composables/useWatcher'

export default {
  setup() {
    const searchQuery = ref('')
    const filters = ref({ status: 'active' })
    
    // 使用自定义 Hook
    useImmediateWatcher(
      searchQuery,
      async (newQuery) => {
        await performSearch(newQuery)
      },
      { debounce: 300 }
    )
    
    useImmediateWatcher(
      filters,
      (newFilters) => {
        applyFilters(newFilters)
      },
      { deep: true, immediate: true }
    )
    
    return {
      searchQuery,
      filters
    }
  }
}

Vue 2 版本的 Mixin 实现:

// mixins/immediateWatcher.js
export const immediateWatcherMixin = {
  created() {
    this._immediateWatchers = []
  },
  
  methods: {
    $watchImmediate(expOrFn, callback, options = {}) {
      // 立即执行一次
      const unwatch = this.$watch(
        expOrFn,
        (...args) => {
          callback(...args)
        },
        { ...options, immediate: true }
      )
      
      this._immediateWatchers.push(unwatch)
      return unwatch
    }
  },
  
  beforeDestroy() {
    // 清理所有监听器
    this._immediateWatchers.forEach(unwatch => unwatch())
    this._immediateWatchers = []
  }
}

// 使用
export default {
  mixins: [immediateWatcherMixin],
  
  created() {
    this.$watchImmediate(
      () => this.userId,
      (newId) => {
        this.loadUserData(newId)
      }
    )
  }
}

方案 5:侦听器工厂函数(高级封装)

// utils/watchFactory.js
export function createImmediateWatcher(vm, configs) {
  const unwatchers = []
  
  configs.forEach(config => {
    const {
      source,
      handler,
      immediate = true,
      deep = false,
      flush = 'pre'
    } = config
    
    // 处理 source 可以是函数或字符串
    const getter = typeof source === 'function' 
      ? source 
      : () => vm[source]
    
    // 立即执行
    if (immediate) {
      const initialValue = getter()
      handler.call(vm, initialValue, undefined)
    }
    
    // 创建侦听器
    const unwatch = vm.$watch(
      getter,
      handler.bind(vm),
      { deep, immediate: false, flush }
    )
    
    unwatchers.push(unwatch)
  })
  
  // 返回清理函数
  return function cleanup() {
    unwatchers.forEach(unwatch => unwatch())
  }
}

// 组件中使用
export default {
  data() {
    return {
      filters: { category: 'all', sort: 'newest' },
      pagination: { page: 1, size: 20 }
    }
  },
  
  created() {
    // 批量创建立即执行的侦听器
    this._cleanupWatchers = createImmediateWatcher(this, [
      {
        source: 'filters',
        handler(newFilters) {
          this.applyFilters(newFilters)
        },
        deep: true
      },
      {
        source: () => this.pagination.page,
        handler(newPage) {
          this.loadPage(newPage)
        }
      }
    ])
  },
  
  beforeDestroy() {
    // 清理
    if (this._cleanupWatchers) {
      this._cleanupWatchers()
    }
  }
}

四、实战场景:表单初始化与验证

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.email" @blur="validateEmail" />
    <input v-model="form.password" type="password" />
    
    <div v-if="errors.email">{{ errors.email }}</div>
    <button :disabled="!isFormValid">提交</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      form: {
        email: '',
        password: ''
      },
      errors: {
        email: '',
        password: ''
      },
      isInitialValidationDone: false
    }
  },
  
  computed: {
    isFormValid() {
      return !this.errors.email && !this.errors.password
    }
  },
  
  watch: {
    'form.email': {
      handler(newEmail) {
        // 只在初始化验证后,或者用户修改时验证
        if (this.isInitialValidationDone || newEmail) {
          this.validateEmail()
        }
      },
      immediate: true  // ✅ 初始化时触发验证
    },
    
    'form.password': {
      handler(newPassword) {
        this.validatePassword(newPassword)
      },
      immediate: true  // ✅ 初始化时触发验证
    }
  },
  
  created() {
    // 标记初始化验证完成
    this.$nextTick(() => {
      this.isInitialValidationDone = true
    })
  },
  
  methods: {
    validateEmail() {
      const email = this.form.email
      if (!email) {
        this.errors.email = '邮箱不能为空'
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
        this.errors.email = '邮箱格式不正确'
      } else {
        this.errors.email = ''
      }
    },
    
    validatePassword(password) {
      if (!password) {
        this.errors.password = '密码不能为空'
      } else if (password.length < 6) {
        this.errors.password = '密码至少6位'
      } else {
        this.errors.password = ''
      }
    }
  }
}
</script>

五、性能优化与注意事项

1. 避免无限循环

export default {
  data() {
    return {
      count: 0,
      doubled: 0
    }
  },
  
  watch: {
    count: {
      handler(newVal) {
        // ❌ 危险:可能导致无限循环
        this.doubled = newVal * 2
        
        // 在某些条件下修改自身依赖
        if (newVal > 10) {
          this.count = 10  // 这会导致循环
        }
      },
      immediate: true
    }
  }
}

2. 合理使用 deep 监听

export default {
  data() {
    return {
      config: {
        theme: 'dark',
        notifications: {
          email: true,
          push: false
        }
      }
    }
  },
  
  watch: {
    // ❌ 过度使用 deep
    config: {
      handler() {
        this.saveConfig()
      },
      deep: true,  // 整个对象深度监听,性能开销大
      immediate: true
    },
    
    // ✅ 精确监听
    'config.theme': {
      handler(newTheme) {
        this.applyTheme(newTheme)
      },
      immediate: true
    },
    
    // ✅ 监听特定嵌套属性
    'config.notifications.email': {
      handler(newValue) {
        this.updateNotificationPref('email', newValue)
      },
      immediate: true
    }
  }
}

3. 异步操作的防抖与取消

export default {
  data() {
    return {
      searchInput: '',
      searchRequest: null
    }
  },
  
  watch: {
    searchInput: {
      async handler(newVal) {
        // 取消之前的请求
        if (this.searchRequest) {
          this.searchRequest.cancel('取消旧请求')
        }
        
        // 创建新的可取消请求
        this.searchRequest = this.$axios.CancelToken.source()
        
        try {
          const response = await api.search(newVal, {
            cancelToken: this.searchRequest.token
          })
          this.searchResults = response.data
        } catch (error) {
          if (!this.$axios.isCancel(error)) {
            console.error('搜索错误:', error)
          }
        }
      },
      immediate: true,
      debounce: 300  // 需要配合 debounce 插件
    }
  }
}

六、Vue 3 Composition API 特别指南

<script setup>
import { ref, watch, watchEffect } from 'vue'

const userId = ref(null)
const userData = ref(null)
const loading = ref(false)

// 方案1: watch + immediate
watch(
  userId,
  async (newId) => {
    loading.value = true
    try {
      userData.value = await fetchUser(newId)
    } finally {
      loading.value = false
    }
  },
  { immediate: true }  // ✅ 立即执行
)

// 方案2: watchEffect(自动追踪依赖)
const searchQuery = ref('')
const searchResults = ref([])

watchEffect(async () => {
  // 自动追踪 searchQuery 依赖
  if (searchQuery.value.trim()) {
    const results = await searchApi(searchQuery.value)
    searchResults.value = results
  } else {
    searchResults.value = []
  }
})  // ✅ watchEffect 会立即执行一次

// 方案3: 自定义立即执行的 composable
function useImmediateWatch(source, callback, options = {}) {
  const { immediate = true, ...watchOptions } = options
  
  // 立即执行
  if (immediate && source.value !== undefined) {
    callback(source.value, undefined)
  }
  
  return watch(source, callback, watchOptions)
}

// 使用
const filters = ref({ category: 'all' })
useImmediateWatch(
  filters,
  (newFilters) => {
    applyFilters(newFilters)
  },
  { deep: true }
)
</script>

七、决策流程图

graph TD
    A[需要初始化执行watch] --> B{场景分析}
    
    B -->|简单监听,逻辑不复杂| C[方案1: immediate:true]
    B -->|复杂逻辑,需要复用| D[方案2: 提取方法]
    B -->|派生数据,无副作用| E[方案3: 计算属性]
    B -->|Vue3,需要组合复用| F[方案4: 自定义Hook]
    B -->|多个相似监听器| G[方案5: 工厂函数]
    
    C --> H[完成]
    D --> H
    E --> H
    F --> H
    G --> H
    
    style C fill:#e1f5e1
    style D fill:#e1f5e1

八、总结与最佳实践

核心原则:

  1. 优先使用 immediate: true - 对于简单的监听需求
  2. 复杂逻辑提取方法 - 提高可测试性和复用性
  3. 避免副作用在计算属性中 - 保持计算属性的纯函数特性
  4. Vue 3 优先使用 Composition API - 更好的逻辑组织和复用

代码规范建议:

// ✅ 良好实践
export default {
  watch: {
    // 明确注释为什么需要立即执行
    userId: {
      handler: 'loadUserData', // 使用方法名,更清晰
      immediate: true // 初始化时需要加载用户数据
    }
  },
  
  created() {
    // 复杂初始化逻辑放在 created
    this.initializeComponent()
  },
  
  methods: {
    loadUserData(userId) {
      // 可复用的方法
    },
    
    initializeComponent() {
      // 集中处理初始化逻辑
    }
  }
}

常见陷阱提醒:

  1. 不要immediate 回调中修改依赖数据(可能导致循环)
  2. 谨慎使用 deep: true,特别是对于大型对象
  3. 记得清理手动创建的侦听器(避免内存泄漏)
  4. 考虑 SSR 场景下 immediate 的执行时机

Vue 组件模板的 7 种定义方式:从基础到高级的完整指南

Vue 组件模板的 7 种定义方式:从基础到高级的完整指南

模板是 Vue 组件的核心视图层,但你可能不知道它竟有如此多灵活的定义方式。掌握这些技巧,让你的组件开发更加得心应手。

一、模板定义全景图

在深入细节之前,先了解 Vue 组件模板的完整知识体系:

graph TD
    A[Vue 组件模板] --> B[单文件组件 SFC]
    A --> C[内联模板]
    A --> D[字符串模板]
    A --> E[渲染函数]
    A --> F[JSX]
    A --> G[动态组件]
    A --> H[函数式组件]
    
    B --> B1[&lttemplate&gt标签]
    B --> B2[作用域 slot]
    
    D --> D1[template 选项]
    D --> D2[内联模板字符串]
    
    E --> E1[createElement]
    E --> E2[h 函数]
    
    G --> G1[component:is]
    G --> G2[异步组件]

下面我们来详细探讨每种方式的特点和适用场景。

二、7 种模板定义方式详解

1. 单文件组件(SFC)模板 - 现代 Vue 开发的标准

<!-- UserProfile.vue -->
<template>
  <!-- 最常用、最推荐的方式 -->
  <div class="user-profile">
    <h2>{{ user.name }}</h2>
    <img :src="user.avatar" alt="Avatar" />
    <slot name="actions"></slot>
  </div>
</template>

<script>
export default {
  props: ['user']
}
</script>

<style scoped>
.user-profile {
  padding: 20px;
}
</style>

特点:

  • ✅ 语法高亮和提示
  • ✅ CSS 作用域支持
  • ✅ 良好的可维护性
  • ✅ 构建工具优化(如 Vue Loader)

最佳实践:

<template>
  <!-- 始终使用单个根元素(Vue 2) -->
  <div class="container">
    <!-- 使用 PascalCase 的组件名 -->
    <UserProfile :user="currentUser" />
    
    <!-- 复杂逻辑使用计算属性 -->
    <p v-if="shouldShowMessage">{{ formattedMessage }}</p>
  </div>
</template>

2. 字符串模板 - 简单场景的轻量选择

// 方式1:template 选项
new Vue({
  el: '#app',
  template: `
    <div class="app">
      <h1>{{ title }}</h1>
      <button @click="handleClick">点击</button>
    </div>
  `,
  data() {
    return {
      title: '字符串模板示例'
    }
  },
  methods: {
    handleClick() {
      alert('按钮被点击')
    }
  }
})

// 方式2:内联模板字符串
const InlineComponent = {
  template: '<div>{{ message }}</div>',
  data() {
    return { message: 'Hello' }
  }
}

适用场景:

  • 简单的 UI 组件
  • 快速原型开发
  • 小型项目或演示代码

注意事项:

// ⚠️ 模板字符串中的换行和缩进
const BadTemplate = `
<div>
  <p>第一行
  </p>
</div>  // 缩进可能被包含

// ✅ 使用模板字面量保持整洁
const GoodTemplate = `<div>
  <p>第一行</p>
</div>`

3. 内联模板 - 快速但不推荐

<!-- 父组件 -->
<div id="parent">
  <child-component inline-template>
    <!-- 直接在 HTML 中写模板 -->
    <div>
      <p>来自子组件: {{ childData }}</p>
      <p>来自父组件: {{ parentMessage }}</p>
    </div>
  </child-component>
</div>

<script>
new Vue({
  el: '#parent',
  data: {
    parentMessage: '父组件数据'
  },
  components: {
    'child-component': {
      data() {
        return { childData: '子组件数据' }
      }
    }
  }
})
</script>

⚠️ 警告:

  • ❌ 作用域难以理解
  • ❌ 破坏组件封装性
  • ❌ 不利于维护
  • ✅ 唯一优势:快速原型

4. X-Templates - 分离但老式

<!-- 在 HTML 中定义模板 -->
<script type="text/x-template" id="user-template">
  <div class="user">
    <h3>{{ name }}</h3>
    <p>{{ email }}</p>
  </div>
</script>

<script>
// 在 JavaScript 中引用
Vue.component('user-component', {
  template: '#user-template',
  props: ['name', 'email']
})
</script>

特点:

  • 🟡 模板与逻辑分离
  • 🟡 无需构建工具
  • ❌ 全局命名空间污染
  • ❌ 无法使用构建工具优化

5. 渲染函数 - 完全的 JavaScript 控制力

// 基本渲染函数
export default {
  props: ['items'],
  render(h) {
    return h('ul', 
      this.items.map(item => 
        h('li', { key: item.id }, item.name)
      )
    )
  }
}

// 带条件渲染和事件
export default {
  data() {
    return { count: 0 }
  },
  render(h) {
    return h('div', [
      h('h1', `计数: ${this.count}`),
      h('button', {
        on: {
          click: () => this.count++
        }
      }, '增加')
    ])
  }
}

高级模式 - 动态组件工厂:

// 组件工厂函数
const ComponentFactory = {
  functional: true,
  props: ['type', 'data'],
  render(h, { props }) {
    const components = {
      text: TextComponent,
      image: ImageComponent,
      video: VideoComponent
    }
    
    const Component = components[props.type]
    return h(Component, {
      props: { data: props.data }
    })
  }
}

// 动态 slot 内容
const LayoutComponent = {
  render(h) {
    // 获取具名 slot
    const header = this.$slots.header
    const defaultSlot = this.$slots.default
    const footer = this.$slots.footer
    
    return h('div', { class: 'layout' }, [
      header && h('header', header),
      h('main', defaultSlot),
      footer && h('footer', footer)
    ])
  }
}

6. JSX - React 开发者的福音

// .vue 文件中使用 JSX
<script>
export default {
  data() {
    return {
      items: ['Vue', 'React', 'Angular']
    }
  },
  render() {
    return (
      <div class="jsx-demo">
        <h1>JSX 在 Vue 中</h1>
        <ul>
          {this.items.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
        {/* 使用指令 */}
        <input vModel={this.inputValue} />
        {/* 事件监听 */}
        <button onClick={this.handleClick}>点击</button>
      </div>
    )
  }
}
</script>

配置方法:

// babel.config.js
module.exports = {
  presets: ['@vue/cli-plugin-babel/preset'],
  plugins: [
    '@vue/babel-plugin-jsx' // 启用 Vue JSX 支持
  ]
}

JSX vs 模板:

// JSX 的优势:动态性更强
const DynamicList = {
  props: ['config'],
  render() {
    const { tag: Tag, items, itemComponent: Item } = this.config
    
    return (
      <Tag class="dynamic-list">
        {items.map(item => (
          <Item item={item} />
        ))}
      </Tag>
    )
  }
}

7. 动态组件 - 运行时模板决策

<template>
  <!-- component:is 动态组件 -->
  <component 
    :is="currentComponent"
    v-bind="currentProps"
    @custom-event="handleEvent"
  />
</template>

<script>
import TextEditor from './TextEditor.vue'
import ImageUploader from './ImageUploader.vue'
import VideoPlayer from './VideoPlayer.vue'

export default {
  data() {
    return {
      componentType: 'text',
      content: ''
    }
  },
  computed: {
    currentComponent() {
      const components = {
        text: TextEditor,
        image: ImageUploader,
        video: VideoPlayer
      }
      return components[this.componentType]
    },
    currentProps() {
      // 根据组件类型传递不同的 props
      const baseProps = { content: this.content }
      
      if (this.componentType === 'image') {
        return { ...baseProps, maxSize: '5MB' }
      }
      
      return baseProps
    }
  }
}
</script>

三、进阶技巧:混合模式与优化

1. 模板与渲染函数结合

<template>
  <!-- 使用模板定义主体结构 -->
  <div class="data-table">
    <table-header :columns="columns" />
    <table-body :render-row="renderTableRow" />
  </div>
</template>

<script>
export default {
  methods: {
    // 使用渲染函数处理复杂行渲染
    renderTableRow(h, row) {
      return h('tr', 
        this.columns.map(column => 
          h('td', {
            class: column.className,
            style: column.style
          }, column.formatter ? column.formatter(row) : row[column.key])
        )
      )
    }
  }
}
</script>

2. 高阶组件模式

// 高阶组件:增强模板功能
function withLoading(WrappedComponent) {
  return {
    render(h) {
      const directives = [
        {
          name: 'loading',
          value: this.isLoading,
          expression: 'isLoading'
        }
      ]
      
      return h('div', { directives }, [
        h(WrappedComponent, {
          props: this.$attrs,
          on: this.$listeners
        }),
        this.isLoading && h(LoadingSpinner)
      ])
    },
    data() {
      return { isLoading: false }
    },
    mounted() {
      // 加载逻辑
    }
  }
}

3. SSR 优化策略

// 服务端渲染友好的模板
export default {
  // 客户端激活所需
  mounted() {
    // 仅客户端的 DOM 操作
    if (process.client) {
      this.initializeThirdPartyLibrary()
    }
  },
  
  // 服务端渲染优化
  serverPrefetch() {
    // 预取数据
    return this.fetchData()
  },
  
  // 避免客户端 hydration 不匹配
  template: `
    <div>
      <!-- 避免使用随机值 -->
      <p>服务器时间: {{ serverTime }}</p>
      
      <!-- 避免使用 Date.now() 等 -->
      <!-- 服务端和客户端要一致 -->
    </div>
  `
}

四、选择指南:如何决定使用哪种方式?

场景 推荐方式 理由
生产级应用 单文件组件(SFC) 最佳开发体验、工具链支持、可维护性
UI 组件库 SFC + 渲染函数 SFC 提供开发体验,渲染函数处理动态性
高度动态 UI 渲染函数/JSX 完全的 JavaScript 控制力
React 团队迁移 JSX 降低学习成本
原型/演示 字符串模板 快速、简单
遗留项目 X-Templates 渐进式迁移
服务端渲染 SFC(注意 hydration) 良好的 SSR 支持

决策流程图:

graph TD
    A[开始选择模板方式] --> B{需要构建工具?}
    B -->|是| C{组件动态性强?}
    B -->|否| D[使用字符串模板或X-Templates]
    
    C -->|是| E{团队熟悉JSX?}
    C -->|否| F[使用单文件组件SFC]
    
    E -->|是| G[使用JSX]
    E -->|否| H[使用渲染函数]
    
    D --> I[完成选择]
    F --> I
    G --> I
    H --> I

五、性能与最佳实践

1. 编译时 vs 运行时模板

// Vue CLI 默认配置优化了 SFC
module.exports = {
  productionSourceMap: false, // 生产环境不生成 source map
  runtimeCompiler: false, // 不使用运行时编译器,减小包体积
}

2. 模板预编译

// 手动预编译模板
const { compile } = require('vue-template-compiler')

const template = `<div>{{ message }}</div>`
const compiled = compile(template)

console.log(compiled.render)
// 输出渲染函数,可直接在组件中使用

3. 避免的常见反模式

<!-- ❌ 避免在模板中使用复杂表达式 -->
<template>
  <div>
    <!-- 反模式:复杂逻辑在模板中 -->
    <p>{{ user.firstName + ' ' + user.lastName + ' (' + user.age + ')' }}</p>
    
    <!-- 正确:使用计算属性 -->
    <p>{{ fullNameWithAge }}</p>
  </div>
</template>

<script>
export default {
  computed: {
    fullNameWithAge() {
      return `${this.user.firstName} ${this.user.lastName} (${this.user.age})`
    }
  }
}
</script>

六、Vue 3 的新变化

<!-- Vue 3 组合式 API + SFC -->
<template>
  <!-- 支持多个根节点(Fragment) -->
  <header>{{ title }}</header>
  <main>{{ content }}</main>
  <footer>{{ footerText }}</footer>
</template>

<script setup>
// 更简洁的语法
import { ref, computed } from 'vue'

const title = ref('Vue 3 组件')
const content = ref('新特性介绍')

const footerText = computed(() => `© ${new Date().getFullYear()}`)
</script>

总结

Vue 提供了从声明式到命令式的完整模板方案光谱:

  1. 声明式端:SFC 模板 → 易读易写,适合大多数业务组件
  2. 命令式端:渲染函数/JSX → 完全控制,适合高阶组件和库
  3. 灵活选择:根据项目需求和团队偏好选择合适的方式

记住这些关键原则:

  • 默认使用 SFC,除非有特殊需求
  • 保持一致性,一个项目中不要混用太多模式
  • 性能考量:生产环境避免运行时编译
  • 团队协作:选择团队最熟悉的方式

❌