阅读视图

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

Vue3 响应式系统——ref 和 reactive

一、Vue3 响应式系统概述

Vue3 响应式包 @vue/reactivity,核心由三部分构成:

数据 (Proxy Object)  —— 依赖收集 Track  —— 触发更新 Trigger  ——  Effect 执行更新

核心目标:

  • 拦截读取和设置操作
  • 收集依赖
  • 在数据变化时重新触发相关副作用

主要实现 API:

二、reactive() 执行机制

2.1 核心逻辑(核心源码)

function reactive(target) {
  return createReactiveObject(target, false, mutableHandlers)
}

function createReactiveObject(target, isReadonly, baseHandlers) {
  if (!isObject(target)) {
    return target
  }
  if (target already has proxy) return existing proxy
  const proxy = new Proxy(target, baseHandlers)
  cache proxy
  return proxy
}

Vue3 用 Proxy 拦截对象操作,比 Vue2 的 Object.defineProperty 更强(能监听属性增删)。

2.2 reactive 的 handler(简化)

const mutableHandlers = {
  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    track(target, key)
    return isObject(res) ? reactive(res) : res
  },
  set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)
    if (oldValue !== value) {
      trigger(target, key)
    }
    return result
  }
}

三、依赖收集和触发更新:track()trigger()

Vue 内部维护一个 全局的 activeEffect

let activeEffect = null

function effect(fn) {
  activeEffect = wrappedEffect(fn)
  fn() // 执行一次用于收集依赖
  activeEffect = null
}

每次读取(get)响应式数据时:

function track(target, key) {
  if (!activeEffect) return
  const depsMap = targetMap.get(target) || new Map()
  const dep = depsMap.get(key) || new Set()
  dep.add(activeEffect)
}

当数据被设置(set)时:

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap?.get(key)
  dep?.forEach(effect => effect())
}
  • track 只在 读取时收集依赖
  • trigger 只在 数据修改时触发 effect 重新执行

四、ref() 的设计与区别

4.1 ref 是什么?

ref() 主要用于包装 基本类型 (对于对象引用类型内部直接调用上面的 reactive()):

const count = ref(0)

其结构本质上是:

interface RefImpl {
  value: T
}

源码核心:

function ref(rawValue) {
  return createRef(rawValue)
}

function createRef(rawValue) {
  const refImpl = { 
    _value: convert(rawValue), 
    dep: new Set(), // 区别于reactive引用类型复杂的多层嵌套数据结构封装dep,ref这里直接在实例中存放一个dep来实现
    get value() {
      trackRefValue(refImpl)
      return refImpl._value
    },
    set value(newVal) {
      if (hasChanged(newVal, refImpl._value)) {
        refImpl._value = convert(newVal)
        triggerRefValue(refImpl)
      }
    }
  }
  return refImpl
}

4.2 ref vs reactive 的本质区别

五、template / setup 中的自动 unwrap

在 Vue 模板中:

<p>{{ count }}</p>

如果 count 是一个 ref,它会 自动解包,模板中不需要写 .value。这是由编译阶段的 transform 实现的。

六、响应式系统执行流程图(简化)

reactive/ref 数据 -> Proxy getter -> track
                           │
                        effect 注册
                           │
                    数据 setter -> trigger
                           ↓
                    重新执行 effect

Vue3中v-model在表单元素双向绑定中的场景差异与绑定策略是什么?

一、文本输入框与v-model的基础绑定:双向交互的起点

在Vue3中,v-model是处理表单输入的“瑞士军刀”——它通过语法糖简化了“值绑定+事件监听”的重复工作。对于文本输入框(input[type="text"]),v-model的本质是:

  • 将表单元素的value属性绑定到组件的状态变量;
  • 监听表单元素的input事件,当用户输入时更新状态变量。

1. 基础示例:用户名输入框

我们用一个简单的用户名输入案例,直观感受双向绑定的魔力:

