阅读视图

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

Vue3 的 v-model 双向绑定,90% 的人都用错了?(附 2026 最新避坑指南)

你的表单数据绑定了却不动?自定义组件 v-model 写了就是不生效?
而用 Vue3 正确的 v-model 写法一行代码搞定双向绑定,支持多字段同步、自定义事件、TS 完美兼容——再也不用手动写 $emit('input').sync 修饰符

如果你受够了:

  • 输入框改了值,页面没反应
  • 自定义组件传值像“猜谜游戏”
  • Vue2 转 Vue3 后 v-model 突然失效
  • 团队里有人写 :value + @input,有人写 v-model,代码风格混乱

那么,这篇 2026 年最新实操指南,就是为你写的——
不用翻文档,所有代码模板直接复制粘贴,今天就能写出零 bug 的双向绑定


一、先搞懂:Vue3 的 v-model,到底“新”在哪?

很多从 Vue2 过来的开发者,还在用老思维写 v-model,结果频频翻车。
Vue3 对 v-model 做了三大升级

特性 Vue2 Vue3
绑定属性 固定为 value 可自定义(如 titlecount
触发事件 input 统一为 update:xxx
多绑定支持 不支持 一个组件可绑多个 v-model
语法糖 需配合 .sync 原生支持,无需额外修饰符

一句话总结Vue3 的 v-model = 更灵活 + 更统一 + 更少代码


二、核心干货:v-model 3 大场景实战(附可运行模板)

场景1:基础表单绑定(覆盖 80% 日常开发)

适用于 <input><textarea><select>、复选框等。

【实操代码】(直接复制)

<template>
  <div class="form-demo">
    <!-- 文本输入 -->
    <input v-model="username" placeholder="账号" />
    
    <!-- 密码 -->
    <input v-model="password" type="password" placeholder="密码" />
    
    <!-- 多行文本 -->
    <textarea v-model="bio" placeholder="个人简介"></textarea>
    
    <!-- 复选框(布尔值) -->
    <label>
      <input type="checkbox" v-model="agree" />
      同意用户协议
    </label>

    <!-- 实时预览 -->
    <div class="preview">
      账号:{{ username }}<br/>
      密码:{{ password }}<br/>
      简介:{{ bio }}<br/>
      已同意:{{ agree ? '✅' : '❌' }}
    </div>
  </div>
</template>

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

const username = ref('')
const password = ref('')
const bio = ref('')
const agree = ref(false)
</script>

避坑提醒v-model自动忽略元素上的 valuechecked 属性,不要混用


场景2:自定义组件 v-model(组件通信必备)

让自定义组件像原生表单一样使用 v-model

1. 创建组件:MyInput.vue

<template>
  <div class="my-input">
    <span>自定义:</span>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      placeholder="请输入..."
    />
  </div>
</template>

<script setup>
// 必须叫 modelValue!
const props = defineProps(['modelValue'])
// 必须 emit update:modelValue!
const emit = defineEmits(['update:modelValue'])
</script>

2. 父组件使用

<template>
  <MyInput v-model="customText" />
  <p>输入内容:{{ customText }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const customText = ref('')
</script>

效果:父组件 v-model="customText" → 子组件 modelValue 接收 → 输入时触发 update:modelValue → 父组件自动更新!


场景3:多 v-model 绑定(复杂表单神器)

一个组件同时绑定多个双向数据,比如姓名 + 年龄 + 邮箱。

父组件

<template>
  <UserForm 
    v-model:name="user.name"
    v-model:age="user.age"
    v-model:email="user.email"
  />
  <pre>{{ user }}</pre>
</template>

<script setup>
import { reactive } from 'vue'
import UserForm from './UserForm.vue'

const user = reactive({
  name: '',
  age: 0,
  email: ''
})
</script>

子组件:UserForm.vue

<template>
  <div>
    <input v-model="nameProxy" placeholder="姓名" />
    <input v-model.number="ageProxy" type="number" placeholder="年龄" />
    <input v-model="emailProxy" type="email" placeholder="邮箱" />
  </div>
</template>

<script setup>
const props = defineProps(['name', 'age', 'email'])
const emit = defineEmits(['update:name', 'update:age', 'update:email'])

// 使用计算属性代理,让 v-model 在子组件内也能用
import { computed } from 'vue'
const nameProxy = computed({
  get: () => props.name,
  set: (val) => emit('update:name', val)
})
const ageProxy = computed({
  get: () => props.age,
  set: (val) => emit('update:age', val)
})
const emailProxy = computed({
  get: () => props.email,
  set: (val) => emit('update:email', val)
})
</script>

优势:父组件只需写 v-model:xxx,逻辑清晰,维护成本极低!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:绑定非响应式数据

// 错误 
let text = '' // 普通变量
// v-model="text" → 修改无效!

// 正确 
const text = ref('') // 响应式

坑2:自定义组件命名不规范

// 错误(Vue2 写法)
defineProps(['value'])
defineEmits(['input'])

// 正确(Vue3 标准)
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

坑3:v-model:value 混用

<!-- 错误  -->
<input v-model="msg" :value="defaultValue" />

<!-- 正确  -->
<input v-model="msg" />
<!-- 或初始化时:const msg = ref(defaultValue) -->

四、进阶技巧:用 TS 让 v-model 更安全

// MyInput.vue (TypeScript 版)
<script setup lang="ts">
interface Props {
  modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()
</script>

类型检查 + 智能提示,杜绝拼写错误!


五、谁在用 Vue3 的 v-model?

  • 字节跳动:所有内部表单系统强制使用多 v-model 模式
  • 腾讯文档:协作编辑组件通过 v-model:content 实时同步
  • Nuxt 3 官方模板:表单示例全部采用 Composition API + v-model
  • Vue 官方团队:在 RFC 中明确表示 “v-model 是未来组件通信的核心”

结语:双向绑定,本该如此优雅

Vue3 的 v-model 不是“小改动”,而是对组件通信范式的重新定义
当你能用 v-model:titlev-model:count 一行搞定复杂交互,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Pinia 比 Vuex 好用 10 倍?Vue3 状态管理终于不折磨人了!(新手复制即用)

还在为 Vuex 的 statemutationactionmodule 四件套头疼?
而用 Pinia一行代码定义状态,直接修改数据,无需 commit,TS 完美支持,刷新页面还能自动持久化——小项目 5 分钟搞定,大项目维护成本直降 60%!

如果你受够了:

  • 写个计数器要建 3 个文件
  • 改个状态要绕 commit('SET_COUNT', 1) 半天
  • 调试时找不到数据在哪被改了
  • 刷新页面状态全丢,还得手动存 localStorage

那么,这篇手把手实操指南,就是为你写的——
不用看文档,所有代码模板直接复制粘贴,今天就能替换掉 Vuex


一、先说清:为什么 Pinia 是 Vue3 的“官方亲儿子”?

Vuex 是 Vue2 时代的产物,设计时没考虑 Composition API 和 TypeScript。
Pinia 由 Vue 核心团队打造,专为 Vue3 而生,直接解决 Vuex 所有痛点:

痛点 Vuex Pinia
配置复杂度 需创建 store/index.js + modules 一个文件就是一个仓库
修改状态 必须通过 mutation(commit 直接 this.count++
TS 支持 弱,需额外类型声明 原生完美支持
代码体积 ~10KB ~5KB(更轻)
调试体验 多层嵌套难追踪 DevTools 一目了然

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 使用 Pinia,Vuex 已成历史。


二、核心干货:Pinia 3 步上手(附可运行模板)

第一步:安装(1 行命令)

# 推荐 pnpm
pnpm add pinia

# 或 npm / yarn
npm install pinia
yarn add pinia

第二步:全局注册(2 行代码)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // ← 只需引入这个
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // ← 注册
app.mount('#app')

避坑提醒不需要像 Vuex 那样写 new Store({}) 或分模块配置!


第三步:创建并使用仓库(核心!直接复制)

1. 创建仓库:src/store/counterStore.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:直接返回对象(响应式)
  state: () => ({
    count: 0,
    name: 'Pinia测试'
  }),

  // getters:计算属性(自动缓存)
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // actions:同步/异步方法(直接修改 state!)
  actions: {
    increment() {
      this.count++ // 不用 commit!
    },
    async incrementAsync() {
      await new Promise(r => setTimeout(r, 1000))
      this.count++
    }
  }
})

2. 在组件中使用

<template>
  <div>
    <h3>{{ counterStore.name }}</h3>
    <p>当前:{{ counterStore.count }}</p>
    <p>2倍:{{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+1</button>
    <button @click="counterStore.incrementAsync">异步+1</button>
  </div>
</template>

<script setup>
// 引入 + 实例化(关键!)
import { useCounterStore } from '@/store/counterStore'
const counterStore = useCounterStore() // ← 必须实例化!
</script>

效果:状态、计算属性、方法全部自动暴露,无需 mapStatemapActions


三、实战避坑:90% 新手都会踩的 3 个致命错误

坑1:只引入不实例化,导致 undefined

// 错误
import { useCounterStore } from '@/store/counterStore'
console.log(useCounterStore.count) // 报错!

// 正确
const counterStore = useCounterStore()
console.log(counterStore.count) // 正常

坑2:在组件里直接改状态,破坏可维护性

// 不推荐(小型 demo 可以,项目别这么干)
counterStore.count = 999

// 推荐(统一走 actions,便于调试和复用)
counterStore.increment()

坑3:多个仓库用相同 ID,数据互相污染

// 错误
defineStore('user', { ... })
defineStore('user', { ... }) // ID 重复!

// 正确
defineStore('user', { ... })
defineStore('cart', { ... }) // ID 唯一

四、进阶技巧:一行代码实现状态持久化(刷新不丢)

默认 Pinia 状态刷新就没了?用官方插件 pinia-plugin-persistedstate,轻松搞定!

1. 安装插件

pnpm add pinia-plugin-persistedstate

2. 配置插件

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // ← 启用插件
app.use(pinia)
app.mount('#app')

3. 仓库开启持久化

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: { ... },
  persist: true // ← 就这一行!
})

效果count 自动存入 localStorage,刷新页面依然保留!


五、谁在用 Pinia?

  • 字节跳动:抖音 Web 端、飞书文档全面采用 Pinia
  • 腾讯:微信开放平台、腾讯文档 Vue3 项目标配
  • Nuxt 3:官方默认状态管理方案
  • Vue 官方生态:Vue Router、VitePress 示例均使用 Pinia

结语:状态管理,本该如此简单

Pinia 的价值,不只是“替代 Vuex”,而是让状态管理回归本质:直观、可维护、可扩展
当你不再为写 mutation 而烦恼,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

救命!Vue3 的 Composition API,居然能让我少写 80% 冗余代码?(新手也能直接抄)

你的 Vue 组件里是不是还在 datamethodscomputedwatch 之间来回跳转?
而用 Composition API一个 setup 函数搞定所有逻辑,代码量直降 80%,逻辑清晰到实习生都能看懂

如果你受够了:

  • Options API 里找某个变量要翻半天
  • 相同逻辑(比如表单校验)在多个组件里复制粘贴
  • 面试被问 “Vue3 和 Vue2 区别” 只能答“Proxy 更快”
  • 想复用逻辑却只能靠 Mixin(然后陷入命名冲突地狱)

那么,这篇手把手实操指南,就是为你写的——
不用死记硬背,所有代码模板直接复制粘贴,今天就能用上


一、先澄清一个误区:Composition API 不是“花里胡哨”,是真能救急

很多新手觉得:“Options API 能用,为啥换?”
但真相是:Options API 在复杂组件中,逻辑天然割裂

举个真实例子:写一个带防抖搜索 + 加载状态 + 错误提示的搜索框

  • Options API 写法

    • data 里定义 keyword, loading, error
    • methods 里写 search(), debounce()
    • watch 里监听 keyword 触发搜索
    • mounted 里可能还要初始化默认值
      同一个功能,散落在 4 个地方!
  • Composition API 写法

    const { keyword, loading, error, search } = useSearch()
    

一行代码,逻辑内聚,复用无痛

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 强制使用 Composition API,面试必考。


二、核心干货:Composition API 3 个必学用法(附可运行模板)

1. script setup:所有逻辑的“入口”,一次搞定所有

这是 Vue3 官方推荐的写法,无需 return,自动暴露所有变量和方法

实操代码模板(直接复制到项目)

<template>
  <div>
    <input v-model="username" placeholder="请输入账号" />
    <button @click="login" :disabled="isLoading">
      {{ isLoading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup>
// 1. 定义响应式数据(替代 data)
import { ref } from 'vue'
const username = ref('')       // 响应式字符串
const isLoading = ref(false)   // 响应式布尔值

// 2. 定义方法(替代 methods)
const login = () => {
  isLoading.value = true
  // 模拟登录请求
  setTimeout(() => {
    console.log('登录成功,账号:', username.value)
    isLoading.value = false
  }, 1000)
}
// 3. 无需 return!<script setup> 自动暴露
</script>

避坑提醒:只有普通 setup() 函数才需要手动 return<script setup> 不用!


2. ref vs reactive:响应式数据的“两大神器”,别再用混了

记住口诀:**简单数据用 **ref复杂对象用 reactive

场景 推荐 API 修改方式 模板中使用
字符串、数字、布尔值 ref count.value = 1 {{ count }}
对象、数组 reactive user.name = 'Tom' {{ user.name }}

ref 实操示例】

import { ref } from 'vue'
const count = ref(0)

const increment = () => {
  count.value++ // 必须加 .value!
}

【reactive实操示例】

import { reactive } from 'vue'
const user = reactive({
  name: '',
  age: 0,
  hobbies: []
})

const updateUser = () => {
  user.name = 'Alice' // 直接修改,不加 .value
  user.hobbies.push('coding')
}

关键技巧:用 toRefs 解构 reactive 对象,保持响应式

import { reactive, toRefs } from 'vue'
const user = reactive({ username: '', password: '' })

// 解构后仍响应式
const { username, password } = toRefs(user)
username.value = 'test' // 有效!

3. 生命周期钩子:按需引入,不用写空方法

Vue3 生命周期需显式导入,更灵活,且避免无用代码。

【常用生命周期对照表】

Vue2 Vue3
mounted onMounted
updated onUpdated
beforeUnmount onBeforeUnmount

【实操示例:页面加载后请求数据】

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const list = ref([])

onMounted(async () => {
  const res = await axios.get('/api/user/list')
  list.value = res.data
})
</script>

避坑提醒:生命周期钩子必须在 <script setup>setup() 内部调用,不能在外部!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:忘记给 ref.value,导致响应式失效

// 错误
const count = ref(0)
count = 1 // 页面不会更新!

// 正确
count.value = 1

坑2:用 reactive 创建简单数据

// 错误
const count = reactive(0) // reactive 只接受对象/数组
count = 1 // 响应式丢失!

// 正确
const count = ref(0)

坑3:为了“规范”强行封装,把简单逻辑搞复杂

正确姿势:只有跨组件复用的逻辑才封装成 Hook,否则直接写!


四、进阶技巧:用自定义 Hook 复用逻辑,效率拉满!

把重复代码(如表单校验、请求封装、本地存储)抽成 Hook,多个组件直接引入,少写 80% 代码

【实战示例:封装通用表单校验 Hook】

第一步:创建 hooks/useForm.js

// hooks/useForm.js
import { ref } from 'vue'

export const useForm = (rules) => {
  const form = ref({})
  const errors = ref({})

  const validate = () => {
    let isValid = true
    for (const key in rules) {
      const rule = rules[key]
      if (!form.value[key] && rule.required) {
        errors.value[key] = rule.message
        isValid = false
      } else {
        errors.value[key] = ''
      }
    }
    return isValid
  }

  return { form, errors, validate }
}

第二步:在组件中使用

<script setup>
import { useForm } from '@/hooks/useForm'

const { form, errors, validate } = useForm({
  username: { required: true, message: '请输入账号' },
  password: { required: true, message: '请输入密码' }
})

const login = () => {
  if (validate()) {
    console.log('提交数据:', form.value)
  }
}
</script>

<template>
  <div>
    <input v-model="form.username" />
    <span v-if="errors.username" class="error">{{ errors.username }}</span>
    
    <input type="password" v-model="form.password" />
    <span v-if="errors.password" class="error">{{ errors.password }}</span>
    
    <button @click="login">登录</button>
  </div>
</template>

效果:以后任何表单,只需 3 行代码引入,校验逻辑自动生效!


五、谁在用 Composition API?

  • 字节跳动:抖音 Web 端全量 Vue3 + Composition API
  • 腾讯文档:协同编辑组件基于自定义 Hook 构建
  • 阿里云控制台:复杂表单系统 100% 使用 useXXX 模式
  • Vue 官方生态:Pinia、Vue Router 4 全面拥抱 Composition

结语:少写代码,才是高级程序员的终极追求

Composition API 的价值,不只是“新语法”,而是用函数式思维组织逻辑,让代码可读、可测、可复用
当你不再为找变量翻遍整个文件,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

🚨 还在用 rem) 做大屏适配?用 vfit.js 一键搞定,告别改稿8版的噩梦!

 导读:欢迎来到《vfit.js 大屏适配指南》系列第 1 篇。在这个系列中,我们将带你彻底告别大屏适配的折磨,打通"痛点-解法-实战-优化-落地"的完整闭环。

上周,群里一个做政务大屏的兄弟心态崩了:"地图和图表用 rem 调了一整周,好不容易对齐了。结果去交付现场一看,客户的指挥中心用的是3840×2160 的超宽带鱼屏,整个页面全乱套了……"

底下瞬间刷出几十条"太真实了"。

做大屏开发的你,是不是也经常遭遇这些"社死时刻"?

  •  政务指挥中心:设计稿 1920×1080,现场屏幕长宽比极其奇葩,ECharts 图表直接挤成一团。
  • 工业监控大屏:用 rem 算来算去,DataV 的飞线动效死活偏了3 个像素。
  • 数据驾驶舱:老板在 iPad 竖屏打开大屏链接,质问你"为什么右边全白了?"

如果你中招了,请立刻停下手里写了一半的媒体查询!这篇指南,就是你的救命稻草!


🛑 为什么传统的适配方案,在大屏上必死无疑?

说到底,很多人没搞懂一个核心:可视化大屏,根本不是普通网页!

做普通后台管理系统,内容像"水流",用 flex、grid 响应式排版就行。
但做智慧城市、数字孪生这种大屏,页面是一幅"静态油画"。

📌 标题必须死死钉在正中间;
📌 3D 地图必须霸占绝对 C 位;
📌 两侧的数据面板哪怕字小点,也绝不能换行或错位。

所有元素的相对位置,必须焊死!

我们来看看你以前用的方案,为什么会翻车。

1️⃣ rem 方案:万恶的"单位转换地狱"

算比例、转 px,每次窗口一动就要重算。最要命的是,像 ECharts、高德地图、Three.js 这些第三方可视化库,底层全认 px!你用 rem,等于给自己挖了一个永远填不满的兼容坑。

2️⃣ vw/vh 方案:控制不住的"高度变形"

车联网驾驶舱时,宽度用 vw 撑满了,一旦屏幕比例从 16:9 变成 16:10,高度用 vh 就会拉伸,你的圆形仪表盘直接变成"椭圆",客户看着直摇头。


💡 终极解法:Scale 等比缩放,为什么你没早点用?

既然大屏是一幅画,那最完美的适配逻辑就是:把这幅画当成一个整体,等比例放大缩小!

就像在 PPT 里拖拽图片的对角线一样,不改变内部任何尺寸,只改变整体视野

  • • 设计稿是 px,你就写 px:零转换成本,所见即所得。
  • • 可视化库完美兼容:ECharts、DataV 闭眼用,再也不用担心偏移。
  • • 极致性能:利用 GPU 硬件加速,大屏不卡顿。

但手动写 Scale 有个巨坑:你要自己算比例、监听窗口、处理绝对定位失真……


🚀 登场:vfit.js,把你从加班中拯救出来

为了解决 Scale 方案的最后一公里痛点,vfit.js 诞生了。
这是一个专为 Vue 3 可视化大屏打造的轻量级适配神器。

不管你是做公安大屏、还是工厂看板,只需 3 行代码:

import { createApp } from 'vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css'

const app = createApp(App)

// 告诉它设计稿尺寸,剩下交给 vfit
app.use(createFitScale({
  designWidth1920,   
  designHeight1080,  
  scaleMode'auto'    
}))

app.mount('#app')

就这么简单!代码一交,按时下班。


🎁 互动福利:大屏避坑资料包

你以前做大屏遇到过最奇葩的屏幕尺寸是多少?
👇 在评论区吐槽你的经历 👇

🔥 福利时间
关注公众号,后台回复【大屏模板】,即可免费领取:

1. vfit.js 开箱即用 Vue3 工程模板(带 ECharts 示例)

2. 大屏常见奇葩分辨率适配速查表

官方资源直达:


🔗 推荐阅读与下期预告

📚 推荐深度阅读

在开始源码探索前,强烈推荐先阅读这两份权威指南:


🔜 下期预告:别急着复制,先懂底层!

今天我们明确了:Scale 是大屏适配的唯一真理
但作为高级前端,只会调包可不行。万一在嵌套 iframe 的工业后台里失效了怎么办?

下一篇: 《02 - 5分钟看懂 vfit.js 大屏适配源码:政务/工业看板防变形黑科技,就这50行!》
我们将扒开 vfit 的底裤,带你搞懂 ResizeObserver 和 GPU 缩放的核心原理。我们下期见!

最新版vue3+TypeScript开发入门到实战教程之插槽slot详解

插槽概述

Slot,可翻译中文为插槽、空槽、钥匙槽。以下为官方定义Solt(插槽)是 Vue 提供的一种内容分发机制,允许父组件向子组件指定位置注入内容。简单理解为大门样式已经设计好,钥匙空槽预留,使用大门的人可以按装指纹锁、物理锁等锁。 Slot插槽分三种类型

  • 默认插槽
  • 具名插槽
  • 作用于插槽

默认插槽

概述

默认插槽是具名插槽的一个特例,实际类型应分成两类:

  • 具名插槽
  • 作用于插槽

默认插槽实例

  • 创建Fish,Fish组件提供标题、尾部,中间插槽内容由使用者提供
  • 创建App组件,引用Fish组件

App组件代码

<template>
  <div class="app">
    <Fish>
      <div>游泳的鲫鱼</div>
    </Fish>
     <Fish>
      <template>
        <div>会飞的鱼</div>
      </template>
    </Fish>
    <Fish>
      <template v-slot:default>
        <div>跃龙门的鲤鱼</div>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

Fish组件代码

<template>
  <div class="fish">
    <h2>头部</h2>
    <slot></slot>
    <h2>底部</h2>
  </div>

</template>

运行效果: 在这里插入图片描述 注意中间位置,会飞的鱼没有显示出来,默认插槽不需要使用template标签,若使用,必须给标签设置默认名称。

具名插槽

概述

在Fish组件中,可能会有很多个插槽, 如顶部、中部都可以设置一个插槽。使用名称来区分插槽:

  • 给slot设置名称
  • template标签设置slot名称

具名插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content">默认数据</slot>
    <slot name="footer">
      <h2>底部</h2>
    </slot>
  </div>
</template>
<script setup lang="ts">
defineProps(['title']);
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
      <Fish title="飞鱼">
      <template v-slot:footer>
        <h2>鲤鱼跃龙门</h2>
      </template>
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
     <Fish title="草鱼">
      <template #content>
        <div>吃草的鱼</div>
      </template>
      <template v-slot:footer>
        <h2>很爱水草</h2>
      </template>
      </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行效果 在这里插入图片描述

  • 引用第一个Fish组件时,当不使用名称为footer的插槽时,它显示默认值
  • 引用第二个组件说明,template显示位置取决于所选Slot
  • 引用第三个组件说明,v-slot:可用#缩写,如v-slot:content缩写成#content

作用域插槽

概述

插槽实际分两类,一是具名插槽,一是作用域插槽,两者区别:

  • 具名插槽数据与显示都在使用者
  • 作用域插槽的数据是在被引用的组件当中,使用者只负责显示数据
  • 通过slot标签可以将数据传递给template

作用域插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content" :data="fishs">默认数据</slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';

defineProps(['title']);
let fishs = reactive([
  { name: '鲫鱼', price: 10 },
  { name: '草鱼', price: 33 },
  { name: '娃娃鱼', price: 88 },
])
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content="params">
        <ul>
          <li v-for="item in params.data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </li>
        </ul>
      </template>
    </Fish>
    <Fish title="草鱼">
      <template #content="{data}">
          <h4 v-for="item in data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </h4>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行代码查看效果 在这里插入图片描述 核心代码:

  • 传递数据:slot传递数据<slot name="content" :data="fishs">默认数据</slot>
  • 传递数据:template 接收数据<template v-slot:content="params">。 注意param结构赋值data

总结

类型语法特点使用场景
默认插槽<slot />一个组件只有一个主要内容区域
具名插槽<slot name="header" />

1.基于依赖追踪和触发的响应式系统的本质

前言

Vue1、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,都是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。

为什么标题说“基于依赖追踪的响应式系统”,因为在前端响应式的框架有很多,但他们的实现原理却各有不同。 在前端一般谈到响应式框架,可能大家都会不约而同地联想到 Vue,除了 Vue 之外,也许还有人会想到 React 以及 Svelte、SolidJS。他们都有一个共同点,都是通过数据驱动视图。他们在实现方式上又互相有一些相似之处,其中 Vue 和 React 都采用了虚拟DOM技术,Vue 和 SolidJS 的数据响应式实现则都是采用了依赖追踪的方式,所以在数据响应式的实现方面 Vue 和 SolidJS 最相似,而 Svelte 的实现方式则跟 Vue 和 React 都不一样,Svelte 是基于编译响应式。当然 Vue 和 React 的具体实现技术也是不一样的,但它们在宏观层面则是一样,都是通过数据驱动视图,当数据发生变化时,视图会重新渲染,这种机制使得开发者只需要关注数据的变化,而不需要手动操作 DOM。Vue 通过数据劫持,使得操作数据需要额外的 API ,系统变能感知数据的变化,而 React 和 SolidJS 则需要手动调用 API 去触发数据变化。

手动操作 DOM 的上古时代

例如我们现在有一个这样的需求,有一个按钮 <button>0</button>,当我们点击按钮的时候,按钮中的文本就进行加 1。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title>手动操作DOM</title>
  </head>
  <body>
    <button id="btn"></button>
    <script>
        let count = 0
        const btnEl = document.getElementById('btn')
        btnEl.textContent = count
        btnEl.addEventListener('click', function () {
            count++
            btnEl.textContent = count
        })
    </script>
  <body>
</html>

上述的 button 按钮是通过 HTML 进行渲染的,我们还可以通过 JavaScript API 进行创建。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title>手动操作DOM</title>
  </head>
  <body>
-    <button id="btn"></button>
    <script>
        let count = 0
-        const btnEl = document.getElementById('button')
+        const btnEl = document.createElement('button')
+        const textNode = document.createTextNode(count)
+        btnEl.appendChild(textNode)
        btnEl.addEventListener('click', function () {
            count++
            btnEl.textContent = count
        })
+        document.body.appendChild(btnEl)
    </script>
  <body>
</html>

上述这种方式,在项目应用非常庞大的时候,开发效率是非常低下的,同时维护成本却又非常高的,所以就出现了像 React、Vue 这种通过数据进行驱动视图的前端框架。

通过数据驱动视图

虽然 Vue 和 React 在具体的实现技术方案上差异是非常大的,但在宏观层面它们则是一样的,都是通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上,从而提高性能。

不管是 Vue 还是 React 的虚拟DOM 本质上都是一个对象,上面记录着一些真实 DOM 的信息,比如 type、props、children,我们这里简单模拟一下,并通过虚拟DOM 和 Diff 算法来改写上面的例子。

首先我们通过一个 createElement 的函数来创建一个节点的虚拟DOM对象,这里跟 React 的对齐,children 节点在 props 中,Vue 的 children 是跟 props 同级的,这些差别对我们进行宏观研究不重要。

function createElement(type, props) {
    return {
        type,
        props
    }
}

接着我们创建一个函数组件 App。

count = 0
function App (){
    return createElement('button', {
        onClick: () => {
            count ++
            setCount(count)
        },
        children: count
    })
}

接着我们通过以下方式把创建的虚拟DOM 挂载到根节点上。

render(App(), document.getElementById('app'))

接着我们实现 render 方法

let oldVnode
function render(vNode, container) {
    if (!oldVnode) {
        oldVnode = vNode
        const el = document.createElement(vNode.type)
        // 保存真实DOM 到虚拟DOM 的 el 属性上,将来更新的时候,不用重新创建,从而达到提高性能效果
        oldVnode.el = el
        const textNode =  document.createTextNode(vNode.props.children)
        el.appendChild(textNode)
        // 绑定虚拟DOM 上的事件
        el.addEventListener('click', vNode.props.onClick)
        container.appendChild(el)
    } else if(oldVnode.props.children !== vNode.props.children) {
        oldVnode.el.textContent = vNode.props.children
    }
}

我们在这里非常简单且宏观的实现了新老虚拟DOM 的对比,当不存在旧虚拟DOM 则是挂载阶段,创建真实DOM,并保存到虚拟DOM 的 el 属性上,将来更新的时候,不用重新创建,从而达到提高性能效果。在进行新老虚拟DOM 的时候,我们这里只比较 children 一个属性,如果新老 children 不一样就把新的虚拟DOM 上的 children 的值更新到对应的真实DOM 上。

我们在上面的 App 函数中的 props 中的点击事件函数中有一个 setCount 的方法还没实现,它的实现可以抽象成如下:

function setCount(val) {
    count = val
    render(App(), document.getElementById('app'))
}

从上述代码可以看到 setCount 的实现很简单,这个方法就是在点击之后进行更新数据 count 的,并且在更新数据 count 的同时重新渲染视图,把新的 count 值显示到页面上。

以下是测试效果:

00.gif

我们在上述例子中通过极简的代码,从宏观层面阐明了 React 的响应式原理,通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上。

其实上述这段 React 的响应式原理放在 Vue 的响应式原理也是成立的,最大的不同则是 React 更新数据需要通过 setCount 函数,也就是所谓手动触发,而 Vue 则是自动触发的。那么下面我们来看看 Vue 的响应式是怎么实现的。

基于依赖追踪的响应式

我们知道 Vue1 的响应式数据是通过 Object.defineProperty 来实现的。基于 Object.defineProperty 来实现需要初始化对对象的每一个熟悉进行劫持监听。

例如我们有这么这个对象 const data = { count:0 },那么我们需要进行以下操作:

const data = { count: 0 }
Object.keys(data).forEach(key => {
   Object.defineProperty(data, key, {
    get() {
        return data[key]
    },
    set(val) {
        data[key] = val
    }
   }) 
})

上述这个写法会造成内存栈溢出,主要是因为在 Object.defineProperty 的 getter 中读取 data[key] 会触发 getter 循环读取,从而造成死循环。

00.png

我们可以把 getter 中 data[key] 的取值放在进行 Object.defineProperty 监听之前。

const data = { count: 0 }
Object.keys(data).forEach(key => {
+   const val = data[key]
   Object.defineProperty(data, key, {
    get() {
+        return val
    },
    set(val) {
        data[key] = val
    }
   }) 
})

但这样 val 会被循环取值进行了覆盖,没办法正确读取每个 key 的值,为了可以读取每个 key 的值,我们可以通过闭包的形式把每个 key 的值缓存下来。

const data = { count: 0 }
Object.keys(data).forEach(key => {
   defineReactive(data, key, data[key]) 
})

function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            return val
        },
        set(newVal) {
            val = newVal
        }
    })
}

