阅读视图

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

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 技巧揭秘:一个事件触发多个方法,你竟然还不知道?

解锁 v-on 的高级用法,让你的 Vue 代码更加优雅高效!

前言

在 Vue 开发中,v-on(或 @)是我们最常用的指令之一。但你是否曾经遇到过这样的场景:一个按钮点击后需要执行多个操作?比如点击"提交"按钮时,既要验证表单,又要发送请求,还要显示加载状态。

你会怎么处理?嵌套调用?写一个包装函数?其实,Vue 早就为我们提供了更优雅的解决方案!

一、答案是肯定的:可以绑定多个方法!

让我们直接看答案:Vue 的 v-on 确实可以绑定多个方法,而且有不止一种实现方式。

先来看一个最常见的需求场景:

<template>
  <div>
    <!-- 常见的“不优雅”做法 -->
    <button @click="handleSubmit">
      提交订单
    </button>
    
    <!-- 更优雅的多方法绑定 -->
    <button @click="validateForm(), submitData(), logActivity()">
      智能提交
    </button>
  </div>
</template>

<script>
export default {
  methods: {
    handleSubmit() {
      // 传统方式:把所有逻辑写在一个方法里
      this.validateForm()
      this.submitData()
      this.logActivity()
    },
    
    validateForm() {
      console.log('验证表单...')
    },
    
    submitData() {
      console.log('提交数据...')
    },
    
    logActivity() {
      console.log('记录用户行为...')
    }
  }
}
</script>

二、四种实现方式详解

方式一:直接调用多个方法(最简洁)

<template>
  <button @click="method1(), method2(), method3()">
    点击执行三个方法
  </button>
</template>

特点:

  • ✅ 最直观,直接在模板中调用
  • ✅ 可以传递参数
  • ❌ 模板会显得有点"拥挤"

示例:

<template>
  <div>
    <!-- 传递参数 -->
    <button @click="
      logClick('按钮被点击了'),
      incrementCounter(1),
      sendAnalytics('button_click')
    ">
      带参数的多方法调用
    </button>
    
    <!-- 访问事件对象 -->
    <button @click="
      handleClick1($event),
      handleClick2($event),
      preventDefaults($event)
    ">
      使用事件对象
    </button>
  </div>
</template>

<script>
export default {
  methods: {
    logClick(message) {
      console.log(message)
    },
    incrementCounter(amount) {
      this.count += amount
    },
    sendAnalytics(eventName) {
      // 发送分析数据
    },
    preventDefaults(event) {
      event.preventDefault()
    }
  }
}
</script>

方式二:调用一个包装函数(最传统)

<template>
  <button @click="handleAllMethods">
    包装函数方式
  </button>
</template>

<script>
export default {
  methods: {
    handleAllMethods() {
      this.method1()
      this.method2()
      this.method3()
    },
    method1() { /* ... */ },
    method2() { /* ... */ },
    method3() { /* ... */ }
  }
}
</script>

适用场景:

  • 方法之间有复杂的执行顺序
  • 需要条件判断
  • 需要错误处理

示例:

<script>
export default {
  methods: {
    async handleComplexClick() {
      // 1. 先验证
      const isValid = this.validateForm()
      if (!isValid) return
      
      // 2. 显示加载
      this.showLoading = true
      
      try {
        // 3. 执行多个异步操作
        await Promise.all([
          this.submitData(),
          this.logActivity(),
          this.updateCache()
        ])
        
        // 4. 显示成功提示
        this.showSuccess()
      } catch (error) {
        // 5. 错误处理
        this.handleError(error)
      } finally {
        // 6. 隐藏加载
        this.showLoading = false
      }
    }
  }
}
</script>

方式三:使用对象语法(最灵活)

<template>
  <button v-on="{ click: [method1, method2, method3] }">
    对象语法(数组形式)
  </button>
  
  <!-- 或者 -->
  <button v-on="eventHandlers">
    对象语法(响应式对象)
  </button>
</template>

<script>
export default {
  data() {
    return {
      eventHandlers: {
        click: this.handleClick,
        mouseenter: this.handleMouseEnter,
        mouseleave: this.handleMouseLeave
      }
    }
  },
  methods: {
    handleClick() {
      this.method1()
      this.method2()
    },
    method1() { /* ... */ },
    method2() { /* ... */ }
  }
}
</script>

特点:

  • ✅ 可以动态绑定事件处理器
  • ✅ 支持多个不同事件类型
  • ✅ 适合需要动态切换事件处理逻辑的场景

方式四:修饰符组合(最 Vue)

<template>
  <!-- 结合修饰符 -->
  <button @click.stop.prevent="method1(), method2()">
    带修饰符的多方法
  </button>
  
  <!-- 键盘事件多方法 -->
  <input 
    @keyup.enter="
      validateInput(),
      submitForm(),
      clearInput()
    "
    @keyup.esc="cancelEdit(), resetForm()"
  >
</template>

三、实战案例:一个完整的表单组件

让我们来看一个实际的业务场景:

<template>
  <div class="smart-form">
    <form @submit.prevent="handleSmartSubmit">
      <input v-model="formData.email" placeholder="邮箱">
      <input v-model="formData.password" type="password" placeholder="密码">
      
      <button 
        type="submit"
        :disabled="isSubmitting"
        @click="
          $event.stopPropagation(),
          validateBeforeSubmit(),
          trackButtonClick('submit_button')
        "
        @mouseenter="showTooltip = true"
        @mouseleave="showTooltip = false"
      >
        {{ isSubmitting ? '提交中...' : '智能提交' }}
      </button>
      
      <div v-if="showTooltip" class="tooltip">
        点击后执行:验证 → 提交 → 记录 → 分析
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        email: '',
        password: ''
      },
      isSubmitting: false,
      showTooltip: false
    }
  },
  
  methods: {
    async handleSmartSubmit() {
      if (this.isSubmitting) return
      
      this.isSubmitting = true
      
      try {
        // 并行执行多个操作
        await Promise.all([
          this.submitToServer(),
          this.logUserActivity(),
          this.updateLocalStorage()
        ])
        
        // 串行执行后续操作
        this.showSuccessMessage()
        this.redirectToDashboard()
        this.sendAnalytics('form_submit_success')
        
      } catch (error) {
        this.handleError(error)
        this.sendAnalytics('form_submit_error', { error: error.message })
      } finally {
        this.isSubmitting = false
      }
    },
    
    validateBeforeSubmit() {
      if (!this.formData.email) {
        throw new Error('邮箱不能为空')
      }
      // 更多验证逻辑...
    },
    
    trackButtonClick(buttonName) {
      console.log(`按钮被点击: ${buttonName}`)
      // 实际项目中这里可能是发送到分析平台
    },
    
    async submitToServer() {
      // API 调用
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(this.formData)
      })
      return response.json()
    },
    
    logUserActivity() {
      // 记录用户行为
      console.log('用户提交表单', this.formData)
    },
    
    updateLocalStorage() {
      // 保存到本地
      localStorage.setItem('lastSubmit', new Date().toISOString())
    },
    
    showSuccessMessage() {
      this.$emit('success', '提交成功!')
    },
    
    redirectToDashboard() {
      setTimeout(() => {
        this.$router.push('/dashboard')
      }, 1500)
    },
    
    sendAnalytics(eventName, data = {}) {
      // 发送分析数据
      console.log(`分析事件: ${eventName}`, data)
    },
    
    handleError(error) {
      console.error('提交错误:', error)
      this.$emit('error', error.message)
    }
  }
}
</script>

<style scoped>
.smart-form {
  max-width: 400px;
  margin: 0 auto;
}

.tooltip {
  background: #f0f0f0;
  padding: 8px;
  border-radius: 4px;
  margin-top: 8px;
  font-size: 12px;
  color: #666;
}
</style>

四、最佳实践和注意事项

1. 保持模板简洁

<!-- 不推荐:模板过于复杂 -->
<button @click="
  validateForm($event, formData, true),
  submitForm(formData, config),
  logEvent('submit', { time: Date.now() }),
  showLoading(),
  redirectAfter(3000)
">
  提交
</button>

<!-- 推荐:复杂逻辑放在方法中 -->
<button @click="handleComplexSubmit">
  提交
</button>

2. 错误处理很重要

<script>
export default {
  methods: {
    safeMultiMethods() {
      try {
        this.method1()
        this.method2()
        this.method3()
      } catch (error) {
        console.error('方法执行失败:', error)
        this.handleGracefully(error)
      }
    }
  }
}
</script>

3. 考虑执行顺序

<template>
  <!-- 注意:方法按顺序执行 -->
  <button @click="
    firstMethod(),  // 先执行
    secondMethod(), // 然后执行
    thirdMethod()   // 最后执行
  ">
    顺序执行
  </button>
</template>

4. 异步方法处理

<script>
export default {
  methods: {
    async handleAsyncMethods() {
      // 并行执行
      await Promise.all([
        this.asyncMethod1(),
        this.asyncMethod2()
      ])
      
      // 串行执行
      await this.asyncMethod3()
      await this.asyncMethod4()
    }
  }
}
</script>

五、性能考虑

1. 避免在模板中执行复杂计算

<!-- 不推荐 -->
<button @click="
  heavyCalculation(data),
  processResults(result)
">
  执行
</button>

<!-- 推荐 -->
<button @click="handleHeavyOperations">
  执行
</button>

2. 使用 computed 属性减少重复调用

<script>
export default {
  computed: {
    // 缓存计算结果
    processedData() {
      return this.heavyCalculation(this.data)
    }
  },
  
  methods: {
    handleClick() {
      // 直接使用缓存结果
      this.processResults(this.processedData)
      this.logAction()
    }
  }
}
</script>

六、Vue 3 中的变化

在 Vue 3 的 Composition API 中,用法基本保持一致:

<template>
  <button @click="method1(), method2()">
    Vue 3 多方法
  </button>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

const method1 = () => {
  console.log('方法1')
  count.value++
}

const method2 = () => {
  console.log('方法2')
}
</script>

总结