<script setup>
import { ref } from 'vue'  
// 用ref创建响应式变量,初始值为空字符串
const username = ref('')  
</script>

<template>
  <div class="form-item">
    <label>用户名:</label>
    <!-- v-model绑定username,实现双向同步 -->
    <input type="text" v-model="username" placeholder="请输入用户名" />
    <!-- 实时展示输入结果 -->
    <p class="tip">当前输入:{{ username }}</p>
  </div>
</template>

效果说明

  • 用户在输入框中打字时,username会自动同步更新;
  • 若通过代码修改username(比如username.value = 'Vue3'),输入框的内容也会立即更新。

2. 底层原理流程图

v-model的双向绑定逻辑可以用以下流程概括:

graph TD
A[组件状态变量(如username)] --> B[渲染到表单元素的value属性]
B --> C[用户输入触发input事件]
C --> D[Vue更新状态变量]
D --> A[重新渲染表单元素]

二、多行文本与复选框:不同场景的绑定策略

1. 多行文本(textarea):和文本框“无缝衔接”

多行文本框(textarea)的绑定逻辑与文本输入框完全一致——v-model会自动处理value属性和input事件,无需额外配置:

<script setup>
import { ref } from 'vue'  
const introduction = ref('') // 个人简介,初始为空
</script>

<template>
  <div class="form-item">
    <label>个人简介:</label>
    <textarea v-model="introduction" rows="3" placeholder="说说你的故事"></textarea>
    <p class="tip">简介预览:{{ introduction }}</p>
  </div>
</template>

2. 复选框(checkbox):两种绑定场景

复选框的绑定分为单个复选框多个复选框两种情况,核心区别在于变量类型:

  • 单个复选框:绑定布尔值(表示“是否选中”),常用于“同意条款”场景;
  • 多个复选框:绑定数组(存储选中的value值),常用于“选择爱好”场景。
示例:单个复选框(同意条款)
<script setup>
import { ref } from 'vue'  
const agree = ref(false) // 默认未同意
</script>

<template>
  <div class="form-item">
    <input type="checkbox" id="agree" v-model="agree" />
    <label for="agree">我已阅读并同意《用户协议》</label>
    <p class="tip">状态:{{ agree ? '已同意' : '未同意' }}</p>
  </div>
</template>
示例:多个复选框(选择爱好)
<script setup>
import { ref } from 'vue'  
const hobbies = ref([]) // 存储选中的爱好,初始为空数组
const hobbyList = ['阅读', ' coding', '旅行', '摄影'] // 预设爱好列表
</script>

<template>
  <div class="form-item">
    <label>爱好:</label>
    <!-- 用v-for循环生成复选框,绑定hobbies数组 -->
    <div v-for="hobby in hobbyList" :key="hobby">
      <input 
        type="checkbox" 
        :id="hobby" 
        :value="hobby" 
        v-model="hobbies" 
      />
      <label :for="hobby">{{ hobby }}</label>
    </div>
    <p class="tip">已选爱好:{{ hobbies.join('、') }}</p>
  </div>
</template>

三、单选框与下拉选择:分组与关联的艺术

往期文章归档
免费好用的热门在线工具

1. 单选框(radio):用name属性分组

单选框需要通过name属性分组,确保同一组内只能选一个。v-model绑定的变量会存储选中项的value值:

<script setup>
import { ref } from 'vue'  
const gender = ref('male') // 默认选中“男”
</script>

<template>
  <div class="form-item">
    <label>性别:</label>
    <!-- name="gender" 分组,确保互斥 -->
    <input type="radio" name="gender" value="male" v-model="gender" id="male" />
    <label for="male">男</label>
    <input type="radio" name="gender" value="female" v-model="gender" id="female" />
    <label for="female">女</label>
    <p class="tip">选择性别:{{ gender }}</p>
  </div>
</template>

2. 下拉选择(select):单选与多选的区别