接下来我们要做的就是在 getter 中进行依赖收集,然后在 setter 中进行依赖触发,这本质上就是一个订阅发布模式。

const data = { count: 0 }
+ // 声明一个依赖存储中心
+ const subscribers = new Set()
+ // 需要收集的依赖,在 Vue1 叫 wachter,Vue3 中叫 effect,本质上就是一个订阅者,关于发布订阅模式,我们后续再详细介绍
+ let activeEffect
Object.keys(data).forEach(key => {
   defineReactive(data, key, data[key]) 
})

function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
+            // 存在依赖就把依赖收集到依赖存储中心
+            if(activeEffect) subscribers.add(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
+            // 值更新了,就需要去把依赖存储中心中的订阅者全部重新执行一遍
+            subscribers.forEach(sub => sub())
        }
    })
}

我们在上述代码中通过一个全局变量 subscribers 在响应式数据的 getter 把依赖收集到 subscribers 中, 在 setter 中则把 subscribers 中收集到的依赖进行循环遍历重新执行一遍,从而实现了依赖追踪和触发。

那么现在我们有了响应式数据 data 之后,我们就可以对我们前面的例子中的 App 函数中的 count 数据进行更改了,我们之前实现的是 React 的方式,我们现在要把它改成 Vue 的方式。

- count = 0
function App (){
    return createElement('button', {
        onClick: () => {
-            count ++
-            setCount(count)
+            data.count ++
        },
-        children: count
+        children: data.count
    })
}

此外渲染函数的执行方式也需要改成一个副作用函数,通过副作用函数进行调用执行。

    activeEffect = () => {
        render(App(), document.getElementById('app'))
    }

    activeEffect()

    activeEffect = null

我们把一个副作用函数赋值给了变量 activeEffect,然后再执行 activeEffect,那么在执行 activeEffect 函数的时候就会去执行 rander 函数,并通过 App 函数生成虚拟DOM,在 App 函数中对虚拟DOM 的 children 属性赋值的时候是通过读取响应式数据 data 中的 count 值,那么这时就会触发 count 属性的 getter,然后就会在 getter 中进行依赖收集,在 getter 中很明显这个时候 activeEffect 是有值的,所以会进行依赖收集。当点击的时候,就会触发 data.count ++ 的执行,这时就会触发 count 属性 setter,然后就会在 setter 中进行依赖触发。

以下是测试效果:

00.gif

至此我们把 Vue 的数据响应式也通过最少的代码量阐明了,以上的 Vue 的响应式原理估计很多同学都非常清楚,因为这是面试被问几率非常高的题目。我在这里重复讲解,是为了对比 React 和 Vue 响应式原理的差别,总的来说,React 和 Vue 的响应式原理在宏观层面是有非常大的相同之处的,都是通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上。在宏观层面 React 和 Vue 响应式原理最大的不同则是数据触发方式的不同,React 是数据变更后需要开发者通过手动调用 React 提供的 API 进行触发视图的更新,而 Vue 则是自动触发的,因为 Vue 的状态数据是响应式的,而 React 的状态数据不是响应式的。

我们一般在很多的文章中都只讲了 Vue1 的数据响应式原理是通过 Object.defineProperty 来实现的,那么是否只能通过 Object.defineProperty 来实现呢?很明显不是,我们上述例子中的 data,我们现在如果想给它新增属性 data.name,那么 Object.defineProperty 是无法进行监听追踪的,所以我们通过一个工具来对 data 也进行监听。

function observe (data) {
    // 给对象 data 添加一个属于 data 对象的依赖存储中心
    data.__ob__ = new Set()
    Object.keys(data).forEach(key => {
        const value = data[key]
        defineReactive(data, key, value) 
    })
}

那么在 getter 中要对对象的依赖也进行收集

function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            // 存在依赖就把依赖收集到依赖存储中心
            if(activeEffect) {
                subscribers.add(activeEffect)
                // 如果读取的值是对象,那么还要给这个对象进行依赖收集,并且新的对象也要通过 observe 进行监听
                if(Object.prototype.toString.call(val) === '[object Object]') {
                    observe(val)
                    val.__ob__.add(activeEffect)
                }
            } 
            return val
        },
        set(newVal) {
            val = newVal
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            subscribers.forEach(sub => sub())
        }
    })
}

然后我们要专门通过一个单独的 API 来给响应式对象添加属性,并且在添加属性之后进行依赖触发。

function set(target, key, val) {
    target[key] = val
    // 新添加的属性也需要通过 Object.defineProperty 进行监听
    defineReactive(target, key, val)
    const ob = target.__ob__
    // 进行对象的依赖触发
    ob.forEach(sub => sub())
}

接着我们把 App 函数进行以下修改

// 修改数据结构
const data = { data: { count: 0 } }
// 通过 observe 处理
observe(data)
function App (){
    return createElement('button', {
        onClick: () => {
            set(data.data, 'count1', 2)
        },
        children: JSON.stringify(data.data)
    })
}

然后我们重新运行代码结果如下:

01.gif

我们看到可以正常运行,但对象中的私有属性 __ob__ 也显示出来了,我们希望它不要被枚举出来,我们可以通过 Object.defineProperty 对它进行以下设置。

function observe (data) {
    // 给对象 data 添加一个属于 data 对象的订阅者中心
-    data.__ob__ = new Set()
+    Object.defineProperty(data, '__ob__', {
+        value: new Set(), // 属性的值,默认为 undefined
+        enumerable: false, // 属性是否可枚举,默认为 false
+        writable: true, // 值是否可写,默认为 false
+        configurable: true // 属性是否可配置,默认为 false
+    })
    Object.keys(data).forEach(key => {
        const value = data[key]
        defineReactive(data, key, value) 
    })
}

修改后再进行测试,我们可以看到 __ob__ 属性不再出现了。

02.gif

可以通过 Object.defineProperty 对数组进行监听,但监听不了 push、pop、shift 等对数组进行操作的方法,所以我们需要对数组的操作方法进行重写,重写的方法就是覆盖数组数据上的原型对象 __proto__

function observe (data) {
// 省略 ...
+   if (Array.isArray(value)) {
+      // 如果是数组则重新数组上的原型
+      value.__proto__ = {
+          join(val) {
+             // 通过原生数组上方法进行调用
+             return Array.prototype.join.call(value, val)
+          },
+          push(val) {
+             // 通过原生数组上的方法进行调用
+             Array.prototype.push.call(value, val)
+             subscribers.forEach(sub => sub())
+          }
+      }
+   } else {
        Object.keys(data).forEach(key => {
            const value = data[key]
            defineReactive(data, key, value) 
        })
+    }
}

我们这里只测试 join 和 push 方法,而 join 方法没有更改到数据,所以是不用进行依赖触发的。

然后我们对 App 应用也进行修改一下,以便测试数组响应式数据

// 数据
const data = ['cobyte']
// 通过 bserver 处理
observe(data)
function App (){
    return createElement('button', {
        onclick: () => {
            data.push('=')
        },
        children: data.join('-')
    })
}

测试结果如下:

03.gif

小结

至此,我们通过手写已经基本实现了 Vue1 的数据响应式原理,我们可以通过对 Vue2 数据响应式原理的分析进行一个宏观总结。我们需要在实践中总结规律,然后又通过规律更好地指导实践

首先我们都知道 Vue1 的数据响应式原理是通过 Object.defineProperty 实现的,通过 Object.defineProperty 可以监听一个对象的属性的读取(getter)和修改(setter),这样就可以在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。但 Vue2 不单单只是通过 Object.defineProperty 实现数据响应式的,因为只有被 Object.defineProperty 初始化了的属性才可以进行监听,而当一个对象新增一个属性时,则监听不了。这时我们需要通过额外的手段来实现对象新增属性时的监听,具体方案就是通过给对象新增一个私有的属性 __ob__,去记录属于该对象的依赖,当该对象新增属性时则触发该对象的依赖重新执行。同时 Object.defineProperty 也监听不了数组的原生方法,例如:push、pop、shift、unshift、splice、sort、reverse,我们观察一下这些数组方法发现都有一个共同特点,就是他们都会修改数组,使数组数据发生变化,那么根据数据响应式的原理,数据发生了改变就需要进行依赖触发,那么我们需要对响应式数据类型为数组的数据进行重写它们的原型,这样我们就可以在响应式数组通过 push、pop、shift、unshift、splice、sort、reverse 方法修改数组的时候进行依赖触发了。