v-on 绑定多个方法的四种方式:

  1. 直接调用多个方法 - 适合简单场景
  2. 包装函数 - 适合复杂逻辑和复用
  3. 对象语法 - 适合动态事件处理
  4. 修饰符组合 - 适合需要事件修饰的场景

选择建议:

  • 简单逻辑:使用方式一(直接调用)
  • 复杂业务:使用方式二(包装函数)
  • 动态需求:使用方式三(对象语法)
  • 事件控制:使用方式四(修饰符组合)

记住,没有绝对的最佳方式,只有最适合当前场景的方式。关键是保持代码的可读性和可维护性。

希望这篇文章能帮助你更好地使用 Vue 的事件处理机制!如果有更多问题或技巧分享,欢迎在评论区讨论。


Vue 中 computed 和 watch 的深度解析:别再用错了!

大家好!今天我们来聊聊 Vue.js 中两个非常重要的概念:computed 和 watch。很多 Vue 初学者甚至有一定经验的开发者,对这两个功能的使用场景和区别仍然存在困惑。

“什么时候用 computed?什么时候用 watch?” 这可能是 Vue 开发者最常遇到的问题之一。

一、先看一个真实场景

假设我们正在开发一个电商网站,需要计算购物车总价:

// 购物车数据
data() {
  return {
    cartItems: [
      { name'商品A'price100quantity2 },
      { name'商品B'price200quantity1 }
    ],
    discount0.9 // 9折优惠
  }
}

现在我们需要计算:

    1. 商品总价(单价×数量之和)
    1. 折后总价

该怎么实现呢?

二、Computed(计算属性):用于派生数据

computed 的核心思想:基于现有数据计算出一个新的数据值

computed: {
  // 计算商品总价
  totalPrice() {
    return this.cartItems.reduce((sum, item) => {
      return sum + item.price * item.quantity
    }, 0)
  },
  
  // 计算折后价
  finalPrice() {
    return this.totalPrice * this.discount
  }
}

computed 的特点:

  1. 1. 声明式编程:你只需要告诉 Vue "我需要什么数据",Vue 会自动处理依赖和更新
  2. 2. 缓存机制:只有当依赖的数据发生变化时,才会重新计算
  3. 3. 同步计算:适合执行同步操作
  4. 4. 返回一个新值:必须返回一个值

三、Watch(侦听器):用于观察数据变化

watch 的核心思想:当某个数据变化时,执行特定的操作

假设我们希望在购物车商品变化时,自动保存到本地存储:

watch: {
  // 深度监听购物车变化
  cartItems: {
    handler(newVal, oldVal) {
      // 保存到本地存储
      localStorage.setItem('cart'JSON.stringify(newVal))
      // 可以发送到服务器
      this.saveCartToServer(newVal)
    },
    deeptrue// 深度监听
    immediatetrue // 立即执行一次
  },
  
  // 监听总价变化
  totalPrice(newPrice) {
    console.log(`总价变为:${newPrice}`)
    if (newPrice > 1000) {
      this.showDiscountTip() // 显示优惠提示
    }
  }
}

watch 的特点:

  1. 1. 命令式编程:你告诉 Vue "当这个数据变化时,执行这些代码"
  2. 2. 无缓存:每次变化都会执行
  3. 3. 可以执行异步操作:适合 API 调用、复杂业务逻辑
  4. 4. 不返回值:主要目的是执行副作用操作

四、核心区别对比表

特性 computed watch
目的 派生新数据 响应数据变化
缓存 ✅ 有缓存 ❌ 无缓存
返回值 ✅ 必须返回值 ❌ 不返回值
异步 ❌ 不支持异步 ✅ 支持异步
语法 函数形式 对象或函数形式
使用场景 模板中的计算逻辑 数据变化时的副作用

五、什么时候用 computed?什么时候用 watch?

使用 computed 的场景:

  1. 1. 模板中需要复杂表达式时

    <!-- 不推荐 -->
    <div>{{ cartItems.reduce((sum, item) => sum + item.price, 0) }}</div>
    
    <!-- 推荐 -->
    <div>{{ totalPrice }}</div>
    
  2. 2. 一个数据依赖多个数据时

    computed: {
      fullName() {
        return this.firstName + ' ' + this.lastName
      }
    }
    
  3. 3. 需要缓存优化性能时

    // 复杂计算只会在依赖变化时执行
    computed: {
      filteredList() {
        // 假设这是很耗时的筛选操作
        return this.hugeList.filter(item => item.active)
      }
    }
    

使用 watch 的场景:

  1. 1. 数据变化时需要执行异步操作

    watch: {
      searchQuery(newQuery) {
        // 防抖搜索
        clearTimeout(this.timer)
        this.timer = setTimeout(() => {
          this.searchAPI(newQuery)
        }, 500)
      }
    }
    
  2. 2. 数据变化时需要执行复杂业务逻辑

    watch: {
      userLevel(newLevel, oldLevel) {
        if (newLevel === 'vip' && oldLevel !== 'vip') {
          this.showVIPWelcome()
          this.sendVIPNotification()
        }
      }
    }
    
  3. 3. 需要观察对象内部变化时

    watch: {
      formData: {
        handler() {
          this.validateForm()
        },
        deeptrue
      }
    }
    

六、常见误区和最佳实践

误区1:用 watch 实现本该用 computed 的功能

// ❌ 不推荐:用 watch 计算全名
data() {
  return {
    firstName: '张',
    lastName: '三',
    fullName: ''
  }
},
watch: {
  firstName() {
    this.fullName = this.firstName + this.lastName
  },
  lastName() {
    this.fullName = this.firstName + this.lastName
  }
}

// ✅ 推荐:用 computed 计算全名
computed: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

误区2:在 computed 中执行副作用操作

// ❌ 不推荐:在 computed 中修改数据
computed: {
  processedData() {
    // 不要这样做!
    this.someOtherData = 'changed'
    return this.data.map(item => item * 2)
  }
}

// ✅ 推荐:用 watch 执行副作用
watch: {
  data(newData) {
    this.someOtherData = 'changed'
  }
}

七、性能考量

computed 的缓存机制是 Vue 性能优化的重要手段:

computed: {
  // 假设这是一个计算量很大的函数
  expensiveCalculation() {
    console.log('重新计算!')
    // 复杂计算...
    return result
  }
}

在模板中多次使用:

<div>{{ expensiveCalculation }}</div>
<div>{{ expensiveCalculation }}</div>
<div>{{ expensiveCalculation }}</div>

只会输出一次 "重新计算!",因为 computed 会缓存结果。

八、组合式 API 中的使用

在 Vue 3 的 Composition API 中,使用方式略有不同:

import { ref, computed, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubleCount = computed(() => count.value * 2)
    
    watch(count, (newValue, oldValue) => {
      console.log(`count从${oldValue}变为${newValue}`)
    })
    
    return { count, doubleCount }
  }
}

总结

记住这个简单的决策流程

  1. 1. 需要基于现有数据计算一个新值吗?  → 用 computed
  2. 2. 需要在数据变化时执行某些操作吗?  → 用 watch
  3. 3. 这个计算需要在模板中简洁表达吗?  → 用 computed
  4. 4. 需要处理异步操作或复杂业务逻辑吗?  → 用 watch

黄金法则:能用 computed 实现的,优先使用 computed;只有在需要"副作用"操作时,才使用 watch。

希望这篇文章能帮助你更好地理解和使用 Vue 中的 computed 和 watch!在实际开发中灵活运用这两个特性,能让你的代码更加清晰、高效。

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 三剑客:组件、插件、插槽的深度辨析

Vue 三剑客:组件、插件、插槽的深度辨析

组件、插件、插槽是 Vue 生态中的三个核心概念,理解它们的差异是掌握 Vue 架构设计的关键。让我们通过一个完整的对比体系来彻底搞懂它们。

一、核心概念全景图

graph TB
    A[Vue 核心概念] --> B[Component 组件]
    A --> C[Plugin 插件]
    A --> D[Slot 插槽]
    
    B --> B1[UI 复用单元]
    B --> B2[局部作用域]
    B --> B3[父子通信]
    
    C --> C1[全局功能扩展]
    C --> C2[一次配置]
    C --> C3[多组件共享]
    
    D --> D1[内容分发]
    D --> D2[灵活占位]
    D --> D3[模板组合]
    
    B --> E[使用插件]
    B --> F[包含插槽]
    C --> G[增强组件]
    D --> H[扩展组件]

二、三者的本质区别:一句话概括

概念 本质 类比
组件 可复用的 UI 单元 乐高积木块
插件 全局功能扩展包 乐高工具箱
插槽 组件的内容占位符 乐高积木上的接口

三、组件 (Component) - Vue 的基石

定义与核心特征

组件是 Vue 应用的构建块,每个组件都是自包含的、可复用的 Vue 实例。

<!-- UserCard.vue - 组件示例 -->
<template>
  <div class="user-card">
    <img :src="avatar" alt="用户头像" />
    <h3>{{ name }}</h3>
    <p>{{ bio }}</p>
    <!-- 使用插槽提供扩展点 -->
    <slot name="actions"></slot>
  </div>
</template>

<script>
export default {
  // 组件定义
  name: 'UserCard',
  props: {
    name: String,
    avatar: String,
    bio: String
  },
  // 局部状态
  data() {
    return {
      isActive: false
    }
  },
  // 生命周期
  mounted() {
    console.log('组件已挂载')
  }
}
</script>

<style scoped>
.user-card {
  border: 1px solid #ccc;
  padding: 20px;
}
</style>

组件的核心能力:

// 1. 组件注册
// 全局注册
Vue.component('global-component', {
  template: '<div>全局组件</div>'
})

// 局部注册
const LocalComponent = {
  template: '<div>局部组件</div>'
}

new Vue({
  components: {
    'local-component': LocalComponent
  }
})

// 2. 组件通信体系
const ParentComponent = {
  template: `
    <child-component 
      :title="parentTitle" 
      @child-event="handleChildEvent"
    />
  `,
  methods: {
    handleChildEvent(payload) {
      // 处理子组件事件
    }
  }
}

四、插件 (Plugin) - Vue 的扩展系统

定义与核心特征

插件是对 Vue 的全局增强,用于添加全局级的功能。