下拉选择框(select)的绑定逻辑与复选框类似,需根据单选/多选场景选择变量类型:

  • 单选:绑定单个值(如字符串、数字);
  • 多选:绑定数组,并添加multiple属性(按住Ctrl可多选)。
示例:下拉单选(选择城市)
<script setup>
import { ref } from 'vue'  
const city = ref('beijing') // 默认选中北京
const cityList = [ // 城市列表,含value和标签
  { value: 'beijing', label: '北京' },
  { value: 'shanghai', label: '上海' },
  { value: 'guangzhou', label: '广州' }
]
</script>

<template>
  <div class="form-item">
    <label>城市:</label>
    <select v-model="city">
      <!-- 用v-for循环生成选项 -->
      <option v-for="item in cityList" :key="item.value" :value="item.value">
        {{ item.label }}
      </option>
    </select>
    <p class="tip">当前城市:{{ city }}</p>
  </div>
</template>
示例:下拉多选(选择水果)
<script setup>
import { ref } from 'vue'  
const fruits = ref([]) // 存储选中的水果,初始为空数组
const fruitList = ['苹果', '香蕉', '橙子', '草莓']
</script>

<template>
  <div class="form-item">
    <label>喜欢的水果:</label>
    <!-- multiple属性开启多选,v-model绑定数组 -->
    <select v-model="fruits" multiple>
      <option v-for="fruit in fruitList" :key="fruit" :value="fruit">
        {{ fruit }}
      </option>
    </select>
    <p class="tip">已选水果:{{ fruits.join('、') }}</p>
  </div>
</template>

四、v-model修饰符:精准控制输入行为

Vue3为v-model提供了3个实用修饰符,用于解决常见的输入处理问题:

1. .lazy:延迟更新,减少性能消耗

默认情况下,v-model会在每一次输入(如按键、粘贴)时更新状态。对于大型表单(如长文本输入),频繁更新可能影响性能——.lazy修饰符会将更新时机延迟到失去焦点按下回车键时:

<input type="text" v-model.lazy="bio" placeholder="请输入个人简介" />

2. .number:自动转换为数字类型

用户输入的内容默认是字符串类型,若需要处理数字(如年龄、价格),.number修饰符会自动将输入值转换为Number类型:

<input type="text" v-model.number="age" placeholder="请输入年龄" />
<p>年龄类型:{{ typeof age }}</p> <!-- 输出:number -->

3. .trim:自动去除首尾空格

用于处理用户名、昵称等场景,避免用户误输入的空格影响逻辑(如“ Vue3 ”会被转换为“Vue3”):

<input type="text" v-model.trim="nickname" placeholder="请输入昵称" />
<p>昵称长度:{{ nickname.length }}</p> <!-- 去除空格后的长度 -->

课后Quiz:巩固你的理解

问题:请说明v-model在多个复选框下拉多选中的绑定规则,并解释两者的变量类型差异。

答案解析

  • 多个复选框:需将v-model绑定到数组,数组元素为选中项的value值(如hobbies: ['阅读', 'coding']);
  • 下拉多选:同样需要绑定数组,但需为select标签添加multiple属性(如<select v-model="fruits" multiple>);
  • 变量类型差异:两者均绑定数组,但复选框的valueinput标签的value属性指定,下拉多选的valueoption标签的value属性指定。

常见报错与解决方案

1. 报错:v-model cannot be used on input type="checkbox" with multiple values

  • 原因:多个复选框的v-model未绑定数组(比如绑定了布尔值);
  • 解决:将v-model绑定到一个空数组(如const hobbies = ref([]))。

2. 报错:.number modifier requires the input type to be number or text

  • 原因.number修饰符被用在非文本/数字输入类型(如checkboxradio);
  • 解决:仅在input[type="text"]input[type="number"]上使用.number

3. 报错:v-model value must be a ref when using script setup

  • 原因:v-model绑定的变量不是响应式的(未用refreactive包裹);
  • 解决:用ref创建响应式变量(如const username = ref(''))。