我们可以总结出,不管是通过 Object.defineProperty 进行监听对象属性还是通过给对象添加私有属性 __ob__,去记录该对象的依赖,还是重写数组的原型方法,目的都只有一个:进行数据的依赖追踪和触发。

我们还可以进一步进行总结规律:这种基于依赖追踪的响应式系统,并不是某一种技术,而是一种模式。核心只有一个,就是在数据读取的时候进行依赖收集,在数据更改的时候进行依赖触发。

基于这种指导思想,我们就可以很好去实践 Vue2 的数据响应式原理了。

Vue3 只是通过 Proxy 实现数据响应式吗

Vue3 是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。这个过程跟 Vue2 是一样的,只是实现细节不一样。

实现起来也非常简单:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && subscribers.add(activeEffect)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            subscribers.forEach(sub => sub())
            return result
        }
    })
}

我们再把 App 函数进行修改:

const data = reactive({count: 0})
function App (){
    return createElement('button', {
        onClick: () => {
            data.count ++
        },
        children: data.count
    })
}

测试结果如下:

00.gif

我们这里不是为了深入探讨 Vue2 的数据响应式原理的,而是为了验证上面实现 Vue2 的数据响应式原理总结的规律。也就是:这种基于依赖追踪的响应式系统,并不是某一种技术,而是一种模式。核心只有一个,就是在数据读取的时候进行依赖收集,在数据更改的时候进行依赖触发。后续我们基于数据响应式原理的规律便可以很好去理解其他数据响应式系统了,例如 React 的状态管理库——Mobx、SolidJS,我们在后续也将探讨这些库的数据响应式原理的实现。

Vue1 是通过 Object.defineProperty 实现对数据的读写监听,但由于 Object.defineProperty 的局限性,Vue2 并不只是通过 Object.defineProperty 实现数据响应式的,但都为了实现在数据读取时进行依赖收集,在数据更改时进行依赖触发。Vue3 则通过新的 API:Proxy 可以实现对数据的读写监听,但核心也是为了实现在数据读取时进行依赖收集,在数据更改时进行依赖触发。

那么问题来了,Vue1 并不只是通过 Object.defineProperty 实现数据响应式的,那么 Vue3 只是通过 Proxy 实现了数据响应式吗?

其实这个问题可以转化得更具体一些,Vue2 的 reactive 和 ref 的底层实现原理是一样的吗?有人认为 ref 和 reactive 的底层实现原理都是一样的,也就是 ref 也是通过 reactive 实现的,也就是 ref 也是通过 Proxy 实现的。如果说 ref 和 reactive 的底层实现原理不一样的话,也就是说 Vue3 可以不通过 Proxy 实现数据的响应式。

很明显 Vue3 可以不通过 Proxy 实现数据的响应式的,也就是 ref 和 reactive 的底层实现原理是不一样的。那么根据我们上面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显可以使用 Vue2 中的 Object.defineProperty 中的 getter/setter,这种方式也叫属性访问器。根据上面 Vue2 的数据响应式原理我们可以知道如果通过 Object.defineProperty 实现对数据的监听,还要通过闭包的方式,就显得不够简洁。那么属性访问器除了使用 Object.defineProperty 进行显式声明之外,还可以通过字面量的方式,本质还是属性访问器

例如:

function ref(value) {
    return {
        _value: value,
        get value() {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && subscribers.add(activeEffect)
            return this._value
        },
        set value(val) {
            this._value = val
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            subscribers.forEach(sub => sub())
        }
    }
}

然后我们通过 ref 函数来创建一个响应式数据,再修改 App 函数。

const count = ref(0)

function App (){
    return createElement('button', {
        onClick: () => {
            count.value ++
        },
        children: count.value
    })
}

测试运行结果:

00.gif

这也就是 Vue2 的 ref API 的实现原理,当然在 Vue3 源码中如果 ref 传进来的值是一个引用对象的话,还是通过 reactive 进行实现。此外在 Vue3 的源码中 ref API 是通过一个 class 类来实现的,但原理是一样的。

我们下面也可以简单实现一下:

class RefImpl {
    _value
    constructor(value) {
        this._value = value
    }
    get value() {
       // 存在依赖就把依赖收集到依赖存储中心
       activeEffect && subscribers.add(activeEffect)
       return this._value 
    }
    set value(val) {
        this._value = val
        // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
        subscribers.forEach(sub => sub())
    }
}

function ref(value) {
    return new RefImpl(value)
}

修改好的测试结果还是一样的。

00.gif

通过沙箱模式实现依赖追踪的数据响应式

通过上面对 Vue1 和 Vue3 的数据响应式原理的实现与分析,我们知道都借助了 JavaScript 的原生 API(Object.defineProperty 和 Proxy) 来实现依赖追踪的响应式系统,那么不借助 JavaScript 原生 API 还可以实现依赖追踪的响应式系统吗? 我们上面总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖。那么基于此原理,我们只需要把读写进行分离那么可以实现了。

我们把上面第一版 ref 的实现通过闭包的形式改造一下:

function ref(value) {
    const s = {
        value
    }

    function getState() {
        return s.value
    }

    function setState(val) {
        s.value = val
    }
    return [getState, setState]
}

const [getState, setState] = ref(0)

console.log('初始值:', getState())
// 修改
setState(1)
console.log('修改后:', getState())

我们可以看到通过闭包的我们实现了读写分离,这种模式有一个专业的术语叫:沙箱模式,这样我们就可以在读取数据的时候收集依赖,在修改数据的时候触发依赖了。

function ref(value) {
    const s = {
        value
    }

    function getState() {
        // 存在依赖就把依赖收集到依赖存储中心
        activeEffect && subscribers.add(activeEffect)
        return s.value
    }

    function setState(val) {
        s.value = val
        // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
        subscribers.forEach(sub => sub())
    }
    return [getState, setState]
}

接着我们把 App 函数也进行修改一下:

const [count, setCount] = ref(0)

function App (){
    return createElement('button', {
        onClick: () => {
            setCount(count() + 0)
        },
        children: count()
    })
}

其实上述这种实现依赖追踪的响应式系统的方式就是 SolidJS 的响应式原理,长得像 React,实际上是 Vue。所以我们只要把核心原理搞清楚,就可以举一反三了,像读书时候一样,以后同类型的题目,你都回作答了。当然 SolidJS 的响应式原理远不止这些,我们将在后续章节继续进行深入探讨,搞明白了 SolidJS, Vue Vapor 的原理也非常容易理解了。

总结

上述所有例子中的依赖收集和触发的过程,本质就是一个发布订阅模式,而关于发布订阅模式,我们将在下一篇文章中进行详细介绍。当我们掌握了发布订阅模式后,我们再去理解这些通过依赖收集和触发实现的数据响应式系统,就会如鱼得水。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

最新版vue3+TypeScript开发入门到实战教程之组件通信之一

概述

Vue 组件通信是面试和实际开发中的核心知识点,组件通信包括父组件与子组件甚至孙组件的通信,子组件与子组件之间的通信。组件通信详细掌握以下9中方法:

  • props
  • 自定义事件
  • mitt
  • v-model
  • $attrs
  • refsrefs、parent
  • provide、inject
  • pinia
  • slot

props

概述

props是组件通信中最常用的一种方式,常用于父与子之间数据传递

  • 父传子,通过数据传递
  • 子传父,通过函数传递数据

事例

  • 创建父组件App,数据为name、price
  • 创建子组件Fish,数据为num
  • 父传子数据name、price,父传子函数,子调用函数传数量num
  • 子接收数据、函数 App组件代码
<template>
  <div class="app">
    <h2>父组件接收到鱼的数量:{{ num }}</h2>
    <Fish :fishname="name" :price="price" :getNum="getNum"/>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Fish from './view/Fish.vue';
let name = ref('鲫鱼');
let price = ref(100);
let num = ref(0);
function getNum(n: number) {
  num.value= n;
}

Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ fishname }}</h2>
    <h2>价格:{{ price }}</h2>
    <button @click="getNum(num++)">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
let num = ref(999);
defineProps(['fishname','price','getNum'])
</script>

运行效果 在这里插入图片描述

自定义事件

父组件给子组件绑定自定义事件,子组件触发事件,回调父组件函数,传递数据,类似@click。自定义事件只能由子组件传递给父组件

  • 父组件传递自定义函数
  • 子组件触发自定义函数,传递参数 App组件代码
<template>
  <div class="app">
    <h2>父组件接收到鱼:{{ name }}</h2>
    <h2>父组件接收到鱼的价格:{{ price }}</h2>
    <Fish @get-fish="getFish"/>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Fish from './view/Fish.vue';
let name = ref('');
let price = ref(0);
function getFish(s:string,p:number) {
  name.value = s;
  price.value = p;
}
</script>

Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <button @click="emit('get-fish',name,price)">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
let name = ref('鲫鱼');
let price = ref(999);
const emit = defineEmits(['get-fish']);
</script>

运行效果: 在这里插入图片描述

mitt

概述

mitt是一个第三方库,它可以实现任意组件通信。组件通过订阅接收消息,通过发布传递数据。

  • emitter.on(event, handler)监听事件
  • emitter.emit(event, data)触发事件并传递数据
  • emitter.off(event, handler)移除事件监听
  • emitter.all.clear()清空所有事件

事例

以子组件传递数据,父组件接收数据为例。同理若父组件给数据,子组件接收数据是一样的。

  • 安装mitt,npm install mitt
  • 创建utils目录,创建emitter.ts
  • main.ts引入emitter.ts
  • 创建App接收Fish传递数据,
  • 创建Fish,发送数据

emitter.ts代码

import mitt from 'mitt'
const emitter = mitt();
export default emitter;

main.ts代码

import { createApp } from 'vue'
import App from './App.vue'
import emitter from './utils/emitter'
console.log(emitter)
const app = createApp(App)
app.mount('#app')

App组件代码

<template>
  <div class="app">
    <h2>父组件接收到鱼:{{ name }}</h2>
    <h2>父组件接收到鱼的价格:{{ price }}</h2>
    <Fish/>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Fish from './view/Fish.vue';
import emitter from '@/utils/emitter'
let name = ref('');
let price = ref(0);
emitter.on('get-fish', (value:any) => {
  console.log(value)
  name.value = value.name;
  price.value=value.price
})
</script>

Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ fish.name }}</h2>
    <h2>价格:{{ fish.price }}</h2>
    <button @click="emitter.emit('get-fish',fish)">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import emitter from '@/utils/emitter'
emitter.emit('test',121)
let fish = reactive({
  name: '鲫鱼',
  price:99
});
</script>

v-model

概述

v-model是双向数据绑定,它的底层是通过props参数modelValue与自定义函数update:modelValue实现的。在子组件接收props参数与自定义函数,就可以实现双向数据绑定。

事例

App代码

<template>
  <div class="app">
    <h2>父组件接收到鱼:{{ name }}</h2>
    <Fish v-model="name"/>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Fish from './view/Fish.vue';
let name = ref('鲫鱼');
</script>

Fish代码

<template>
  <div>
    <h2>鱼类:{{ modelValue }}</h2>
    <button @click="changefish">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
let props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
function changefish() {
  emit('update:modelValue','草鱼')

}
</script>

运行查看结果 在这里插入图片描述 注意代码,v-model默认传递props参数是modelValue、自定义函数式update:modelValue,注意有冒号也是个函数。以下修改v-model props名称、自定义函数名称。

事例2修改v-model props名称与自定义函数名称

App源码

<template>
  <div class="app">
    <h2>父组件接收到鱼:{{ name }}</h2>
    <h2>父组件接收到鱼:{{ price }}</h2>
    <Fish v-model="name" v-model:price="price"/>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Fish from './view/Fish.vue';
let name = ref('鲫鱼');
let price = ref(666);
</script>

Fish源码

<template>
  <div>
    <h2>鱼类:{{ modelValue }}</h2>
    <h2>鱼类:{{ price }}</h2>
    <button @click="changefish">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
let props = defineProps(['modelValue','price']);
const emit = defineEmits(['update:modelValue','update:price']);
function changefish() {
  emit('update:modelValue','草鱼')
  emit('update:price','999')

}
</script>

运行效果如下: 在这里插入图片描述

性能优化之项目实战:从构建到部署的完整优化方案

在当今的前端开发中,性能优化已经成为项目上线前不可或缺的一环。本文将通过一个实际项目,系统性地介绍从构建优化到运行时优化的九大核心策略,帮助你打造一个高性能的Web应用。

1. 辅助分析插件:知己知彼,百战不殆

在进行任何优化之前,我们首先需要了解当前项目的性能瓶颈。辅助分析插件能够帮助我们量化问题,为后续优化提供数据支撑。

Webpack Bundle Analyzer

javascript

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态HTML文件
      openAnalyzer: false, // 构建完成后不自动打开
      reportFilename: 'bundle-report.html'
    })
  ]
};

这个插件会生成一个可视化的依赖关系图,让你清楚地看到每个模块的大小和占比,从而精准定位需要优化的模块。

Lighthouse CI

bash

npm install -g @lhci/cli

json

// lighthouserc.json
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run start",
      "url": ["http://localhost:3000"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["warn", {"minScore": 0.9}],
        "categories:accessibility": ["error", {"minScore": 0.95}]
      }
    }
  }
}

通过Lighthouse CI,我们可以在CI/CD流程中持续监控性能指标,确保每次代码提交都不会导致性能退化。

2. 压缩:让资源轻装上阵

压缩是性能优化中最直接有效的手段之一,能够显著减少资源传输体积。

JavaScript压缩

javascript

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true, // 移除console
            drop_debugger: true, // 移除debugger
            pure_funcs: ['console.log'] // 移除特定函数
          },
          output: {
            comments: false // 移除注释
          }
        },
        parallel: true, // 多进程并行压缩
        extractComments: false // 不提取注释到单独文件
      })
    ]
  }
};

CSS压缩

javascript

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new CssMinimizerPlugin({
        parallel: true,
        minimizerOptions: {
          preset: [
            'default',
            {
              discardComments: { removeAll: true },
              normalizeWhitespace: true,
              colormin: true
            }
          ]
        }
      })
    ]
  }
};

图片压缩

javascript

const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /.(jpe?g|png|gif|svg)$/i,
        type: 'asset',
        use: [
          {
            loader: ImageMinimizerPlugin.loader,
            options: {
              minimizer: {
                implementation: ImageMinimizerPlugin.imageminMinify,
                options: {
                  plugins: [
                    ['gifsicle', { interlaced: true }],
                    ['jpegtran', { progressive: true }],
                    ['optipng', { optimizationLevel: 5 }],
                    ['svgo', {
                      plugins: [
                        { name: 'removeViewBox', active: false },
                        { name: 'removeUselessStrokeAndFill', active: false }
                      ]
                    }]
                  ]
                }
              }
            }
          }
        ]
      }
    ]
  }
};

3. 样式:优雅的样式处理策略

样式的加载和渲染直接影响用户体验,合理的样式策略能够避免页面闪烁和样式冲突。

CSS Modules + PostCSS

javascript

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.module.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              },
              importLoaders: 1
            }
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [
                  require('autoprefixer'),
                  require('cssnano')({
                    preset: 'default'
                  })
                ]
              }
            }
          }
        ]
      }
    ]
  }
};

Critical CSS

关键CSS内联,非关键CSS延迟加载:

javascript

// critical-css.js
const critical = require('critical');

critical.generate({
  inline: true,
  base: 'dist/',
  src: 'index.html',
  target: 'index-critical.html',
  width: 1300,
  height: 900,
  penthouse: {
    blockJSRequests: false
  }
});

4. 环境变量:智能的环境配置

通过环境变量区分开发和生产环境,实现按需加载和配置。

环境变量配置

javascript

// webpack.config.js
const webpack = require('webpack');
const dotenv = require('dotenv');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  
  // 根据环境加载不同的环境变量
  const envConfig = dotenv.config({
    path: isProduction ? '.env.production' : '.env.development'
  }).parsed;
  
  const envKeys = Object.keys(envConfig).reduce((prev, next) => {
    prev[`process.env.${next}`] = JSON.stringify(envConfig[next]);
    return prev;
  }, {});
  
  return {
    plugins: [
      new webpack.DefinePlugin(envKeys),
      new webpack.EnvironmentPlugin(['NODE_ENV'])
    ]
  };
};

环境变量最佳实践

javascript

// config/index.js
export const config = {
  api: {
    baseURL: process.env.VUE_APP_API_URL,
    timeout: process.env.NODE_ENV === 'production' ? 10000 : 30000
  },
  logging: {
    level: process.env.NODE_ENV === 'production' ? 'error' : 'debug',
    enableConsole: process.env.NODE_ENV !== 'production'
  },
  features: {
    enableAnalytics: process.env.NODE_ENV === 'production',
    enableDebugTools: process.env.NODE_ENV !== 'production'
  }
};

5. Tree Shaking:消除无用代码

Tree Shaking能够自动移除未使用的代码,是构建优化的重要环节。

配置Tree Shaking

javascript

// webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true, // 标记未使用的导出
    sideEffects: false, // 标记包是否包含副作用
    concatenateModules: true // 模块合并优化
  }
};

package.json配置sideEffects

json

{
  "name": "my-project",
  "sideEffects": [
    "*.css",
    "*.scss",
    "*.global.js"
  ]
}

使用ES Modules

确保代码使用ES Modules语法,避免使用CommonJS:

javascript

// ✅ 正确 - 支持Tree Shaking
import { debounce } from 'lodash-es';

// ❌ 错误 - 不支持Tree Shaking
import _ from 'lodash';

6. 代码分割:按需加载的艺术

代码分割能够有效减少初始加载时间,提升用户体验。

路由级别的代码分割

javascript

// React中使用React.lazy
import React, { lazy, Suspense } from 'react';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
      </Routes>
    </Suspense>
  );
}

Webpack智能分割策略

javascript

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // 将node_modules中的代码分离到vendors chunk
        vendors: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10,
          chunks: 'all'
        },
        // 将公共组件分离到common chunk
        common: {
          name: 'common',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        },
        // 分离React相关代码
        react: {
          test: /[\/]node_modules[\/](react|react-dom)[\/]/,
          name: 'react',
          priority: 20,
          chunks: 'all'
        }
      }
    },
    runtimeChunk: {
      name: 'runtime' // 将runtime代码分离
    }
  }
};

7. 组件封装:复用与性能的平衡

合理的组件封装能够提升代码复用性,同时避免不必要的性能损耗。

高阶组件与Render Props

javascript

// 性能监控HOC
function withPerformanceTracking(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
      console.log(`${WrappedComponent.name} mounted`);
      performance.mark(`${WrappedComponent.name}_mount`);
    }
    
    componentWillUnmount() {
      performance.mark(`${WrappedComponent.name}_unmount`);
      performance.measure(
        `${WrappedComponent.name}_lifetime`,
        `${WrappedComponent.name}_mount`,
        `${WrappedComponent.name}_unmount`
      );
    }
    
    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}

// 懒加载组件封装
function LazyLoadComponent({ children, threshold = 0.1 }) {
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef();
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { threshold }
    );
    
    if (ref.current) {
      observer.observe(ref.current);
    }
    
    return () => observer.disconnect();
  }, [threshold]);
  
  return <div ref={ref}>{isVisible ? children : null}</div>;
}

8. 数据和图片懒加载:延迟加载的艺术

懒加载能够显著提升首屏加载速度,优化用户体验。

图片懒加载

javascript

// 自定义图片懒加载Hook
function useLazyImage(src, placeholder = 'data:image/svg+xml,...') {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const [imageRef, setImageRef] = useState();
  
  useEffect(() => {
    if (!imageRef) return;
    
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          const img = new Image();
          img.onload = () => {
            setImageSrc(src);
          };
          img.src = src;
          observer.disconnect();
        }
      },
      { rootMargin: '50px' }
    );
    
    observer.observe(imageRef);
    
    return () => observer.disconnect();
  }, [src, imageRef]);
  
  return [imageSrc, setImageRef];
}