// my-plugin.js - 自定义插件
const MyPlugin = {
  install(Vue, options) {
    // 1. 添加全局方法或属性
    Vue.myGlobalMethod = function() {
      console.log('全局方法')
    }
    
    // 2. 添加全局资源(指令/过滤器/组件)
    Vue.directive('my-directive', {
      bind(el, binding) {
        // 指令逻辑
      }
    })
    
    // 3. 注入组件选项
    Vue.mixin({
      created() {
        console.log('所有组件都会执行')
      }
    })
    
    // 4. 添加实例方法
    Vue.prototype.$myMethod = function() {
      console.log('实例方法')
    }
  }
}

// 使用插件
Vue.use(MyPlugin, { someOption: true })

常见插件类型:

// 1. UI 组件库插件
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)

// 2. 功能增强插件
import VueRouter from 'vue-router'
Vue.use(VueRouter)

import Vuex from 'vuex'
Vue.use(Vuex)

// 3. 工具类插件
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
  loading: '/loading.gif'
})

插件 vs 组件的关键差异:

对比维度 组件 插件
作用范围 局部(需要显式引入) 全局(一次配置,处处可用)
主要目的 构建 UI 界面 增强 Vue 本身的能力
使用频率 高频率、多次使用 一次性配置
典型示例 Button、Modal、Form Router、Vuex、i18n

五、插槽 (Slot) - 组件的灵活扩展点

定义与核心特征

插槽是组件的内容分发出口,让父组件可以向子组件传递模板内容。

<!-- BaseLayout.vue - 包含插槽的组件 -->
<template>
  <div class="container">
    <header>
      <!-- 具名插槽 -->
      <slot name="header"></slot>
    </header>
    
    <main>
      <!-- 默认插槽 -->
      <slot>
        <!-- 后备内容(当父组件不提供内容时显示) -->
        <p>默认内容</p>
      </slot>
    </main>
    
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

插槽的三种类型:

<!-- 父组件使用 -->
<template>
  <BaseLayout>
    <!-- 具名插槽 -->
    <template v-slot:header>
      <h1>页面标题</h1>
    </template>
    
    <!-- 默认插槽(简写) -->
    <p>主要内容区域</p>
    <p>这是默认插槽的内容</p>
    
    <!-- 作用域插槽 -->
    <template v-slot:footer="slotProps">
      <p>页脚: {{ slotProps.year }} 年</p>
    </template>
    
    <!-- 动态插槽名 -->
    <template v-slot:[dynamicSlotName]>
      动态内容
    </template>
  </BaseLayout>
</template>

作用域插槽(高级模式):

<!-- TodoList.vue -->
<template>
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <!-- 作用域插槽:向父组件暴露数据 -->
      <slot :todo="todo" :index="index">
        <!-- 默认显示 -->
        {{ todo.text }}
      </slot>
    </li>
  </ul>
</template>

<!-- 父组件接收数据 -->
<template>
  <TodoList :todos="todos">
    <template v-slot:default="slotProps">
      <span :class="{ completed: slotProps.todo.done }">
        {{ slotProps.index + 1 }}. {{ slotProps.todo.text }}
      </span>
    </template>
  </TodoList>
</template>

六、三者协同工作的完整示例

让我们通过一个实战项目理解三者如何协同:

// 1. 首先安装路由插件
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)  // 🔴 插件:全局启用路由功能

// 2. 定义可复用的布局组件
const AppLayout = {
  template: `
    <div class="app-layout">
      <slot name="navbar"></slot>
      <div class="content">
        <!-- 默认插槽用于显示页面内容 -->
        <slot></slot>
      </div>
      <slot name="footer"></slot>
    </div>
  `
}

// 3. 创建页面组件
const HomePage = {
  template: `
    <AppLayout>
      <template v-slot:navbar>
        <!-- 向布局组件传递自定义导航栏 -->
        <NavBar title="首页" />
      </template>
      
      <!-- 默认插槽内容 -->
      <h1>欢迎访问</h1>
      <ProductList>
        <!-- 作用域插槽自定义产品显示 -->
        <template v-slot:product="props">
          <ProductCard :product="props.product" />
        </template>
      </ProductList>
      
      <template v-slot:footer>
        <AppFooter />
      </template>
    </AppLayout>
  `,
  components: {
    AppLayout,      // 🔵 组件:布局组件
    NavBar,         // 🔵 组件:导航栏组件
    ProductList,    // 🔵 组件:产品列表
    ProductCard,    // 🔵 组件:产品卡片
    AppFooter       // 🔵 组件:页脚组件
  }
}

// 4. 配置路由(使用插件提供的功能)
const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: HomePage  // 使用组件作为路由页面
    }
  ]
})

// 5. 创建Vue实例
new Vue({
  router,  // 🔴 插件提供的路由实例
  template: '<router-view></router-view>'  // 🟡 插槽:路由视图占位
}).$mount('#app')

七、设计模式与最佳实践

何时使用什么?

graph LR
    A[需求分析] --> B{需要什么?}
    
    B -->|构建UI界面| C[使用组件]
    B -->|全局功能扩展| D[使用插件]
    B -->|自定义组件内容| E[使用插槽]
    
    C --> F{组件需要灵活性?}
    F -->|是| G[在组件中添加插槽]
    F -->|否| H[创建完整组件]
    
    D --> I{功能需要复用?}
    I -->|是| J[开发为插件]
    I -->|否| K[使用局部混入]

组件设计原则:

<!-- 好的组件设计示例 -->
<template>
  <!-- 提供清晰的插槽接口 -->
  <div class="card">
    <div class="card-header" v-if="$slots.header">
      <slot name="header"></slot>
    </div>
    
    <div class="card-body">
      <slot>
        <!-- 合理的默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
    
    <!-- 作用域插槽提供数据 -->
    <div class="card-footer" v-if="$slots.footer">
      <slot name="footer" :data="footerData"></slot>
    </div>
  </div>
</template>

插件开发规范:

// 良好的插件结构
const WellDesignedPlugin = {
  install(Vue, options = {}) {
    // 1. 参数验证
    if (!options.requiredConfig) {
      console.warn('插件需要配置 requiredConfig')
    }
    
    // 2. 安全的全局扩展
    const version = Number(Vue.version.split('.')[0])
    if (version >= 2) {
      Vue.prototype.$safeMethod = function() {
        // 兼容性处理
      }
    }
    
    // 3. 提供卸载方法
    const originalDestroy = Vue.prototype.$destroy
    Vue.prototype.$destroy = function() {
      // 清理逻辑
      originalDestroy.call(this)
    }
  }
}

八、常见误区与澄清

误区1:插件可以替代组件

// ❌ 错误:用插件实现UI组件
Vue.use({
  install(Vue) {
    Vue.prototype.$showModal = function(content) {
      // 这应该是组件,不是插件
    }
  }
})

// ✅ 正确:组件实现UI,插件封装工具
// Modal.vue - 作为组件
// modal-plugin.js - 如果需要全局调用,可以包装为插件

误区2:插槽就是子组件

<!-- ❌ 误解:插槽是子组件 -->
<Parent>
  <Child />  <!-- 这是组件,不是插槽内容 -->
</Parent>

<!-- ✅ 正确理解 -->
<Parent>
  <!-- 这是插槽内容,会被分发到Parent的<slot>位置 -->
  <template v-slot:default>
    <Child />
  </template>
</Parent>

误区3:过度使用混入(Mixin)

// ❌ 过度使用:应该用插槽或组合式API代替
Vue.mixin({
  data() {
    return {
      globalData: '应该避免'
    }
  }
})

// ✅ 更好的方式:组合式函数(Vue 3)
// 或使用作用域插槽传递数据

九、Vue 3 中的演进

组合式 API 的影响:

<!-- Vue 3 中三者关系更加清晰 -->
<script setup>
// 1. 组件 - 更简洁的定义
import { defineComponent } from 'vue'

// 2. 插件 - 通过 provide/inject 更好地集成
import { provide } from 'vue'
provide('pluginData', data)

// 3. 插槽 - 更灵活的用法
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <!-- 插槽作用域解构 -->
  <slot name="item" v-bind="{ id, name }"></slot>
</template>

十、总结:三位一体的 Vue 架构

概念 角色 关键特征 最佳实践
组件 构建者 局部作用域、props/events接口、可复用 单一职责、合理拆分、明确接口
插件 增强者 全局作用域、一次配置、功能扩展 轻量封装、提供选项、良好文档
插槽 连接者 内容分发、模板组合、作用域暴露 明确命名、提供后备、作用域数据

记住这个核心公式:

应用 = 插件增强的Vue实例 + 组件构建的UI树 + 插槽连接的组件关系

最终决策指南:

  1. 当你需要...

    • 复用UI片段 → 创建组件
    • 添加全局功能 → 开发插件
    • 自定义组件内部结构 → 使用插槽
  2. 在架构中...

    • 插件在最外层配置全局能力
    • 组件在中间层构建功能模块
    • 插槽在最内层实现灵活定制
  3. 进化方向...

    • Vue 2:Options API + 三者分明
    • Vue 3:Composition API + 更灵活的组合

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,除非有特殊需求
  • 保持一致性,一个项目中不要混用太多模式
  • 性能考量:生产环境避免运行时编译
  • 团队协作:选择团队最熟悉的方式

深入理解 Vue 生命周期:created 与 mounted 的核心差异与实战指南

深入理解 Vue 生命周期:created 与 mounted 的核心差异与实战指南

掌握生命周期钩子,是 Vue 开发从入门到精通的关键一步。今天我们来深度剖析两个最容易混淆的钩子:createdmounted

一、生命周期全景图:先看森林,再见树木

在深入细节之前,让我们先回顾 Vue 实例的完整生命周期:

graph TD
    A[new Vue()] --> B[Init Events & Lifecycle]
    B --> C[beforeCreate]
    C --> D[Init Injections & Reactivity]
    D --> E[created]
    E --> F[Compile Template]
    F --> G[beforeMount]
    G --> H[Create vm.$el]
    H --> I[mounted]
    I --> J[Data Changes]
    J --> K[beforeUpdate]
    K --> L[Virtual DOM Re-render]
    L --> M[updated]
    M --> N[beforeDestroy]
    N --> O[Teardown]
    O --> P[destroyed]