参考链接

Vuex 核心概念全解析:构建优雅的 Vue 应用状态管理

Vuex 核心概念全解析:构建优雅的 Vue 应用状态管理

你是否曾在 Vue 项目中遇到过这样的困扰:

  • • 组件间数据传递像“击鼓传花”,层层 props 透传令人头疼
  • • 兄弟组件通信需要借助父组件做“中转站”
  • • 多个组件依赖同一份数据,一处修改处处需要同步

今天我们就来聊聊 Vue 的官方状态管理库——Vuex,帮你彻底解决这些痛点!

一、为什么需要 Vuex?

想象一下,如果每个组件都有自己的“小账本”,当应用复杂时,数据就像散落的珍珠,难以统一管理。Vuex 就是一个“中央账本”,把数据集中存储,让状态变化变得可预测、可追踪。

二、Vuex 五大核心概念详解

1. State(状态)—— 数据仓库

State 是 Vuex 的“数据库”,存储所有需要共享的数据。

const store new Vuex.Store({
  state: {
    user: {
      name'小明',
      age25
    },
    cart: []
  }
})

特点:

  • • 响应式:State 变化,依赖它的组件自动更新
  • • 单一数据源:整个应用只有一个 store
  • • 在组件中使用:this.$store.state.user

2. Getters(计算属性)—— 数据的“加工厂”

Getters 就像 Vue 中的 computed,用于从 state 派生出新数据。

getters: {
  // 获取购物车商品总数
  cartItemCountstate => {
    return state.cart.reduce((total, item) => total + item.quantity0)
  },
  
  // 获取折扣后的价格
  discountedPrice(state) => (productId) => {
    const product = state.products.find(p => p.id === productId)
    return product.price * 0.8
  }
}

使用场景:

  • • 数据过滤、格式化
  • • 复杂计算逻辑封装
  • • 组件中调用:this.$store.getters.cartItemCount

3. Mutations(变更)—— 唯一的状态修改者

Mutations 是修改 state 的唯一途径,每个 mutation 都有一个字符串类型的“事件类型”和一个回调函数。

mutations: {
  // 添加商品到购物车
  ADD_TO_CART(state, product) {
    const existingItem = state.cart.find(item => item.id === product.id)
    if (existingItem) {
      existingItem.quantity++
    } else {
      state.cart.push({ ...product, quantity: 1 })
    }
  },
  
  // 清空购物车
  CLEAR_CART(state) {
    state.cart = []
  }
}

重要原则:

  • • 必须是同步函数
  • • 通过 store.commit('mutation名', payload) 调用
  • • 让每次状态变化都可追踪

4. Actions(动作)—— 处理异步操作的“指挥官”

Actions 可以包含任意异步操作,最终通过提交 mutation 来修改状态。