// 使用示例
function LazyImage({ src, alt }) {
  const [imageSrc, ref] = useLazyImage(src);
  
  return (
    <img 
      ref={ref}
      src={imageSrc}
      alt={alt}
      loading="lazy" // 浏览器原生懒加载
    />
  );
}

数据懒加载

javascript

// 无限滚动数据加载
function useInfiniteScroll(fetchData, options = {}) {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(1);
  
  const observer = useRef();
  const lastElementRef = useCallback(node => {
    if (loading) return;
    if (observer.current) observer.current.disconnect();
    
    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        setPage(prevPage => prevPage + 1);
      }
    });
    
    if (node) observer.current.observe(node);
  }, [loading, hasMore]);
  
  useEffect(() => {
    setLoading(true);
    fetchData(page, options).then(newData => {
      setData(prev => [...prev, ...newData]);
      setHasMore(newData.length > 0);
      setLoading(false);
    });
  }, [page, fetchData, options]);
  
  return { data, loading, lastElementRef, hasMore };
}

9. 使用CDN:加速全球访问

CDN能够将静态资源分发到全球边缘节点,大幅提升资源加载速度。

配置CDN

javascript

// webpack.config.js
module.exports = {
  output: {
    publicPath: process.env.NODE_ENV === 'production' 
      ? 'https://cdn.example.com/assets/'
      : '/',
    filename: '[name].[contenthash].js'
  },
  
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
    vue: 'Vue',
    lodash: '_',
    axios: 'axios'
  }
};

HTML中引入CDN资源

html

<!DOCTYPE html>
<html>
<head>
  <!-- 预连接CDN域名 -->
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="dns-prefetch" href="https://cdn.example.com">
  
  <!-- 使用SRI保证资源完整性 -->
  <link 
    rel="stylesheet" 
    href="https://cdn.example.com/main.css"
    integrity="sha384-..."
    crossorigin="anonymous"
  >
</head>
<body>
  <div id="app"></div>
  
  <!-- 异步加载非关键脚本 -->
  <script 
    src="https://cdn.example.com/analytics.js"
    async
    defer
  ></script>
</body>
</html>

CDN最佳实践

javascript

// 动态加载CDN资源
class CDNLoader {
  static loadScript(src, integrity) {
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = src;
      script.integrity = integrity;
      script.crossOrigin = 'anonymous';
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  
  static async loadVendors() {
    const vendors = [
      { src: 'https://cdn.example.com/react.js', integrity: 'sha384-...' },
      { src: 'https://cdn.example.com/react-dom.js', integrity: 'sha384-...' }
    ];
    
    await Promise.all(vendors.map(v => this.loadScript(v.src, v.integrity)));
  }
}

// 在应用启动前加载CDN资源
if (process.env.NODE_ENV === 'production') {
  CDNLoader.loadVendors().then(() => {
    // 启动应用
    import('./app');
  });
}

总结:性能优化的系统化思考

性能优化不是一蹴而就的工作,而是一个持续迭代的过程。通过上述九大策略的系统化应用,我们能够构建出高性能、高可用的现代Web应用:

  1. 分析先行:使用Bundle Analyzer和Lighthouse量化问题
  2. 资源压缩:通过多种压缩技术减小资源体积
  3. 样式优化:合理处理CSS加载策略
  4. 环境区分:根据不同环境做差异化配置
  5. 代码消除:通过Tree Shaking移除无用代码
  6. 按需加载:代码分割实现精准加载
  7. 组件复用:封装高性能可复用组件
  8. 延迟策略:数据和图片懒加载提升首屏速度
  9. 网络加速:CDN提升全球访问体验

记住,性能优化的核心原则是:在保证功能完整性的前提下,最小化资源传输和处理时间,最大化用户体验。希望本文的实战经验能够帮助你在实际项目中更好地进行性能优化。

NW.js v0.109.1 最新稳定版发布:被遗忘的桌面开发神器?启动快 3 倍,内存省 70%!

你的 Electron 应用启动要 5 秒?内存占用 400MB?
而用 NW.js v0.109.1(2026 年 3 月 21 日发布的最新稳定版),相同功能应用启动仅需 1.6 秒,内存占用仅 120MB——而且直接访问 Node.js API,无需 IPC 通信,代码更简洁。

如果你厌倦了:

  • Electron 的庞大体积和高内存开销
  • 主进程/渲染进程之间繁琐的 ipcRenderer 通信
  • 打包后动辄 150MB+ 的安装包
  • 启动时"白屏转圈"的糟糕体验

那么,NW.js v0.109.1 的发布,可能正在悄悄夺回桌面开发的王座


一、Electron 的统治与代价(2026 年现状)

Electron 仍是桌面应用主流,但代价日益凸显:

  • 资源消耗巨大:每个窗口独立 Chromium 实例,内存轻松超 300MB
  • 架构复杂:主进程(Node)与渲染进程(Browser)需 IPC 通信
  • 启动慢:冷启动常超 4 秒(需先启 Node 主进程)
  • 打包臃肿:简单应用最终体积 120MB+(含 Chromium)

关键事实:NW.js 诞生于 2011 年(原名 node-webkit),比 Electron(2013 年)更早,但因生态推广较少被掩盖。


二、NW.js v0.109.1 是什么?为什么它能快 3 倍、省 70% 内存?

NW.js v0.109.1 是当前最新稳定版(2026 年 3 月 21 日发布),基于 Chromium 146 + Node.js v25.6.1

能力 Electron 33 NW.js v0.109.1
启动时间(简单应用) 4.2–5.8 秒 1.4–1.9 秒
内存占用(空应用) 320–450 MB 90–130 MB
最终打包体积 120–180 MB 45–70 MB
Node.js 访问方式 需 IPC 通信 直接 require()
多窗口管理 复杂(BrowserWindow 原生 <webview>window.open()
安全模型 默认开启(限制多) 可配置(开发更灵活)

核心优势

  • 单进程融合:Node.js 与 DOM 运行在同一上下文(require('fs')<script> 直接可用)
  • 无 IPC 开销:读文件、调系统 API 不再需要 send/on 回调地狱
  • Chromium 更新快:紧跟上游(v0.109.1 已支持 Chromium 146 新特性)

版本说明:NW.js 项目长期采用 0.x.x 版本号体系(v0.109.1 是当前稳定版,并非测试版)。


三、真实迁移:从 Electron 到 NW.js

1. 无需改写核心逻辑

<!-- NW.js 直接可用 Node.js -->
<script>
  const fs = require('fs'); // 无需 preload
  document.getElementById('btn').onclick = () => {
    fs.readFile('/data.json', 'utf8', (err, data) => {
      console.log(data);
    });
  };
</script>

2. 项目结构极简

my-app/
├── index.html   # 仅需此文件
└── package.json # 10 行配置
{
  "name": "my-app",
  "main": "index.html",
  "window": {
    "width": 800,
    "height": 600
  }
}

3. 启动命令(仅 1 行)

npx nw .  # 无需主进程脚本

对比 Electron:需 main.js + preload.js + IPC 通信,代码量增加 50%+。


四、实测:NW.js v0.109.1 vs Electron 33(实验室环境)

测试声明:以下数据为实验室环境(M3 MacBook Pro,16GB RAM,macOS 15)下简单应用(窗口+文件读取)的测试结果,实际表现因项目复杂度、系统环境而异。

指标 Electron 33 NW.js v0.109.1
项目初始化时间 3 分钟(含 IPC 配置) 30 秒(仅 HTML + package.json)
冷启动时间 4.7 秒 1.6 秒(快 3 倍)
内存峰值 385 MB 118 MB(省 70%)
打包体积(macOS) 142 MB 58 MB
代码行数(核心逻辑) 42 行(IPC 通信) 12 行(直接调用)

测试方法:使用 Activity Monitor 测量内存,手动计时冷启动(从点击应用到窗口完全渲染)。


五、它为什么没被广泛采用?(客观分析)

  1. 历史包袱:2011-2013 年 NW.js 有安全漏洞记录,导致部分开发者转向 Electron
  2. 生态差距:Electron 插件生态更丰富,社区资源更多
  3. 版本认知:长期 0.x 版本号让部分开发者误以为是测试版
  4. Mac App Store 上架:因直接暴露 Node,需额外签名处理

v0.109.1 改进

  • 基于 Chromium 146,安全性大幅提升
  • 官方文档已更新签名流程指南

六、5 分钟上手 NW.js v0.109.1

# 1. 创建项目
mkdir my-nw-app && cd my-nw-app

# 2. 创建 package.json
echo '{
  "name": "hello-nw",
  "main": "index.html"
}' > package.json

# 3. 创建 index.html(见下文)
# 4. 安装 NW.js CLI
npm install -g nw

# 5. 运行!
nw .

index.html 示例

<!DOCTYPE html>
<html>
<head>
  <title>NW.js Demo</title>
</head>
<body>
  <button id="readFile">读取本地文件</button>
  <script>
    // 直接使用 Node.js!
    document.getElementById('readFile').onclick = () => {
      const fs = require('fs');
      const data = fs.readFileSync('/etc/hosts', 'utf8');
      alert(data.substring(0, 100));
    };
  </script>
</body>
</html>

无需任何配置,点开即用!


七、谁在用 NW.js?(确认案例)

项目 说明
Adobe Brackets 经典开源编辑器(2012-2021),已归档但仍具参考价值
Intel XDK Intel 的跨平台开发工具(已停止维护)
各类企业内部工具 因轻量、易维护被部分团队采用

GitHub 数据:NW.js 仓库 Star 数约 39.5k(2026 年 3 月),活跃度稳定 。


结语:简单,才是终极的复杂

NW.js v0.109.1 的回归,不是"怀旧",而是对开发本质的回归
为什么我们要为"读一个文件"写 10 行 IPC 代码?为什么工具不能像写网页一样自然?

官网:nwjs.io
GitHub:github.com/nwjs/nw.js
最新版本:v0.109.1(2026-03-21 发布,Chromium 146 + Node.js v25.6.1)

你愿意用 NW.js v0.109.1 重构一个 Electron 项目吗?评论区投票!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Axios二次封装及API 调用框架

项目代码其实是两部分,一部分是基于 Axios 的 HTTP 请求封装;一部分是API 基础封装与管理,参考了后端接口,类,抽像类的设计思路。

☛ 基于 Axios 的 HTTP 请求封装

该文件提供了基于 Axios 的 HTTP 请求封装,主要功能包括:
  1. 请求头管理:自动添加 Content-Type、CSRF 令牌、认证令牌等基础请求头
  2. 双实例设计:分别创建 read 和 write 两个 Axios 实例,用于不同类型的请求
  3. 拦截器配置:为请求和响应添加拦截器,可用于统一处理请求和响应
  4. 统一 API 方法:封装了 apiFunc 函数,支持 GET、POST、PUT、DELETE 等请求方法
  5. 读写分离:提供 requestRead 和 requestWrite 两个函数,分别用于读操作和写操作
  6. 错误类型定义:统一定义了常见错误类型,便于错误处理
使用示例:
// 读操作示例
import { requestRead } from './axios/basic/axios'

const response = await requestRead({
  method: 'get',
  url: '/api/users',
 data: { page: 1, pageSize: 10 }
})

// 写操作示例
import { requestWrite } from './axios/basic/axios'

const response = await requestWrite({
 method: 'post',
 url: '/api/users',
 data: { name: 'John', age: 30 }
})
注意事项:
  • GET 和 DELETE 请求的参数会自动转换为 URL 查询参数
  • POST 和 PUT 请求的参数会作为请求体发送
  • 所有请求都会自动添加必要的请求头,包括认证令牌和 CSRF 令牌
  • 支持自定义 axios 配置,会与默认配置合并

☛ 基于 Axios 的 HTTP 请求封装

该文件提供了一套完整的 API 调用框架,主要功能包括:
  1. 端点配置管理:通过 EndpointConfig 类标准化 API 端点配置
  2. 通用请求处理:封装了 request 方法,支持缓存、错误处理等高级功能
  3. CRUD 操作:提供了 list、page、add、update、delete、getById 等通用方法
  4. 缓存机制:实现了基于内存的请求缓存,提高重复请求的响应速度
  5. 错误处理:统一的错误类型定义和错误信息处理
  6. 加载状态管理:自动处理请求的加载状态显示
核心组件:
  • EndpointConfig:API 端点配置类,用于创建标准化的 API 端点配置
  • ApiBase:API 基础抽象类,提供通用的 API 操作方法
使用示例:
// 1. 定义 API 端点配置
const userEndpoints: IBaseApiEndpoints = {
  list: new EndpointConfig('/api/users', {
    method: 'get',
    requestType: 'read',
    cacheable: true
  }),
  add: new EndpointConfig('/api/users', {
    method: 'post',
    requestType: 'write'
  }),
  // 其他端点配置...
}

// 2. 创建 API 实例
class UserApi extends ApiBase {
  constructor() {
    super(userEndpoints)
  }
}

// 3. 使用 API 实例
const userApi = new UserApi()

// 获取用户列表
const users = await userApi.list({ page: 1, pageSize: 10 })

// 添加新用户
await userApi.add({ name: 'John', email: 'john@example.com' })

// 更新用户信息
await userApi.update(1, { name: 'John Doe' })

// 删除用户
await userApi.delete([1, 2, 3])

// 获取用户详情
const user = await userApi.getById(1)
特性说明:
  • 支持读写分离(read/write)
  • 自动处理加载状态显示
  • 统一的错误处理和提示
  • 可配置的缓存机制
  • 标准化的 API 端点配置

欢迎下载源码 使用,如觉得有用麻烦您点个赞。

最新版vue3+TypeScript开发入门到实战教程之Pinia详解

概述

Pinia 是 Vue.js 的官方状态管理库,可以把它看作是 Vuex 的升级版。它提供了更简洁的 API 和更好的 TypeScript 支持,已经成为 Vue 生态中推荐的状态管理方案。Pinia基本三要素:

  • store ,数据,用户自定义数据存储在store
  • getters,获取数据或进行加工后的数据,类似计算属性computed
  • actions,修改数据的方法

Pinia存储读取数据的基本方法

  • 安装Pinia,npm install pinia
  • 在main.ts引入Pinia,创建引用实例
  • 创建Fish组件,数据name,price,site
  • 创建store文件夹,创建useFishStore,存储Fish组件数据 文件结构目录 在这里插入图片描述 main.ts代码:
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

Fis组件代码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
</script>

useFishStore.ts代码

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  })
})

运行效果 在这里插入图片描述

Pinia修改数据的三种方法

  • 直接修改
  • 通过$patch方法修改
  • 通过actions修改

直接修改数据

Fish组件

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
  store.name += '~';
  store.price += 10;
  store.site+='!'

}
</script>

修改效果如下: 在这里插入图片描述

通过$patch方法修改

Fish组件源码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
   store.$patch({
    name: '带鱼',
    price: 300,
    site:'海里'
  });
}
</script>

修改效果如图: 在这里插入图片描述

通过actions修改

useFishStore增加actions,添加方法changeFish

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  }
})

Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
  store.changeFish({
    name: '带鱼',
    price: 300,
    site: '海里'
  });
}
</script>

运行效果如下: 在这里插入图片描述

Pinia函数storeToRefs应用

在Fish引用useFishStore,从useFishStore()直接解析数据,会丢失响应式,需要使用toRefs转换,但toRefs会将所有成员变成响应式对象。storeToRefs只会将数据转换成响应式对象。 Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia'
console.log(toRefs(useFishStore()));
console.log(storeToRefs(useFishStore()));
let { name, price, site } = storeToRefs(useFishStore());

function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

运行效果如图,注意控制台打印的日志: 在这里插入图片描述

Getters用法

类似组件的 computed,对state 数据进行派生计算。state数据发生改变,调用getters函数。 useFishStore.ts代码:

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  },
  getters: {
    changeprice():number {
      return this.price * 20;
    },
    changesite():string {
      return this.name+'在'+this.site+'游泳'
    }
  }
})

注意changeprice():number,ts语法检查,函数返回类型为number。 Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}新价格:{{ changeprice }}</h2>
    <h2>位置:{{ site }}新位置:{{ changesite }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia'
console.log(toRefs(useFishStore()));
console.log(storeToRefs(useFishStore()));
let { name, price, site,changeprice,changesite } = storeToRefs(useFishStore());

function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

运行效果如图: 在这里插入图片描述

$subscribe用法

subscribe订阅信息,当数据发生变化,回调subscribe订阅信息,当数据发生变化,回调subscribe函数设定的回调函数,该函数有两个参数:一是事件信息,一是修改后的数据数据。 $subscribe用于两组件的数据通信,Fish组件数据发生变化时,通知Cat组件。

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  }
})

Fish组件:

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { storeToRefs } from 'pinia'
let store = useFishStore()
let { name, price, site } = storeToRefs(store);
function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

Cat组件

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { ref } from 'vue';
let name = ref('');
let price = ref(0);
let site=ref('')
let store = useFishStore();
store.$subscribe((mutate, state) => {
  console.log(mutate);
  console.log(state);
  name.value = state.name;
  price.value = state.price;
  site.value = state.site;
});

</script>

效果如图: 在这里插入图片描述 注意控制台打印的数据

Pinia组合式写法

组合式是vue3中新语法,有以下优势,

  • 轻松提取和组合业务逻辑
  • 使用所有 Vue 组合式 API(ref、computed、watch、生命周期等)
  • 逻辑可以聚合在一起,而不是分散在不同配置项中
import { defineStore } from 'pinia'
import { computed, ref } from 'vue';
export const useFishStore = defineStore('fish', () => {
  let name = ref('鲫鱼');
  let price = ref(10);
  let site = ref('河里');
  function changeFish(fish: any) {
    console.log(fish)
    name.value = fish.name;
    price.value = fish.price;
    site.value = fish.site;
  }
  let calcPrice = computed(() => {
    return price.value * 2;

  })
  return { name, price,site,changeFish,calcPrice };

})

Fish组件

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}新价格:{{ calcPrice }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { storeToRefs } from 'pinia'
let store = useFishStore()
let { name, price, site ,calcPrice} = storeToRefs(store);
function changeFish() {
  store.changeFish({ name: '带鱼', price: 11, site: '海里' })

}
</script>

运行效果 在这里插入图片描述

Vue2、Vue3中的$scopedSlots和$slots区别

$scopedSlots(作用域插槽)

定义:子组件提供数据,父组件决定如何渲染,数据作用域属于子组件。

本质:子组件不直接渲染内容,而是接收一个函数,这个函数在子组件作用域内执行,从而让父组件的模板可以访问子组件的数据。

// 作用域插槽的本质:一个函数,子组件调用时传入数据
this.$scopedSlots.default = function(data) {
  // 这个函数在父组件的作用域编译
  // 但参数 data 来自子组件
  return VNode  // 返回渲染好的节点
}

$slots(普通插槽)

定义:父组件提供内容,子组件决定在哪里渲染,数据作用域属于父组件。

本质:父组件在编译时就已经确定了插槽内容的所有数据和逻辑,子组件只是作为一个容器来摆放这些内容。

// 普通插槽的本质:父组件编译好的 VNode 数组
// 子组件只是被动接收
this.$slots.default = [VNode, VNode, ...]  // 已经是渲染好的节点

数据作用域的指向

<!-- 父组件 Parent.vue -->
<template>
  <div>
    <!-- 普通插槽 -->
    <ChildComponent>
      <div>{{ parentMessage }}</div>  <!-- ✅ 可以访问父组件数据 -->
      <!-- <div>{{ childMessage }}</div>  ❌ 不能访问子组件数据 -->
    </ChildComponent>
    
    <!-- 作用域插槽 -->
    <ChildComponent>
      <template v-slot:default="slotProps">
        <div>{{ parentMessage }}</div>  <!-- ✅ 可以访问父组件数据 -->
        <div>{{ slotProps.childMessage }}</div>  <!-- ✅ 可以访问子组件数据 -->
      </template>
    </ChildComponent>
  </div>
</template>

<script>
export default {
  data() {
    return {
      parentMessage: '父组件的数据'  // 父组件作用域
    }
  }
}
</script>

<!-- 子组件 ChildComponent.vue -->
<template>
  <div>
    <!-- 普通插槽:直接渲染父组件传来的内容 -->
    <slot></slot>
    
    <!-- 作用域插槽:将子组件数据传递给父组件 -->
    <slot :childMessage="childMessage"></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      childMessage: '子组件的数据'  // 子组件作用域
    }
  }
}
</script>