理解这张图,你就掌握了 Vue 组件从出生到消亡的完整轨迹。而今天的主角——createdmounted,正是这个旅程中两个关键的里程碑。

二、核心对比:created vs mounted

让我们通过一个表格直观对比:

特性 created mounted
执行时机 数据观测/方法/计算属性初始化后,模板编译前 模板编译完成,DOM 挂载到页面后
DOM 可访问性 ❌ 无法访问 DOM ✅ 可以访问 DOM
$el 状态 undefined 已挂载的 DOM 元素
主要用途 数据初始化、API 调用、事件监听 DOM 操作、第三方库初始化
SSR 支持 ✅ 在服务端和客户端都会执行 ❌ 仅在客户端执行

三、实战代码解析:从理论到实践

场景 1:API 数据获取的正确姿势

export default {
  data() {
    return {
      userData: null,
      loading: true
    }
  },
  
  async created() {
    // ✅ 最佳实践:在 created 中发起数据请求
    // 此时数据观测已就绪,可以设置响应式数据
    try {
      this.userData = await fetchUserData()
    } catch (error) {
      console.error('数据获取失败:', error)
    } finally {
      this.loading = false
    }
    
    // ❌ 这里访问 DOM 会失败
    // console.log(this.$el) // undefined
  },
  
  mounted() {
    // ✅ DOM 已就绪,可以执行依赖 DOM 的操作
    const userCard = document.getElementById('user-card')
    if (userCard) {
      // 使用第三方图表库渲染数据
      this.renderChart(userCard, this.userData)
    }
    
    // ✅ 初始化需要 DOM 的第三方插件
    this.initCarousel('.carousel-container')
  }
}

关键洞察:数据获取应尽早开始(created),DOM 相关操作必须等待 mounted。

场景 2:计算属性与 DOM 的微妙关系

<template>
  <div ref="container">
    <p>容器宽度: {{ containerWidth }}px</p>
    <div class="content">
      <!-- 动态内容 -->
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: []
    }
  },
  
  computed: {
    // ❌ 错误示例:在 created 阶段访问 $refs
    containerWidth() {
      // created 阶段:this.$refs.container 是 undefined
      // mounted 阶段:可以正常访问
      return this.$refs.container?.offsetWidth || 0
    }
  },
  
  created() {
    // ✅ 安全操作:初始化数据
    this.items = this.generateItems()
    
    // ⚠️ 注意:computed 属性在此阶段可能基于错误的前提计算
    console.log('created 阶段宽度:', this.containerWidth) // 0
  },
  
  mounted() {
    console.log('mounted 阶段宽度:', this.containerWidth) // 实际宽度
    
    // ✅ 正确的 DOM 相关初始化
    this.observeResize()
  },
  
  methods: {
    observeResize() {
      // 使用 ResizeObserver 监听容器大小变化
      const observer = new ResizeObserver(entries => {
        this.handleResize(entries[0].contentRect.width)
      })
      observer.observe(this.$refs.container)
    }
  }
}
</script>

四、性能优化:理解渲染流程避免常见陷阱

1. 避免在 created 中执行阻塞操作

export default {
  created() {
    // ⚠️ 潜在的渲染阻塞
    this.processLargeData(this.rawData) // 如果处理时间过长,会延迟首次渲染
    
    // ✅ 优化方案:使用 Web Worker 或分块处理
    this.asyncProcessData()
  },
  
  async asyncProcessData() {
    // 使用 requestIdleCallback 避免阻塞主线程
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        this.processInBackground()
      })
    } else {
      // 回退方案:setTimeout 让出主线程
      setTimeout(() => this.processInBackground(), 0)
    }
  }
}

2. 理解异步更新队列

export default {
  mounted() {
    // 情景 1:直接修改数据
    this.someData = 'new value'
    console.log(this.$el.textContent) // ❌ 可能还是旧值
    
    // 情景 2:使用 $nextTick
    this.someData = 'new value'
    this.$nextTick(() => {
      console.log(this.$el.textContent) // ✅ 更新后的值
    })
    
    // 情景 3:多个数据变更
    this.data1 = 'new1'
    this.data2 = 'new2'
    this.data3 = 'new3'
    
    // Vue 会批量处理,只触发一次更新
    this.$nextTick(() => {
      // 所有变更都已反映到 DOM
    })
  }
}

五、高级应用:SSR 场景下的特殊考量

export default {
  // created 在服务端和客户端都会执行
  async created() {
    // 服务端渲染时,无法访问 window、document 等浏览器 API
    if (process.client) {
      // 客户端特定逻辑
      this.screenWidth = window.innerWidth
    }
    
    // 数据预取(Universal)
    await this.fetchUniversalData()
  },
  
  // mounted 只在客户端执行
  mounted() {
    // 安全的浏览器 API 使用
    this.initializeBrowserOnlyLibrary()
    
    // 处理客户端 hydration
    this.handleHydrationEffects()
  },
  
  // 兼容 SSR 的数据获取模式
  async fetchUniversalData() {
    // 避免重复获取数据
    if (this.$ssrContext && this.$ssrContext.data) {
      // 服务端已获取数据
      Object.assign(this, this.$ssrContext.data)
    } else {
      // 客户端获取数据
      const data = await this.$axios.get('/api/data')
      Object.assign(this, data)
    }
  }
}

六、实战技巧:常见问题与解决方案

Q1:应该在哪个钩子初始化第三方库?

export default {
  mounted() {
    // ✅ 大多数 UI 库需要 DOM 存在
    this.$nextTick(() => {
      // 确保 DOM 完全渲染
      this.initSelect2('#my-select')
      this.initDatepicker('.date-input')
    })
  },
  
  beforeDestroy() {
    // 记得清理,防止内存泄漏
    this.destroySelect2()
    this.destroyDatepicker()
  }
}

Q2:如何处理动态组件?

<template>
  <component :is="currentComponent" ref="dynamicComponent" />
</template>

<script>
export default {
  data() {
    return {
      currentComponent: 'ComponentA'
    }
  },
  
  watch: {
    currentComponent(newVal, oldVal) {
      // 组件切换时,新的 mounted 会在下次更新后执行
      this.$nextTick(() => {
        console.log('新组件已挂载:', this.$refs.dynamicComponent)
      })
    }
  },
  
  mounted() {
    // 初次挂载
    this.initializeCurrentComponent()
  }
}
</script>

七、最佳实践总结

  1. 数据初始化 → 优先选择 created
  2. DOM 操作 → 必须使用 mounted(配合 $nextTick 确保渲染完成)
  3. 第三方库初始化mounted + beforeDestroy 清理
  4. 性能敏感操作 → 考虑使用 requestIdleCallback 或 Web Worker
  5. SSR 应用 → 注意浏览器 API 的兼容性检查

写在最后

理解 createdmounted 的区别,本质上是理解 Vue 的渲染流程。记住这个核心原则:

created 是关于数据的准备,mounted 是关于视图的准备。

随着 Vue 3 Composition API 的普及,生命周期有了新的使用方式,但底层原理依然相通。掌握这些基础知识,能帮助你在各种场景下做出更合适的架构决策。

Vuex日渐式微?状态管理的三大痛点与新时代方案

作为Vue生态曾经的“官方标配”,Vuex在无数项目中立下汗马功劳。但近年来,随着Vue 3和Composition API的崛起,越来越多的开发者开始重新审视这个老牌状态管理库。

Vuex的设计初衷:解决组件通信难题

回想Vue 2时代,当我们的应用从简单的单页面逐渐演变成复杂的中大型应用时,组件间的数据共享成为了一大痛点。

// 经典的Vuex store结构
const store = new Vuex.Store({
  state: {
    count0,
    usernull
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    async fetchUser({ commit }) {
      const user = await api.getUser()
      commit('SET_USER', user)
    }
  },
  getters: {
    doubleCountstate => state.count * 2
  }
})

这种集中式的状态管理模式,确实在当时解决了:

  • • 多个组件共享同一状态的问题
  • • 状态变更的可追溯性
  • • 开发工具的时间旅行调试

痛点浮现:Vuex的三大“时代局限”

1. 样板代码过多,开发体验繁琐

这是Vuex最常被诟病的问题。一个简单的状态更新,需要经过actionmutationstate的完整流程:

// 定义部分
const actions = {
  updateUser({ commit }, user) {
    commit('SET_USER', user)
  }
}

const mutations = {
  SET_USER(state, user) {
    state.user = user
  }
}

// 使用部分
this.$store.dispatch('updateUser', newUser)

相比之下,直接的状态赋值只需要一行代码。在中小型项目中,这种复杂度常常显得“杀鸡用牛刀”。

2. TypeScript支持不友好

虽然Vuex 4改进了TS支持,但其基于字符串的dispatchcommit调用方式,始终难以获得完美的类型推断:

// 类型安全较弱
store.commit('SET_USER', user) // 'SET_USER'字符串无类型检查

// 需要额外定义类型
interface User {
  idnumber
  namestring
}

// 但定义和使用仍是分离的

3. 模块系统复杂,代码组织困难

随着项目增大,Vuex的模块系统(namespaced modules)带来了新的复杂度:

// 访问模块中的状态需要命名空间前缀
computed: {
  ...mapState({
    userstate => state.moduleA.user
  })
}

// 派发action也需要前缀
this.$store.dispatch('moduleA/fetchData')

动态注册模块、模块间的依赖关系处理等问题,让代码维护成本逐渐升高。

新时代的解决方案:更轻量、更灵活的选择

方案一:Composition API + Provide/Inject

Vue 3的Composition API为状态管理提供了全新思路:

// 使用Composition API创建响应式store
export function useUserStore() {
  const user = ref<User | null>(null)
  
  const setUser = (newUser: User) => {
    user.value = newUser
  }
  
  return {
    user: readonly(user),
    setUser
  }
}

// 在组件中使用
const { user, setUser } = useUserStore()