actions: {
  // 异步获取用户信息
  async fetchUser({ commit }, userId) {
    try {
      const response = await api.getUser(userId)
      commit('SET_USER', response.data// 调用 mutation
      return response.data
    } catch (error) {
      commit('SET_ERROR', error.message)
      throw error
    }
  },
  
  // 组合多个 mutation
  checkout({ commit, state }) {
    // 保存订单
    commit('CREATE_ORDER', state.cart)
    // 清空购物车
    commit('CLEAR_CART')
    // 显示成功提示
    commit('SHOW_MESSAGE''订单提交成功!')
  }
}

与 Mutation 的区别:

  • • Action 提交的是 mutation,而不是直接变更状态
  • • Action 可以包含任意异步操作
  • • 通过 store.dispatch('action名', payload) 调用

5. Modules(模块)—— 大型应用的“分治策略”

当应用复杂时,可以将 store 分割成模块,每个模块拥有自己的 state、mutations、actions、getters。

const userModule = {
  namespaced: true// 开启命名空间
  state: () => ({ userInfo: null }),
  mutations: { /* ... */ },
  actions: { /* ... */ }
}

const productModule = {
  namespaced: true,
  state: () => ({ products: [] }),
  mutations: { /* ... */ }
}

const store new Vuex.Store({
  modules: {
    user: userModule,
    product: productModule
  }
})

模块化的好处:

  • • 避免 state 对象过于臃肿
  • • 让相关功能组织在一起
  • • 调用方式:this.$store.dispatch('user/login', credentials)

三、实战:购物车完整示例

// store.js
export default new Vuex.Store({
  state: {
    cart: [],
    products: []
  },
  
  getters: {
    totalPricestate => {
      return state.cart.reduce((sum, item) => {
        return sum + (item.price * item.quantity)
      }, 0)
    }
  },
  
  mutations: {
    ADD_ITEM(state, product) {
      // ... 添加商品逻辑
    }
  },
  
  actions: {
    async loadProducts({ commit }) {
      const products = await api.getProducts()
      commit('SET_PRODUCTS', products)
    }
  }
})

四、最佳实践建议

  1. 1. 遵循单向数据流

    组件 → Actions → Mutations → State → 组件更新
    
  2. 2. 合理划分模块

    • • 按功能领域划分(user、product、order等)
    • • 大型项目考虑动态注册模块
  3. 3. 使用辅助函数简化代码:

    import { mapState, mapActions } from 'vuex'
    
    export default {
      computed: {
        ...mapState(['user''cart']),
        ...mapGetters(['totalPrice'])
      },
      methods: {
        ...mapActions(['fetchUser''addToCart'])
      }
    }
    
  4. 4. TypeScript 支持
    Vuex 4 对 TypeScript 有更好的类型支持

五、总结

Vuex 的五员大将各司其职:

  • • State:数据存储中心
  • • Getters:数据的计算加工
  • • Mutations:同步修改状态
  • • Actions:处理异步和复杂逻辑
  • • Modules:模块化管理

记住这个简单的比喻:State 是仓库,Getters 是包装部,Mutations 是仓库管理员,Actions 是采购员,Modules 是分公司。

Vuex 的学习曲线可能有点陡峭,但一旦掌握,你将拥有管理复杂应用状态的超能力!

Vue3的v-model如何实现表单双向绑定?

一、为什么需要表单输入绑定?

你有没有过这样的经历?做登录页时,想让用户输入的用户名实时显示在页面上;或者做设置页时,修改开关按钮的状态要同步到后台数据。这时候,如果手动监听每个输入框的事件、手动更新数据,代码会变得非常繁琐——比如:

<input type="text" id="username" oninput="updateUsername(event)">
function updateUsername(e) {
  this.username = e.target.value;
}

不仅要写一堆事件监听,还要处理不同表单元素的差异(比如复选框的checked属性、下拉框的selected属性)。而Vue3的表单输入绑定就是为了解决这个问题——它帮你把“输入→数据→视图”的同步逻辑封装成了一个简单的指令:v-model

二、双向绑定:Vue3的“数据-视图”同步魔法

在讲v-model之前,我们得先搞懂双向绑定的核心逻辑。简单来说,双向绑定就是:

  • 当用户修改视图(比如输入文字、点击复选框),数据自动更新;
  • 当代码修改数据(比如this.username = 'admin'),视图自动同步。

双向绑定的原理流程图

graph TD
A[用户修改视图 输入/点击] --> B[触发对应事件 input/change]
B --> C[更新数据如username 输入内容]
C --> D[Vue响应式系统检测到数据变化]
D --> E[自动更新视图显示]

举个例子:当你在输入框里敲“hello”,Vue会做这几件事:

  1. 监听输入框的input事件,拿到你输入的“hello”;
  2. username数据更新为“hello”;
  3. 响应式系统发现username变了,立刻通知输入框显示“hello”。

三、v-model指令:双向绑定的语法糖

Vue3为双向绑定提供了语法糖——v-model,它把“绑定value+监听事件”的逻辑封装成了一个指令。比如:

<input v-model="username">

等价于:

<input :value="username" @input="username = $event.target.value">

是不是简洁多了?v-model帮你省掉了手动写事件监听的麻烦,而且适用于所有表单元素。

四、v-model在不同表单元素中的应用

v-model不是只能用在文本输入框,它支持所有常见的表单元素,我们逐个看:

1. 文本输入框(input[type="text"])与多行文本(textarea)

  • 文本输入框:直接绑定字符串类型的响应式数据;
  • 多行文本(textarea):不能用插值表达式{{ message }}),必须用v-model