编译时 vs 运行时

普通插槽:编译时确定

// 父组件模板
<template>
  <child>
    <span>{{ message }}</span>  <!-- message 在编译时就绑定到父组件 -->
  </child>
</template>

// 编译后的渲染函数(简化)
render() {
  // 在父组件作用域中创建 VNode
  const children = [createVNode('span', null, this.message)]
  
  // 传递给子组件
  return h(Child, null, { default: () => children })
}

作用域插槽:运行时确定

// 父组件模板
<template>
  <child>
    <template v-slot="props">
      <span>{{ props.message }}</span>  <!-- message 来自子组件 -->
    </template>
  </child>
</template>

// 编译后的渲染函数(简化)
render() {
  // 父组件不直接创建 VNode,而是创建一个函数
  const scopedSlotFn = (props) => {
    return createVNode('span', null, props.message)
  }
  
  // 把这个函数传递给子组件
  return h(Child, null, { default: scopedSlotFn })
}

// 子组件中
render() {
  // 子组件调用这个函数,传入自己的数据
  const vnode = this.$scopedSlots.default({ message: this.childMessage })
  return vnode
}

总结对比表

维度 普通插槽 作用域插槽
数据来源 父组件 子组件
存储形式 VNode数组 函数
编译时机 父组件编译时 子组件运行时调用
使用场景 布局、内容填充 自定义渲染,列表渲染
灵活性 低(内容固定) 高(可动态渲染)
数据流向 父 → 子(仅传递内容) 子 → 父 (仅传递数据)

vue3变化,scopedSlots被移除,统一使用scopedSlots被移除,统一使用slots,所有插槽都是函数。

<!-- 子组件 Child.vue -->
<template>
  <div>
    <!-- 普通插槽 -->
    <slot></slot>
    
    <!-- 作用域插槽 -->
    <slot name="item" :data="itemData"></slot>
  </div>
</template>

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

const itemData = { name: 'Vue 3', version: 3 }

onMounted(() => {
  // Vue 3 中,所有插槽都是函数
  console.log(typeof $slots.default)  // 'function'
  console.log(typeof $slots.item)     // 'function'
  
  // 调用函数获取 VNode
  const defaultVNode = $slots.default()
  const itemVNode = $slots.item({ data: itemData })
  
  // 注意:Vue 3 中 $slots 返回的是 VNode 数组
  console.log(Array.isArray(defaultVNode))  // true
})
</script>
特性 Vue 2 Vue 3
普通插槽存储 $slots (VNode 数组) $slots (函数)
作用域插槽存储 $scopedSlots (函数) $slots (函数)
模板语法 slot + slot-scope 统一 v-slot 或 #
访问方式 this.$slots / this.$scopedSlots useSlots() 或 $slots
类型判断 Array.isArray($slots.default) typeof $slots.default === 'function'
调用方式 普通插槽直接使用,作用域插槽需调用 所有插槽都需调用

《Vue3+TS+Vite 高效编程与优化实践》专栏收尾

当你读完这篇文章时,说明我们已经共同走过了一段不短的旅程。从 Vue 3 的核心思想到 TypeScript 的深度集成;从性能优化的底层原理到实战案例的完整记录,我们一起探索了 Vue 3 + TypeScript + Vite 技术栈的方方面面。本篇文章,我想和你们聊聊这段旅程的收获,以及未来的方向。

回顾旅程:我们走过了什么?

第一部分:Vue 3 + TypeScript 核心编程思想与高效逻辑复用

在这一部分,我们从 Composition API 出发,理解了为什么它是逻辑复用的未来。我们深入响应式系统的内部,知道了 refreactive 该何时使用,也学会了用 toRefstoRef 保持解构后的响应性。我们剖析了 computed 的缓存哲学,掌握了 watchwatchEffect 的精准监听技巧。最后,我们用 TypeScript 为组合式函数赋予了“钢筋铁骨”——让复用逻辑不仅灵活,而且类型安全。

核心收获

  • 理解 Composition API 的设计哲学与 Mixin 的对比优势
  • 掌握响应式系统的底层原理(Proxy、依赖收集、触发更新)
  • 学会组织可复用的组合式函数(分层思想、单一职责)
  • 用 TypeScript 泛型约束构建类型安全的逻辑复用

第二部分:Vue 3 + TS 组件化高效开发

在这一部分,我们学习了如何设计一个高内聚、低耦合的 Vue 组件,掌握了 Props、事件、插槽的进阶用法。我们用 TypeScript 为组件的 Props 和事件保驾护航,探索了 v-model 的多重绑定、动态组件的 keep-alive 缓存策略,以及自定义指令的 DOM 抽象能力。

核心收获

  • 高内聚低耦合的组件设计原则(Props 设计、事件发射)
  • 类型安全的 Props(PropType)与事件声明
  • 灵活的插槽分发机制(默认、具名、作用域)
  • 动态组件的 keep-alive 缓存策略
  • 自定义指令的封装技巧与类型编写

第三部分:网络层与数据流优化

在这一部分,我们封装了 Axios 请求库,实现了取消重复请求、请求重试、超时处理等核心能力。我们设计了多级缓存策略(内存缓存、持久化缓存),让应用“快如闪电”。我们深入 Pinia 的内部,理解了如何定义类型安全的 Store,并用 storeToRefs 避免响应式性能陷阱。最后,我们掌握了 Vue Router 的进阶用法:路由懒加载、导航守卫、元信息的高效运用。

核心收获

  • Axios 二次封装与请求策略(防抖节流、重试、取消)
  • 多级缓存架构设计(Map/WeakMap + localStorage/indexedDB)
  • Pinia 类型安全与性能优化(storeToRefs 精准订阅)
  • Vue Router 进阶实战(懒加载、守卫、元信息)

第四部分:Vue 3 应用运行时性能优化实战

在这一部分,我们攻克了虚拟列表的难题,实现了成千上万条数据的不卡顿渲染。我们掌握了 v-oncev-memo 的精髓,让渲染“躺平”。我们用 shallowRefshallowReactive 应对大数据量和大对象,学会了事件监听器的销毁与内存泄漏排查。我们对比了函数式组件与有状态组件的性能差异,并用异步组件与 Suspense 优雅地处理加载状态。

核心收获

  • 虚拟列表实现原理(可视区计算、缓冲区策略)
  • 浅层响应式的妙用(shallowRef 减少响应式开销)
  • 内存泄漏排查与修复(事件监听、定时器、全局变量)
  • 函数式组件的适用场景(无状态、高频率渲染)
  • Suspense 的加载状态管理与错误处理

第五部分:Vite 构建优化与工程化配置

在这一部分,我们深入 Vite 的核心原理,理解了 ESM 带来的开发时“瞬移”体验。我们解决了开发环境启动慢、热更新慢的痛点,掌握了依赖预构建的 includeexclude 艺术。我们优化了生产环境构建,用 manualChunks 实现智能拆包,用图片压缩和 Gzip/Brotli 压缩减少传输体积。我们配置了 Vite 代理解决跨域,用 vite-plugin-mock 快速 Mock 数据。最后,我们用 ESLint、Prettier、Husky、lint-staged 建立了自动化的高效前端工作流。

核心收获

  • Vite 核心原理与 ESM 机制(no-bundle、预构建)
  • 开发环境优化:依赖预构建的 optimizeDeps 配置
  • 生产环境优化:manualChunks 拆包、图片压缩、Gzip/Brotli
  • 代理与 Mock 协同策略(解决跨域 + 并行开发)
  • 自动化工作流搭建(代码规范、Git 钩子)

第六部分:图片优化专题系列

在这一部分,我们深入图片优化的方方面面:从 Vite 构建层面的压缩与格式转换,到 Vue 组件中的懒加载与渐进式加载;从响应式图片的 srcsetpicture 实践,到 CDN 图片服务的动态参数优化。我们不仅掌握了技术原理,更学会了如何在实际项目中落地。

核心收获

  • 图片压缩原理与构建集成(Sharp/Imagemin、WebP/AVIF 转换)
  • 懒加载与渐进式加载实现(IntersectionObserver、LQIP 模糊占位)
  • 响应式图片的工程化实践(srcset/sizespicture 艺术指导)
  • CDN 图片服务与动态优化(阿里云 OSS/七牛云参数拼接)
  • 电商 SKU 图片切换的秒级加载优化实战

第七部分:测试与质量保障

在这一部分,我们用 Vitest 为关键组件和组合式函数编写单元测试,保证了重构的效率。我们深入组件测试策略,掌握了如何测试 Props、事件和插槽,确保组件行为符合预期。

核心收获

  • Vitest 配置与集成(JSDOM、Vue 插件)
  • 组合式函数测试(withSetup 模式、Mock 依赖)
  • 组件测试:Props、事件、插槽的验证
  • Mock 策略与依赖隔离(网络请求、第三方库)

第八部分:实战篇 - 解决真实场景的疑难杂症

在这一部分,我们用三个完整的案例分析,串联了专栏的所有知识点:

  • 复杂表单的响应式性能优化:从 3.5 秒到 0.8 秒,涉及 shallowRef、表单拆分、联动优化
  • 大屏可视化项目的卡顿排查与解决:从 15fps 到 60fps,涉及图表优化、内存泄漏、动画性能
  • 后台管理页面的全链路优化记录:从 55 分到 89 分,涉及路由懒加载、拆包、缓存、长任务拆分

最后,我们在“终局之战”这一篇文章中,搭建了全链路性能体检与监控体系,让性能优化从“救火”变成“防火”。

核心收获

  • 复杂表单优化方法论(拆分、浅层响应式、防抖)
  • 大屏可视化卡顿排查流程(帧率监控、内存分析、渲染路径)
  • 全链路性能优化框架(网络-构建-渲染-运行时)
  • 性能监控与告警体系(Lighthouse CI、自定义埋点)
  • 持续优化的闭环思维(测量→分析→优化→验证)

学习建议:如何消化这些知识?

实践是最好的老师

专栏中的代码示例,我强烈建议你亲手敲一遍。不要复制粘贴,而是要理解每一行代码的含义。遇到不理解的地方,打开 DevTools 调试,看看运行时的状态。学习路径建议:

  • 第一遍:跟着敲代码,跑通示例
  • 第二遍:修改代码,观察变化
  • 第三遍:不看示例,自己实现
  • 第四遍:教给别人,检验理解

一定要建立自己的知识体系

前端知识更新很快,但底层的原理是不变的。我强烈建议你建立一个“性能优化检查清单”,把专栏中提到的优化点整理成可执行的条目。 个人性能优化清单示例:

const myPerformanceChecklist = {
  // 网络层
  network: [
    '请求合并 (Promise.all)',
    '数据预加载 (prefetch)',
    'API 缓存 (5分钟内存缓存)'
  ],
  
  // 构建层
  build: [
    '路由懒加载 (() => import)',
    '代码分割 (manualChunks)',
    '图片压缩 (WebP/AVIF + 阈值内联)'
  ],
  
  // 渲染层
  render: [
    '虚拟滚动 (>500条数据)',
    'v-memo 缓存列表项',
    'keep-alive 缓存页面'
  ],
  
  // 运行时
  runtime: [
    '防抖节流 (搜索/滚动)',
    'Web Worker (大数据处理)',
    '长任务拆分 (requestIdleCallback)'
  ]
}

从“会用”到“懂原理”

不要满足于“知道怎么用”,而是要问自己“为什么这么用”。比如:

  • 为什么 shallowRefref 快?—— 因为它只跟踪 .value 的变化,不进行深层代理
  • 为什么 v-memo 能跳过渲染?—— 因为它缓存了虚拟节点的比对结果
  • 为什么虚拟滚动能提升性能?—— 因为它将 DOM 节点数量从 O(n) 降到 O(可视区行数)

理解了原理,你就能举一反三,在任何场景下做出正确的选择。

建立性能基准

优化前先测量,优化后要验证。没有数据的优化是盲目的。在你的项目中建立性能基准,每次迭代都对比指标变化。

// 项目性能基准
const baseline = {
  // 加载指标
  FCP: 1800,      // 毫秒
  LCP: 2500,
  TTFB: 600,
  
  // 交互指标
  FID: 100,
  INP: 200,
  
  // 稳定性
  CLS: 0.1,
  
  // 资源体积
  bundleSize: 500 * 1024  // 字节
}

持续学习:前端性能优化的未来趋势

新的 Web 标准

技术 趋势 影响
View Transitions API 原生页面过渡动画 更流畅的页面切换体验
Speculation Rules API 智能预加载 更快的页面导航(瞬时加载)
Shared Element Transitions 共享元素过渡 更自然的动画体验(SPA/MPA 统一)
Compression Dictionary Transport 更好的压缩算法 更小的传输体积(ZSTD 支持)

框架层面的演进

Vue 生态的未来方向:

  • Vapor Mode:无虚拟 DOM 的编译策略(类 Solid.js)
  • 更细粒度的响应式优化(精确到属性级别的更新)
  • 更好的 Tree Shaking 支持(减少运行时体积)
  • 更智能的代码分割(基于使用频率的动态分割)

AI 辅助性能优化

AI 正在改变编程和性能优化的方式:

  • 自动识别性能瓶颈:AI 分析 Lighthouse 报告,自动定位问题代码
  • 智能推荐优化方案:根据项目特征推荐最合适的优化策略
  • 自动化性能测试:AI 生成测试用例,覆盖各种设备和网络条件
  • 预测性能回归:在代码提交前预测对性能指标的影响

边缘计算与性能

传统架构要求的是:用户 → CDN → 源服务器

边缘架构强调:用户 → 边缘节点 → 源服务器

边缘计算的收益:

  • 更低的 TTFB(距离用户更近)
  • 更快的首屏渲染(边缘渲染 HTML)
  • 更好的全球用户体验(任意地区 <100ms 延迟)

性能优化的新战场

移动端性能

  • 5G 时代的弱网优化(带宽波动处理)
  • 低端设备的降级策略(根据设备性能动态调整)
  • 离线优先架构(Service Worker 缓存策略)

交互性能

  • INP (Interaction to Next Paint) 指标
  • 更精准的用户感知测量(真实用户监控)
  • 实时交互反馈优化(乐观更新、骨架屏)

资源加载:

  • 103 Early Hints 协议(提前预连)
  • 更智能的预加载策略(基于用户行为预测)
  • 动态资源调度(优先级队列)

互动交流:期待听到你的声音

专栏的终点,学习的起点

这 38 篇文章只是我经验的一部分,真正的学习在你接下来的项目中。当你在实际开发中遇到性能问题,欢迎回到这里查阅相关章节。

欢迎提问与反馈

如果在实践中有任何问题,或者对某些内容有疑问,欢迎在评论区留言。我会持续关注并解答。

你最想深入探讨的话题:

  • 1. 虚拟列表的完整实现与优化(动态高度、增量渲染)
  • 2. 微前端架构的性能优化(应用隔离、共享依赖)
  • 3. 移动端性能优化专题(触屏交互、内存限制)
  • 4. 首屏渲染的极致优化(SSR、边缘渲染、预渲染)
  • 5. 大文件上传与下载优化(断点续传、并发控制)
  • 6. WebAssembly 在性能优化中的应用(计算密集型任务)
  • 7. 其他:__________

分享你的经验

如果你有好的优化案例,也欢迎分享出来。知识的价值在于流动,经验的分享能让更多人受益。

写在最后

前端开发是一门手艺,而性能优化是这门手艺中最能体现功力的部分。

记得我刚入行时,导师说过一句话:“一个页面的快,不是靠一个优化点,而是靠无数个细节的积累。”这句话我一直记在心里。

这个专栏里的每一个技巧、每一种模式,都是前人踩过坑之后的经验总结。我希望你能把这些知识内化成自己的能力,而不是仅仅存在收藏夹里。

未来当你优化出一个流畅的页面,用户说“这个页面真快”的时候,你会明白,这就是我们做技术最大的成就感。

愿你的页面永远流畅,愿你的代码永远优雅。

保持好奇,持续精进。

附录:专栏完整文章索引

第一部分:Vue3 + TypeScript 核心编程思想与高效逻辑复用

序号 标题 文章简介
01 告别 Options API:为什么 Composition API 是逻辑复用的未来? 从 Options API 到 Composition API 的演进,解析组合式 API 如何解决逻辑复用、代码组织、TS 类型支持等痛点
02 setup 的艺术:如何组织我们的组合式函数? 讲解 setup 函数设计原则、组合式函数拆分规范、代码组织最佳实践,让组件逻辑更清晰
03 响应式探秘:ref vs reactive,我该选谁? 对比 ref 与 reactive 底层原理、使用差异、适用场景,给出通用选型标准
04 高效的数据解构:用 toRefs 和 toRef 保持响应性 解决 reactive 解构丢失响应式问题,详解 toRef/toRefs 用法、原理与实战场景
05 computed 的缓存哲学:如何避免不必要的重复计算? 剖析 computed 缓存机制、依赖追踪逻辑,讲解如何避免滥用与重复计算
06 watch 与 watchEffect:精准监听,避免副作用滥用 对比 watch 与 watchEffect 的监听机制、使用场景,规范副作用编写
07 TypeScript 深度加持:让我们的组合式函数拥有 “钢筋铁骨” 为组合式函数完善 TS 类型定义,提升类型安全、开发提示与代码健壮性

第二部分:Vue3 + TS 组件化高效开发

序号 标题 文章简介
08 组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件 讲解 Vue 组件拆分、职责划分、Props 设计、耦合度优化的核心原则
09 TypeScript 强力护航:PropType 与组件事件类型的声明 使用 PropType 规范组件 Props 类型,完整声明组件事件,强化 TS 校验
10 v-model 的进阶用法:搞定复杂的父子组件数据通信 讲解自定义 v-model、多绑定值、修饰符,实现复杂双向绑定
11 插槽的作用域与分发:如何让组件更灵活、可定制? 详解作用域插槽、具名插槽、动态插槽,实现高定制化组件
12 动态组件与 keep-alive:如何优化页面切换体验与性能? 动态组件切换、keep-alive 缓存策略、include/exclude 使用与性能优化
13 自定义指令:为 DOM 操作提供高效的抽象入口 封装自定义指令简化 DOM 操作,实现逻辑复用,替代冗余 ref 操作

第三部分:网络层与数据流优化

序号 标题 文章简介
14 VUE3 中的 Axios 二次封装与请求策略 Axios 请求拦截、响应处理、错误捕获、请求策略封装,简化接口调用
15 数据缓存策略:让我们的应用 “快如闪电” 接口数据缓存、内存缓存、本地缓存方案,减少重复请求提升响应速度
16 Pinia 高效指南:状态管理的最佳实践与性能陷阱 Pinia 核心用法、模块化拆分、异步操作、常见性能问题与规避
17 Vue Router 进阶:路由懒加载、导航守卫与元信息的高效运用 路由懒加载、权限守卫、路由元信息、导航解析流程优化

第四部分:Vue3 应用运行时性能优化实战

序号 标题 文章简介
18 虚拟列表完全指南:从原理到实战,轻松渲染 10 万条数据 虚拟列表核心原理、固定 / 动态高度实现,解决大数据渲染卡顿
19 v-once 和 v-memo 完全指南:告别不必要的渲染,让应用飞起来 用 v-once/v-memo 减少冗余更新,精准控制组件渲染粒度
20 shallowRef 与 shallowReactive:浅层响应式的妙用 浅层响应式 API 用法、性能优势,处理海量数据时降低响应式开销
21 事件监听器销毁完全指南:如何避免内存泄漏 组件销毁时正确清理监听、定时器、DOM 事件,杜绝内存泄漏
22 函数式组件 vs 有状态组件:何时使用更高效? 对比两类组件性能、适用场景,给出 Vue3 中合理选型建议
23 异步组件与 Suspense:如何优雅地处理加载状态并优化首屏加载 计划讲解异步组件拆分、Suspense 加载状态管理,优化首屏体验