优点

  • • 零依赖、零学习成本
  • • 完美的TypeScript支持
  • • 按需导入,Tree-shaking友好

方案二:Pinia——Vuex的现代继承者

Pinia被看作是“下一代Vuex”,解决了Vuex的许多痛点:

// 定义store
export const useUserStore = defineStore('user', {
  state() => ({
    usernull as User | null,
  }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser()
    },
  },
})

// 使用store
const userStore = useUserStore()
userStore.fetchUser()

Pinia的进步

  • • 移除mutations,actions可直接修改状态
  • • 完整的TypeScript支持
  • • 更简洁的API设计
  • • 支持Composition API和Options API

实战建议:如何选择?

根据我的项目经验,建议如下:

继续使用Vuex的情况

  • • 维护已有的Vue 2大型项目
  • • 团队已深度熟悉Vuex,且项目运行稳定
  • • 需要利用Vuex DevTools的特定功能

考虑迁移/使用新方案的情况

  • • 新项目:优先考虑Pinia
  • • Vue 3项目:中小型可用Composition API,大型推荐Pinia
  • • 对TypeScript要求高:直接选择Pinia

迁移策略:平稳过渡

如果你决定从Vuex迁移到Pinia,可以采取渐进式策略:

  1. 1. 并行运行:新旧store系统共存
  2. 2. 模块逐个迁移:按业务模块逐步迁移
  3. 3. 工具辅助:利用官方迁移指南和工具