示例代码:

<script setup>
import { ref } from 'vue'
const username = ref('') // 字符串类型
const intro = ref('')    // 多行文本内容
</script>

<template>
  <div>
    <label>用户名:<input type="text" v-model="username"></label>
    <label>个人简介:<textarea v-model="intro" rows="3"></textarea></label>
  </div>
</template>

2. 复选框(input[type="checkbox"])

复选框分两种情况:

  • 单个复选框:绑定布尔值(true/false),表示“是否选中”;
  • 多个复选框:绑定数组,数组元素是选中的value值。

示例代码:

<script setup>
import { ref } from 'vue'
const rememberMe = ref(false) // 单个复选框(布尔值)
const hobbies = ref([])       // 多个复选框(数组)
</script>

<template>
  <div>
    <!-- 单个复选框:记住我 -->
    <label><input type="checkbox" v-model="rememberMe"> 记住我</label>
    
    <!-- 多个复选框:爱好 -->
    <label><input type="checkbox" value="reading" v-model="hobbies"> 阅读</label>
    <label><input type="checkbox" value="sports" v-model="hobbies"> 运动</label>
    <label><input type="checkbox" value="coding" v-model="hobbies"> 编程</label>
  </div>
</template>

3. 单选按钮(input[type="radio"])

单选按钮绑定字符串,值为选中的value属性。

示例代码:

<script setup>
import { ref } from 'vue'
const gender = ref('male') // 默认选中“男”
</script>

<template>
  <div>
    <label><input type="radio" value="male" v-model="gender"></label>
    <label><input type="radio" value="female" v-model="gender"></label>
  </div>
</template>

4. 下拉框(select)

下拉框的v-model绑定选中的value值,optionvalue属性对应选项值。

示例代码:

<script setup>
import { ref } from 'vue'
const city = ref('beijing') // 默认选中“北京”
</script>

<template>
  <div>
    <label>城市:
      <select v-model="city">
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="guangzhou">广州</option>
      </select>
    </label>
  </div>
</template>

五、数据响应式:双向绑定的底层支撑

你可能会问:“为什么数据变了,视图会自动更新?”这要归功于Vue3的响应式系统

往期文章归档
免费好用的热门在线工具

Vue3用refreactive创建响应式数据,当数据变化时,Vue会自动追踪依赖(比如模板中用到username的地方),并更新对应的视图。而v-model正是利用了这个系统,让数据和视图双向同步。

比如用ref创建username

const username = ref('')

ref会把username包装成一个响应式对象,当你修改username.value(或通过v-model修改),Vue会立刻知道,并更新视图。

六、实际案例:打造一个注册表单

让我们把前面的知识点整合起来,做一个注册表单,包含用户名、密码、记住我、性别、爱好、城市,提交时打印表单数据。

完整代码(带样式)

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

// 用ref创建表单对象,包含所有字段
const form = ref({
  username: '',
  password: '',
  rememberMe: false,
  gender: 'male',
  hobbies: [],
  city: 'beijing'
})

// 提交处理函数:阻止默认刷新,打印表单数据
const handleSubmit = (e) => {
  e.preventDefault()
  console.log('表单数据:', form.value)
  // 这里可以加发送请求到后台的逻辑,比如axios.post('/api/register', form.value)
}
</script>