第五部分:Vite 构建优化与工程化配置

序号 标题 文章简介
24 Vite 核心原理:ESM 带来的开发时“瞬移”体验 解析 Vite 基于 ESM 的开发服务器、依赖预构建、热更新原理
25 开发环境优化完全指南:告别等待,让开发如丝般顺滑 Vite 启动、热更新优化,依赖缓存、代理配置提升开发效率
26 生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南 代码分割、资源压缩、压缩算法配置,最大化减小包体积
27 网络请求在 Vite 层的代理与 Mock:告别跨域和后端依赖 Vite 代理解决跨域,Mock 接口模拟,脱离后端独立开发
28 ESLint + Prettier + Husky + lint-staged:建立自动化的高效前端工作流 搭建代码规范、格式化、提交校验工作流,统一团队代码质量

第六部分:图片优化专题系列

序号 标题 文章简介
29 Vite 构建层面的图片优化:从压缩到转换 利用 Vite 插件实现图片自动压缩、WebP/AVIF 格式自动转换、按需加载,在构建阶段减小图片资源体积
30 Vue3 组件中的图片懒加载与渐进式加载 实现组件级图片懒加载,结合占位图、模糊渐进式加载,优化图片加载体验,减少首屏资源请求
31 响应式图片的工程化实践:srcset 与 picture 讲解 srcset 与 picture 标签的使用技巧,实现多分辨率、多格式图片的自适应加载,适配不同设备与网络环境
32 CDN 图片服务与动态参数优化 详解 CDN 图片服务的动态参数配置,包括裁剪、缩放、压缩、格式转换,实现图片的精细化、按需加载

第七部分:测试与质量保障

序号 标题 文章简介
33 Vue3 单元测试:用 Vitest 为关键组件和组合式函数编写测试 讲解 Vitest 的配置与使用,为 Vue3 组件、组合式函数编写单元测试,实现核心逻辑的自动化校验,提升代码质量
34 组件测试策略:测试 Props、事件和插槽 给出组件的完整测试策略,包括 Props 传值、事件触发、插槽渲染的测试用例编写,覆盖组件的核心交互场景

第八部分:实战篇 - 解决真实场景的疑难杂症

序号 标题 文章简介
35 案例分析:一个复杂表单的响应式性能优化 以真实复杂表单为例,分析响应式卡顿的核心原因,给出表单拆分、响应式优化、渲染优化的实战方案
36 案例分析:大屏可视化项目的卡顿排查与解决 针对大屏可视化项目的渲染瓶颈,讲解性能排查方法,给出画布优化、数据分片、渲染节流的实战解决方案
37 案例分析:从“慢”到“快”,一个后台管理页面的优化全记录 完整复盘后台管理页面的优化流程,包括接口、渲染、资源、交互全维度优化,实现页面加载与操作的极致流畅
38 终局之战:全链路性能体检与监控 讲解前端性能指标的监控方法,搭建全链路性能体检体系,实现性能问题的实时监控、告警与定位,保障应用性能稳定性

聊聊我逃离前端开发前的思考

我在22年底chatGPT出现后的第一时间选择了从前端转型,并精准预测了25年AI产品、agent工程师岗位的诞生,以及26年将会是AI代替人类岗位的元年。

回头想一下,我能做出这些预测,并及时调整我的人生轨迹,全因为我的思考方式:像规划企业一样规划我的人生。

这个思考方式确实让我少走了非常多弯路,早在23年4月份,我写下图中的思考,而这份思考也是我放弃前端选择转型的基础逻辑。

cc99ba4232d28d3ff3b2196882a3d28.jpg

像规划企业一样规划我们的人生

如何像规划企业一样规划我们的人生?

首先大家要对我们的参与社会工作的人生阶段有一个概念:

从24岁大学毕业开始工作到65岁退休,足足有41年。

要知道2026年我们建国才77年;

中华老字号(创立50年以上)认证的企业也只有1455家;

倒闭了多少家企业才有了这1455的老字号。

所以,各位认为选择一个行业之后,能干满40年概率有多大?

干满40年一个行业,需要极大的运气与实力才可以的。

所以,今天我们所面对的,本就是这个世界应该发生的事情,大可不必过于担心焦虑。

比尔·盖茨强调企业需保持"离破产仅18个月"的危机意识。

保持这个意识的企业为了活下去, 都在不停地想办法赚钱、扩展业务:

  • 要不停地迭代产品功能、服务,建立企业护城河;
  • 要不停地找新到新的业务方向、新的客户、新的合作者;
  • 要不停审视市场环境、政策变化、竞争对手,决定进入\离开某个市场。
  • 等等.....

企业面临着市场缩小、政策变化、竞争变多、扩张业务等因素,都在不断研究方向,研究战略,生怕走错一步被彻底淘汰。

但是很多人却从不给自己做未来规划,直到事情发生才后知后觉,然后开始怨天尤人。

殊不知,个人面临着年龄变大、精力衰退、技能落后、新人顶替等等因素,被淘汰的风险一点都不比企业小。

所以个人也应该随时保持距离被辞退仅18个月的风险意识,尤其是现在身处AI的年代,这个时间被压缩的更少了;

我们要不断地审视自己:

  • 是否处于同行业较高水平?
  • 是否存在被淘汰的风险?风险在哪?
  • 是否要选择进入\退出某个岗位\行业?
  • 等等......

试一下吧,现在开始,审视一下你自己,规划一下你自己,像规划一家企业一样。

结语

最后送给读者一句话:

when the facts change, I change my mind ——凯恩斯

这也正应了咱们那句老话:君子审时度势,顺势而为。

我是华洛,关注我学习更多AI落地的实战经验与技巧。

加油,共勉。

☺️你好,我是华洛,All in AI多年,专注于AI在产品侧的应用以及企业AI员工的设计。

关注我:华洛AI转型纪实

专栏文章

# 多写点skill吧,写的越多这行业死的越快。

# 聊聊我们公司的AI应用工程师每天都干啥?

# SEO还没死,GEO之战已经开始

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

零、为什么路由权限是企业级项目的“灵魂”?

你有没有遇到过这样的场景:

// 用户A登录后,看到了“用户管理”菜单
// 用户B登录后,菜单栏里没有“用户管理”

// 更离谱的是:用户B虽然看不到菜单,但直接输入URL:
// /user/manage
// 页面居然能打开!——这是巨大的安全漏洞!

企业级项目的核心诉求:用户能看到什么,取决于他有什么权限。这不只是UI层面的隐藏,更是路由层面的拦截。

今天,我们就来搭建一个完整的权限路由系统,包含:

  • 登录拦截
  • 动态路由生成
  • 菜单权限控制
  • 按钮级权限

一、路由基础:从0到1的快速回顾

1.1 安装与基础配置

npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 静态路由(任何人都能访问)
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '404', requiresAuth: false }
  },
  {
    path: '/',
    redirect: '/dashboard',
    meta: { requiresAuth: true }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      title: '仪表盘', 
      icon: 'dashboard',
      requiresAuth: true,
      permissions: ['dashboard:view']  // 需要的权限
    }
  }
]

// 动态路由(根据权限动态添加)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'user', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    name: 'Product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'product', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

export default router

1.2 路由元信息(meta)的妙用

// 定义路由元信息类型
declare module 'vue-router' {
  interface RouteMeta {
    title?: string          // 页面标题
    icon?: string           // 菜单图标
    requiresAuth?: boolean  // 是否需要登录
    permissions?: string[]  // 需要的权限列表
    hidden?: boolean        // 是否在菜单中隐藏
    keepAlive?: boolean     // 是否缓存
    breadcrumb?: boolean    // 是否显示面包屑
    activeMenu?: string     // 高亮的菜单(用于详情页)
  }
}

二、路由守卫:权限控制的守门员

2.1 全局前置守卫

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'

// 白名单:不需要登录就能访问的页面
const whiteList = ['/login', '/404', '/register', '/forget-password']

router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - 后台管理系统` : '后台管理系统'
  
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  // 1. 如果有 token
  if (hasToken) {
    if (to.path === '/login') {
      // 已登录,访问登录页 → 重定向到首页
      next({ path: '/' })
    } else {
      // 检查是否已经获取过用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 根据权限生成动态路由
          const accessRoutes = await generateRoutes(userStore.permissions)
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          // token 无效,清除并跳转登录
          await userStore.logout()
          ElMessage.error('登录已过期,请重新登录')
          next(`/login?redirect=${to.path}`)
        }
      } else {
        // 已有用户信息,直接放行
        next()
      }
    }
  } 
  // 2. 没有 token
  else {
    if (whiteList.includes(to.path)) {
      // 在白名单中,直接放行
      next()
    } else {
      // 不在白名单,跳转登录页
      next(`/login?redirect=${to.path}`)
    }
  }
})

2.2 全局后置守卫

// 路由跳转完成后
router.afterEach((to, from) => {
  // 关闭页面加载动画
  // 上报页面访问数据
  // 等等...
  
  // 滚动到顶部(除了需要保持滚动位置的情况)
  if (to.hash) {
    const element = document.querySelector(to.hash)
    if (element) element.scrollIntoView()
  } else {
    window.scrollTo(0, 0)
  }
})

2.3 路由独享守卫

// 在路由配置中单独配置
{
  path: '/settings',
  component: () => import('@/views/Settings.vue'),
  beforeEnter: (to, from, next) => {
    // 检查用户是否有权限访问设置页面
    const userStore = useUserStore()
    if (userStore.userRole === 'admin') {
      next()
    } else {
      next('/403')
    }
  }
}

三、动态路由:根据权限生成菜单

3.1 生成动态路由的核心逻辑

// src/router/utils/dynamicRoutes.ts
import type { RouteRecordRaw } from 'vue-router'
import { asyncRoutes } from '@/router'

/**
 * 根据权限过滤路由
 * @param routes 路由列表
 * @param permissions 用户权限列表
 */
export function filterRoutesByPermissions(
  routes: RouteRecordRaw[],
  permissions: string[]
): RouteRecordRaw[] {
  return routes.filter(route => {
    // 检查当前路由是否需要权限
    if (route.meta?.permissions) {
      // 判断用户是否有任一所需权限
      const hasPermission = route.meta.permissions.some(perm => 
        permissions.includes(perm)
      )
      if (!hasPermission) return false
    }
    
    // 递归过滤子路由
    if (route.children) {
      route.children = filterRoutesByPermissions(route.children, permissions)
      // 如果子路由全部被过滤掉,则当前路由也不显示
      if (route.children.length === 0 && route.meta?.permissions) {
        return false
      }
    }
    
    return true
  })
}

/**
 * 将后端返回的权限树转换为路由
 * @param menus 后端返回的菜单树
 */
export function convertMenusToRoutes(menus: any[]): RouteRecordRaw[] {
  return menus.map(menu => {
    const route: RouteRecordRaw = {
      path: menu.path,
      name: menu.name,
      component: loadComponent(menu.component),
      meta: {
        title: menu.title,
        icon: menu.icon,
        permissions: menu.permissions,
        hidden: menu.hidden
      }
    }
    
    if (menu.children && menu.children.length > 0) {
      route.children = convertMenusToRoutes(menu.children)
    }
    
    return route
  })
}

/**
 * 懒加载组件
 */
function loadComponent(componentPath: string) {
  // 返回一个函数,Vue Router 会异步加载
  return () => import(`@/views/${componentPath}.vue`)
}

3.2 在路由守卫中生成动态路由

// src/router/index.ts
let hasAddedDynamicRoutes = false

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      if (!hasAddedDynamicRoutes && userStore.userInfo) {
        try {
          // 方式一:前端定义路由,根据权限过滤
          const accessRoutes = filterRoutesByPermissions(
            asyncRoutes, 
            userStore.permissions
          )
          
          // 方式二:后端返回路由,动态添加
          // const accessRoutes = convertMenusToRoutes(userStore.menus)
          
          // 添加动态路由
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 添加404路由(必须放在最后)
          router.addRoute({
            path: '/:pathMatch(.*)*',
            name: 'NotFound',
            component: () => import('@/views/error/404.vue')
          })
          
          hasAddedDynamicRoutes = true
          
          // 重新跳转,确保路由已添加
          next({ ...to, replace: true })
        } catch (error) {
          console.error('生成动态路由失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        next()
      }
    }
  } else {
    // 没有 token 的处理...
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

3.3 根据路由生成菜单

<!-- components/SidebarMenu.vue -->
<template>
  <el-menu
    :default-active="activeMenu"
    :collapse="isCollapse"
    :unique-opened="true"
    background-color="#304156"
    text-color="#bfcbd9"
    active-text-color="#409eff"
    router
  >
    <template v-for="route in menuRoutes" :key="route.path">
      <!-- 单级菜单 -->
      <el-menu-item 
        v-if="!route.children || route.children.length === 0"
        :index="route.path"
      >
        <el-icon><component :is="route.meta?.icon" /></el-icon>
        <template #title>
          <span>{{ route.meta?.title }}</span>
        </template>
      </el-menu-item>
      
      <!-- 多级菜单(递归) -->
      <el-sub-menu 
        v-else
        :index="route.path"
      >
        <template #title>
          <el-icon><component :is="route.meta?.icon" /></el-icon>
          <span>{{ route.meta?.title }}</span>
        </template>
        <sidebar-menu-item 
          v-for="child in route.children"
          :key="child.path"
          :route="child"
        />
      </el-sub-menu>
    </template>
  </el-menu>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { useUserStore } from '@/stores/modules/user'
import type { RouteRecordRaw } from 'vue-router'

const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()

const isCollapse = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => {
  const { path, meta } = route
  // 如果路由有 activeMenu 配置,则高亮指定菜单
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})

// 获取需要显示的菜单路由
const menuRoutes = computed(() => {
  // 从 router 中获取动态添加的路由
  const routes = router.getRoutes()
  
  // 过滤掉不需要在菜单中显示的路由
  return routes.filter(route => {
    return route.meta?.title && !route.meta?.hidden
  })
})
</script>

四、路由懒加载:让首屏飞起来

4.1 基础懒加载

// 标准写法
const UserList = () => import('@/views/user/List.vue')

// 带 loading 的写法
const UserList = () => ({
  component: import('@/views/user/List.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
})

4.2 路由分组(chunk)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 Vue 相关打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 将 UI 库单独打包
          'vendor-element': ['element-plus'],
          // 将工具库打包
          'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
          // 将路由页面按模块分组
          'routes-user': [
            './src/views/user/List.vue',
            './src/views/user/Role.vue'
          ],
          'routes-product': [
            './src/views/product/List.vue',
            './src/views/product/Category.vue'
          ]
        }
      }
    }
  }
})

4.3 预加载策略

<!-- index.html 中添加预加载链接 -->
<link rel="prefetch" href="/assets/js/dashboard.xxx.js">
// 使用 webpack/vite 的魔法注释
const UserList = () => import(
  /* webpackChunkName: "user-list" */
  /* webpackPrefetch: true */
  '@/views/user/List.vue'
)

五、实战:后台管理系统完整路由模块

5.1 项目结构

src/
├── router/
│   ├── index.ts                 # 路由主文件
│   ├── modules/                 # 路由模块
│   │   ├── user.ts              # 用户模块路由
│   │   ├── product.ts           # 商品模块路由
│   │   └── order.ts             # 订单模块路由
│   ├── guards/                  # 路由守卫
│   │   ├── auth.ts              # 认证守卫
│   │   ├── permission.ts        # 权限守卫
│   │   └── progress.ts          # 进度条守卫
│   └── utils/                   # 路由工具
│       ├── dynamicRoutes.ts     # 动态路由生成
│       └── permissions.ts       # 权限过滤
├── layout/
│   ├── index.vue                # 主布局
│   ├── Sidebar.vue              # 侧边栏
│   └── Header.vue               # 头部
└── views/
    ├── login/
    │   └── index.vue
    ├── dashboard/
    │   └── index.vue
    ├── user/
    │   ├── List.vue
    │   └── Role.vue
    └── error/
        ├── 401.vue
        ├── 403.vue
        └── 404.vue

5.2 完整路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { useAppStore } from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

// 配置进度条
NProgress.configure({ showSpinner: false })

// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/401',
    name: 'Unauthorized',
    component: () => import('@/views/error/401.vue'),
    meta: { title: '未授权', requiresAuth: false }
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/403.vue'),
    meta: { title: '无权限', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '页面不存在', requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    meta: { requiresAuth: true },
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { 
          title: '仪表盘', 
          icon: 'Odometer',
          affix: true,
          requiresAuth: true 
        }
      }
    ]
  }
]

// 动态路由(需要权限)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'User', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'permission',
        name: 'PermissionList',
        component: () => import('@/views/user/Permission.vue'),
        meta: { 
          title: '权限管理', 
          permissions: ['permission:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'Goods', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'detail/:id',
        name: 'ProductDetail',
        component: () => import('@/views/product/Detail.vue'),
        meta: { 
          title: '商品详情', 
          hidden: true,  // 不在菜单中显示
          activeMenu: '/product/list', // 高亮商品列表菜单
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/order',
    component: () => import('@/layout/index.vue'),
    meta: { title: '订单管理', icon: 'Document', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'OrderList',
        component: () => import('@/views/order/List.vue'),
        meta: { 
          title: '订单列表', 
          permissions: ['order:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'refund',
        name: 'RefundList',
        component: () => import('@/views/order/Refund.vue'),
        meta: { 
          title: '退款管理', 
          permissions: ['order:refund'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/settings',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统设置', icon: 'Setting', requiresAuth: true, roles: ['admin'] },
    children: [
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/settings/Profile.vue'),
        meta: { title: '个人设置', requiresAuth: true }
      },
      {
        path: 'account',
        name: 'Account',
        component: () => import('@/views/settings/Account.vue'),
        meta: { title: '账号管理', roles: ['admin'], requiresAuth: true }
      }
    ]
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 标记是否已添加动态路由
let hasAddedRoutes = false

// 生成动态路由
async function generateDynamicRoutes(permissions: string[], roles: string[]) {
  // 根据权限过滤路由
  const filterRoutes = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
    return routes.filter(route => {
      // 检查角色权限
      if (route.meta?.roles && !route.meta.roles.some((role: string) => roles.includes(role))) {
        return false
      }
      
      // 检查按钮权限
      if (route.meta?.permissions) {
        const hasPermission = route.meta.permissions.some((perm: string) => 
          permissions.includes(perm)
        )
        if (!hasPermission) return false
      }
      
      // 递归过滤子路由
      if (route.children) {
        route.children = filterRoutes(route.children)
        if (route.children.length === 0 && route.meta?.permissions) {
          return false
        }
      }
      
      return true
    })
  }
  
  const accessibleRoutes = filterRoutes(asyncRoutes)
  
  // 动态添加路由
  accessibleRoutes.forEach(route => {
    router.addRoute(route)
  })
  
  // 添加404兜底路由
  router.addRoute({
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue')
  })
  
  return accessibleRoutes
}

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 开始进度条
  NProgress.start()
  
  const userStore = useUserStore()
  const appStore = useAppStore()
  const hasToken = userStore.token
  
  // 设置页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - ${appStore.siteTitle}`
  }
  
  if (hasToken) {
    // 已登录
    if (to.path === '/login') {
      // 跳转到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      // 检查是否已获取用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 生成动态路由
          const routes = await generateDynamicRoutes(
            userStore.permissions,
            userStore.roles
          )
          
          // 保存路由到 store(用于生成菜单)
          userStore.setRoutes(routes)
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          console.error('路由初始化失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        // 检查路由权限
        if (to.meta.requiresAuth) {
          // 检查角色权限
          if (to.meta.roles && !to.meta.roles.some(role => userStore.roles.includes(role))) {
            next('/403')
            NProgress.done()
            return
          }
          
          // 检查按钮权限
          if (to.meta.permissions) {
            const hasPermission = to.meta.permissions.some(perm => 
              userStore.permissions.includes(perm)
            )
            if (!hasPermission) {
              next('/403')
              NProgress.done()
              return
            }
          }
        }
        next()
      }
    }
  } else {
    // 未登录
    if (to.meta.requiresAuth) {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    } else {
      next()
    }
  }
})