// 迁移示例:将Vuex模块转为Pinia store
// Vuex版本
const userModule = {
  state: { name'' },
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// Pinia版本
const useUserStore = defineStore('user', {
  state() => ({ name'' }),
  actions: {
    setName(name: string) {
      this.name = name
    }
  }
})

写在最后

技术总是在不断演进。Vuex作为特定历史阶段的优秀解决方案,完成了它的使命。而今天,我们有更多、更好的选择。

核心不是追求最新技术,而是为项目选择最合适的工具。

对于大多数新项目,Pinia无疑是更现代、更优雅的选择。但对于已有的Vuex项目,除非有明确的痛点需要解决,否则“稳定压倒一切”。

Vue插槽

一、先明确核心概念

  1. 具名插槽:给 <slot> 标签添加 name 属性,用于区分不同位置的插槽,让父组件可以精准地将内容插入到子组件的指定位置,解决「默认插槽只能插入一处内容」的问题。
  2. 默认插槽:没有 name 属性的 <slot>,是具名插槽的特殊形式(默认名称为 default),父组件中未指定插槽名称的内容,会默认插入到这里。
  3. 插槽默认内容:在子组件的 <slot> 标签内部写入内容,当父组件未给该插槽传递任何内容时,会显示这份默认内容;若父组件传递了内容,会覆盖默认内容,提升组件的复用性和容错性。
  4. 作用域插槽:子组件通过「属性绑定」的方式给 <slot> 传递内部私有数据,父组件在使用插槽时可以接收这些数据并自定义渲染,解决「父组件无法访问子组件内部数据」的问题,实现「子组件供数、父组件定制渲染」。

二、分步实例演示

第一步:实现最基础的「具名插槽 + 默认插槽」

核心需求:创建一个通用的「页面容器组件」,包含「页头」「页面内容」「页脚」三个部分,其中「页面内容」用默认插槽,「页头」「页脚」用具名插槽。

1. 子组件:定义插槽(文件名:PageContainer.vue

<template>
  <!-- 通用页面容器样式(简单美化,方便查看效果) -->
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头(name="header") -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容(无name属性,对应default) -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(name="footer") -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer" />
    </div>
  </div>
</template>

<script setup>
// 子组件无需额外逻辑,仅定义插槽结构即可
</script>

2. 父组件:使用插槽(传递内容,文件名:App.vue

父组件通过 v-slot:插槽名(简写:#插槽名)指定内容要插入的具名插槽,未指定的内容默认插入到默认插槽。

<template>
  <h2>基础具名插槽 + 默认插槽演示</h2>

  <!-- 使用子组件 PageContainer -->
  <PageContainer>
    <!-- 给具名插槽 header 传递内容(简写 #header,完整写法 v-slot:header) -->
    <template #header>
      <h3>这是文章详情页的页头</h3>
      <nav>首页 > 文章 > Vue 插槽教程</nav>
    </template>

    <!-- 未指定插槽名,默认插入到子组件的默认插槽 -->
    <div>
      <p>1. 具名插槽可以让父组件精准控制内容插入位置。</p>
      <p>2. 默认插槽用于承载组件的核心内容,使用更简洁。</p>
      <p>3. 这部分内容会显示在页头和页脚之间。</p>
    </div>

    <!-- 给具名插槽 footer 传递内容(简写 #footer) -->
    <template #footer>
      <span>发布时间:2026-01-13</span>
      <button style="margin-left: 20px; padding: 4px 12px;">收藏文章</button>
    </template>
  </PageContainer>
</template>

<script setup>
// 导入子组件
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 页头区域显示「文章详情页标题 + 面包屑导航」(对应 #header 插槽内容)。
  • 中间区域显示核心正文(对应默认插槽内容)。
  • 页脚区域显示「发布时间 + 收藏按钮」(对应 #footer 插槽内容)。
  • 关键:父组件的 <template> 标签包裹插槽内容,通过 #插槽名 绑定子组件的具名插槽,结构清晰,互不干扰。

第二步:实现「带默认内容的插槽」

核心需求:优化上面的 PageContainer.vue,给「页脚插槽」添加默认内容(默认显示「返回顶部」按钮),当父组件未给 footer 插槽传递内容时,显示默认按钮;若传递了内容,覆盖默认内容。

1. 修改子组件:给插槽添加默认内容(PageContainer.vue

仅修改 footer 插槽部分,在 <slot name="footer"> 内部写入默认内容:

<template>
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头 -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容 -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(带默认内容) -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer">
        <!-- 插槽默认内容:父组件未传递footer内容时,显示该按钮 -->
        <button style="padding: 4px 12px;" @click="backToTop">返回顶部</button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 定义默认内容的点击事件(返回顶部)
const backToTop = () => {
  window.scrollTo({
    top: 0,
    behavior: 'smooth' // 平滑滚动
  });
};
</script>

2. 父组件演示两种场景(App.vue

分别演示「不传递 footer 内容」和「传递 footer 内容」的效果:

<template>
  <h2>带默认内容的插槽演示</h2>

  <!-- 场景1:父组件不传递 footer 插槽内容,显示子组件的默认「返回顶部」按钮 -->
  <h4>场景1:未传递页脚内容(显示默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是未传递页脚的页面</h3>
    </template>
    <p>该页面父组件没有给 footer 插槽传递内容,所以页脚会显示子组件默认的「返回顶部」按钮。</p>
  </PageContainer>

  <!-- 场景2:父组件传递 footer 插槽内容,覆盖默认按钮 -->
  <h4 style="margin-top: 40px;">场景2:传递页脚内容(覆盖默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是传递了页脚的页面</h3>
    </template>
    <p>该页面父组件给 footer 插槽传递了自定义内容,会覆盖子组件的默认「返回顶部」按钮。</p>
    <template #footer>
      <span>作者:Vue 小白教程</span>
      <button style="margin-left: 20px; padding: 4px 12px;">点赞</button>
      <button style="margin-left: 10px; padding: 4px 12px;">评论</button>
    </template>
  </PageContainer>
</template>

<script setup>
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 场景1:页脚显示「返回顶部」按钮,点击可实现平滑滚动到页面顶部(默认内容生效)。
  • 场景2:页脚显示「作者 + 点赞 + 评论」,默认的「返回顶部」按钮被覆盖(自定义内容生效)。
  • 核心价值:插槽默认内容让组件更「健壮」,无需父组件每次都传递所有插槽内容,减少冗余代码,提升组件复用性。

第三步:实际业务场景综合应用(卡片组件)

核心需求:创建一个通用的「商品卡片组件」,使用具名插槽实现「商品图片」「商品标题」「商品价格」「操作按钮」的自定义配置,其中「操作按钮」插槽带默认内容(默认「加入购物车」按钮)。

1. 子组件:商品卡片(GoodsCard.vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 具名插槽:商品图片 -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" />
    </div>

    <!-- 具名插槽:商品标题 -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" />
    </div>

    <!-- 具名插槽:商品价格 -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 无需额外逻辑,仅提供插槽结构和默认内容
</script>

2. 父组件:使用商品卡片组件(App.vue

自定义不同商品的内容,演示插槽的灵活性:

<template>
  <h2>实际业务场景:商品卡片组件</h2>
  <div style="overflow: hidden; clear: both;">
    <!-- 商品1:使用默认操作按钮(加入购物车) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=1" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        小米手机 14 旗舰智能手机
      </template>
      <template #price>
        ¥ 4999
      </template>
      <!-- 未传递 #action 插槽,显示默认「加入购物车」按钮 -->
    </GoodsCard>

    <!-- 商品2:自定义操作按钮(立即购买 + 收藏) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=2" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        苹果 iPad Pro 平板电脑
      </template>
      <template #price>
        ¥ 7999
      </template>
      <!-- 自定义 #action 插槽内容,覆盖默认按钮 -->
      <template #action>
        <button style="width: 48%; padding: 8px 0; background: #0071e3; color: #fff; border: none; border-radius: 8px; cursor: pointer; margin-right: 4%;">
          立即购买
        </button>
        <button style="width: 48%; padding: 8px 0; background: #f0f0f0; color: #333; border: none; border-radius: 8px; cursor: pointer;">
          收藏
        </button>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './GoodsCard.vue';
</script>

3. 运行效果与说明

  • 商品1:操作按钮显示默认的「加入购物车」,快速实现基础功能。
  • 商品2:操作按钮显示「立即购买 + 收藏」,满足自定义需求。
  • 业务价值:通过具名插槽,打造了「通用可复用」的商品卡片组件,父组件可以根据不同商品场景,灵活配置各个区域的内容,既减少了重复代码,又保证了灵活性。

第四步:实现「作用域插槽」

核心需求:基于现有商品卡片组件优化,让子组件持有私有商品数据,通过作用域插槽传递给父组件,父组件自定义渲染格式(如给高价商品加「高端」标识、显示商品优惠信息)。

1. 修改子组件:定义作用域插槽,传递内部数据(GoodsCard.vue

子组件新增内部私有数据,通过「属性绑定」给插槽传递数据(:数据名="子组件内部数据"):

vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 作用域插槽:商品图片(暴露商品id和图片地址) -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" :goodsId="goods.id" :imgUrl="goods.imgUrl" />
    </div>

    <!-- 作用域插槽:商品标题(暴露商品名称和价格) -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" :goodsName="goods.name" :goodsPrice="goods.price" />
    </div>

    <!-- 作用域插槽:商品价格(暴露价格和优惠信息) -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" :price="goods.price" :discount="goods.discount" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 子组件内部私有数据(模拟接口返回,父组件无法直接访问)
const goods = {
  id: 1001,
  name: "小米手机 14 旗舰智能手机",
  price: 4999,
  imgUrl: "https://picsum.photos/240/180?random=1",
  discount: "立减200元,支持分期免息"
};
</script>

2. 父组件:接收并使用作用域插槽数据(App.vue

父组件通过 template #插槽名="插槽数据对象" 接收子组件暴露的数据,支持解构赋值简化代码,自定义渲染逻辑:

vue

<template>
  <h2>进阶:作用域插槽演示(子组件供数,父组件定制渲染)</h2>
  <div style="overflow: hidden; clear: both; margin-top: 40px;">
    <GoodsCard>
      <!-- 接收图片插槽的作用域数据:slotProps(自定义名称,包含goodsId、imgUrl) -->
      <template #image="slotProps">
        <img :src="slotProps.imgUrl" :alt="'商品' + slotProps.goodsId" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
        <!-- 利用子组件传递的goodsId,添加自定义标识 -->
        <span style="position: absolute; top: 8px; left: 8px; background: red; color: #fff; padding: 2px 8px; border-radius: 4px; z-index: 10;">
          编号:{{ slotProps.goodsId }}
        </span>
      </template>

      <!-- 接收标题插槽的作用域数据:解构赋值(更简洁,推荐) -->
      <template #title="{ goodsName, goodsPrice }">
        {{ goodsName }}
        <!-- 父组件自定义逻辑:价格高于4000加「高端」标识 -->
        <span v-if="goodsPrice > 4000" style="color: #ff4400; font-size: 12px; margin-left: 8px;">
          高端
        </span>
      </template>

      <!-- 接收价格插槽的作用域数据:结合优惠信息渲染 -->
      <template #price="{ price, discount }">
        <span>¥ {{ price }}</span>
        <!-- 渲染子组件传递的优惠信息,自定义样式 -->
        <p style="font-size: 12px; color: #999; margin-top: 4px; text-align: left;">
          {{ discount }}
        </p>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './components/GoodsCard.vue';
</script>

3. 运行效果与说明

  • 父组件成功获取子组件私有数据(goodsIddiscount 等),并实现自定义渲染(商品编号、高端标识、优惠信息);
  • 核心语法:子组件「属性绑定传数据」,父组件「插槽数据对象接收」,支持解构赋值简化代码;
  • 核心价值:通用组件(列表、卡片、表格)既保留内部数据逻辑,又开放渲染格式定制权,极大提升组件灵活性和复用性;
  • 注意:作用域插槽本质仍是具名 / 默认插槽,只是增加了「子向父」的数据传递能力。

三、总结(核心知识点回顾,加深记忆)

  1. 使用步骤
  • 子组件:用 <slot name="xxx"> 定义具名插槽(内部可写默认内容),用 :数据名="内部数据" 给插槽传递数据(作用域插槽);
  • 父组件:用 <template #xxx> 给指定具名插槽传内容,用 <template #xxx="slotProps"> 接收作用域插槽数据,未指定插槽名的内容默认插入到 <slot>(默认插槽)。
  1. 核心语法
  • v-slot:插槽名 可简写为 #插槽名,仅能用于 <template> 标签或组件标签上;
  • 作用域插槽数据支持解构赋值,可设置默认值(如 #title="{ goodsName = '默认商品', goodsPrice = 0 }")避免报错。
  1. 插槽体系
  • 基础层:默认插槽(单一区域)、具名插槽(多区域精准定制);
  • 增强层:插槽默认内容(提升健壮性)、作用域插槽(子供数 + 父定制,进阶核心)。

🔥 Vue 3 项目深度优化之旅:从 787KB 到极致性能

当你以为优化已经结束,真正的挑战才刚刚开始

🎬 序章:优化永无止境

还记得上次我们把构建时间从 35 秒优化到 21 秒,把 vendor 包从 227 KB 压缩到 157 KB 的故事吗?

那时候我以为优化工作已经完成了,直到我看到了这个数字:

element-plus-jMvik2ez.js    787.16 KB  (Gzip: 241.53 KB)

787 KB! 一个 UI 库就占了整个项目 40% 的体积!

这就像你辛辛苦苦减肥成功,结果发现衣柜里还藏着一堆 XXL 的衣服。是时候来一次"断舍离"了。

🔍 第一步:侦探工作 - 找出真凶

工具准备

# 生成包体积分析报告
VITE_ANALYZE=true npm run build:dev

# 打开 dist/stats.html
open dist/stats.html

打开报告的那一刻,我惊呆了:

📦 包体积分布
├─ element-plus (787 KB) 👈 占比 40.8% 🔴
├─ vendor (157 KB)       👈 占比 8.1%  🟢
├─ framework (180 KB)    👈 占比 9.4%  🟢
├─ main (153 KB)         👈 占比 7.9%  🟢
└─ others (651 KB)       👈 占比 33.8% 🟡

Element Plus 一家独大,比其他所有第三方库加起来还要大!

深入调查

让我们看看项目到底用了哪些 Element Plus 组件:

# 搜索所有 Element Plus 组件的使用
grep -r "from 'element-plus'" src/

结果让人意外:

// 实际使用的组件(15 个)
ElMessage          // 消息提示
ElNotification     // 通知
ElMessageBox       // 确认框
ElDialog           // 对话框
ElButton           // 按钮
ElTable            // 表格
ElCheckbox         // 复选框
ElUpload           // 上传
ElIcon             // 图标
ElPopover          // 弹出框
ElScrollbar        // 滚动条
ElCollapseTransition // 折叠动画
ElTour, ElTourStep // 引导
ElTag              // 标签
ElConfigProvider   // 全局配置

// Element Plus 提供的组件(80+ 个)
ElCalendar         // ❌ 未使用
ElDatePicker       // ❌ 未使用
ElTimePicker       // ❌ 未使用
ElCascader         // ❌ 未使用
ElTree             // ❌ 未使用
ElTransfer         // ❌ 未使用
// ... 还有 60+ 个未使用的组件

真相大白: 我们只用了 15 个组件,却打包了 80+ 个组件!

这就像去超市买一瓶水,结果收银员说:"不好意思,我们只卖整箱。"

💡 第二步:制定作战计划

方案 A:手术刀式精准切除

思路: 手动导入需要的组件,排除不需要的

// build/plugins.ts
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的大型组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

优点:

  • 精准控制
  • 风险可控
  • 易于维护

缺点:

  • 需要手动维护排除列表
  • 可能遗漏某些组件

预期效果: 减少 100-150 KB

方案 B:CSS 瘦身计划

问题: Element Plus CSS 也有 211 KB

element-plus.css    210.92 KB  (Gzip: 26.43 KB)

思路: 使用更高效的 CSS 压缩工具

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',  // 比 esbuild 更快更小
  },
})

lightningcss vs esbuild:

指标 esbuild lightningcss 提升
压缩率 87.5% 90.2% ↑ 3.1%
速度 更快 ↑ 20%
兼容性 更好

预期效果: 减少 30-50 KB

方案 C:图片"减肥"大作战

发现问题:

ls -lh dist/assets/webp/

-rw-r--r--  login-bg-line.webp     5.37 KB  ✅ 合理
-rw-r--r--  empty.webp             8.50 KB  ✅ 合理
-rw-r--r--  cargo-ship.webp       13.78 KB  ✅ 合理
-rw-r--r--  logo.webp             14.46 KB  ✅ 合理
-rw-r--r--  login-bg2.webp       267.07 KB  🔴 过大!

267 KB 的背景图! 这相当于 1.7 个 lodash 库的大小!

优化方案:

# 方案 1:压缩图片
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2-optimized.webp \
  --webp-quality 80

# 结果:267 KB → 120 KB (减少 55%)
<!-- 方案 2:懒加载 -->
<template>
  <img
    v-lazy="loginBg"
    alt="Login Background"
    class="login-bg"
  />
</template>

<script setup lang="ts">
// 只在需要时加载
const loginBg = new URL('@/assets/images/login-bg2.webp', import.meta.url).href
</script>
// 方案 3:使用 CDN
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: [/\.(png|jpe?g|gif|svg|webp)$/],
    },
  },
})

预期效果: 减少 200-300 KB

🎯 第三步:实战演练

优化 1:Element Plus 精准打击

实施前的准备

// 1. 创建组件使用清单
const usedComponents = [
  'ElMessage',
  'ElNotification',
  'ElMessageBox',
  'ElDialog',
  'ElButton',
  'ElTable',
  'ElCheckbox',
  'ElUpload',
  'ElIcon',
  'ElPopover',
  'ElScrollbar',
  'ElCollapseTransition',
  'ElTour',
  'ElTourStep',
  'ElTag',
  'ElConfigProvider',
]

// 2. 创建排除清单
const excludedComponents = [
  'ElCalendar',
  'ElDatePicker',
  'ElTimePicker',
  'ElCascader',
  'ElTree',
  'ElTransfer',
  'ElColorPicker',
  'ElRate',
  'ElSlider',
  'ElSwitch',
  // ... 更多未使用的组件
]

配置优化

// build/plugins.ts
AutoImport({
  resolvers: [
    ElementPlusResolver({
      // 只自动导入使用的 API
      exclude: /^El(Calendar|DatePicker|TimePicker)$/,
    }),
  ],
})

Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

验证效果

# 构建并分析
VITE_ANALYZE=true npm run build:dev

# 对比结果
Before: element-plus-xxx.js  787.16 KB (Gzip: 241.53 KB)
After:  element-plus-xxx.js  650.00 KB (Gzip: 195.00 KB)

# 减少:137 KB (17.4%)  🎉

优化 2:CSS 压缩升级

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',
  },
})
# 构建并对比
Before: element-plus.css  210.92 KB (Gzip: 26.43 KB)
After:  element-plus.css  210.92 KB (Gzip: 24.50 KB)

# 减少:1.93 KB (7.3%)  ✨

优化 3:图片压缩

# 压缩背景图
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2.webp \
  --webp-quality 80

# 结果
Before: 267.07 KB
After:  120.00 KB

# 减少:147 KB (55%)  🚀

📊 第四步:战果统计

优化前后对比

指标 优化前 优化后 减少
Element Plus JS 787 KB 650 KB ↓ 137 KB (17%) 🎉
Element Plus CSS 211 KB 211 KB -
CSS (Gzip) 26.43 KB 24.50 KB ↓ 1.93 KB (7%)
背景图片 267 KB 120 KB ↓ 147 KB (55%) 🚀
总计减少 - - ↓ 286 KB 🎊

性能提升

指标 优化前 优化后 提升
首次加载 2.8s 2.2s ↓ 21% 👍
二次访问 0.8s 0.6s ↓ 25% 🚀
FCP 1.8s 1.4s ↓ 22%
LCP 2.5s 2.0s ↓ 20% 💨

用户体验提升

优化前的用户体验:
[========== 加载中 ==========] 2.8s
"怎么这么慢?" 😤

优化后的用户体验:
[====== 加载中 ======] 2.2s
"还不错!" 😊

🎓 第五步:经验总结

踩过的坑

坑 1:过度排除组件

问题:

// ❌ 错误:排除了实际使用的组件
exclude: /^El(Dialog|Button|Table)$/

结果: 页面报错,组件无法加载

解决:

// ✅ 正确:只排除确认未使用的组件
exclude: /^El(Calendar|DatePicker|TimePicker)$/

教训: 充分测试所有功能,确保没有遗漏

坑 2:CSS 压缩导致样式丢失

问题:

// ❌ 错误:使用 PurgeCSS 过度清理
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
})

结果: 动态生成的样式被移除

解决:

// ✅ 正确:配置 safelist
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
  safelist: {
    standard: [/^el-/],
    deep: [/^el-.*__/],
  },
})

教训: 保守优化,充分测试

坑 3:图片压缩过度

问题:

# ❌ 错误:质量设置过低
--webp-quality 50

结果: 图片模糊,用户体验差

解决:

# ✅ 正确:平衡质量和大小
--webp-quality 80

教训: 在质量和大小之间找平衡

最佳实践

1. 组件使用分析

// 创建组件使用清单
const componentUsage = {
  used: [
    'ElMessage',
    'ElDialog',
    // ...
  ],
  unused: [
    'ElCalendar',
    'ElDatePicker',
    // ...
  ],
}

// 定期审查
npm run analyze:components

2. 渐进式优化

第一阶段:低风险优化
├─ CSS 压缩 ✅
├─ 图片压缩 ✅
└─ 代码分割 ✅

第二阶段:中风险优化
├─ 组件排除 ⚠️
├─ CSS 清理 ⚠️
└─ 动态导入 ⚠️

第三阶段:高风险优化
├─ 替换大型库 🔴
├─ 自定义组件 🔴
└─ 深度定制 🔴

3. 持续监控

// package.json
{
  "scripts": {
    "analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "lighthouse": "lighthouse https://your-domain.com --view"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "200 KB"  // 设置预算
    }
  ]
}

🚀 第六步:展望未来

下一步优化方向

1. 考虑替代方案

Element Plus 的轻量级替代:

大小 组件数 优势
Element Plus 787 KB 80+ 功能完整
Naive UI 450 KB 80+ 更轻量
Arco Design 380 KB 60+ 性能好
自定义组件 100 KB 15 完全可控

权衡:

  • 迁移成本 vs 性能收益
  • 功能完整性 vs 包体积
  • 团队熟悉度 vs 学习成本

2. 微前端架构

// 按需加载子应用
const loadSubApp = async (name: string) => {
  const app = await import(`./apps/${name}/index.js`)
  return app.mount('#app')
}

// 只加载当前需要的功能
if (route.path.startsWith('/user')) {
  await loadSubApp('user-management')
}

优势:

  • 更细粒度的代码分割
  • 独立部署和更新
  • 更好的缓存策略

3. 边缘计算

// 使用 CDN 边缘节点
const CDN_BASE = 'https://cdn.example.com'

// 静态资源从 CDN 加载
const loadAsset = (path: string) => {
  return `${CDN_BASE}${path}`
}

优势:

  • 更快的加载速度
  • 减轻服务器压力
  • 全球加速

💰 ROI 分析

投入产出比

投入:

  • 分析时间:2 小时
  • 优化时间:3 小时
  • 测试时间:2 小时
  • 总计:7 小时

产出:

1. 性能提升

  • 包体积减少:286 KB
  • 加载速度提升:21-25%
  • 用户体验提升:显著

2. 成本节省

  • 带宽节省:286 KB × 10000 用户/月 = 2.8 GB/月
  • 服务器成本:约 $50/月
  • 年度节省:$600

3. 用户留存

  • 加载速度提升 → 跳出率降低 15%
  • 用户体验提升 → 留存率提升 10%
  • 潜在价值:难以估量

ROI = (600 + 无形价值) / (7 × 时薪) > 1000%

🎬 尾声:优化是一场马拉松

经过这次深度优化,我们实现了:

  1. Element Plus 瘦身 17%:从 787 KB 到 650 KB
  2. CSS 优化 7%:更高效的压缩
  3. 图片减肥 55%:从 267 KB 到 120 KB
  4. 总体减少 286 KB:约 15% 的体积优化

但更重要的是,我们学会了:

  • 🔍 如何分析:使用工具找出真正的瓶颈
  • 💡 如何决策:权衡收益和风险
  • 🛠️ 如何实施:渐进式优化,充分测试
  • 📊 如何验证:用数据说话
  • 🔄 如何持续:建立监控和预算

记住:

  • 优化不是一次性的工作,而是持续的过程
  • 不要为了优化而优化,要关注用户体验
  • 数据驱动决策,不要凭感觉
  • 保持代码可维护性,不要过度优化

下一站: 微前端架构?边缘计算?还是自定义组件库?

敬请期待下一篇: 《从 Element Plus 到自定义组件库:一次大胆的尝试》


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

有任何问题欢迎在评论区讨论,我会尽快回复!


关键词: Vue 3、Vite、性能优化、Element Plus、包体积优化、深度优化

标签: #Vue3 #Vite #性能优化 #ElementPlus #前端工程化 #深度优化

Vue 3 项目包体积优化实战:从 227KB 到精细化分包

通过精细化分包策略,优化缓存效率,提升加载性能

🎯 优化目标

在完成构建速度优化后,我们发现包体积也有优化空间:

  • Element Plus 占 787 KB(40.8%)- 过大
  • Vendor 包 227 KB - 包含多个库,缓存效率低
  • 总体积 1.93 MB - 需要优化

📊 优化前后对比

分包策略对比

包名 优化前 优化后 变化
element-plus 787.16 KB 787.19 KB ≈ 0
framework 180.42 KB 180.42 KB ≈ 0
vendor 226.66 KB 157.37 KB ↓ 30.6% 🎉
lodash - 27.61 KB 新增
axios - 38.96 KB 新增
dayjs - 18.25 KB 新增
crypto - 69.90 KB 新增

关键改进

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB(减少 69 KB
  2. 精细化分包:将常用库独立打包,提升缓存效率
  3. 并行加载:多个小包可以并行下载,提升加载速度

🔧 优化实施

优化 1:精细化分包策略

问题分析

原来的配置将所有工具库打包到一个 utils chunk:

// ❌ 优化前:粗粒度分包
if (normalized.includes('/lodash') || 
    normalized.includes('/dayjs') || 
    normalized.includes('/axios')) {
  return 'utils'  // 所有工具库打包在一起
}

问题:

  • 单个文件过大(包含 lodash + dayjs + axios)
  • 任何一个库更新,整个 chunk 缓存失效
  • 不常用的库也会被加载

优化方案

// ✅ 优化后:细粒度分包
// 工具库细分 - 提升缓存效率
if (normalized.includes('/lodash')) {
  return 'lodash'  // lodash 单独打包
}
if (normalized.includes('/dayjs')) {
  return 'dayjs'   // dayjs 单独打包
}
if (normalized.includes('/axios')) {
  return 'axios'   // axios 单独打包
}

// 大型库单独打包
if (normalized.includes('/xlsx')) {
  return 'xlsx'
}
if (normalized.includes('/crypto-js')) {
  return 'crypto'
}
if (normalized.includes('/dompurify')) {
  return 'dompurify'
}

优化效果

缓存效率提升:

  • 场景 1:只更新业务代码

    • 优化前:vendor (227 KB) 缓存失效
    • 优化后:只有 vendor (157 KB) 缓存失效,lodash/axios/dayjs 仍然有效
  • 场景 2:升级 axios

    • 优化前:整个 utils chunk 缓存失效
    • 优化后:只有 axios (39 KB) 缓存失效

并行加载:

  • 浏览器可以同时下载多个小文件
  • HTTP/2 多路复用,并行下载更高效

优化 2:Element Plus 自动导入优化

问题分析

Element Plus 占 787 KB,虽然已经使用了按需导入,但仍然很大。

优化方案

// 1. 在 AutoImport 中也添加 Element Plus resolver
AutoImport({
  imports: ["vue", "vue-router", "pinia", "vue-i18n"],
  resolvers: [
    ElementPlusResolver(),  // 自动导入 Element Plus API
  ],
})

// 2. 在 Components 中配置
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: "sass",
      directives: false,  // 不自动导入指令,减少体积
    }),
  ],
})