<template>
  <div class="register-form">
    <h2>用户注册</h2>
    <form @submit.prevent="handleSubmit">
      <!-- 用户名 -->
      <div class="form-group">
        <label for="username">用户名:</label>
        <input 
          type="text" 
          id="username" 
          v-model="form.username" 
          placeholder="请输入用户名"
          required
        >
      </div>
      
      <!-- 密码 -->
      <div class="form-group">
        <label for="password">密码:</label>
        <input 
          type="password" 
          id="password" 
          v-model="form.password" 
          placeholder="请输入密码"
          required
        >
      </div>
      
      <!-- 记住我 -->
      <div class="form-group">
        <label><input type="checkbox" v-model="form.rememberMe"> 记住登录状态</label>
      </div>
      
      <!-- 性别 -->
      <div class="form-group">
        <label>性别:</label>
        <input type="radio" value="male" v-model="form.gender"><input type="radio" value="female" v-model="form.gender"></div>
      
      <!-- 爱好 -->
      <div class="form-group">
        <label>爱好:</label>
        <input type="checkbox" value="reading" v-model="form.hobbies"> 阅读
        <input type="checkbox" value="sports" v-model="form.hobbies"> 运动
        <input type="checkbox" value="coding" v-model="form.hobbies"> 编程
      </div>
      
      <!-- 城市 -->
      <div class="form-group">
        <label for="city">城市:</label>
        <select id="city" v-model="form.city">
          <option value="beijing">北京</option>
          <option value="shanghai">上海</option>
          <option value="guangzhou">广州</option>
          <option value="shenzhen">深圳</option>
        </select>
      </div>
      
      <!-- 提交按钮 -->
      <button type="submit" class="submit-btn">注册</button>
    </form>
  </div>
</template>