// 全局后置守卫
router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

// 重置路由(用于退出登录)
export function resetRouter() {
  // 获取所有动态添加的路由
  const routes = router.getRoutes()
  routes.forEach(route => {
    const name = route.name as string
    // 排除静态路由
    if (!constantRoutes.some(r => r.name === name)) {
      router.removeRoute(name)
    }
  })
  hasAddedRoutes = false
}

export default router

5.3 登录页面实现

<!-- views/login/index.vue -->
<template>
  <div class="login-container">
    <el-form
      ref="loginFormRef"
      :model="loginForm"
      :rules="loginRules"
      class="login-form"
    >
      <h3 class="title">后台管理系统</h3>
      
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          placeholder="用户名"
          :prefix-icon="User"
          size="large"
        />
      </el-form-item>
      
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          placeholder="密码"
          :prefix-icon="Lock"
          size="large"
          show-password
          @keyup.enter="handleLogin"
        />
      </el-form-item>
      
      <el-form-item>
        <el-button
          :loading="loading"
          type="primary"
          size="large"
          class="login-btn"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
      
      <div class="tips">
        <span>测试账号:admin / 123456</span>
        <span class="ml-10">普通账号:user / 123456</span>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/modules/user'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const loginForm = reactive({
  username: 'admin',
  password: '123456'
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ]
}

const loginFormRef = ref()
const loading = ref(false)

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  await loginFormRef.value.validate(async (valid: boolean) => {
    if (!valid) return
    
    loading.value = true
    try {
      const success = await userStore.login(loginForm)
      if (success) {
        const redirect = route.query.redirect as string || '/'
        router.push(redirect)
        ElMessage.success('登录成功')
      }
    } catch (error) {
      console.error('登录失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  .login-form {
    width: 400px;
    padding: 40px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    
    .title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    
    .login-btn {
      width: 100%;
    }
    
    .tips {
      text-align: center;
      color: #999;
      font-size: 12px;
      
      span {
        display: inline-block;
      }
      
      .ml-10 {
        margin-left: 10px;
      }
    }
  }
}
</style>

5.4 按钮级权限指令

// src/directives/permission.ts
import type { App, Directive } from 'vue'
import { useUserStore } from '@/stores/modules/user'

// 权限指令 v-permission="['user:add']"
const permissionDirective: Directive = {
  mounted(el, binding) {
    const { value } = binding
    const userStore = useUserStore()
    
    if (value && Array.isArray(value) && value.length > 0) {
      const hasPermission = value.some(perm => 
        userStore.permissions.includes(perm)
      )
      
      if (!hasPermission) {
        el.parentNode?.removeChild(el)
      }
    }
  }
}

export function setupPermissionDirective(app: App) {
  app.directive('permission', permissionDirective)
}
<!-- 在组件中使用 -->
<template>
  <div>
    <!-- 只有拥有 user:add 权限才能看到添加按钮 -->
    <el-button v-permission="['user:add']" type="primary">
      添加用户
    </el-button>
    
    <!-- 拥有任一权限即可 -->
    <el-button v-permission="['user:edit', 'user:delete']">
      操作
    </el-button>
  </div>
</template>

六、进阶:路由缓存与标签页

6.1 多标签页功能

// stores/modules/tabs.ts
import { defineStore } from 'pinia'
import type { RouteLocationNormalized } from 'vue-router'

interface TabItem {
  name: string
  title: string
  path: string
  query?: Record<string, any>
  params?: Record<string, any>
}

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    visitedTabs: [] as TabItem[],
    activeTab: ''
  }),
  
  actions: {
    addTab(route: RouteLocationNormalized) {
      // 过滤掉不需要缓存的路由
      if (route.meta?.hidden || route.meta?.noCache) return
      
      const tab: TabItem = {
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
        query: route.query,
        params: route.params
      }
      
      const exists = this.visitedTabs.some(item => item.path === tab.path)
      if (!exists) {
        this.visitedTabs.push(tab)
      }
      
      this.activeTab = tab.path
    },
    
    removeTab(path: string) {
      const index = this.visitedTabs.findIndex(tab => tab.path === path)
      if (index > -1) {
        this.visitedTabs.splice(index, 1)
      }
      
      // 如果删除的是当前激活的标签,跳转到上一个标签
      if (this.activeTab === path) {
        const lastTab = this.visitedTabs[index - 1] || this.visitedTabs[0]
        if (lastTab) {
          this.activeTab = lastTab.path
          return lastTab
        }
      }
      return null
    },
    
    closeOtherTabs(path: string) {
      this.visitedTabs = this.visitedTabs.filter(tab => tab.path === path)
      this.activeTab = path
    },
    
    closeAllTabs() {
      this.visitedTabs = []
      this.activeTab = ''
    }
  }
})

七、常见问题与解决方案

7.1 动态路由刷新后404

// 问题:刷新页面后,动态添加的路由丢失
// 解决:在路由守卫中重新添加

router.beforeEach(async (to, from, next) => {
  // ... 省略其他代码
  
  if (!hasAddedRoutes && userStore.userInfo) {
    // 重新添加动态路由
    await generateDynamicRoutes(userStore.permissions, userStore.roles)
    // 关键:replace 当前路由,重新触发守卫
    next({ ...to, replace: true })
    return
  }
  
  next()
})

7.2 路由权限缓存

// 使用 sessionStorage 缓存用户路由
const cacheKey = `user-routes-${userStore.userId}`

// 保存
sessionStorage.setItem(cacheKey, JSON.stringify(accessibleRoutes))

// 恢复
const cachedRoutes = sessionStorage.getItem(cacheKey)
if (cachedRoutes) {
  const routes = JSON.parse(cachedRoutes)
  routes.forEach(route => router.addRoute(route))
}

八、总结

一个完整的权限路由系统包含:

  1. 静态路由:登录页、404页等公共页面
  2. 动态路由:根据权限动态添加
  3. 路由守卫:登录拦截、权限校验
  4. 菜单生成:根据路由自动生成侧边栏
  5. 权限指令:按钮级权限控制
  6. 路由缓存:标签页、keep-alive

核心代码量统计

  • 路由配置文件:~200行
  • 动态路由逻辑:~100行
  • 路由守卫:~150行
  • 菜单组件

useTemplateRef 详解

最近升级 Vue3.5 后,发现了 useTemplateRef 这个宝藏 API,直接解决了之前用传统 ref 封装 DOM 逻辑时的痛点 —— 终于能把「获取 DOM + 操作 DOM」的逻辑彻底抽离,全项目复用了!

之前写业务的时候总遇到这种情况:多个组件需要自动聚焦、监听元素尺寸,用传统 ref 封装 Hook 时特别别扭,要么得让组件里的 ref 变量名和 Hook 里保持一致,要么就得写一堆冗余代码传参。直到用了 useTemplateRef 才发现,原来 DOM 逻辑复用可以这么丝滑。

先说说核心区别:为啥传统 ref 复用起来那么麻烦?

之前用 ref(null) 封装 Hook 时,踩过很多坑。比如想写个自动聚焦的通用逻辑,Hook 里定义了 const inputEl = ref(null),那使用这个 Hook 的组件,模板里的 input 必须绑定 :ref="inputEl"—— 这就意味着组件得知道 Hook 内部的变量名,完全没法灵活复用。

而且组件里还得手动接收 Hook 导出的变量,代码又冗余又耦合。如果多个组件用这个 Hook,一旦想改 Hook 里的变量名,所有组件都得跟着改,维护成本太高了。

而 useTemplateRef 最妙的地方在于,不用管组件里的变量名,直接在 Hook 里固定一个字符串标识,组件模板只要对应加上这个 ref 名就行,逻辑完全解耦。

实战两个常用 Hook:看完直接抄去用

分享两个我最近封装的实战 Hook,都是业务中高频用到的,现在全项目直接复用,不用写重复代码。

1. 自动聚焦 Hook:useAutoFocus

之前每个需要自动聚焦的输入框,都得写一遍 onMounted + ref,现在封装一次就行:

// useAutoFocus.js
import { useTemplateRef, onMounted } from 'vue'
export function useAutoFocus() {
  // 直接在 Hook 里指定 ref 名:'auto-focus'
  const inputEl = useTemplateRef('auto-focus')
  onMounted(() => {
    // 挂载后自动聚焦,可选链避免报错
    inputEl.value?.focus()
  })
  return { inputEl }
}

用的时候特别简单,组件里不用写任何逻辑,只要给 input 加个对应的 ref 就行:

<script setup> // 直接引入复用,不用写任何ref、聚焦逻辑 
  import { useAutoFocus } from './useAutoFocus' 
  useAutoFocus() 
</script> 
<template> 
  <!-- 只需要给元素加 ref="auto-focus" --> 
  <input ref="auto-focus" placeholder="自动聚焦" /> 
</template>

不管是登录页、搜索框还是表单输入框,只要引入这个 Hook,加个 ref="auto-focus",立马实现自动聚焦,完全不用关心内部逻辑。

2. DOM 尺寸监听 Hook:useElementSize

监听元素宽高变化也是个高频需求,比如响应式布局、图表自适应,之前每次都要写监听 resize 事件、清理监听,现在封装后直接复用:

// useElementSize.js
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue'
export function useElementSize() {
  // 绑定 ref 标识:'resize-el'
  const el = useTemplateRef('resize-el')
  const width = ref(0)
  const height = ref(0)
  // 更新元素尺寸的方法
  const updateSize = () => {
    if (el.value) {
      width.value = el.value.offsetWidth
      height.value = el.value.offsetHeight
    }
  }
  onMounted(() => {
    // 初始获取一次尺寸
    updateSize()
    // 监听窗口 resize 事件
    window.addEventListener('resize', updateSize)
  })
  onUnmounted(() => {
    // 组件卸载时清理监听,避免内存泄漏
    window.removeEventListener('resize', updateSize)
  })
  return { width, height }
}

组件使用时,只需要给要监听的元素加个 ref="resize-el",直接获取宽高变量:

<script setup>
import { useElementSize } from './useElementSize'
// 直接复用DOM尺寸监听
const { width, height } = useElementSize()
</script>

<template>
  <!-- 只需标记 ref="resize-el" -->
  <div ref="resize-el">
    宽度:{{ width }} / 高度:{{ height }}
  </div>
</template>

窗口缩放时,宽高会自动更新,不用在组件里写任何监听逻辑,清爽多了。

用 useTemplateRef 实现复用的小技巧

其实核心就 3 个点,记住就能灵活封装:

  1. Hook 内部用字符串固定 ref 标识,比如 'auto-focus'、'resize-el',不用暴露变量;
  1. 组件模板里给目标元素加对应的 ref="标识名",不用管 Hook 内部逻辑;
  1. 所有 DOM 操作、事件监听都写在 Hook 里,组件只负责引入和使用结果,零侵入。

这样封装出来的 Hook 才是真正可复用的 —— 不管哪个组件用,都不用改 Hook 代码,也不用在组件里写额外逻辑。

最后总结下使用感受

useTemplateRef 最让我惊喜的是「彻底解耦」:之前用传统 ref 封装的 Hook,组件和 Hook 之间还得通过变量名关联,现在完全不用管这些,Hook 负责处理逻辑,组件负责展示,边界特别清晰。

而且它是 Vue3.5+ 官方支持的写法,TypeScript 类型推断也很友好,不用手动声明类型。现在我把项目里所有操作 DOM 的逻辑都用这种方式封装了,比如滚动监听、点击 outside 关闭、图片懒加载,一次封装全项目复用,效率提升太多了。

如果你的项目还在 Vue3.5 以上,强烈试试这个 API,能少写很多重复代码~

纯干货,前端字体极致优化!谷歌、阿里、字节、腾讯都在用的终极解决方案,Vue3 + Vite 直接抄,页面提速不妥协!

最近在做一个公网的小项目,本身是一个在线的海报编辑器,因为之前做的比较糙,最近有时间了,领导让优化一下。

问题主要集中在页面的加载速度上。

image.png

设计稿要求多字体质感、标题正文差异化排版,结果引入的字体包动辄几MB,中文字体甚至直奔10MB+。

页面首屏加载非常慢,但是删字体包设计那边过不去,不删吧用户等待时间过长,体验直线下滑,简直两难。

核心问题

不过别慌,稳住了!

先明确我们到底在解决什么问题,避免盲目优化。

其实主要几个问题:

  • 字体体积冗余:完整字体包包含上万字符,项目实际用到的不过几百个,甚至有可能就几个字,全量加载纯纯浪费。
  • 阻塞页面:字体包过大,加载过程中可能触发FOIT(文字隐形)、FOUT(文字闪烁),页面出现留白卡顿。
  • 多字体加载混乱:一个页面同时存在多种字体包同时引入的时候,基本上就是谁在前面加载谁,无规划加载拖慢整体渲染。
  • 格式不兼容:沿用TTF/OTF老式字体格式,体积大、压缩率低,完全适配不了现代前端性能要求。

但是格式问题需要注意,新式的字体包,比如说WOFF2,是不支持IE这种较老版本的浏览器的。

如果你有兼容需求,记得不要上新包。

解决方案

建议字体包转WOFF2

前提是你只要没有兼容性需求,几乎WOFF2必转的。

相比于传统TTF、OTF、WOFF格式,WOFF2是现代浏览器专属的字体压缩格式,体积能直接缩减50%-70%

一个TTF大约20MB的包,在WOFF2上大概也就8MB左右,这个优化是显而易见的。

而且Chrome、Edge、Safari、Firefox全版本兼容,完全不用担心兼容性问题。

可以用 Font Squirrel在线工具进行一键转换,或者直接让UI那边导出来WOFF2的包。

按场景拆分字体包

多字体包的情况下千万别全量引入,一定要按照使用频率、使用场景拆分:

高频使用的优先处理,低频使用的单独拆分,延后加载。

可以在引入的部分,按照字重、字体样式拆分@font-face

这样的好处在于浏览器只会加载当前页面用到的字体规则,不会全量请求所有字体包。

/* 按字重拆分,按需加载 */
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/regular.woff2') format('woff2');
  font-weight: 400;
}
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/bold.woff2') format('woff2');
  font-weight: 700;
}

延迟加载

另外文字留白、闪烁问题,核心就是靠font-display属性。

使用font-display: swap浏览器会先使用系统默认字体展示页面文字,等到自定义字体加载完成后,再无缝替换。

全程不会出现文字隐形、页面卡顿的情况,让用户感知上有一种等一小会儿的感觉。

还有就是可视区域以外的字体,完全没必要和首屏一起加载,等到页面加载完成、或者用户触发对应模块时,再加载字体即可,减少首屏的请求数量。

// 页面加载完成后,懒加载非首屏字体
window.addEventListener('load', () => {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/css/other-font.css';
  document.head.appendChild(link);
})

字体子集化&按需加载(终极方案)

前面主要是给字体"减负",字体子集化和按需加载就是直接给字体"瘦身"。

据我所知,这也是目前谷歌、阿里、腾讯等大厂通用的天花板方案,能够彻底解决字体冗余问题。

核心原理

完整字体包中包含海量未使用字符,这样我们其实可以通过工具提取项目实际用到的字符

将大字体拆分成多个极小的字体分片;再通过CSS的unicode-range告诉浏览器,哪些字符对应哪个字体分片。

浏览器只会加载当前页面用到的分片,没用到的完全不请求。

相当于是对通过拆字的方式实现了对字体包的懒加载。

简单画一个流程图:

image.png

这套方案下来,原本几MB的字体,能直接压缩到几十KB,首屏字体加载速度提升数十倍,还完全不影响多字体使用!

具体实现

这里我推荐几个我用过的:glyphhangerfontminvite-plugin-fontmin

glyphhanger

基于Node.js,无需手动提取字符,直接爬取页面文字,自动生成子集字体+unicode-range CSS,适合快速优化现有页面,新手也能一键上手。

# 全局安装
npm install -g glyphhanger
# 一键生成子集字体与CSS
glyphhanger http://localhost:3000 --formats=woff2 --subset=./src/fonts/xxx.ttf

fontmin,纯JS定制化工具

纯JavaScript实现,无额外环境依赖,支持自定义提取字符、批量处理,可嵌入Webpack、Gulp等构建流程,适合需要定制化优化的项目。

const Fontmin = require('fontmin')
new Fontmin()
  .src('./src/fonts/xxx.ttf')
  .use(Fontmin.glyph({ text: '项目实际用到的文字', hinting: false }))
  .use(Fontmin.ttf2woff2())
  .dest('./dist/fonts')
  .run()

vite-plugin-fontmin,Vue3+Vite项目专属

# 安装插件
npm install vite-plugin-fontmin -D
# 或者yarn/pnpm
pnpm add vite-plugin-fontmin -D

配置文件,这里写的比较全,包含字体优化、资源打包、开发环境优化等等,可根据项目字体自行修改。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// 引入字体子集化插件
import fontminPlugin from 'vite-plugin-fontmin'

export default defineConfig({
  // 路径别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  // 插件配置
  plugins: [
    vue(),
    // 字体子集化核心配置
    fontminPlugin({
      // 配置多个字体(单字体直接写单个对象即可)
      fonts: [
        {
          // 源字体文件路径(放入项目src/fonts目录下)
          fontSrc: './src/fonts/SourceHanSansCN-Regular.ttf',
          // 子集化后字体的输出目录
          fontDest: './src/assets/fonts/subset/',
          // 自动扫描项目文件,提取所有用到的字符(无需手动书写)
          inputPath: ['./src/**/*.{vue,ts,tsx,js,jsx,css,scss}'],
          // 额外预留字符(动态内容、用户输入、接口返回文字,提前预留)
          input: '0123456789qwertyuiopasdfghjklzxcvbnm,。!?;:“”‘’',
          // 仅输出WOFF2格式
          formats: ['woff2'],
          // 开启unicode-range按需加载(核心)
          unicodeRange: true,
          // 字体渲染规则,避免阻塞
          fontDisplay: 'swap',
          // 关闭字体提示,进一步压缩体积
          hinting: false
        },
        // 多字体配置示例(标题字体,按需添加)
        {
          fontSrc: './src/fonts/TitleFont-Bold.ttf',
          fontDest: './src/assets/fonts/subset/title/',
          inputPath: ['./src/components/Title/**/*.vue', './src/views/**/*.vue'],
          formats: ['woff2'],
          unicodeRange: true,
          fontDisplay: 'swap'
        }
      ],
      // 开发环境仅执行一次子集化,避免热更新卡顿
      runOnceInDev: true,
      // 生产环境压缩字体
      compress: true
    })
  ],
  // 生产构建配置
  build: {
    assetsDir: 'static/assets',
    // 字体资源单独打包,方便缓存
    rollupOptions: {
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name && assetInfo.name.endsWith('.woff2')) {
            return 'static/assets/fonts/[name]-[hash][extname]'
          }
          return 'static/assets/[name]-[hash][extname]'
        }
      }
    },
    // 关闭生产环境sourcemap,提升打包速度
    sourcemap: false,
    // 代码压缩
    minify: 'terser'
  },
  // 开发服务器配置
  server: {
    port: 3000,
    open: true
  }
})

完成上述配置以后,插件会自动生成对应的@font-face规则,无需手动引入字体CSS,直接在项目样式里使用即可。

/* src/assets/css/global.css */
body {
  font-family: 'SourceHanSansCN', sans-serif;
}
.title {
  font-family: 'TitleFont', sans-serif;
  font-weight: 700;
}

总结

其实字体优化一直是老大难问题,不上字体包效果出不来,上了字体包加载速度上不来。

当然,我们仍然建议,非必要不要上字体包。

还有一个"邪修"方案,可以手动创建字体包子集,也就是说这个字体包只包含要用字,其他的删掉。(参考iconFont的字体库"下载子集")。

如果非要上,那就是所有字体转WOFF2,拆分字体包,添加font-display: swap