预期效果

  • 更精确的按需导入
  • 避免导入未使用的 API 和指令
  • 预计可减少 10-15% 的 Element Plus 体积

📈 性能提升分析

1. 缓存命中率提升

场景模拟:

假设每月发版 4 次,每次更新:

  • 业务代码更新:100%
  • 依赖库更新:10%

优化前:

  • 每次发版,用户需要重新下载 vendor (227 KB)
  • 月流量:227 KB × 4 = 908 KB

优化后:

  • 业务代码更新:vendor (157 KB)
  • 依赖更新(10% 概率):lodash/axios/dayjs 之一 (约 30 KB)
  • 月流量:157 KB × 4 + 30 KB × 0.4 = 640 KB

节省流量: 268 KB/月/用户(减少 29.5%

2. 首屏加载优化

并行下载优势:

优化前(串行):
[====== vendor 227KB ======] 2.27s (假设 100KB/s)

优化后(并行):
[== vendor 157KB ==] 1.57s
[= lodash 28KB =] 0.28s
[= axios 39KB ==] 0.39s
[= dayjs 18KB ==] 0.18s
总时间:max(1.57, 0.28, 0.39, 0.18) = 1.57s

加载时间减少: 0.7s(提升 30.8%

3. 用户体验提升

指标 优化前 优化后 提升
首次加载 ~3.5s ~2.8s ↓ 20%
二次访问 ~1.2s ~0.8s ↓ 33%
更新后访问 ~2.0s ~1.4s ↓ 30%

🎓 深度解析:为什么这样优化有效?

1. HTTP/2 的多路复用

现代浏览器支持 HTTP/2,可以:

  • 在单个连接上并行传输多个文件
  • 避免队头阻塞
  • 更高效的资源利用

最佳实践:

  • 单个文件大小:20-100 KB
  • 文件数量:5-15 个
  • 避免过度分割(< 10 KB 的文件)

2. 浏览器缓存策略

浏览器缓存基于文件名(包含 hash):

  • 文件内容不变 → hash 不变 → 使用缓存
  • 文件内容改变 → hash 改变 → 重新下载

精细化分包的优势:

  • 减少缓存失效的范围
  • 提高缓存命中率
  • 降低用户流量消耗

3. 关键渲染路径优化

首屏渲染需要:
1. HTML
2. 关键 CSS
3. 关键 JS(framework + main)
4. 非关键 JS(vendor + 其他库)

优化策略:
- 关键资源:内联或优先加载
- 非关键资源:延迟加载或并行加载

🛠️ 实战技巧

技巧 1:分析包体积

# 生成可视化报告
VITE_ANALYZE=true npm run build:dev

# 查看 stats.html
open dist/stats.html

关注指标:

  • 单个 chunk 大小(建议 < 200 KB)
  • 重复依赖(应该为 0)
  • 未使用的代码(通过 Tree Shaking 移除)

技巧 2:合理的分包粒度

// 🎯 最佳实践
const chunkSizeMap = {
  'element-plus': 787,  // 大型 UI 库,单独打包
  'framework': 180,     // 核心框架,单独打包
  'vendor': 157,        // 其他依赖,合并打包
  'lodash': 28,         // 常用工具库,单独打包
  'axios': 39,          // HTTP 库,单独打包
  'dayjs': 18,          // 日期库,单独打包
  'crypto': 70,         // 加密库,单独打包
}

// ❌ 过度分割
const chunkSizeMap = {
  'lodash-debounce': 2,    // 太小,不值得单独打包
  'lodash-throttle': 2,    // 太小,不值得单独打包
  'lodash-cloneDeep': 3,   // 太小,不值得单独打包
}

技巧 3:监控包体积变化

// package.json
{
  "scripts": {
    "build:analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "size-limit:check": "size-limit --why"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "250 KB"
    },
    {
      "path": "dist/assets/js/vendor-*.js",
      "limit": "160 KB"
    }
  ]
}

📋 优化检查清单

分包策略

  • 大型库(> 100 KB)单独打包
  • 常用库(20-100 KB)单独打包
  • 小型库(< 20 KB)合并打包
  • 避免过度分割(< 10 KB)

缓存策略

  • 使用 contenthash 命名
  • 稳定的 chunk 名称
  • 合理的缓存时间
  • CDN 配置正确

性能监控

  • 定期生成包体积报告
  • 设置体积预算
  • 监控首屏加载时间
  • 跟踪缓存命中率

🎯 下一步优化方向

1. Element Plus 深度优化

当前状态: 787 KB(Gzip: 242 KB)

优化方向:

  • 分析实际使用的组件
  • 移除未使用的组件
  • 考虑使用更轻量的替代方案

预期收益: 减少 150-200 KB

2. 动态导入优化

当前状态: 所有路由组件都在首屏加载

优化方向:

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/dashboard/index.vue'),
  },
  {
    path: '/settings',
    component: () => import('@/views/settings/index.vue'),
  },
]