<style scoped>
.register-form {
  max-width: 400px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
.form-group {
  margin-bottom: 15px;
}
label {
  display: block;
  margin-bottom: 5px;
}
input, select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
.submit-btn {
  width: 100%;
  padding: 10px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.submit-btn:hover {
  background-color: #3aa776;
}
</style>

代码说明

  1. 表单数据管理:用ref创建form对象,把所有表单字段放在一起,方便管理;
  2. 提交处理:用@submit.prevent阻止表单默认的刷新行为,打印表单数据;
  3. 响应式同步:每个字段用v-model绑定到form对象的属性,输入时自动同步。

七、课后Quiz:巩固你的理解

来做两个小练习,检验一下学习成果~

1. 问题:v-model的语法糖本质是什么?请写出等价的原生绑定代码。

答案解析
v-model是value属性绑定 + input事件监听的语法糖。比如<input v-model="message">等价于:

<input :value="message" @input="message = $event.target.value">
  • :value="message":把数据绑定到输入框的value属性;
  • @input:监听输入事件,把输入内容更新到message

2. 问题:多个复选框如何用v-model实现多选?请写出示例代码。

答案解析
多个复选框需要绑定到数组类型的响应式数据。每个复选框的value对应数组中的元素,选中时加入数组,取消时移除。示例:

<script setup>
import { ref } from 'vue'
const hobbies = ref([]) // 数组类型
</script>

<template>
  <label><input type="checkbox" value="reading" v-model="hobbies"> 阅读</label>
  <label><input type="checkbox" value="sports" v-model="hobbies"> 运动</label>
  <label><input type="checkbox" value="coding" v-model="hobbies"> 编程</label>
</template>

比如选中“阅读”和“编程”,hobbies.value会变成['reading', 'coding']

八、常见报错与解决方案

学习过程中遇到报错别慌,以下是表单绑定常见的3个错误及解决办法:

1. 报错:v-model is not allowed on <input type="file">

  • 原因:文件输入框(type="file")的value只读的,无法通过v-model修改。
  • 解决办法:用ref获取DOM元素,监听change事件拿文件:
    <script setup>
    import { ref } from 'vue'
    const fileInput = ref(null)
    const handleFile = () => {
      const file = fileInput.value.files[0] // 获取选中的文件
      console.log('文件:', file)
    }
    </script>
    
    <template>
      <input type="file" ref="fileInput" @change="handleFile">
    </template>
    

2. 报错:Property "message" was accessed during render but is not defined

  • 原因:模板里用了message,但没在setup中定义响应式数据。
  • 解决办法:用refreactive定义message
    import { ref } from 'vue'
    const message = ref('') // 必须定义!
    

3. 报错:v-model requires a valid Vue instance

  • 原因:可能在非Vue组件中用了v-model(比如纯HTML文件没挂载Vue),或组件未正确注册。
  • 解决办法:确保在Vue组件中使用,并正确挂载应用:
    // main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    createApp(App).mount('#app') // 挂载到#app元素
    

参考链接

官网表单处理文档:vuejs.org/guide/essen…

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

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

一、核心基础:ref 与 reactive

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

1. 核心特性与区别

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

2. 代码示例

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

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

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

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

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

3. 适用场景

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

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

二、性能优化:shallowRef 与 shallowReactive

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

1. 核心特性与区别

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

2. 代码示例

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

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

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

3. 适用场景

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

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

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

三、只读防护:readonly 与 shallowReadonly

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

1. 核心特性与区别

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

2. 代码示例

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

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

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

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

3. 适用场景

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

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

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

1. 快速选型流程图

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

2. 常见坑点规避

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

五、总结

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

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

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

文章简介

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

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

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

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

工具站源码地址

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

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

实现原理

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

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

实现思路

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

:root 伪 class 定义

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

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

默认主题明亮模式 class 定义

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

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

默认主题暗夜模式 class 定义

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

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

主题切换源码

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

主题、模式手动切换组件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

vue main.js 文件内容

源码位置:src/main.js

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

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

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

主题初始化、模式监听

源码位置:src/App.vue

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

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

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

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

仓库地址:

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

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

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

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

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

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

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


1. 使用 shallowReactive 替代 reactive

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

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

示例

import { shallowReactive } from 'vue';

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

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


2. 用 toRefs 解构响应式对象

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

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

示例

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

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


3. 优先使用 watchEffect 而非 watch

区别

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

示例

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

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


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

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

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

示例

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

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


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

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

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

示例

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

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

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


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

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

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

示例

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

使用

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

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


7. 用 Pinia 插件扩展 store 能力

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

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

正确实现

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

使用

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

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

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


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

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

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

示例

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

注意事项

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

v-memo 是 Vue 3.2+ 的功能。


9. 虚拟滚动(Virtual Scrolling)

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

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

推荐库(Vue 3 兼容)

安装 & 示例(以 vueuc 为例)

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

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

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


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

组件懒加载

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

写法

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

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

图片优化

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

简单懒加载(原生支持)

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

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


总结

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

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

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

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

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

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

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

🤔 为什么需要全局方法?

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

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

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

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

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

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

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

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

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

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

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

优点:

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

缺点:

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

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

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

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

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

Vue.mixin(GlobalMixin)

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

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

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

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

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

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

const app = createApp(App)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const app = createApp(App)

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

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

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

📊 5种方案对比总结

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

💡 最佳实践建议

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

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

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

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

✨ 结语

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

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

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

vscode 中找settings.json 配置

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

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

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

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

方法2:设置界面跳转

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

方法3:文件路径访问

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

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

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

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

三、配置注意事项

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

四、验证配置是否生效

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

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

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

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

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

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

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

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

在项目中安装 xlsx 库:

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

核心 API

import * as XLSX from 'xlsx';

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

Excel 文件读取与解析

1. 使用 FileReader 读取文件

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

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

2. 解析 Excel 文件

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

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

3. 不同数据格式的转换

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

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

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

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

表头验证与数据提取

1. 验证表头格式

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

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

2. 提取数据行

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

3. 数据验证与清洗

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