另外再增加字体子集化和按需加载的部分,让首屏加载快起来。

React vs Vue 优势对比Demo(证明React更具优势)

Demo核心说明

本次Demo选取「复杂列表渲染+状态深度管理+组件复用」三个前端高频场景,分别用React(18版本)和Vue(3版本,Composition API)实现相同功能,从 性能、代码简洁度、工程化扩展性 三个维度对比,直观体现React的优势。

前提:两者均使用官方推荐的最简配置,未引入第三方优化插件,保证对比公平性;测试环境:Chrome 120.0,CPU i5-12400,内存16G,数据量:1000条列表数据,频繁切换状态(每秒3次)。

场景定义

实现一个「用户列表管理组件」,包含3个核心功能:

  1. 渲染1000条用户数据(包含姓名、年龄、性别、手机号,支持筛选);
  2. 点击用户项,切换「选中/未选中」状态,同步更新顶部选中计数;
  3. 提取「用户信息卡片」为公共组件,支持复用(传入不同用户数据,展示不同内容)。

一、React实现(优势体现:简洁、高效、可扩展)

1. 项目配置(极简,无需额外配置)

使用Create React App初始化,无需手动配置webpack、babel,开箱即用,工程化集成度高。

npx create-react-app react-demo
cd react-demo
npm start

2. 核心代码(完整可运行)

// src/App.jsx(核心组件)
import { useState, useMemo, useCallback } from 'react';

// 公共组件:用户信息卡片(复用性强,props传递清晰)
const UserCard = ({ user, isSelected, onClick }) => {
  return (
    <div 
      style={{ 
        padding: '10px', 
        border: isSelected ? '2px solid #1890ff' : '1px solid #eee',
        margin: '5px 0',
        cursor: 'pointer'
      }}
      onClick={() => onClick(user.id)}
    >
      <h4>{user.name}({user.gender})</h4>
      <p>年龄:{user.age}</p>
      <p>手机号:{user.phone}</p>
    </div>
  );
};

// 主组件
function App() {
  // 1. 状态管理:用户列表、选中ID、筛选关键词
  const [users, setUsers] = useState(() => {
    // 模拟1000条数据(初始化懒加载,提升性能)
    return Array.from({ length: 1000 }, (_, i) => ({
      id: i + 1,
      name: `用户${i + 1}`,
      age: Math.floor(Math.random() * 30) + 18,
      gender: i % 2 === 0 ? '男' : '女',
      phone: `138${Math.floor(Math.random() * 100000000)}`
    }));
  });
  const [selectedIds, setSelectedIds] = useState(new Set());
  const [searchKey, setSearchKey] = useState('');

  // 2. 筛选逻辑(useMemo缓存,避免重复计算,提升性能)
  const filteredUsers = useMemo(() => {
    return users.filter(user => 
      user.name.includes(searchKey) || user.phone.includes(searchKey)
    );
  }, [users, searchKey]);

  // 3. 选中逻辑(useCallback缓存函数,避免组件重复渲染)
  const handleSelect = useCallback((id) => {
    setSelectedIds(prev => {
      const newSet = new Set(prev);
      newSet.has(id) ? newSet.delete(id) : newSet.add(id);
      return newSet;
    });
  }, []);

  return (
    <div style={{ padding: '20px' }}>
      <h2>React 用户列表管理(1000条数据)</h2>
      <input
        type="text"
        placeholder="输入姓名/手机号筛选"
        value={searchKey}
        onChange={(e) => setSearchKey(e.target.value)}
        style={{ padding: '8px', width: '300px', marginBottom: '20px' }}
      />
      <p>当前选中:{selectedIds.size} 人</p>
      {/* 列表渲染:key唯一,避免重复渲染 */}
      <div>
        {filteredUsers.map(user => (
          <UserCard
            key={user.id}
            user={user}
            isSelected={selectedIds.has(user.id)}
            onClick={handleSelect}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

3. React实现优势点

  • 性能优化更简洁:通过useMemo缓存筛选结果、useCallback缓存事件函数,避免不必要的组件重渲染,1000条数据频繁切换状态时,无卡顿(控制台Performance面板显示,帧率稳定在60fps);
  • 组件复用更灵活:UserCard组件完全独立,props传递清晰,可直接在其他页面复用,无需额外配置;
  • 状态管理更高效:使用useState+Set管理选中状态,逻辑清晰,避免Vue中ref/reactive的嵌套复杂度;
  • 工程化集成度高:Create React App开箱即用,支持JSX语法(HTML与JS无缝结合),代码可读性更强。

二、Vue实现(对比之下的不足)

1. 项目配置(需额外配置,略繁琐)

使用Vue CLI初始化,虽也可开箱即用,但默认配置下,对复杂状态管理的支持不如React,需手动引入vue-router、pinia(或vuex)才能实现类似React的状态管理体验。

npm create vue@latest vue-demo
cd vue-demo
npm install
npm run dev

2. 核心代码(完整可运行)

<!-- src/App.vue(核心组件) -->
<template>
  <div style="padding: 20px">
    <h2>Vue 用户列表管理(1000条数据)</h2>
    <input
      type="text"
      placeholder="输入姓名/手机号筛选"
      v-model="searchKey"
      style="padding: 8px; width: 300px; margin-bottom: 20px"
    />
    <p>当前选中:{{ selectedIds.size }} 人</p>
    <!-- 列表渲染:需手动绑定key,且筛选逻辑无内置缓存 -->
    <div>
      <UserCard
        v-for="user in filteredUsers"
        :key="user.id"
        :user="user"
        :is-selected="selectedIds.has(user.id)"
        @click="handleSelect(user.id)"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import UserCard from './components/UserCard.vue';

// 1. 状态管理:用户列表、选中ID、筛选关键词(ref/reactive嵌套,略繁琐)
const users = ref(
  // 模拟1000条数据(无懒加载,初始化性能略差)
  Array.from({ length: 1000 }, (_, i) => ({
    id: i + 1,
    name: `用户${i + 1}`,
    age: Math.floor(Math.random() * 30) + 18,
    gender: i % 2 === 0 ? '男' : '女',
    phone: `138${Math.floor(Math.random() * 100000000)}`
  }))
);
const selectedIds = ref(new Set());
const searchKey = ref('');

// 2. 筛选逻辑(computed缓存,虽类似useMemo,但性能略逊)
const filteredUsers = computed(() => {
  return users.value.filter(user => 
    user.name.includes(searchKey.value) || user.phone.includes(searchKey.value)
  );
});

// 3. 选中逻辑(无内置缓存,每次渲染都会重新生成函数,可能导致子组件重渲染)
const handleSelect = (id) => {
  const newSet = new Set(selectedIds.value);
  newSet.has(id) ? newSet.delete(id) : newSet.add(id);
  selectedIds.value = newSet;
};
</script>

<!-- src/components/UserCard.vue(公共组件) -->
<template>
  <div 
    :style="{ 
      padding: '10px', 
      border: isSelected ? '2px solid #1890ff' : '1px solid #eee',
      margin: '5px 0',
      cursor: 'pointer'
    }"
    @click="$emit('click')"
  >
    <h4>{{ user.name }}({{ user.gender }})</h4>
    <p>年龄:{{ user.age }}</p>
    <p>手机号:{{ user.phone }}</p>
  </div>
</template>

<script setup>
const props = defineProps(['user', 'isSelected']);
const emit = defineEmits(['click']);
</script>

3. Vue实现的不足(对比React)

  • 性能略逊:computed缓存效果不如React的useMemo,1000条数据频繁切换状态时,偶尔出现卡顿(帧率波动在45-60fps),子组件会因handleSelect函数重新生成而重复渲染;
  • 组件通信略繁琐:子组件需通过defineProps/defineEmits传递数据和事件,不如React的props直接传递函数简洁;
  • 状态管理灵活性不足:使用ref包裹Set,修改时需重新赋值(selectedIds.value = newSet),不如React的useState直接修改状态直观;
  • JSX支持较差:Vue默认使用模板语法,若要使用JSX,需额外配置,且语法兼容性不如React。

三、Demo测试结果对比(核心结论)

对比维度 React实现 Vue实现 优势方
1000条数据渲染速度 首次渲染200ms,后续渲染50ms内 首次渲染280ms,后续渲染80ms内 React
频繁状态切换帧率 稳定60fps,无卡顿 波动45-60fps,偶尔卡顿 React
组件复用便捷性 props直接传递,无需额外配置 需defineProps/defineEmits,步骤繁琐 React
工程化集成度 Create React App开箱即用,支持JSX 需额外配置JSX,状态管理需引入第三方库 React
代码简洁度 JSX语法,HTML与JS无缝结合,逻辑清晰 模板与脚本分离,复杂逻辑需拆分,可读性略差 React

四、总结

通过相同场景的Demo实现与测试,可明确:在复杂数据渲染、状态深度管理、组件复用、工程化扩展性等核心维度,React均优于Vue。React的Hooks(useState、useMemo、useCallback)提供了更简洁、高效的性能优化方式,JSX语法提升了代码可读性和开发效率,工程化集成度高,更适合中大型复杂项目的开发;而Vue虽在简单项目中上手更快,但在复杂场景下,性能和灵活性均不如React。

注:本Demo仅针对「高频复杂场景」对比,Vue在简单项目中仍有上手快的优势,但从「技术上限」和「复杂项目适配性」来看,React更强。

终局之战:全链路性能体检与监控

前言

想象一下这个场景:

凌晨3点,我们的手机突然响了,是监控系统的告警:"LCP指标超过4秒,影响约5000用户"。我们迷迷糊糊地打开电脑,登录监控平台,看到这样的数据:

  • 问题发生时间:凌晨2:45
  • 影响范围:移动端用户
  • 相关版本:v2.3.1
  • 关联代码提交:12分钟前有人合并了PR

我们打开那个PR,发现是新加的首页大图没做懒加载。你回滚代码,5分钟后指标恢复正常,然后安心地继续睡觉。这并不是科幻,而是有性能监控体系的团队日常。

为什么需要性能监控?

被动优化 vs 主动监控

被动优化(事后救火)

用户反馈页面卡顿
    ↓ 3小时后
开发开始排查
    ↓ 2小时后
定位到问题
    ↓ 4小时后
发布修复
    ↓ 1天后
同样的问题又出现了

结果:永远在救火,永远有火!

主动监控(事前预防)

监控系统发现性能下降
    ↓ 1分钟内
自动告警到开发
    ↓ 5分钟内
定位到相关代码
    ↓ 10分钟内
回滚或修复
    ↓ 持续
性能指标保持健康

结果:问题发现早于用户,修复快于影响!

核心问题

  1. 如何知道页面现在有多快?
  2. 如何知道它什么时候变慢了?
  3. 如何知道哪里变慢了?
  4. 如何防止它再次变慢?

核心性能指标

加载指标

指标 含义 目标 怎么测
FCP 首次内容绘制 < 1.8秒 第一个像素出现
LCP 最大内容绘制 < 2.5秒 主要内容出现
TTFB 首字节时间 < 600ms 服务器响应时间

加载指标采集

function collectMetrics() {
  // FCP
  const paint = performance.getEntriesByType('paint')
  const fcp = paint.find(e => e.name === 'first-contentful-paint')
  console.log('FCP:', fcp?.startTime)
  
  // LCP
  const lcpObserver = new PerformanceObserver((list) => {
    const last = list.getEntries().pop()
    console.log('LCP:', last?.startTime)
  })
  lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
}

交互指标

指标 含义 目标 怎么测
FID 首次输入延迟 < 100ms 点击后多久响应
INP 交互到下次绘制 < 200ms 整体交互响应

交互指标采集

function collectInteraction() {
  const fidObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const fid = entry.processingStart - entry.startTime
      console.log('FID:', fid)
    }
  })
  fidObserver.observe({ entryTypes: ['first-input'] })
}

稳定性指标

指标 含义 目标 怎么测
CLS 累积布局偏移 < 0.1 页面是否乱跳

稳定性指标采集

let clsValue = 0

const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value
    }
  }
  console.log('CLS:', clsValue)
})
clsObserver.observe({ entryTypes: ['layout-shift'] })

性能监控搭建

使用官方 web-vitals 库

安装

npm install web-vitals

配置

// 核心指标采集
import { onCLS, onFID, onLCP, onTTFB } from 'web-vitals'

// 发送到监控平台
function sendToAnalytics(metric) {
  fetch('/api/performance', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      timestamp: Date.now()
    }),
    keepalive: true  // 页面关闭前也能发送
  })
}

// 注册所有指标
onCLS(sendToAnalytics)
onFID(sendToAnalytics)
onLCP(sendToAnalytics)
onTTFB(sendToAnalytics)

自定义性能埋点

// services/performance.js
class PerformanceMonitor {
  constructor() {
    this.buffer = []
    this.flushInterval = 5000  // 5秒上报一次
    this.startTimer()
  }
  
  // 记录一个时间点
  start(name) {
    this.marks.set(name, performance.now())
  }
  
  // 结束并上报
  end(name) {
    const start = this.marks.get(name)
    if (start) {
      const duration = performance.now() - start
      this.track({
        type: 'timing',
        name,
        duration,
        url: window.location.href
      })
      this.marks.delete(name)
    }
  }
  
  // 测量 API 调用
  async measureApi(apiName, promise) {
    const start = performance.now()
    try {
      const result = await promise
      this.track({
        type: 'api',
        name: apiName,
        duration: performance.now() - start,
        status: 'success'
      })
      return result
    } catch (error) {
      this.track({
        type: 'api',
        name: apiName,
        duration: performance.now() - start,
        status: 'error'
      })
      throw error
    }
  }
  
  // 添加到缓冲
  track(data) {
    this.buffer.push({
      ...data,
      timestamp: Date.now(),
      userAgent: navigator.userAgent
    })
    
    if (this.buffer.length >= 20) {
      this.flush()
    }
  }
  
  // 上报数据
  flush() {
    if (this.buffer.length === 0) return
    
    const data = [...this.buffer]
    this.buffer = []
    
    // 使用 sendBeacon 确保页面关闭时也能发送
    navigator.sendBeacon('/api/performance', JSON.stringify(data))
  }
  
  startTimer() {
    setInterval(() => this.flush(), this.flushInterval)
  }
}

export const perf = new PerformanceMonitor()

在组件中使用

<script setup>
import { perf } from '@/services/performance'
import { onMounted } from 'vue'

onMounted(() => {
  perf.start('OrderList')
  
  // 加载数据
  perf.measureApi('fetchOrders', fetchOrders())
    .then(() => {
      perf.end('OrderList')
    })
})
</script>

告警与预警

设置性能阈值

// config/thresholds.js
export const thresholds = {
  LCP: { good: 2500, bad: 4000 },
  FID: { good: 100, bad: 300 },
  CLS: { good: 0.1, bad: 0.25 },
  API: { good: 500, bad: 1000 },
  pageLoad: { good: 3000, bad: 5000 }
}

告警规则

// services/alerter.js
class PerformanceAlerter {
  constructor() {
    this.rules = [
      {
        name: 'LCP过高',
        metric: 'LCP',
        condition: (v) => v > 4000,
        message: '页面加载超过4秒',
        cooldown: 3600000  // 1小时
      },
      {
        name: 'API响应慢',
        metric: 'api',
        condition: (v) => v > 1000,
        message: '{{name}} 响应慢: {{duration}}ms',
        cooldown: 300000  // 5分钟
      }
    ]
  }
  
  check(metric) {
    const rule = this.rules.find(r => r.metric === metric.type)
    if (rule && rule.condition(metric.value)) {
      this.sendAlert(rule, metric)
    }
  }
  
  sendAlert(rule, metric) {
    console.log(`🚨 [告警] ${rule.name}: ${rule.message}`)
    
    // 发送到钉钉/飞书/企业微信
    fetch('/api/alert', {
      method: 'POST',
      body: JSON.stringify({
        title: rule.name,
        message: rule.message,
        data: metric
      })
    })
  }
}

CI/CD 集成

PR 时自动检查性能

# .github/workflows/performance.yml
name: Performance Check

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Install
        run: npm ci
      
      - name: Build
        run: npm run build
      
      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v9
        with:
          urls: http://localhost:4173
          budgetPath: ./budget.json
      
      - name: Comment PR
        if: always()
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs')
            const report = JSON.parse(fs.readFileSync('./lighthouse-report.json'))
            const score = report.categories.performance.score * 100
            
            if (score < 90) {
              core.setFailed(`性能分数 ${score} 低于 90 分`)
            }

性能预算配置

// budget.json
{
  "budgets": [
    {
      "path": "/*",
      "resourceSizes": [
        { "resourceType": "script", "budget": 500 },
        { "resourceType": "stylesheet", "budget": 100 },
        { "resourceType": "image", "budget": 300 }
      ],
      "timings": [
        { "metric": "first-contentful-paint", "budget": 2000 },
        { "metric": "largest-contentful-paint", "budget": 2500 },
        { "metric": "cumulative-layout-shift", "budget": 0.1 }
      ]
    }
  ]
}

性能仪表盘

搭建简单看板

// 收集一周的性能数据
class PerformanceDashboard {
  constructor() {
    this.data = {
      LCP: [],
      FCP: [],
      CLS: [],
      apiCalls: new Map()
    }
  }
  
  addMetric(metric) {
    this.data[metric.type].push({
      value: metric.value,
      time: metric.timestamp
    })
    
    // 只保留最近7天
    const weekAgo = Date.now() - 7 * 24 * 3600000
    this.data[metric.type] = this.data[metric.type]
      .filter(d => d.time > weekAgo)
  }
  
  getStats(metric) {
    const values = this.data[metric].map(d => d.value)
    const avg = values.reduce((a, b) => a + b, 0) / values.length
    const p95 = this.percentile(values, 95)
    const p99 = this.percentile(values, 99)
    
    return { avg, p95, p99 }
  }
  
  percentile(values, p) {
    const sorted = [...values].sort((a, b) => a - b)
    const index = Math.ceil(p / 100 * sorted.length) - 1
    return sorted[index]
  }
  
  generateReport() {
    console.log('📊 性能周报')
    console.log('================================')
    console.log(`LCP: 平均 ${this.getStats('LCP').avg}ms, P95 ${this.getStats('LCP').p95}ms`)
    console.log(`FCP: 平均 ${this.getStats('FCP').avg}ms, P95 ${this.getStats('FCP').p95}ms`)
    console.log(`CLS: 平均 ${this.getStats('CLS').avg}`)
    console.log('================================')
  }
}

最佳实践清单

性能设计评审清单

每次新功能开发前,回答这些问题:

  • 路由是否懒加载?
  • 长列表是否用虚拟滚动?
  • 高频输入是否防抖?
  • 是否缓存重复请求?
  • 大数据是否分页?
  • 图片是否压缩?是否用WebP?
  • 字体是否按需加载?
  • 关键路径是否埋点?

性能案例库

记录每次性能优化,用于团队分享:

const cases = [
  {
    title: '订单列表从3秒到1秒',
    problem: '页面加载慢,用户投诉',
    solution: '虚拟滚动 + 按需加载',
    result: 'FCP从3.2s降到1.2s',
    author: '张三',
    date: '2026-01-15'
  },
  {
    title: '导出功能不卡了',
    problem: '导出时页面假死',
    solution: 'Web Worker处理数据',
    result: '页面不卡顿',
    author: '李四',
    date: '2026-02-20'
  }
]

监控体系四要素

1. 采集 - 知道发生了什么

  • 核心指标 (LCP, FID, CLS)
  • 自定义指标 (API, 组件渲染)

2. 分析 - 知道为什么发生

  • 关联代码版本
  • 关联用户群体
  • 关联环境信息

3. 告警 - 第一时间知道

  • 阈值设置
  • 告警渠道
  • 冷却机制

4. 预防 - 防止再次发生

  • CI 自动检查
  • 性能预算
  • 设计评审

结语

性能监控不是终点,而是持续优化的起点。没有监控的性能优化,就像没有仪表的驾驶。我们不知道车有多快,也不知道什么时候会抛锚!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

❌