预期收益: 首屏减少 30-40%

3. Tree Shaking 优化

当前状态: 可能存在未使用的代码

优化方向:

  • 检查 lodash-es 导入方式
  • 使用具名导入
  • 配置 sideEffects

预期收益: 减少 50-100 KB

📊 ROI 分析

投入时间: 2 小时

收益:

  • 包体积优化:69 KB(vendor)
  • 缓存效率提升:29.5%
  • 加载时间减少:30.8%
  • 用户体验提升:20-33%

长期收益:

  • 每月节省流量:268 KB × 用户数
  • 提升用户留存率
  • 降低服务器带宽成本

🎬 总结

通过精细化分包策略,我们实现了:

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB
  2. 缓存效率提升:29.5% 的流量节省
  3. 加载速度提升:30.8% 的时间减少
  4. 更好的可维护性:清晰的依赖关系

核心原则

  1. 合理分包:根据更新频率和大小分包
  2. 提升缓存:减少缓存失效范围
  3. 并行加载:利用 HTTP/2 多路复用
  4. 持续监控:定期检查包体积变化

最后的建议

  • DO:定期分析包体积
  • DO:设置体积预算
  • DO:监控性能指标
  • DON'T:过度分割
  • DON'T:忽视缓存策略
  • DON'T:盲目追求极致

关键词: Vite 包体积优化、代码分割、缓存策略、性能优化、Vue 3

标签: #Vite #包体积优化 #性能优化 #前端工程化


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

更多前端性能优化技巧,请关注我的专栏《前端性能优化实战》

服务端返回的二进制流excel文件,前端实现下载

近期有个excel的下载功能,服务端返回的是二进制的文件流,前端实现excel文件下载。

简易axios:

// utils/request.ts
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios'
axios.interceptors.request.use(config => {
    ……
    return config
})
axios.interceptors.response.use(
    response => {
        return new Promise((resolve, reject) => {
            ……
        })
    }
)
    
 export const getBlobFile = (url: string, params = {}) =>
    axios({
        data: params,
        responseType: 'blob',
        method: 'POST',
        url
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
    }) as Promise<any>

下面是工具函数文件的方法:

// utils/index.ts
export function useState<T>(initData: T): [Ref<T>, (val?: T) => void] {
    const data = ref(initData) as Ref<T>
    function setData(newVal?: T) {
        data.value = newVal || initData
    }
    return [data, setData]
}

/**
 * 下载二进制文件
 * @param file
 * @param fn
 */
import { ref, Ref } from 'vue'

export function downloadFile(file: File, fn?: () => void) {
    if ('msSaveOrOpenBlob' in navigator) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const nav = navigator as any
        nav.msSaveOrOpenBlob(file, file.name)
    } else {
        const url = URL.createObjectURL(file)
        const event = new MouseEvent('click')
        const link = document.createElement('a')
        link.href = url
        link.download = file.name
        file.type && (link.type = file.type)
        link.dispatchEvent(event)
        URL.revokeObjectURL(url)
        fn && fn()
    }
}

实现下载相关逻辑的hooks如下:

// hooks.ts
import { ref } from 'vue'
import { ElLoading } from 'element-plus'
import { EXCEL_EXPORT_URL } from '@/const/url'
import { useState, downloadFile } from '@/utils'
import { getBlobFile } from '@/utils/request'

export const OK_STATUS_CODE = '200'

export function useExportExcelFile() {
    const [exportData, handleExport] = useState([] as Array<ApiRes.ExcelListItem>)

    const exportLoading = ref(false)

    async function exportExcelFile(params = {}) {
        const text = '正在导出当前数据,请稍等~~ '
        const loading = ElLoading.service({
            lock: true,
            text,
            background: 'rgba(0, 0, 0, 0.7)',
            customClass: 'export-loading-class'
        })
        try {
            queryBlobExcelFile(params).then((res) => {
                exportExcelFileByPost(res)
            }).finally(() => {
                loading.close()
                exportLoading.value = false
            })
        } catch (error) {
            console.error('导出失败:', error)
        }
    }

    async function queryBlobExcelFile (params = {}) {
        return new Promise((resolve, reject) => {
            getBlobFile(EXCEL_EXPORT_URL, params)
                .then(res => {
                    if (res && res?.status === OK_STATUS_CODE) {
                        resolve(res)
                    }
                })
                .catch(err => reject(err))
        })
    }

    async function exportExcelFileByPost(res: {
        type: string
        data: Blob
    }) {
        const fileName =  `Excel文件-${+new Date()}.xlsx`
        downloadFile(new File([res.data], fileName))
    }

    return {
        exportData,
        handleExport,
        exportLoading,
        exportExcelFile,
    }
}

在页面中的使用

</template>
    <el-button
        type="primary"
        :disabled="false"
        :export-loading="exportLoading"
        @click="doExport"
    >
        导出
    </el-button>
</template>

import { ElMessageBox } from 'element-plus'
import { useExportExcelFile } from './hooks'

// 导出
const { exportLoading, exportExcelFile } = useExportExcelFile()
function doExport() {
    ElMessageBox.confirm('确定导出excel数据吗?', '导出', {
        cancelButtonText: '取消',
        confirmButtonText: '确认',
        showClose: true
    }).then(() => {
        exportLoading.value = true
        exportExcelFile(formData)
    })
}
❌