普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月14日首页

前端高频面试题之Vuex篇

2025年11月14日 21:15

1、Vuex 是什么?什么情况下应该使用 Vuex?

Vuex 是专门为 Vue.js 应用提供状态管理模式的一个库,也是 Vue.js 官方推荐的状态管理方案,它将所有数据集中存储到一个全局 store 对象中,并制定了一定的规则,保证状态以预期的方式发生变化。

它的核心概念有:

  • state:存储状态,并提供响应式能力。
  • getter: 从 state 中派生出一些状态,相当于 Vue.js 中的计算属性 computed。
  • mutation: 通过提交 mutation,是 Vuex 中修改 state 的推荐方式。
  • action:可以包括异步操作,异步操作处理完后,通过提交 mutation 修改状态。
  • module: 模块化,可以将 store 分割成一个个小模块,每个模块拥有自己的 state、getter、mutation、action,甚至是嵌套子模块。

在构建中大型单页应用时,各组件和模块的状态流转逻辑会相当复杂,这时候就可以使用 Vuex 进行全局状态管理,并且里面用严格的 mutation 保证了状态的预期流转,使得项目的数据流变得清晰,提高了项目可维护性。

2、如何解决页面刷新后 Vuex 的数据丢失问题?

数据丢失原因:Vuex 中的状态 state 是存储在内存中的,刷新页面会导致内存清空,所以数据丢失。

解决方案:

2.1 第一步:使用持久化存储保存数据

将 Vuex 的数据在合适时机(比如监听 window 的beforeunload 事件)保存到浏览器的本地存储(localStoragesessionStorage),也可以直接采用 vuex-persistedstate 持久化插件(默认会存储到 localStorage 中,可通过配置修改)进行本地存储。

2.2 第二步:初始化应用,替换状态

应用初始化加载时,获取存储中的状态进行替换。Vuex 给我们提供了一个 replaceState(state: Object) API,可以很方便进行状态替换。

2.3 第三步:检查数据,发起请求

在状态替换后,还需要检查 Vuex 中的数据是否存在,如果不存在则可以在 action 中发送接口请求拿到数据,通过提交 mutation 修改状态把数据存储到 store 中。

2.4 第四步:状态同步

状态变化后将状态同步到浏览器存储中,保证本地存储中状态的实时性。

不过要注意的是,如果把数据持久化到 localStorage 或者 sessionStorage 中,会有一定的安全风险:

  1. 数据直接全部暴露在 storage 可通过控制台的 Application 选项卡进行查看,数据容易泄漏。持久化的数据毕竟没有内存中的数据安全。
  2. 用户可以直接在控制台 Application 中直接修改数据,从而可能绕过某些权限校验,看到一些预期外的界面和交互。

3、mutation 和 action 的区别有哪些?

  • 作用不同:action 是用来处理异步逻辑或者业务逻辑,而 mutation 是用来修改状态的。
  • 使用限制:action 中可以调用 mutation 或者其他 action,而 mutation 中则只能修改 state。
  • 返回值不同dispatch 时会将 action 包装成 promise,而 mutation 则没进行包装。
  • 严格模式下的差异:在 Vuex 开启严格模式 strict: true 后,任何非 mutation 函数修改的状态,将会抛出错误。

扩展:vuex 严格模式是如何监听非 mutation 函数修改状态的?

其核心思路如下:

  1. this._committing 表示程序是否处于 commit 执行过程。
  2. 用同步 watch(同步监听的意思是,一旦数据发生变化会立即调用回调,而不是在 下一次 Tick 中调用) 监听 store 中的 state 状态(深度监听)。
  3. 如果在 commit 执行过程中,state 发生了变化,在开发环境会报错。
class Store {
  commit(_type, _payload, _options) {
    this._withCommit(() => {
      // commit 中的处理
      entry.forEach(function commitIterator(handler) {
        handler(payload);
      });
    });
  }
  _withCommit(fn) {
    const committing = this._committing;
    this._committing = true;
    fn(); // 如果函数内部有异步修改状态逻辑,则下面的 watch 时会报错
    this._committing = committing;
  }
}
function enableStrictMode(store) {
  watch(
    () => store._state.data,
    () => {
      if (__DEV__) { // 开发环境报错
        assert(
          store._committing,
          `do not mutate vuex store state outside mutation handlers.`
        );
      }
    },
    { deep: true, flush: "sync" } // 定义同步的 watcher 进行同步监控
  );
}

4、Vuex 的 module 在什么情况下会使用?

用官方的话来说就是,“使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得非常复杂时,store 对象就有可能变得相当臃肿。”

所以我们在开发复杂应用时,可以按照业务逻辑将应用状态进行 modules 拆分,比如:

  1. 用户模块 user;
  2. 订单模块 order;
  3. 课程模块 course;
  4. ...等其它模块。

这样在开发应用和维护状态时更加精细和清晰,可维护性更强。

5、Vuex 和 Pinia 的区别?

Pinia 是以 Vuex 5 为原型,由 Vue.js 官方团队开发的新一代 Vue 官方推荐的状态管理方案。

它对比 Vuex 有以下区别:

5.1 API 设计和使用方式

  • Vuex:采用单一 store 结构,需要严格区分 mutation(同步修改状态)和 action(异步操作)。状态修改必须通过 commit mutations 进行,虽然让数据流向更清晰,但也会让代码更加冗长。
  • Pinia:更简单的 API 设计,所见即所得,也提供了符合组合式 API 风格的 API(比如用 defineStore 定义 store)。去掉了 mutation,直接在 actions 中修改 state(支持同步/异步)。

5.2 模块化和结构

  • Vuex:支持模块化(modules),但需要在单一 store 中组织,可能导致大型项目 store 膨胀。
  • Pinia:天生模块化,每个 store 独立定义和导入,支持动态注册和热重载。更适合大型应用,便于拆分成小 store。

5.3 TypeScript 支持

  • Vuex:TypeScript 支持一般,需要额外配置;
  • Pinia:本身源码就是用 TypeScript 编写,所以对TypeScript 支持十分友好,具备自动推断类型、类型安全和代码补全。

5.4 性能和集成

  • Vuex:Vuex4 在 Vue3 中可用,但与 Composition API 集成不够顺畅,可能需要额外的适配;
  • Pinia:更轻量(体积小,约1kb),性能更好;完美支持 Vue 3 的 Composition API 和 reactivity 系统。

6、Pinia 和 Vuex 如何选择?

  • 新项目:强烈推荐用 Vue3 + Pinia
  • 老 Vue2 项目:如果不把项目升级到 Vue3 还是建议用 Vuex,如果需要升级到 vue3,就可以逐步把 Vuex 替换为 Pinia,Vuex 和 Pinia 是可以同时安装在同一个项目中,这也为项目升级提供了一定的便利。当然,由 Vuex -> Pinia,是一次,无疑和 Vue2 -> Vue3 一样,是一次大的破坏性升级,工作量还是相当大的。

结语

以上是整理的 Vuex 的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vue-router 相关面试题。

《uni-app跨平台开发完全指南》- 07 - 数据绑定与事件处理

2025年11月14日 17:38

引言:在上一章节中,我们详细介绍了页面路由与导航的相关知识点。今天我们讨论的是数据绑定与事件处理,深入研究数据是如何流动、用户交互如何响应的问题。我们平时用的app比如说输入框中打字,下方实时显示输入内容。这个看似简单的交互背后,隐藏着前端框架的核心思想——数据驱动视图

对比:传统DOM操作 vs 数据驱动

graph TB
    A[传统DOM操作] --> B[手动选择元素]
    B --> C[监听事件]
    C --> D[直接修改DOM]
    
    E[数据驱动模式] --> F[修改数据]
    F --> G[框架自动更新DOM]
    G --> H[视图同步更新]

在传统开发中,我们需要:

// 传统方式
const input = document.getElementById('myInput');
const display = document.getElementById('display');

input.addEventListener('input', function(e) {
    // 手动更新DOM
    display.textContent = e.target.value; 
});

而在 uni-app 中:

<template>
  <input v-model="message">
  <div>{{ message }}</div>
</template>

<script>
export default {
  data() {
    return {
      // 只需关注数据,DOM自动更新
      message: '' 
    }
  }
}
</script>

这种模式的转变,正是现代前端框架的核心突破。下面让我们深入研究其实现原理。


一、响应式数据绑定

1.1 数据劫持

Vue 2.x 使用 Object.defineProperty 定义对象属性实现数据响应式,让我们通过一段代码来加深理解这个机制:

// 响应式原理
function defineReactive(obj, key, val) {
  // 每个属性都有自己的依赖收集器
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log(`读取属性 ${key}: ${val}`)
      // 依赖收集:记录当前谁在读取这个属性
      dep.depend()
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log(`设置属性 ${key}: ${newVal}`)
      if (newVal === val) return
      val = newVal
      // 通知更新:值改变时通知所有依赖者
      dep.notify()
    }
  })
}

// 测试
const data = {}
defineReactive(data, 'message', 'Hello')
data.message = 'World'    // 控制台输出:设置属性 message: World
console.log(data.message) // 控制台输出:读取属性 message: World

1.2 完整的响应式系统架构

graph LR
    A[数据变更] --> B[Setter 触发]
    B --> C[通知 Dep]
    C --> D[Watcher 更新]
    D --> E[组件重新渲染]
    E --> F[虚拟DOM Diff]
    F --> G[DOM 更新]
    
    H[模板编译] --> I[收集依赖]
    I --> J[建立数据与视图关联]

原理说明

  • 当对响应式数据进行赋值操作时,会触发通过Object.defineProperty定义的setter方法。
  • setter首先比较新旧值是否相同,如果相同则直接返回,避免不必要的更新。
  • 如果值发生变化,则更新数据,并通过依赖收集器(Dep)通知所有观察者(Watcher)进行更新。
  • 这个过程是同步的,但实际的DOM更新是异步的,通过队列进行批量处理以提高性能。

1.3 v-model 的双向绑定原理

v-model 不是魔法,而是语法糖:

<!-- 这行代码: -->
<input v-model="username">

<!-- 等价于: -->
<input 
  :value="username" 
  @input="username = $event.target.value"
>

原理分解:

sequenceDiagram
    participant U as 用户
    participant I as Input元素
    participant V as Vue实例
    participant D as DOM视图
    
    U->>I: 输入文字
    I->>V: 触发input事件,携带新值
    V->>V: 更新data中的响应式数据
    V->>D: 触发重新渲染
    D->>I: 更新input的value属性

1.4 不同表单元素的双向绑定

文本输入框

<template>
  <view class="example">
    <text class="title">文本输入框绑定</text>
    <input 
      type="text" 
      v-model="textValue" 
      placeholder="请输入文本"
      class="input"
    />
    <text class="display">实时显示: {{ textValue }}</text>
    
    <!-- 原理展示 -->
    <view class="principle">
      <text class="principle-title">实现原理:</text>
      <input 
        :value="textValue" 
        @input="textValue = $event.detail.value"
        placeholder="手动实现的v-model"
        class="input"
      />
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      textValue: ''
    }
  }
}
</script>

<style scoped>
.example {
  padding: 20rpx;
  border: 2rpx solid #eee;
  margin: 20rpx;
  border-radius: 10rpx;
}
.title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
}
.display {
  color: #007AFF;
  font-size: 28rpx;
}
.principle {
  background: #f9f9f9;
  padding: 20rpx;
  border-radius: 8rpx;
  margin-top: 30rpx;
}
.principle-title {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-bottom: 15rpx;
}
</style>

单选按钮组

<template>
  <view class="example">
    <text class="title">单选按钮组绑定</text>
    
    <radio-group @change="onGenderChange" class="radio-group">
      <label class="radio-item">
        <radio value="male" :checked="gender === 'male'" /></label>
      <label class="radio-item">
        <radio value="female" :checked="gender === 'female'" /></label>
    </radio-group>
    
    <text class="display">选中: {{ gender }}</text>
    
    <!-- 使用v-model -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <radio-group v-model="simpleGender" class="radio-group">
      <label class="radio-item">
        <radio value="male" /></label>
      <label class="radio-item">
        <radio value="female" /></label>
    </radio-group>
    
    <text class="display">选中: {{ simpleGender }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      gender: 'male',
      simpleGender: 'male'
    }
  },
  methods: {
    onGenderChange(e) {
      this.gender = e.detail.value
    }
  }
}
</script>

<style scoped>
.radio-group {
  display: flex;
  gap: 40rpx;
  margin: 20rpx 0;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

复选框数组

<template>
  <view class="example">
    <text class="title">复选框数组绑定</text>
    
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          :checked="selectedHobbies.includes(hobby.value)"
          @change="onHobbyChange($event, hobby.value)"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ selectedHobbies }}</text>
    
    <!-- v-model简化版 -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          v-model="simpleHobbies"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ simpleHobbies }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      hobbyOptions: [
        { name: '篮球', value: 'basketball' },
        { name: '阅读', value: 'reading' },
        { name: '音乐', value: 'music' },
        { name: '旅行', value: 'travel' }
      ],
      selectedHobbies: ['basketball'],
      simpleHobbies: ['basketball']
    }
  },
  methods: {
    onHobbyChange(event, value) {
      const checked = event.detail.value.length > 0
      if (checked) {
        if (!this.selectedHobbies.includes(value)) {
          this.selectedHobbies.push(value)
        }
      } else {
        const index = this.selectedHobbies.indexOf(value)
        if (index > -1) {
          this.selectedHobbies.splice(index, 1)
        }
      }
    }
  }
}
</script>

<style scoped>
.checkbox-group {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

二、事件处理

2.1 事件流:从点击到响应

浏览器中的事件流包含三个阶段:

graph TB
    A[事件发生] --> B[捕获阶段 Capture Phase]
    B --> C[目标阶段 Target Phase]
    C --> D[冒泡阶段 Bubble Phase]
    
    B --> E[从window向下传递到目标]
    C --> F[在目标元素上触发]
    D --> G[从目标向上冒泡到window]
解释说明:

第一阶段: 捕获阶段(事件从window向下传递到目标元素) 传递路径:Window → Document → HTML → Body → 父元素 → 目标元素; 监听方式:addEventListener(event, handler, true)第三个参数设为true;

第二阶段: 目标阶段(事件在目标元素上触发处理程序) 事件处理:在目标元素上执行绑定的事件处理函数,无论是否使用捕获模式; 执行顺序:按照事件监听器的注册顺序执行,与捕获/冒泡设置无关;

第三阶段: 冒泡阶段(事件从目标元素向上冒泡到window) 传递路径:目标元素 → 父元素 → Body → HTML → Document → Window; 默认行为:大多数事件都会冒泡,但focus、blur等事件不会冒泡;

2.2 事件修饰符原理详解

事件修饰符

.stop 修饰符原理

// .stop 修饰符的实现原理
function handleClick(event) {
  // 没有.stop时,事件正常冒泡
  console.log('按钮被点击')
  // 事件会继续向上冒泡,触发父元素的事件处理函数
}

function handleClickWithStop(event) {
  console.log('按钮被点击,但阻止了冒泡')
  event.stopPropagation() 
  // 事件不会继续向上冒泡
}

事件修饰符对照表

修饰符 原生JS等价操作 作用 使用场景
.stop event.stopPropagation() 阻止事件冒泡 点击按钮不触发父容器点击事件
.prevent event.preventDefault() 阻止默认行为 阻止表单提交、链接跳转
.capture addEventListener(..., true) 使用捕获模式 需要在捕获阶段处理事件
.self if (event.target !== this) return 仅元素自身触发 忽略子元素触发的事件
.once 手动移除监听器 只触发一次 一次性提交按钮

2.3 综合案例

<template>
  <view class="event-demo">
    <!-- 1. .stop修饰符 -->
    <view class="demo-section">
      <text class="section-title">1. .stop 修饰符 - 阻止事件冒泡</text>
      <view class="parent-box" @click="handleParentClick">
        <text>父容器 (点击这里会触发)</text>
        <button @click="handleButtonClick">普通按钮</button>
        <button @click.stop="handleButtonClickWithStop">使用.stop的按钮</button>
      </view>
      <text class="log">日志: {{ logs }}</text>
    </view>

    <!-- 2. .prevent修饰符 -->
    <view class="demo-section">
      <text class="section-title">2. .prevent 修饰符 - 阻止默认行为</text>
      <form @submit="handleFormSubmit">
        <input type="text" v-model="formData.name" placeholder="请输入姓名" />
        <button form-type="submit">普通提交</button>
        <button form-type="submit" @click.prevent="handlePreventSubmit">
          使用.prevent的提交
        </button>
      </form>
    </view>

    <!-- 3. .self修饰符 -->
    <view class="demo-section">
      <text class="section-title">3. .self 修饰符 - 仅自身触发</text>
      <view class="self-demo">
        <view @click.self="handleSelfClick" class="self-box">
          <text>点击这个文本(自身)会触发</text>
          <button>点击这个按钮(子元素)不会触发</button>
        </view>
      </view>
    </view>

    <!-- 4. 修饰符串联 -->
    <view class="demo-section">
      <text class="section-title">4. 修饰符串联使用</text>
      <view @click="handleChainParent">
        <button @click.stop.prevent="handleChainClick">
          同时使用.stop和.prevent
        </button>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      logs: [],
      formData: {
        name: ''
      }
    }
  },
  methods: {
    handleParentClick() {
      this.addLog('父容器被点击')
    },
    handleButtonClick() {
      this.addLog('普通按钮被点击 → 会触发父容器事件')
    },
    handleButtonClickWithStop() {
      this.addLog('使用.stop的按钮被点击 → 不会触发父容器事件')
    },
    handleFormSubmit(e) {
      this.addLog('表单提交,页面可能会刷新')
    },
    handlePreventSubmit(e) {
      this.addLog('使用.prevent,阻止了表单默认提交行为')
      // 这里可以执行自定义的提交逻辑
      this.submitForm()
    },
    handleSelfClick() {
      this.addLog('.self: 只有点击容器本身才触发')
    },
    handleChainParent() {
      this.addLog('父容器点击事件')
    },
    handleChainClick() {
      this.addLog('按钮点击,但阻止了冒泡和默认行为')
    },
    addLog(message) {
      this.logs.unshift(`${new Date().toLocaleTimeString()}: ${message}`)
      // 只保留最近5条日志
      if (this.logs.length > 5) {
        this.logs.pop()
      }
    },
    submitForm() {
      uni.showToast({
        title: '表单提交成功',
        icon: 'success'
      })
    }
  }
}
</script>

<style scoped>
.event-demo {
  padding: 20rpx;
}
.demo-section {
  margin-bottom: 40rpx;
  padding: 20rpx;
  border: 1rpx solid #e0e0e0;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
  font-size: 28rpx;
}
.parent-box {
  background: #f5f5f5;
  padding: 20rpx;
  border-radius: 8rpx;
}
.log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 15rpx;
  border-radius: 6rpx;
  font-family: monospace;
  font-size: 24rpx;
  margin-top: 15rpx;
  max-height: 200rpx;
  overflow-y: auto;
}
.self-box {
  background: #e3f2fd;
  padding: 30rpx;
  border: 2rpx dashed #2196f3;
}
</style>

三、表单数据处理

3.1 复杂表单设计

graph TB
    A[表单组件] --> B[表单数据模型]
    B --> C[验证规则]
    B --> D[提交处理]
    
    C --> E[即时验证]
    C --> F[提交验证]
    
    D --> G[数据预处理]
    D --> H[API调用]
    D --> I[响应处理]
    
    E --> J[错误提示]
    F --> J

3.2 表单案例

<template>
  <view class="form-container">
    <text class="form-title">用户注册</text>
    
    <!-- 用户名 -->
    <view class="form-item" :class="{ error: errors.username }">
      <text class="label">用户名</text>
      <input 
        type="text" 
        v-model="formData.username" 
        placeholder="请输入用户名"
        @blur="validateField('username')"
        class="input"
      />
      <text class="error-msg" v-if="errors.username">{{ errors.username }}</text>
    </view>

    <!-- 邮箱 -->
    <view class="form-item" :class="{ error: errors.email }">
      <text class="label">邮箱</text>
      <input 
        type="text" 
        v-model="formData.email" 
        placeholder="请输入邮箱"
        @blur="validateField('email')"
        class="input"
      />
      <text class="error-msg" v-if="errors.email">{{ errors.email }}</text>
    </view>

    <!-- 密码 -->
    <view class="form-item" :class="{ error: errors.password }">
      <text class="label">密码</text>
      <input 
        type="password" 
        v-model="formData.password" 
        placeholder="请输入密码"
        @blur="validateField('password')"
        class="input"
      />
      <text class="error-msg" v-if="errors.password">{{ errors.password }}</text>
    </view>

    <!-- 性别 -->
    <view class="form-item">
      <text class="label">性别</text>
      <radio-group v-model="formData.gender" class="radio-group">
        <label class="radio-item" v-for="item in genderOptions" :key="item.value">
          <radio :value="item.value" /> {{ item.label }}
        </label>
      </radio-group>
    </view>

    <!-- 兴趣爱好 -->
    <view class="form-item">
      <text class="label">兴趣爱好</text>
      <view class="checkbox-group">
        <label 
          class="checkbox-item" 
          v-for="hobby in hobbyOptions" 
          :key="hobby.value"
        >
          <checkbox :value="hobby.value" v-model="formData.hobbies" /> 
          {{ hobby.label }}
        </label>
      </view>
    </view>

    <!-- 提交按钮 -->
    <button 
      @click="handleSubmit" 
      :disabled="!isFormValid"
      class="submit-btn"
      :class="{ disabled: !isFormValid }"
    >
      {{ isSubmitting ? '提交中...' : '注册' }}
    </button>

    <!-- 表单数据预览 -->
    <view class="form-preview">
      <text class="preview-title">表单数据预览</text>
      <text class="preview-data">{{ JSON.stringify(formData, null, 2) }}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      },
      errors: {
        username: '',
        email: '',
        password: ''
      },
      isSubmitting: false,
      genderOptions: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' },
        { label: '其他', value: 'other' }
      ],
      hobbyOptions: [
        { label: '运动', value: 'sports' },
        { label: '阅读', value: 'reading' },
        { label: '音乐', value: 'music' },
        { label: '旅行', value: 'travel' },
        { label: '游戏', value: 'gaming' }
      ]
    }
  },
  computed: {
    isFormValid() {
      return (
        !this.errors.username &&
        !this.errors.email &&
        !this.errors.password &&
        this.formData.username &&
        this.formData.email &&
        this.formData.password &&
        !this.isSubmitting
      )
    }
  },
  methods: {
    validateField(fieldName) {
      const value = this.formData[fieldName]
      
      switch (fieldName) {
        case 'username':
          if (!value) {
            this.errors.username = '用户名不能为空'
          } else if (value.length < 3) {
            this.errors.username = '用户名至少3个字符'
          } else {
            this.errors.username = ''
          }
          break
          
        case 'email':
          if (!value) {
            this.errors.email = '邮箱不能为空'
          } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            this.errors.email = '邮箱格式不正确'
          } else {
            this.errors.email = ''
          }
          break
          
        case 'password':
          if (!value) {
            this.errors.password = '密码不能为空'
          } else if (value.length < 6) {
            this.errors.password = '密码至少6个字符'
          } else {
            this.errors.password = ''
          }
          break
      }
    },
    
    async handleSubmit() {
      // 提交前验证所有字段
      this.validateField('username')
      this.validateField('email')
      this.validateField('password')
      
      // 报错直接返回
      if (this.errors.username || this.errors.email || this.errors.password) {
        uni.showToast({
          title: '请正确填写表单',
          icon: 'none'
        })
        return
      }
      
      this.isSubmitting = true
      
      try {
        // 接口调用
        await this.mockApiCall()
        
        uni.showToast({
          title: '注册成功',
          icon: 'success'
        })
        
        // 重置表单
        this.resetForm()
        
      } catch (error) {
        uni.showToast({
          title: '注册失败',
          icon: 'error'
        })
      } finally {
        this.isSubmitting = false
      }
    },
    
    mockApiCall() {
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log('提交的数据:', this.formData)
          resolve()
        }, 2000)
      })
    },
    
    resetForm() {
      this.formData = {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      }
      this.errors = {
        username: '',
        email: '',
        password: ''
      }
    }
  }
}
</script>

<style scoped>
.form-container {
  padding: 30rpx;
  max-width: 600rpx;
  margin: 0 auto;
}
.form-title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
}
.form-item {
  margin-bottom: 30rpx;
}
.label {
  display: block;
  margin-bottom: 15rpx;
  font-weight: 500;
  color: #333;
}
.input {
  border: 2rpx solid #e0e0e0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-size: 28rpx;
}
.form-item.error .input {
  border-color: #ff4757;
}
.error-msg {
  color: #ff4757;
  font-size: 24rpx;
  margin-top: 8rpx;
  display: block;
}
.radio-group {
  display: flex;
  gap: 40rpx;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
.checkbox-group {
  display: flex;
  flex-wrap: wrap;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
  min-width: 150rpx;
}
.submit-btn {
  background: #007AFF;
  color: white;
  border: none;
  padding: 25rpx;
  border-radius: 10rpx;
  font-size: 32rpx;
  margin-top: 40rpx;
}
.submit-btn.disabled {
  background: #ccc;
  color: #666;
}
.form-preview {
  margin-top: 50rpx;
  padding: 30rpx;
  background: #f9f9f9;
  border-radius: 10rpx;
}
.preview-title {
  font-weight: bold;
  margin-bottom: 20rpx;
  display: block;
}
.preview-data {
  font-family: monospace;
  font-size: 24rpx;
  color: #666;
  word-break: break-all;
}
</style>

四、组件间通信-自定义事件

4.1 自定义事件原理

4.2 以计数器组件为例

<!-- 子组件:custom-counter.vue -->
<template>
  <view class="custom-counter">
    <text class="counter-title">{{ title }}</text>
    
    <view class="counter-controls">
      <button 
        @click="decrement" 
        :disabled="currentValue <= min"
        class="counter-btn"
      >
        -
      </button>
      
      <text class="counter-value">{{ currentValue }}</text>
      
      <button 
        @click="increment" 
        :disabled="currentValue >= max"
        class="counter-btn"
      >
        +
      </button>
    </view>
    
    <view class="counter-stats">
      <text>最小值: {{ min }}</text>
      <text>最大值: {{ max }}</text>
      <text>步长: {{ step }}</text>
    </view>
    
    <!-- 操作 -->
    <view class="quick-actions">
      <button @click="reset" size="mini">重置</button>
      <button @click="setToMax" size="mini">设为最大</button>
      <button @click="setToMin" size="mini">设为最小</button>
    </view>
  </view>
</template>

<script>
export default {
  name: 'CustomCounter',
  props: {
    // 当前值
    value: {
      type: Number,
      default: 0
    },
    // 最小值
    min: {
      type: Number,
      default: 0
    },
    // 最大值
    max: {
      type: Number,
      default: 100
    },
    // 步长
    step: {
      type: Number,
      default: 1
    },
    // 标题
    title: {
      type: String,
      default: '计数器'
    }
  },
  data() {
    return {
      currentValue: this.value
    }
  },
  watch: {
    value(newVal) {
      this.currentValue = newVal
    },
    currentValue(newVal) {
      // 设置限制范围
      if (newVal < this.min) {
        this.currentValue = this.min
      } else if (newVal > this.max) {
        this.currentValue = this.max
      }
    }
  },
  methods: {
    increment() {
      const newValue = this.currentValue + this.step
      if (newValue <= this.max) {
        this.updateValue(newValue)
      }
    },
    
    decrement() {
      const newValue = this.currentValue - this.step
      if (newValue >= this.min) {
        this.updateValue(newValue)
      }
    },
    
    updateValue(newValue) {
      this.currentValue = newValue
      
      // 触发自定义事件,通知父组件
      this.$emit('input', newValue)  // 用于 v-model
      this.$emit('change', {         // 用于普通事件监听
        value: newValue,
        oldValue: this.value,
        type: 'change'
      })
    },
    
    reset() {
      this.updateValue(0)
      this.$emit('reset', { value: 0 })
    },
    
    setToMax() {
      this.updateValue(this.max)
      this.$emit('set-to-max', { value: this.max })
    },
    
    setToMin() {
      this.updateValue(this.min)
      this.$emit('set-to-min', { value: this.min })
    }
  }
}
</script>

<style scoped>
.custom-counter {
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  padding: 30rpx;
  margin: 20rpx 0;
  background: white;
}
.counter-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 25rpx;
  color: #333;
}
.counter-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 30rpx;
  margin-bottom: 25rpx;
}
.counter-btn {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 36rpx;
  font-weight: bold;
}
.counter-value {
  font-size: 48rpx;
  font-weight: bold;
  color: #007AFF;
  min-width: 100rpx;
  text-align: center;
}
.counter-stats {
  display: flex;
  justify-content: space-around;
  margin-bottom: 25rpx;
  padding: 15rpx;
  background: #f8f9fa;
  border-radius: 8rpx;
}
.counter-stats text {
  font-size: 24rpx;
  color: #666;
}
.quick-actions {
  display: flex;
  justify-content: center;
  gap: 15rpx;
}
</style>

4.3 父组件使用

<!-- 父组件:parent-component.vue -->
<template>
  <view class="parent-container">
    <text class="main-title">自定义计数器组件演示</text>
    
    <!-- 方式1:使用 v-model -->
    <view class="demo-section">
      <text class="section-title">1. 使用 v-model 双向绑定</text>
      <custom-counter 
        v-model="counter1" 
        title="基础计数器"
        :min="0" 
        :max="10"
        :step="1"
      />
      <text class="value-display">当前值: {{ counter1 }}</text>
    </view>
    
    <!-- 方式2:监听 change 事件 -->
    <view class="demo-section">
      <text class="section-title">2. 监听 change 事件</text>
      <custom-counter 
        :value="counter2"
        title="高级计数器"
        :min="-10"
        :max="20"
        :step="2"
        @change="onCounterChange"
      />
      <text class="value-display">当前值: {{ counter2 }}</text>
      <text class="event-log">事件日志: {{ eventLog }}</text>
    </view>
    
    <!-- 方式3:监听多个事件 -->
    <view class="demo-section">
      <text class="section-title">3. 监听多个事件</text>
      <custom-counter 
        v-model="counter3"
        title="多功能计数器"
        @reset="onCounterReset"
        @set-to-max="onSetToMax"
        @set-to-min="onSetToMin"
      />
      <text class="value-display">当前值: {{ counter3 }}</text>
    </view>
    
    
    <view class="demo-section">
      <text class="section-title">4. 计数器联动</text>
      <custom-counter 
        v-model="masterCounter"
        title="主计数器"
        @change="onMasterChange"
      />
      <custom-counter 
        :value="slaveCounter"
        title="从计数器"
        :min="0"
        :max="50"
        readonly
      />
    </view>
  </view>
</template>

<script>
import CustomCounter from '@/components/custom-counter.vue'

export default {
  components: {
    CustomCounter
  },
  data() {
    return {
      counter1: 5,
      counter2: 0,
      counter3: 10,
      masterCounter: 0,
      slaveCounter: 0,
      eventLog: ''
    }
  },
  methods: {
    onCounterChange(event) {
      console.log('计数器变化事件:', event)
      this.counter2 = event.value
      this.addEventLog(`计数器变化: ${event.oldValue}${event.value}`)
    },
    
    onCounterReset(event) {
      console.log('计数器重置:', event)
      this.addEventLog(`计数器重置为: ${event.value}`)
    },
    
    onSetToMax(event) {
      console.log('设置为最大值:', event)
      this.addEventLog(`设置为最大值: ${event.value}`)
    },
    
    onSetToMin(event) {
      console.log('设置为最小值:', event)
      this.addEventLog(`设置为最小值: ${event.value}`)
    },
    
    onMasterChange(event) {
      this.slaveCounter = Math.floor(event.value / 2)
    },
    
    addEventLog(message) {
      const timestamp = new Date().toLocaleTimeString()
      this.eventLog = `${timestamp}: ${message}\n${this.eventLog}`
      
      // 增进日志长度
      if (this.eventLog.split('\n').length > 5) {
        this.eventLog = this.eventLog.split('\n').slice(0, 5).join('\n')
      }
    }
  }
}
</script>

<style scoped>
.parent-container {
  padding: 30rpx;
  max-width: 700rpx;
  margin: 0 auto;
}
.main-title {
  font-size: 40rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
  display: block;
}
.demo-section {
  margin-bottom: 50rpx;
  padding: 30rpx;
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  background: #fafafa;
}
.section-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 25rpx;
}
.value-display {
  display: block;
  text-align: center;
  font-size: 28rpx;
  margin-top: 20rpx;
  color: #333;
}
.event-log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-family: monospace;
  font-size: 22rpx;
  margin-top: 15rpx;
  white-space: pre-wrap;
  max-height: 200rpx;
  overflow-y: auto;
}
</style>

五、性能优化

5.1 数据绑定性能优化

graph TB
    A[性能问题] --> B[大量数据响应式]
    A --> C[频繁的重新渲染]
    A --> D[内存泄漏]
    
    B --> E[Object.freeze 冻结数据]
    B --> F[虚拟滚动]
    
    C --> G[计算属性缓存]
    C --> H[v-once 单次渲染]
    C --> I[合理使用 v-if vs v-show]
    
    D --> J[及时销毁事件监听]
    D --> K[清除定时器]

5.2 优化技巧

<template>
  <view class="optimization-demo">
    <text class="title">性能优化</text>
    
    <!-- 1. 计算属性缓存 -->
    <view class="optimization-section">
      <text class="section-title">1. 计算属性 vs 方法</text>
      <input v-model="filterText" placeholder="过滤文本" class="input" />
      
      <view class="result">
        <text>过滤后数量(计算属性): {{ filteredListLength }}</text>
        <text>过滤后数量(方法调用): {{ getFilteredListLength() }}</text>
      </view>
      
      <button @click="refreshCount">刷新计数</button>
      <text class="hint">打开控制台查看调用次数</text>
    </view>
    
    <!-- 2. v-once 静态内容优化 -->
    <view class="optimization-section">
      <text class="section-title">2. v-once 静态内容</text>
      <view v-once class="static-content">
        <text>这个内容只渲染一次: {{ staticTimestamp }}</text>
      </view>
      <button @click="updateStatic">更新静态内容(不会变化)</button>
    </view>
    
    <!-- 3. 大数据列表优化 -->
    <view class="optimization-section">
      <text class="section-title">3. 大数据列表渲染</text>
      <button @click="loadBigData">加载1000条数据</button>
      <button @click="loadOptimizedData">加载优化后的数据</button>
      
      <!-- 普通渲染 -->
      <view v-if="showNormalList">
        <text>普通渲染({{ normalList.length }}条):</text>
        <view v-for="item in normalList" :key="item.id" class="list-item">
          <text>{{ item.name }}</text>
        </view>
      </view>
      
      <!-- 虚拟滚动优化 -->
      <view v-if="showOptimizedList">
        <text>虚拟滚动渲染({{ optimizedList.length }}条):</text>
        <view class="virtual-list">
          <view 
            v-for="item in visibleItems" 
            :key="item.id" 
            class="list-item optimized"
          >
            <text>{{ item.name }}</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      filterText: '',
      refreshCount: 0,
      staticTimestamp: new Date().toLocaleTimeString(),
      normalList: [],
      optimizedList: [],
      showNormalList: false,
      showOptimizedList: false,
      visibleItems: [],
      bigData: []
    }
  },
  computed: {
    // 计算属性会自动缓存,只有依赖变化时才重新计算
    filteredListLength() {
      console.log('计算属性被执行')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    }
  },
  methods: {
    // 方法每次调用都会执行
    getFilteredListLength() {
      console.log('方法被调用')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    },
    
    generateTestList() {
      return Array.from({ length: 100 }, (_, i) => ({
        id: i,
        name: `项目 ${i}`
      }))
    },
    
    refreshCount() {
      this.refreshCount++
    },
    
    updateStatic() {
      this.staticTimestamp = new Date().toLocaleTimeString()
    },
    
    loadBigData() {
      this.showNormalList = true
      this.showOptimizedList = false
      
      // 生成大量数据
      this.normalList = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        name: `数据项 ${i}`,
        value: Math.random() * 1000
      }))
    },
    
    loadOptimizedData() {
      this.showNormalList = false
      this.showOptimizedList = true
      
      // 使用 Object.freeze 避免不必要的响应式
      this.optimizedList = Object.freeze(
        Array.from({ length: 1000 }, (_, i) => ({
          id: i,
          name: `数据项 ${i}`,
          value: Math.random() * 1000
        }))
      )
      
      // 虚拟滚动:只渲染可见项
      this.updateVisibleItems()
    },
    
    updateVisibleItems() {
      // 简化的虚拟滚动实现
      this.visibleItems = this.optimizedList.slice(0, 20)
    },
    
    // 防抖函数优化频繁触发的事件
    debounce(func, wait) {
      let timeout
      return function executedFunction(...args) {
        const later = () => {
          clearTimeout(timeout)
          func(...args)
        }
        clearTimeout(timeout)
        timeout = setTimeout(later, wait)
      }
    }
  },
  
  // 组件销毁时清理资源
  beforeDestroy() {
    this.normalList = []
    this.optimizedList = []
    this.visibleItems = []
  }
}
</script>

<style scoped>
.optimization-demo {
  padding: 30rpx;
}
.title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 40rpx;
}
.optimization-section {
  margin-bottom: 40rpx;
  padding: 30rpx;
  border: 1rpx solid #ddd;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 6rpx;
  margin-bottom: 15rpx;
}
.result {
  margin: 15rpx 0;
}
.result text {
  display: block;
  margin: 5rpx 0;
}
.hint {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-top: 10rpx;
}
.static-content {
  background: #e8f5e8;
  padding: 20rpx;
  border-radius: 6rpx;
  margin: 15rpx 0;
}
.list-item {
  padding: 10rpx;
  border-bottom: 1rpx solid #eee;
}
.list-item.optimized {
  background: #f0f8ff;
}
.virtual-list {
  max-height: 400rpx;
  overflow-y: auto;
}
</style>

总结

通过以上学习,我们深入掌握了 uni-app 中数据绑定与事件处理的核心概念:

  1. 响应式原理:理解了 Vue 2.x 基于 Object.defineProperty 的数据劫持机制
  2. 双向绑定v-model 的本质是 :value + @input 的语法糖
  3. 事件系统:掌握了事件流、修饰符及其底层实现原理
  4. 组件通信:通过自定义事件实现子父组件间的数据传递
  5. 性能优化:学会了计算属性、虚拟滚动等优化技巧

至此数据绑定与时间处理就全部介绍完了,如果觉得这篇文章对你有帮助,别忘了一键三连~~~ 遇到任何问题,欢迎在评论区留言讨论。Happy Coding!

vue3学习笔记

作者 乐一李
2025年11月14日 15:37

1. Vue3简介

1695089947298-161c1b47-eb86-42fb-b1f8-d6a4fcab8ee2.png

1.1. 【性能的提升】

  • 打包大小减少41%

  • 初次渲染快55%, 更新渲染快133%

  • 内存减少54%

1.2.【 源码的升级】

  • 使用Proxy代替defineProperty实现响应式。

  • 重写虚拟DOM的实现和Tree-Shaking

1.3. 【拥抱TypeScript】

  • Vue3可以更好的支持TypeScript

1.4. 【新的特性】

  1. Composition API(组合API):

    • setup

    • refreactive

    • computedwatch

      ......

  2. 新的内置组件:

    • Fragment

    • Teleport

    • Suspense

      ......

  3. 其他改变:

    • 新的生命周期钩子

    • data 选项应始终被声明为一个函数

    • 移除keyCode支持作为 v-on 的修饰符

      ......

2. 创建Vue3工程

2.1. 【基于 vue-cli 创建】

点击查看官方文档

备注:目前vue-cli已处于维护模式,官方推荐基于 Vite 创建项目。

## 查看@vue/cli版本,确保@vue/cli版本在4.5.0以上
vue --version

## 安装或者升级你的@vue/cli 
npm install -g @vue/cli

## 执行创建命令
vue create vue_test

##  随后选择3.x
##  Choose a version of Vue.js that you want to start the project with (Use arrow keys)
##  > 3.x
##    2.x

## 启动
cd vue_test
npm run serve

2.2. 【基于 vite 创建】(推荐)

vite 是新一代前端构建工具,官网地址:vitejs.cnvite的优势如下:

  • 轻量快速的热重载(HMR),能实现极速的服务启动。
  • TypeScriptJSXCSS 等支持开箱即用。
  • 真正的按需编译,不再等待整个应用编译完成。
  • webpack构建 与 vite构建对比图如下: webpack构建转存失败,建议直接上传图片文件vite构建转存失败,建议直接上传图片文件
## 1.创建命令
npm create vue@latest

## 2.具体配置
## 配置项目名称
√ Project name: vue3_test
## 是否添加TypeScript支持
√ Add TypeScript?  Yes
## 是否添加JSX支持
√ Add JSX Support?  No
## 是否添加路由环境
√ Add Vue Router for Single Page Application development?  No
## 是否添加pinia环境
√ Add Pinia for state management?  No
## 是否添加单元测试
√ Add Vitest for Unit Testing?  No
## 是否添加端到端测试方案
√ Add an End-to-End Testing Solution? » No
## 是否添加ESLint语法检查
√ Add ESLint for code quality?  Yes
## 是否添加Prettiert代码格式化
√ Add Prettier for code formatting?  No

自己动手编写一个App组件

<template>
  <div class="app">
    <h1>你好啊!</h1>
  </div>
</template>

<script lang="ts">
  export default {
    name:'App' //组件名
  }
</script>

<style>
  .app {
    background-color: #ddd;
    box-shadow: 0 0 10px;
    border-radius: 10px;
    padding: 20px;
  }
</style>

安装官方推荐的vscode插件:

volar.png

image-20231218085906380.png 总结:

  • Vite 项目中,index.html 是项目的入口文件,在项目最外层。
  • 加载index.html后,Vite 解析 <script type="module" src="xxx"> 指向的JavaScript
  • Vue3**中是通过 **createApp 函数创建一个应用实例。

2.3. 【一个简单的效果】

Vue3向下兼容Vue2语法,且Vue3中的模板中可以没有根标签

<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script lang="ts">
  export default {
    name:'App',
    data() {
      return {
        name:'张三',
        age:18,
        tel:'13888888888'
      }
    },
    methods:{
      changeName(){
        this.name = 'zhang-san'
      },
      changeAge(){
        this.age += 1
      },
      showTel(){
        alert(this.tel)
      }
    },
  }
</script>

3. Vue3核心语法

3.1. 【OptionsAPI 与 CompositionAPI】

  • Vue2API设计是Options(配置)风格的。
  • Vue3API设计是Composition(组合)风格的。

Options API 的弊端

Options类型的 API,数据、方法、计算属性等,是分散在:datamethodscomputed中的,若想新增或者修改一个需求,就需要分别修改:datamethodscomputed,不便于维护和复用。

1696662197101-55d2b251-f6e5-47f4-b3f1-d8531bbf9279.gif

1696662200734-1bad8249-d7a2-423e-a3c3-ab4c110628be.gif

Composition API 的优势

可以用函数的方式,更加优雅的组织代码,让相关功能的代码更加有序的组织在一起。

1696662249851-db6403a1-acb5-481a-88e0-e1e34d2ef53a.gif

1696662256560-7239b9f9-a770-43c1-9386-6cc12ef1e9c0.gif

说明:以上四张动图原创作者:大帅老猿

3.2. 【拉开序幕的 setup】

setup 概述

setupVue3中一个新的配置项,值是一个函数,它是 Composition API “表演的舞台***”***,组件中所用到的:数据、方法、计算属性、监视......等等,均配置在setup中。

特点如下:

  • setup函数返回的对象中的内容,可直接在模板中使用。
  • setup中访问thisundefined
  • setup函数会在beforeCreate之前调用,它是“领先”所有钩子执行的。
<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script lang="ts">
  export default {
    name:'Person',
    setup(){
      // 数据,原来写在data中(注意:此时的name、age、tel数据都不是响应式数据)
      let name = '张三'
      let age = 18
      let tel = '13888888888'

      // 方法,原来写在methods中
      function changeName(){
        name = 'zhang-san' //注意:此时这么修改name页面是不变化的
        console.log(name)
      }
      function changeAge(){
        age += 1 //注意:此时这么修改age页面是不变化的
        console.log(age)
      }
      function showTel(){
        alert(tel)
      }

      // 返回一个对象,对象中的内容,模板中可以直接使用
      return {name,age,tel,changeName,changeAge,showTel}
    }
  }
</script>

setup 的返回值

  • 若返回一个对象:则对象中的:属性、方法等,在模板中均可以直接使用**(重点关注)。**
  • 若返回一个函数:则可以自定义渲染内容,代码如下:
setup(){
  return ()=> '你好啊!'
}

setup 与 Options API 的关系

  • Vue2 的配置(datamethos......)中可以访问到 setup中的属性、方法。
  • 但在setup不能访问到Vue2的配置(datamethos......)。
  • 如果与Vue2冲突,则setup优先。

setup 语法糖

setup函数有一个语法糖,这个语法糖,可以让我们把setup独立出去,代码如下:

<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changName">修改名字</button>
    <button @click="changAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script lang="ts">
  export default {
    name:'Person',
  }
</script>

<!-- 下面的写法是setup语法糖 -->
<script setup lang="ts">
  console.log(this) //undefined
  
  // 数据(注意:此时的name、age、tel都不是响应式数据)
  let name = '张三'
  let age = 18
  let tel = '13888888888'

  // 方法
  function changName(){
    name = '李四'//注意:此时这么修改name页面是不变化的
  }
  function changAge(){
    console.log(age)
    age += 1 //注意:此时这么修改age页面是不变化的
  }
  function showTel(){
    alert(tel)
  }
</script>

扩展:上述代码,还需要编写一个不写setupscript标签,去指定组件名字,比较麻烦,我们可以借助vite中的插件简化

  1. 第一步:npm i vite-plugin-vue-setup-extend -D
  2. 第二步:vite.config.ts
import { defineConfig } from 'vite'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'

export default defineConfig({
  plugins: [ VueSetupExtend() ]
})
  1. 第三步:<script setup lang="ts" name="Person">

3.3. 【ref 创建:基本类型的响应式数据】

  • **作用:**定义响应式变量。
  • 语法:let xxx = ref(初始值)
  • **返回值:**一个RefImpl的实例对象,简称ref对象refref对象的value属性是响应式的
  • 注意点:
    • JS中操作数据需要:xxx.value,但模板中不需要.value,直接使用即可。
    • 对于let name = ref('张三')来说,name不是响应式的,name.value是响应式的。
<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script setup lang="ts" name="Person">
  import {ref} from 'vue'
  // name和age是一个RefImpl的实例对象,简称ref对象,它们的value属性是响应式的。
  let name = ref('张三')
  let age = ref(18)
  // tel就是一个普通的字符串,不是响应式的
  let tel = '13888888888'

  function changeName(){
    // JS中操作ref对象时候需要.value
    name.value = '李四'
    console.log(name.value)

    // 注意:name不是响应式的,name.value是响应式的,所以如下代码并不会引起页面的更新。
    // name = ref('zhang-san')
  }
  function changeAge(){
    // JS中操作ref对象时候需要.value
    age.value += 1 
    console.log(age.value)
  }
  function showTel(){
    alert(tel)
  }
</script>

3.4. 【reactive 创建:对象类型的响应式数据】

  • 作用:定义一个响应式对象(基本类型不要用它,要用ref,否则报错)
  • 语法:let 响应式对象= reactive(源对象)
  • **返回值:**一个Proxy的实例对象,简称:响应式对象。
  • 注意点:reactive定义的响应式数据是“深层次”的。
<template>
  <div class="person">
    <h2>汽车信息:一台{{ car.brand }}汽车,价值{{ car.price }}万</h2>
    <h2>游戏列表:</h2>
    <ul>
      <li v-for="g in games" :key="g.id">{{ g.name }}</li>
    </ul>
    <h2>测试:{{obj.a.b.c.d}}</h2>
    <button @click="changeCarPrice">修改汽车价格</button>
    <button @click="changeFirstGame">修改第一游戏</button>
    <button @click="test">测试</button>
  </div>
</template>

<script lang="ts" setup name="Person">
import { reactive } from 'vue'

// 数据
let car = reactive({ brand: '奔驰', price: 100 })
let games = reactive([
  { id: 'ahsgdyfa01', name: '英雄联盟' },
  { id: 'ahsgdyfa02', name: '王者荣耀' },
  { id: 'ahsgdyfa03', name: '原神' }
])
let obj = reactive({
  a:{
    b:{
      c:{
        d:666
      }
    }
  }
})

function changeCarPrice() {
  car.price += 10
}
function changeFirstGame() {
  games[0].name = '流星蝴蝶剑'
}
function test(){
  obj.a.b.c.d = 999
}
</script>

3.5. 【ref 创建:对象类型的响应式数据】

  • 其实ref接收的数据可以是:基本类型对象类型
  • ref接收的是对象类型,内部其实也是调用了reactive函数。
<template>
  <div class="person">
    <h2>汽车信息:一台{{ car.brand }}汽车,价值{{ car.price }}万</h2>
    <h2>游戏列表:</h2>
    <ul>
      <li v-for="g in games" :key="g.id">{{ g.name }}</li>
    </ul>
    <h2>测试:{{obj.a.b.c.d}}</h2>
    <button @click="changeCarPrice">修改汽车价格</button>
    <button @click="changeFirstGame">修改第一游戏</button>
    <button @click="test">测试</button>
  </div>
</template>

<script lang="ts" setup name="Person">
import { ref } from 'vue'

// 数据
let car = ref({ brand: '奔驰', price: 100 })
let games = ref([
  { id: 'ahsgdyfa01', name: '英雄联盟' },
  { id: 'ahsgdyfa02', name: '王者荣耀' },
  { id: 'ahsgdyfa03', name: '原神' }
])
let obj = ref({
  a:{
    b:{
      c:{
        d:666
      }
    }
  }
})

console.log(car)

function changeCarPrice() {
  car.value.price += 10
}
function changeFirstGame() {
  games.value[0].name = '流星蝴蝶剑'
}
function test(){
  obj.value.a.b.c.d = 999
}
</script>

3.6. 【ref 对比 reactive】

宏观角度看:

  1. ref用来定义:基本类型数据对象类型数据

  2. reactive用来定义:对象类型数据

  • 区别:
  1. ref创建的变量必须使用.value(可以使用volar插件自动添加.value)。

自动补充value.png

  1. reactive重新分配一个新对象,会失去响应式(可以使用Object.assign去整体替换)。
  • 使用原则:
  1. 若需要一个基本类型的响应式数据,必须使用ref
  2. 若需要一个响应式对象,层级不深,refreactive都可以。
  3. 若需要一个响应式对象,且层级较深,推荐使用reactive

3.7. 【toRefs 与 toRef】

  • 作用:将一个响应式对象中的每一个属性,转换为ref对象。
  • 备注:toRefstoRef功能一致,但toRefs可以批量转换。
  • 语法如下:
<template>
  <div class="person">
    <h2>姓名:{{person.name}}</h2>
    <h2>年龄:{{person.age}}</h2>
    <h2>性别:{{person.gender}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changeGender">修改性别</button>
  </div>
</template>

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

  // 数据
  let person = reactive({name:'张三', age:18, gender:'男'})

  // 通过toRefs将person对象中的n个属性批量取出,且依然保持响应式的能力
  let {name,gender} =  toRefs(person)

  // 通过toRef将person对象中的gender属性取出,且依然保持响应式的能力
  let age = toRef(person,'age')

  // 方法
  function changeName(){
    name.value += '~'
  }
  function changeAge(){
    age.value += 1
  }
  function changeGender(){
    gender.value = '女'
  }
</script>

3.8. 【computed】

作用:根据已有数据计算出新数据(和Vue2中的computed作用一致)。

computed.gif

<template>
  <div class="person">
    姓:<input type="text" v-model="firstName"> <br>
    名:<input type="text" v-model="lastName"> <br>
    全名:<span>{{fullName}}</span> <br>
    <button @click="changeFullName">全名改为:li-si</button>
  </div>
</template>

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

  let firstName = ref('zhang')
  let lastName = ref('san')

  // 计算属性——只读取,不修改
  /* let fullName = computed(()=>{
    return firstName.value + '-' + lastName.value
  }) */


  // 计算属性——既读取又修改
  let fullName = computed({
    // 读取
    get(){
      return firstName.value + '-' + lastName.value
    },
    // 修改
    set(val){
      console.log('有人修改了fullName',val)
      firstName.value = val.split('-')[0]
      lastName.value = val.split('-')[1]
    }
  })

  function changeFullName(){
    fullName.value = 'li-si'
  } 
</script>

3.9.【watch】

  • 作用:监视数据的变化(和Vue2中的watch作用一致)
  • 特点:Vue3中的watch只能监视以下四种数据
  1. ref定义的数据。
  2. reactive定义的数据。
  3. 函数返回一个值(getter函数)。
  4. 一个包含上述内容的数组。

我们在Vue3中使用watch的时候,通常会遇到以下几种情况:

* 情况一

监视ref定义的【基本类型】数据:直接写数据名即可,监视的是其value值的改变。

<template>
  <div class="person">
    <h1>情况一:监视【ref】定义的【基本类型】数据</h1>
    <h2>当前求和为:{{sum}}</h2>
    <button @click="changeSum">点我sum+1</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref,watch} from 'vue'
  // 数据
  let sum = ref(0)
  // 方法
  function changeSum(){
    sum.value += 1
  }
  // 监视,情况一:监视【ref】定义的【基本类型】数据
  const stopWatch = watch(sum,(newValue,oldValue)=>{
    console.log('sum变化了',newValue,oldValue)
    if(newValue >= 10){
      stopWatch()
    }
  })
</script>

* 情况二

监视ref定义的【对象类型】数据:直接写数据名,监视的是对象的【地址值】,若想监视对象内部的数据,要手动开启深度监视。

注意:

  • 若修改的是ref定义的对象中的属性,newValueoldValue 都是新值,因为它们是同一个对象。

  • 若修改整个ref定义的对象,newValue 是新值, oldValue 是旧值,因为不是同一个对象了。

<template>
  <div class="person">
    <h1>情况二:监视【ref】定义的【对象类型】数据</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changePerson">修改整个人</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref,watch} from 'vue'
  // 数据
  let person = ref({
    name:'张三',
    age:18
  })
  // 方法
  function changeName(){
    person.value.name += '~'
  }
  function changeAge(){
    person.value.age += 1
  }
  function changePerson(){
    person.value = {name:'李四',age:90}
  }
  /* 
    监视,情况一:监视【ref】定义的【对象类型】数据,监视的是对象的地址值,若想监视对象内部属性的变化,需要手动开启深度监视
    watch的第一个参数是:被监视的数据
    watch的第二个参数是:监视的回调
    watch的第三个参数是:配置对象(deep、immediate等等.....) 
  */
  watch(person,(newValue,oldValue)=>{
    console.log('person变化了',newValue,oldValue)
  },{deep:true})
  
</script>

* 情况三

监视reactive定义的【对象类型】数据,且默认开启了深度监视。

<template>
  <div class="person">
    <h1>情况三:监视【reactive】定义的【对象类型】数据</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changePerson">修改整个人</button>
    <hr>
    <h2>测试:{{obj.a.b.c}}</h2>
    <button @click="test">修改obj.a.b.c</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {reactive,watch} from 'vue'
  // 数据
  let person = reactive({
    name:'张三',
    age:18
  })
  let obj = reactive({
    a:{
      b:{
        c:666
      }
    }
  })
  // 方法
  function changeName(){
    person.name += '~'
  }
  function changeAge(){
    person.age += 1
  }
  function changePerson(){
    Object.assign(person,{name:'李四',age:80})
  }
  function test(){
    obj.a.b.c = 888
  }

  // 监视,情况三:监视【reactive】定义的【对象类型】数据,且默认是开启深度监视的
  watch(person,(newValue,oldValue)=>{
    console.log('person变化了',newValue,oldValue)
  })
  watch(obj,(newValue,oldValue)=>{
    console.log('Obj变化了',newValue,oldValue)
  })
</script>

* 情况四

监视refreactive定义的【对象类型】数据中的某个属性,注意点如下:

  1. 若该属性值不是【对象类型】,需要写成函数形式。
  2. 若该属性值是依然是【对象类型】,可直接编,也可写成函数,建议写成函数。

结论:监视的要是对象里的属性,那么最好写函数式,注意点:若是对象监视的是地址值,需要关注对象内部,需要手动开启深度监视。

<template>
  <div class="person">
    <h1>情况四:监视【ref】或【reactive】定义的【对象类型】数据中的某个属性</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <h2>汽车:{{ person.car.c1 }}、{{ person.car.c2 }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changeC1">修改第一台车</button>
    <button @click="changeC2">修改第二台车</button>
    <button @click="changeCar">修改整个车</button>
  </div>
</template>

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

  // 数据
  let person = reactive({
    name:'张三',
    age:18,
    car:{
      c1:'奔驰',
      c2:'宝马'
    }
  })
  // 方法
  function changeName(){
    person.name += '~'
  }
  function changeAge(){
    person.age += 1
  }
  function changeC1(){
    person.car.c1 = '奥迪'
  }
  function changeC2(){
    person.car.c2 = '大众'
  }
  function changeCar(){
    person.car = {c1:'雅迪',c2:'爱玛'}
  }

  // 监视,情况四:监视响应式对象中的某个属性,且该属性是基本类型的,要写成函数式
  /* watch(()=> person.name,(newValue,oldValue)=>{
    console.log('person.name变化了',newValue,oldValue)
  }) */

  // 监视,情况四:监视响应式对象中的某个属性,且该属性是对象类型的,可以直接写,也能写函数,更推荐写函数
  watch(()=>person.car,(newValue,oldValue)=>{
    console.log('person.car变化了',newValue,oldValue)
  },{deep:true})
</script>

* 情况五

监视上述的多个数据

<template>
  <div class="person">
    <h1>情况五:监视上述的多个数据</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <h2>汽车:{{ person.car.c1 }}、{{ person.car.c2 }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changeC1">修改第一台车</button>
    <button @click="changeC2">修改第二台车</button>
    <button @click="changeCar">修改整个车</button>
  </div>
</template>

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

  // 数据
  let person = reactive({
    name:'张三',
    age:18,
    car:{
      c1:'奔驰',
      c2:'宝马'
    }
  })
  // 方法
  function changeName(){
    person.name += '~'
  }
  function changeAge(){
    person.age += 1
  }
  function changeC1(){
    person.car.c1 = '奥迪'
  }
  function changeC2(){
    person.car.c2 = '大众'
  }
  function changeCar(){
    person.car = {c1:'雅迪',c2:'爱玛'}
  }

  // 监视,情况五:监视上述的多个数据
  watch([()=>person.name,person.car],(newValue,oldValue)=>{
    console.log('person.car变化了',newValue,oldValue)
  },{deep:true})

</script>

3.10. 【watchEffect】

  • 官网:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行该函数。

  • watch对比watchEffect

    1. 都能监听响应式数据的变化,不同的是监听数据变化的方式不同

    2. watch:要明确指出监视的数据

    3. watchEffect:不用明确指出监视的数据(函数中用到哪些属性,那就监视哪些属性)。

  • 示例代码:

    <template>
      <div class="person">
        <h1>需求:水温达到50℃,或水位达到20cm,则联系服务器</h1>
        <h2 id="demo">水温:{{temp}}</h2>
        <h2>水位:{{height}}</h2>
        <button @click="changePrice">水温+1</button>
        <button @click="changeSum">水位+10</button>
      </div>
    </template>
    
    <script lang="ts" setup name="Person">
      import {ref,watch,watchEffect} from 'vue'
      // 数据
      let temp = ref(0)
      let height = ref(0)
    
      // 方法
      function changePrice(){
        temp.value += 10
      }
      function changeSum(){
        height.value += 1
      }
    
      // 用watch实现,需要明确的指出要监视:temp、height
      watch([temp,height],(value)=>{
        // 从value中获取最新的temp值、height值
        const [newTemp,newHeight] = value
        // 室温达到50℃,或水位达到20cm,立刻联系服务器
        if(newTemp >= 50 || newHeight >= 20){
          console.log('联系服务器')
        }
      })
    
      // 用watchEffect实现,不用
      const stopWtach = watchEffect(()=>{
        // 室温达到50℃,或水位达到20cm,立刻联系服务器
        if(temp.value >= 50 || height.value >= 20){
          console.log(document.getElementById('demo')?.innerText)
          console.log('联系服务器')
        }
        // 水温达到100,或水位达到50,取消监视
        if(temp.value === 100 || height.value === 50){
          console.log('清理了')
          stopWtach()
        }
      })
    </script>
    

3.11. 【标签的 ref 属性】

作用:用于注册模板引用。

  • 用在普通DOM标签上,获取的是DOM节点。

  • 用在组件标签上,获取的是组件实例对象。

用在普通DOM标签上:

<template>
  <div class="person">
    <h1 ref="title1">尚硅谷</h1>
    <h2 ref="title2">前端</h2>
    <h3 ref="title3">Vue</h3>
    <input type="text" ref="inpt"> <br><br>
    <button @click="showLog">点我打印内容</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref} from 'vue'

  let title1 = ref()
  let title2 = ref()
  let title3 = ref()

  function showLog(){
    // 通过id获取元素
    const t1 = document.getElementById('title1')
    // 打印内容
    console.log((t1 as HTMLElement).innerText)
    console.log((<HTMLElement>t1).innerText)
    console.log(t1?.innerText)
    
/************************************/

    // 通过ref获取元素
    console.log(title1.value)
    console.log(title2.value)
    console.log(title3.value)
  }
</script>

用在组件标签上:

<!-- 父组件App.vue -->
<template>
  <Person ref="ren"/>
  <button @click="test">测试</button>
</template>

<script lang="ts" setup name="App">
  import Person from './components/Person.vue'
  import {ref} from 'vue'

  let ren = ref()

  function test(){
    console.log(ren.value.name)
    console.log(ren.value.age)
  }
</script>


<!-- 子组件Person.vue中要使用defineExpose暴露内容 -->
<script lang="ts" setup name="Person">
  import {ref,defineExpose} from 'vue'
// 数据
  let name = ref('张三')
  let age = ref(18)
  /****************************/
  /****************************/
  // 使用defineExpose将组件中的数据交给外部
  defineExpose({name,age})
</script>

3.12. 【props】

// 定义一个接口,限制每个Person对象的格式
export interface PersonInter {
 id:string,
 name:string,
    age:number
   }
   
// 定义一个自定义类型Persons
export type Persons = Array<PersonInter>

App.vue中代码:

<template>
<Person :list="persons"/>
</template>
  
<script lang="ts" setup name="App">
  import Person from './components/Person.vue'
  import {reactive} from 'vue'
    import {type Persons} from './types'
  
    let persons = reactive<Persons>([
     {id:'e98219e12',name:'张三',age:18},
      {id:'e98219e13',name:'李四',age:19},
       {id:'e98219e14',name:'王五',age:20}
     ])
   </script>
  

Person.vue中代码:

<template>
<div class="person">
 <ul>
     <li v-for="item in list" :key="item.id">
        {{item.name}}--{{item.age}}
      </li>
    </ul>
   </div>
   </template>
  
<script lang="ts" setup name="Person">
import {defineProps} from 'vue'
import {type PersonInter} from '@/types'
  
  // 第一种写法:仅接收
// const props = defineProps(['list'])
  
  // 第二种写法:接收+限制类型
// defineProps<{list:Persons}>()
  
  // 第三种写法:接收+限制类型+指定默认值+限制必要性
let props = withDefaults(defineProps<{list?:Persons}>(),{
     list:()=>[{id:'asdasg01',name:'小猪佩奇',age:18}]
  })
   console.log(props)
  </script>

3.13. 【生命周期】

  • 概念:Vue组件实例在创建时要经历一系列的初始化步骤,在此过程中Vue会在合适的时机,调用特定的函数,从而让开发者有机会在特定阶段运行自己的代码,这些特定的函数统称为:生命周期钩子

  • 规律:

    生命周期整体分为四个阶段,分别是:创建、挂载、更新、销毁,每个阶段都有两个钩子,一前一后。

  • Vue2的生命周期

    创建阶段:beforeCreatecreated

    挂载阶段:beforeMountmounted

    更新阶段:beforeUpdateupdated

    销毁阶段:beforeDestroydestroyed

  • Vue3的生命周期

    创建阶段:setup

    挂载阶段:onBeforeMountonMounted

    更新阶段:onBeforeUpdateonUpdated

    卸载阶段:onBeforeUnmountonUnmounted

  • 常用的钩子:onMounted(挂载完毕)、onUpdated(更新完毕)、onBeforeUnmount(卸载之前)

  • 示例代码:

    <template>
      <div class="person">
        <h2>当前求和为:{{ sum }}</h2>
        <button @click="changeSum">点我sum+1</button>
      </div>
    </template>
    
    <!-- vue3写法 -->
    <script lang="ts" setup name="Person">
      import { 
        ref, 
        onBeforeMount, 
        onMounted, 
        onBeforeUpdate, 
        onUpdated, 
        onBeforeUnmount, 
        onUnmounted 
      } from 'vue'
    
      // 数据
      let sum = ref(0)
      // 方法
      function changeSum() {
        sum.value += 1
      }
      console.log('setup')
      // 生命周期钩子
      onBeforeMount(()=>{
        console.log('挂载之前')
      })
      onMounted(()=>{
        console.log('挂载完毕')
      })
      onBeforeUpdate(()=>{
        console.log('更新之前')
      })
      onUpdated(()=>{
        console.log('更新完毕')
      })
      onBeforeUnmount(()=>{
        console.log('卸载之前')
      })
      onUnmounted(()=>{
        console.log('卸载完毕')
      })
    </script>
    

3.14. 【自定义hook】

  • 什么是hook?—— 本质是一个函数,把setup函数中使用的Composition API进行了封装,类似于vue2.x中的mixin

  • 自定义hook的优势:复用代码, 让setup中的逻辑更清楚易懂。

示例代码:

  • useSum.ts中内容如下:

    import {ref,onMounted} from 'vue'
    
    export default function(){
      let sum = ref(0)
    
      const increment = ()=>{
        sum.value += 1
      }
      const decrement = ()=>{
        sum.value -= 1
      }
      onMounted(()=>{
        increment()
      })
    
      //向外部暴露数据
      return {sum,increment,decrement}
    }
    
  • useDog.ts中内容如下:

    import {reactive,onMounted} from 'vue'
    import axios,{AxiosError} from 'axios'
    
    export default function(){
      let dogList = reactive<string[]>([])
    
      // 方法
      async function getDog(){
        try {
          // 发请求
          let {data} = await axios.get('https://dog.ceo/api/breed/pembroke/images/random')
          // 维护数据
          dogList.push(data.message)
        } catch (error) {
          // 处理错误
          const err = <AxiosError>error
          console.log(err.message)
        }
      }
    
      // 挂载钩子
      onMounted(()=>{
        getDog()
      })
    
      //向外部暴露数据
      return {dogList,getDog}
    }
    
  • 组件中具体使用:

    <template>
      <h2>当前求和为:{{sum}}</h2>
      <button @click="increment">点我+1</button>
      <button @click="decrement">点我-1</button>
      <hr>
      <img v-for="(u,index) in dogList.urlList" :key="index" :src="(u as string)"> 
      <span v-show="dogList.isLoading">加载中......</span><br>
      <button @click="getDog">再来一只狗</button>
    </template>
    
    <script lang="ts">
      import {defineComponent} from 'vue'
    
      export default defineComponent({
        name:'App',
      })
    </script>
    
    <script setup lang="ts">
      import useSum from './hooks/useSum'
      import useDog from './hooks/useDog'
    
      let {sum,increment,decrement} = useSum()
      let {dogList,getDog} = useDog()
    </script>
    

4. 路由

4.1. 【对路由的理解】

image-20231018144351536.png

4.2. 【基本切换效果】

  • Vue3中要使用vue-router的最新版本,目前是4版本。

  • 路由配置文件代码如下:

    import {createRouter,createWebHistory} from 'vue-router'
    import Home from '@/pages/Home.vue'
    import News from '@/pages/News.vue'
    import About from '@/pages/About.vue'
    
    const router = createRouter({
    history:createWebHistory(),
    routes:[
    {
    path:'/home',
    component:Home
    },
    {
    path:'/about',
    component:About
    }
    ]
    })
    export default router
    
  • main.ts代码如下:

    import router from './router/index'
    app.use(router)
    
    app.mount('#app')
    
  • App.vue代码如下

    <template>
      <div class="app">
        <h2 class="title">Vue路由测试</h2>
        <!-- 导航区 -->
        <div class="navigate">
          <RouterLink to="/home" active-class="active">首页</RouterLink>
          <RouterLink to="/news" active-class="active">新闻</RouterLink>
          <RouterLink to="/about" active-class="active">关于</RouterLink>
        </div>
        <!-- 展示区 -->
        <div class="main-content">
          <RouterView></RouterView>
        </div>
      </div>
    </template>
    
    <script lang="ts" setup name="App">
      import {RouterLink,RouterView} from 'vue-router'  
    </script>
    

4.3. 【两个注意点】

  1. 路由组件通常存放在pagesviews文件夹,一般组件通常存放在components文件夹。

  2. 通过点击导航,视觉效果上“消失” 了的路由组件,默认是被卸载掉的,需要的时候再去挂载

4.4.【路由器工作模式】

  1. history模式

    优点:URL更加美观,不带有#,更接近传统的网站URL

    缺点:后期项目上线,需要服务端配合处理路径问题,否则刷新会有404错误。

    const router = createRouter({
      history:createWebHistory(), //history模式
      /******/
    })
    
  2. hash模式

    优点:兼容性更好,因为不需要服务器端处理路径。

    缺点:URL带有#不太美观,且在SEO优化方面相对较差。

    const router = createRouter({
      history:createWebHashHistory(), //hash模式
      /******/
    })
    

4.5. 【to的两种写法】

<!-- 第一种:to的字符串写法 -->
<router-link active-class="active" to="/home">主页</router-link>

<!-- 第二种:to的对象写法 -->
<router-link active-class="active" :to="{path:'/home'}">Home</router-link>

4.6. 【命名路由】

作用:可以简化路由跳转及传参(后面就讲)。

给路由规则命名:

routes:[
  {
    name:'zhuye',
    path:'/home',
    component:Home
  },
  {
    name:'xinwen',
    path:'/news',
    component:News,
  },
  {
    name:'guanyu',
    path:'/about',
    component:About
  }
]

跳转路由:

<!--简化前:需要写完整的路径(to的字符串写法) -->
<router-link to="/news/detail">跳转</router-link>

<!--简化后:直接通过名字跳转(to的对象写法配合name属性) -->
<router-link :to="{name:'guanyu'}">跳转</router-link>

4.7. 【嵌套路由】

  1. 编写News的子路由:Detail.vue

  2. 配置路由规则,使用children配置项:

    const router = createRouter({
      history:createWebHistory(),
    routes:[
    {
    name:'zhuye',
    path:'/home',
    component:Home
    },
    {
    name:'xinwen',
    path:'/news',
    component:News,
    children:[
    {
    name:'xiang',
    path:'detail',
    component:Detail
    }
    ]
    },
    {
    name:'guanyu',
    path:'/about',
    component:About
    }
    ]
    })
    export default router
    
  3. 跳转路由(记得要加完整路径):

    <router-link to="/news/detail">xxxx</router-link>
    <!-- 或 -->
    <router-link :to="{path:'/news/detail'}">xxxx</router-link>
    
  4. 记得去Home组件中预留一个<router-view>

    <template>
      <div class="news">
        <nav class="news-list">
          <RouterLink v-for="news in newsList" :key="news.id" :to="{path:'/news/detail'}">
            {{news.name}}
          </RouterLink>
        </nav>
        <div class="news-detail">
          <RouterView/>
        </div>
      </div>
    </template>
    

4.8. 【路由传参】

query参数

  1. 传递参数

    <!-- 跳转并携带query参数(to的字符串写法) -->
    <router-link to="/news/detail?a=1&b=2&content=欢迎你">
    跳转
    </router-link>
    
    <!-- 跳转并携带query参数(to的对象写法) -->
    <RouterLink 
      :to="{
        //name:'xiang', //用name也可以跳转
        path:'/news/detail',
        query:{
          id:news.id,
          title:news.title,
          content:news.content
        }
      }"
    >
      {{news.title}}
    </RouterLink>
    
  2. 接收参数:

    import {useRoute} from 'vue-router'
    const route = useRoute()
    // 打印query参数
    console.log(route.query)
    

params参数

  1. 传递参数

    <!-- 跳转并携带params参数(to的字符串写法) -->
    <RouterLink :to="`/news/detail/001/新闻001/内容001`">{{news.title}}</RouterLink>
    
    <!-- 跳转并携带params参数(to的对象写法) -->
    <RouterLink 
      :to="{
        name:'xiang', //用name跳转
        params:{
          id:news.id,
          title:news.title,
          content:news.title
        }
      }"
    >
      {{news.title}}
    </RouterLink>
    
  2. 接收参数:

    import {useRoute} from 'vue-router'
    const route = useRoute()
    // 打印params参数
    console.log(route.params)
    

备注1:传递params参数时,若使用to的对象写法,必须使用name配置项,不能用path

备注2:传递params参数时,需要提前在规则中占位。

4.9. 【路由的props配置】

作用:让路由组件更方便的收到参数(可以将路由参数作为props传给组件)

{
name:'xiang',
path:'detail/:id/:title/:content',
component:Detail,

  // props的对象写法,作用:把对象中的每一组key-value作为props传给Detail组件
  // props:{a:1,b:2,c:3}, 

  // props的布尔值写法,作用:把收到了每一组params参数,作为props传给Detail组件
  // props:true
  
  // props的函数写法,作用:把返回的对象中每一组key-value作为props传给Detail组件
  props(route){
    return route.query
  }
}

4.10. 【 replace属性】

  1. 作用:控制路由跳转时操作浏览器历史记录的模式。

  2. 浏览器的历史记录有两种写入方式:分别为pushreplace

    • push是追加历史记录(默认值)。
    • replace是替换当前记录。
  3. 开启replace模式:

    <RouterLink replace .......>News</RouterLink>
    

4.11. 【编程式导航】

路由组件的两个重要的属性:$route$router变成了两个hooks

import {useRoute,useRouter} from 'vue-router'

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

console.log(route.query)
console.log(route.parmas)
console.log(router.push)
console.log(router.replace)

4.12. 【重定向】

  1. 作用:将特定的路径,重新定向到已有路由。

  2. 具体编码:

    {
        path:'/',
        redirect:'/about'
    }
    

5. pinia

5.1【准备一个效果】

pinia_example.gif

5.2【搭建 pinia 环境】

第一步:npm install pinia

第二步:操作src/main.ts

import { createApp } from 'vue'
import App from './App.vue'

/* 引入createPinia,用于创建pinia */
import { createPinia } from 'pinia'

/* 创建pinia */
const pinia = createPinia()
const app = createApp(App)

/* 使用插件 */{}
app.use(pinia)
app.mount('#app')

此时开发者工具中已经有了pinia选项

5.3【存储+读取数据】

  1. Store是一个保存:状态业务逻辑 的实体,每个组件都可以读取写入它。

  2. 它有三个概念:stategetteraction,相当于组件中的: datacomputedmethods

  3. 具体编码:src/store/count.ts

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useCountStore = defineStore('count',{
      // 动作
      actions:{},
      // 状态
      state(){
        return {
          sum:6
        }
      },
      // 计算
      getters:{}
    })
    
  4. 具体编码:src/store/talk.ts

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useTalkStore = defineStore('talk',{
      // 动作
      actions:{},
      // 状态
      state(){
        return {
          talkList:[
            {id:'yuysada01',content:'你今天有点怪,哪里怪?怪好看的!'},
         {id:'yuysada02',content:'草莓、蓝莓、蔓越莓,你想我了没?'},
            {id:'yuysada03',content:'心里给你留了一块地,我的死心塌地'}
          ]
        }
      },
      // 计算
      getters:{}
    })
    
  5. 组件中使用state中的数据

    <template>
      <h2>当前求和为:{{ sumStore.sum }}</h2>
    </template>
    
    <script setup lang="ts" name="Count">
      // 引入对应的useXxxxxStore
      import {useSumStore} from '@/store/sum'
      
      // 调用useXxxxxStore得到对应的store
      const sumStore = useSumStore()
    </script>
    
    <template>
    <ul>
        <li v-for="talk in talkStore.talkList" :key="talk.id">
          {{ talk.content }}
        </li>
      </ul>
    </template>
    
    <script setup lang="ts" name="Count">
      import axios from 'axios'
      import {useTalkStore} from '@/store/talk'
    
      const talkStore = useTalkStore()
    </script>
    

5.4.【修改数据】(三种方式)

  1. 第一种修改方式,直接修改

    countStore.sum = 666
    
  2. 第二种修改方式:批量修改

    countStore.$patch({
      sum:999,
      school:'atguigu'
    })
    
  3. 第三种修改方式:借助action修改(action中可以编写一些业务逻辑)

    import { defineStore } from 'pinia'
    
    export const useCountStore = defineStore('count', {
      /*************/
      actions: {
        //加
        increment(value:number) {
          if (this.sum < 10) {
            //操作countStore中的sum
            this.sum += value
          }
        },
        //减
        decrement(value:number){
          if(this.sum > 1){
            this.sum -= value
          }
        }
      },
      /*************/
    })
    
  4. 组件中调用action即可

    // 使用countStore
    const countStore = useCountStore()
    
    // 调用对应action
    countStore.incrementOdd(n.value)
    

5.5.【storeToRefs】

  • 借助storeToRefsstore中的数据转为ref对象,方便在模板中使用。
  • 注意:pinia提供的storeToRefs只会将数据做转换,而VuetoRefs会转换store中数据。
<template>
<div class="count">
<h2>当前求和为:{{sum}}</h2>
</div>
</template>

<script setup lang="ts" name="Count">
  import { useCountStore } from '@/store/count'
  /* 引入storeToRefs */
  import { storeToRefs } from 'pinia'

/* 得到countStore */
  const countStore = useCountStore()
  /* 使用storeToRefs转换countStore,随后解构 */
  const {sum} = storeToRefs(countStore)
</script>

5.6.【getters】

  1. 概念:当state中的数据,需要经过处理后再使用时,可以使用getters配置。

  2. 追加getters配置。

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useCountStore = defineStore('count',{
      // 动作
      actions:{
        /************/
      },
      // 状态
      state(){
        return {
          sum:1,
          school:'atguigu'
        }
      },
      // 计算
      getters:{
        bigSum:(state):number => state.sum *10,
        upperSchool():string{
          return this. school.toUpperCase()
        }
      }
    })
    
  3. 组件中读取数据:

    const {increment,decrement} = countStore
    let {sum,school,bigSum,upperSchool} = storeToRefs(countStore)
    

5.7.【$subscribe】

通过 store 的 $subscribe() 方法侦听 state 及其变化

talkStore.$subscribe((mutate,state)=>{
  console.log('LoveTalk',mutate,state)
  localStorage.setItem('talk',JSON.stringify(talkList.value))
})

5.8. 【store组合式写法】

import {defineStore} from 'pinia'
import axios from 'axios'
import {nanoid} from 'nanoid'
import {reactive} from 'vue'

export const useTalkStore = defineStore('talk',()=>{
  // talkList就是state
  const talkList = reactive(
    JSON.parse(localStorage.getItem('talkList') as string) || []
  )

  // getATalk函数相当于action
  async function getATalk(){
    // 发请求,下面这行的写法是:连续解构赋值+重命名
    let {data:{content:title}} = await axios.get('https://api.uomg.com/api/rand.qinghua?format=json')
    // 把请求回来的字符串,包装成一个对象
    let obj = {id:nanoid(),title}
    // 放到数组中
    talkList.unshift(obj)
  }
  return {talkList,getATalk}
})

6. 组件通信

Vue3组件通信和Vue2的区别:

  • 移出事件总线,使用mitt代替。
  • vuex换成了pinia
  • .sync优化到了v-model里面了。
  • $listeners所有的东西,合并到$attrs中了。
  • $children被砍掉了。

常见搭配形式:

image-20231119185900990.png

6.1. 【props】

概述:props是使用频率最高的一种通信方式,常用与 :父 ↔ 子

  • 父传子:属性值是非函数
  • 子传父:属性值是函数

父组件:

<template>
  <div class="father">
    <h3>父组件,</h3>
<h4>我的车:{{ car }}</h4>
<h4>儿子给的玩具:{{ toy }}</h4>
<Child :car="car" :getToy="getToy"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
// 数据
const car = ref('奔驰')
const toy = ref()
// 方法
function getToy(value:string){
toy.value = value
}
</script>

子组件

<template>
  <div class="child">
    <h3>子组件</h3>
<h4>我的玩具:{{ toy }}</h4>
<h4>父给我的车:{{ car }}</h4>
<button @click="getToy(toy)">玩具给父亲</button>
  </div>
</template>

<script setup lang="ts" name="Child">
import { ref } from "vue";
const toy = ref('奥特曼')

defineProps(['car','getToy'])
</script>

6.2. 【自定义事件】

  1. 概述:自定义事件常用于:子 => 父。
  2. 注意区分好:原生事件、自定义事件。
  • 原生事件:
    • 事件名是特定的(clickmosueenter等等)
    • 事件对象$event: 是包含事件相关信息的对象(pageXpageYtargetkeyCode
  • 自定义事件:
    • 事件名是任意名称
    • 事件对象$event: 是调用emit时所提供的数据,可以是任意类型!!!
  1. 示例:

    <!--在父组件中,给子组件绑定自定义事件:-->
    <Child @send-toy="toy = $event"/>
    
    <!--注意区分原生事件与自定义事件中的$event-->
    <button @click="toy = $event">测试</button>
    
    //子组件中,触发事件:
    this.$emit('send-toy', 具体数据)
    

6.3. 【mitt】

概述:与消息订阅与发布(pubsub)功能类似,可以实现任意组件间通信。

安装mitt

npm i mitt

新建文件:src\utils\emitter.ts

// 引入mitt 
import mitt from "mitt";

// 创建emitter
const emitter = mitt()

/*
  // 绑定事件
  emitter.on('abc',(value)=>{
    console.log('abc事件被触发',value)
  })
  emitter.on('xyz',(value)=>{
    console.log('xyz事件被触发',value)
  })

  setInterval(() => {
    // 触发事件
    emitter.emit('abc',666)
    emitter.emit('xyz',777)
  }, 1000);

  setTimeout(() => {
    // 清理事件
    emitter.all.clear()
  }, 3000); 
*/

// 创建并暴露mitt
export default emitter

接收数据的组件中:绑定事件、同时在销毁前解绑事件:

import emitter from "@/utils/emitter";
import { onUnmounted } from "vue";

// 绑定事件
emitter.on('send-toy',(value)=>{
  console.log('send-toy事件被触发',value)
})

onUnmounted(()=>{
  // 解绑事件
  emitter.off('send-toy')
})

【第三步】:提供数据的组件,在合适的时候触发事件

import emitter from "@/utils/emitter";

function sendToy(){
  // 触发事件
  emitter.emit('send-toy',toy.value)
}

注意这个重要的内置关系,总线依赖着这个内置关系

6.4.【v-model】

  1. 概述:实现 父↔子 之间相互通信。

  2. 前序知识 —— v-model的本质

    <!-- 使用v-model指令 -->
    <input type="text" v-model="userName">
    
    <!-- v-model的本质是下面这行代码 -->
    <input 
      type="text" 
      :value="userName" 
      @input="userName =(<HTMLInputElement>$event.target).value"
    >
    
  3. 组件标签上的v-model的本质::moldeValueupdate:modelValue事件。

    <!-- 组件标签上使用v-model指令 -->
    <AtguiguInput v-model="userName"/>
    
    <!-- 组件标签上v-model的本质 -->
    <AtguiguInput :modelValue="userName" @update:model-value="userName = $event"/>
    

    AtguiguInput组件中:

    <template>
      <div class="box">
        <!--将接收的value值赋给input元素的value属性,目的是:为了呈现数据 -->
    <!--给input元素绑定原生input事件,触发input事件时,进而触发update:model-value事件-->
        <input 
           type="text" 
           :value="modelValue" 
           @input="emit('update:model-value',$event.target.value)"
        >
      </div>
    </template>
    
    <script setup lang="ts" name="AtguiguInput">
      // 接收props
      defineProps(['modelValue'])
      // 声明事件
      const emit = defineEmits(['update:model-value'])
    </script>
    
  4. 也可以更换value,例如改成abc

    <!-- 也可以更换value,例如改成abc-->
    <AtguiguInput v-model:abc="userName"/>
    
    <!-- 上面代码的本质如下 -->
    <AtguiguInput :abc="userName" @update:abc="userName = $event"/>
    

    AtguiguInput组件中:

    <template>
      <div class="box">
        <input 
           type="text" 
           :value="abc" 
           @input="emit('update:abc',$event.target.value)"
        >
      </div>
    </template>
    
    <script setup lang="ts" name="AtguiguInput">
      // 接收props
      defineProps(['abc'])
      // 声明事件
      const emit = defineEmits(['update:abc'])
    </script>
    
  5. 如果value可以更换,那么就可以在组件标签上多次使用v-model

    <AtguiguInput v-model:abc="userName" v-model:xyz="password"/>
    

6.5.【$attrs 】

  1. 概述:$attrs用于实现当前组件的父组件,向当前组件的子组件通信(祖→孙)。

  2. 具体说明:$attrs是一个对象,包含所有父组件传入的标签属性。

    注意:$attrs会自动排除props中声明的属性(可以认为声明过的 props 被子组件自己“消费”了)

父组件:

<template>
  <div class="father">
    <h3>父组件</h3>
<Child :a="a" :b="b" :c="c" :d="d" v-bind="{x:100,y:200}" :updateA="updateA"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
let a = ref(1)
let b = ref(2)
let c = ref(3)
let d = ref(4)

function updateA(value){
a.value = value
}
</script>

子组件:

<template>
<div class="child">
<h3>子组件</h3>
<GrandChild v-bind="$attrs"/>
</div>
</template>

<script setup lang="ts" name="Child">
import GrandChild from './GrandChild.vue'
</script>

孙组件:

<template>
<div class="grand-child">
<h3>孙组件</h3>
<h4>a:{{ a }}</h4>
<h4>b:{{ b }}</h4>
<h4>c:{{ c }}</h4>
<h4>d:{{ d }}</h4>
<h4>x:{{ x }}</h4>
<h4>y:{{ y }}</h4>
<button @click="updateA(666)">点我更新A</button>
</div>
</template>

<script setup lang="ts" name="GrandChild">
defineProps(['a','b','c','d','x','y','updateA'])
</script>

6.6. 【refsrefs、parent】

  1. 概述:

    • $refs用于 :父→子。
    • $parent用于:子→父。
  2. 原理如下:

    属性 说明
    $refs 值为对象,包含所有被ref属性标识的DOM元素或组件实例。
    $parent 值为对象,当前组件的父组件实例对象。

6.7. 【provide、inject】

  1. 概述:实现祖孙组件直接通信

  2. 具体使用:

    • 在祖先组件中通过provide配置向后代组件提供数据
    • 在后代组件中通过inject配置来声明接收数据
  3. 具体编码:

    【第一步】父组件中,使用provide提供数据

    <template>
      <div class="father">
        <h3>父组件</h3>
        <h4>资产:{{ money }}</h4>
        <h4>汽车:{{ car }}</h4>
        <button @click="money += 1">资产+1</button>
        <button @click="car.price += 1">汽车价格+1</button>
        <Child/>
      </div>
    </template>
    
    <script setup lang="ts" name="Father">
      import Child from './Child.vue'
      import { ref,reactive,provide } from "vue";
      // 数据
      let money = ref(100)
      let car = reactive({
        brand:'奔驰',
        price:100
      })
      // 用于更新money的方法
      function updateMoney(value:number){
        money.value += value
      }
      // 提供数据
      provide('moneyContext',{money,updateMoney})
      provide('car',car)
    </script>
    

    注意:子组件中不用编写任何东西,是不受到任何打扰的

    【第二步】孙组件中使用inject配置项接受数据。

    <template>
      <div class="grand-child">
        <h3>我是孙组件</h3>
        <h4>资产:{{ money }}</h4>
        <h4>汽车:{{ car }}</h4>
        <button @click="updateMoney(6)">点我</button>
      </div>
    </template>
    
    <script setup lang="ts" name="GrandChild">
      import { inject } from 'vue';
      // 注入数据
     let {money,updateMoney} = inject('moneyContext',{money:0,updateMoney:(x:number)=>{}})
      let car = inject('car')
    
```

6.8. 【pinia】

参考之前pinia部分的讲解

6.9. 【slot】

1. 默认插槽

img

父组件中:
        <Category title="今日热门游戏">
          <ul>
            <li v-for="g in games" :key="g.id">{{ g.name }}</li>
          </ul>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <!-- 默认插槽 -->
            <slot></slot>
          </div>
        </template>

2. 具名插槽

父组件中:
        <Category title="今日热门游戏">
          <template v-slot:s1>
            <ul>
              <li v-for="g in games" :key="g.id">{{ g.name }}</li>
            </ul>
          </template>
          <template #s2>
            <a href="">更多</a>
          </template>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <slot name="s1"></slot>
            <slot name="s2"></slot>
          </div>
        </template>

3. 作用域插槽

  1. 理解:数据在组件的自身,但根据数据生成的结构需要组件的使用者来决定。(新闻数据在News组件中,但使用数据所遍历出来的结构由App组件决定)

  2. 具体编码:

    父组件中:
          <Game v-slot="params">
          <!-- <Game v-slot:default="params"> -->
          <!-- <Game #default="params"> -->
            <ul>
              <li v-for="g in params.games" :key="g.id">{{ g.name }}</li>
            </ul>
          </Game>
    
    子组件中:
          <template>
            <div class="category">
              <h2>今日游戏榜单</h2>
              <slot :games="games" a="哈哈"></slot>
            </div>
          </template>
    
          <script setup lang="ts" name="Category">
            import {reactive} from 'vue'
            let games = reactive([
              {id:'asgdytsa01',name:'英雄联盟'},
              {id:'asgdytsa02',name:'王者荣耀'},
              {id:'asgdytsa03',name:'红色警戒'},
              {id:'asgdytsa04',name:'斗罗大陆'}
            ])
          </script>
    

7. 其它 API

7.1.【shallowRef 与 shallowReactive 】

shallowRef

  1. 作用:创建一个响应式数据,但只对顶层属性进行响应式处理。

  2. 用法:

    let myVar = shallowRef(initialValue);
    
  3. 特点:只跟踪引用值的变化,不关心值内部的属性变化。

shallowReactive

  1. 作用:创建一个浅层响应式对象,只会使对象的最顶层属性变成响应式的,对象内部的嵌套属性则不会变成响应式的

  2. 用法:

    const myObj = shallowReactive({ ... });
    
  3. 特点:对象的顶层属性是响应式的,但嵌套对象的属性不是。

总结

通过使用 shallowRef()shallowReactive() 来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理,避免了对每一个内部属性做响应式所带来的性能成本,这使得属性的访问变得更快,可提升性能。

7.2.【readonly 与 shallowReadonly】

readonly

  1. 作用:用于创建一个对象的深只读副本。

  2. 用法:

    const original = reactive({ ... });
    const readOnlyCopy = readonly(original);
    
  3. 特点:

    • 对象的所有嵌套属性都将变为只读。
    • 任何尝试修改这个对象的操作都会被阻止(在开发模式下,还会在控制台中发出警告)。
  4. 应用场景:

    • 创建不可变的状态快照。
    • 保护全局状态或配置不被修改。

shallowReadonly

  1. 作用:与 readonly 类似,但只作用于对象的顶层属性。

  2. 用法:

    const original = reactive({ ... });
    const shallowReadOnlyCopy = shallowReadonly(original);
    
  3. 特点:

    • 只将对象的顶层属性设置为只读,对象内部的嵌套属性仍然是可变的。

    • 适用于只需保护对象顶层属性的场景。

7.3.【toRaw 与 markRaw】

toRaw

  1. 作用:用于获取一个响应式对象的原始对象, toRaw 返回的对象不再是响应式的,不会触发视图更新。

    官网描述:这是一个可以用于临时读取而不引起代理访问/跟踪开销,或是写入而不触发更改的特殊方法。不建议保存对原始对象的持久引用,请谨慎使用。

    何时使用? —— 在需要将响应式对象传递给非 Vue 的库或外部系统时,使用 toRaw 可以确保它们收到的是普通对象

  2. 具体编码:

    import { reactive,toRaw,markRaw,isReactive } from "vue";
    
    /* toRaw */
    // 响应式对象
    let person = reactive({name:'tony',age:18})
    // 原始对象
    let rawPerson = toRaw(person)
    
    
    /* markRaw */
    let citysd = markRaw([
      {id:'asdda01',name:'北京'},
      {id:'asdda02',name:'上海'},
      {id:'asdda03',name:'天津'},
      {id:'asdda04',name:'重庆'}
    ])
    // 根据原始对象citys去创建响应式对象citys2 —— 创建失败,因为citys被markRaw标记了
    let citys2 = reactive(citys)
    console.log(isReactive(person))
    console.log(isReactive(rawPerson))
    console.log(isReactive(citys))
    console.log(isReactive(citys2))
    

markRaw

  1. 作用:标记一个对象,使其永远不会变成响应式的。

    例如使用mockjs时,为了防止误把mockjs变为响应式对象,可以使用 markRaw 去标记mockjs

  2. 编码:

    /* markRaw */
    let citys = markRaw([
      {id:'asdda01',name:'北京'},
      {id:'asdda02',name:'上海'},
      {id:'asdda03',name:'天津'},
      {id:'asdda04',name:'重庆'}
    ])
    // 根据原始对象citys去创建响应式对象citys2 —— 创建失败,因为citys被markRaw标记了
    let citys2 = reactive(citys)
    

7.4.【customRef】

作用:创建一个自定义的ref,并对其依赖项跟踪和更新触发进行逻辑控制。

实现防抖效果(useSumRef.ts):

import {customRef } from "vue";

export default function(initValue:string,delay:number){
  let msg = customRef((track,trigger)=>{
    let timer:number
    return {
      get(){
        track() // 告诉Vue数据msg很重要,要对msg持续关注,一旦变化就更新
        return initValue
      },
      set(value){
        clearTimeout(timer)
        timer = setTimeout(() => {
          initValue = value
          trigger() //通知Vue数据msg变化了
        }, delay);
      }
    }
  }) 
  return {msg}
}

组件中使用:

8. Vue3新组件

8.1. 【Teleport】

  • 什么是Teleport?—— Teleport 是一种能够将我们的组件html结构移动到指定位置的技术。
<teleport to='body' >
    <div class="modal" v-show="isShow">
      <h2>我是一个弹窗</h2>
      <p>我是弹窗中的一些内容</p>
      <button @click="isShow = false">关闭弹窗</button>
    </div>
</teleport>

8.2. 【Suspense】

  • 等待异步组件时渲染一些额外内容,让应用有更好的用户体验
  • 使用步骤:
    • 异步引入组件
    • 使用Suspense包裹组件,并配置好defaultfallback
import { defineAsyncComponent,Suspense } from "vue";
const Child = defineAsyncComponent(()=>import('./Child.vue'))
<template>
    <div class="app">
        <h3>我是App组件</h3>
        <Suspense>
          <template v-slot:default>
            <Child/>
          </template>
          <template v-slot:fallback>
            <h3>加载中.......</h3>
          </template>
        </Suspense>
    </div>
</template>

8.3.【全局API转移到应用对象】

  • app.component
  • app.config
  • app.directive
  • app.mount
  • app.unmount
  • app.use

8.4.【其他】

  • 过渡类名 v-enter 修改为 v-enter-from、过渡类名 v-leave 修改为 v-leave-from

  • keyCode 作为 v-on 修饰符的支持。

  • v-model 指令在组件上的使用已经被重新设计,替换掉了 v-bind.sync。

  • v-ifv-for 在同一个元素身上使用时的优先级发生了变化。

  • 移除了$on$off$once 实例方法。

  • 移除了过滤器 filter

  • 移除了$children 实例 propert

    ......

Vue3实现拖拽排序

2025年11月14日 13:54

Vue3 + Element Plus + SortableJS 实现表格拖拽排序功能

📋 目录

功能概述

在管理后台系统中,表格数据的排序功能是一个常见的需求。本文介绍如何使用 Vue3、Element Plus 和 SortableJS 实现一个完整的表格拖拽排序功能,支持:

  • ✅ 通过拖拽图标对表格行进行排序
  • ✅ 实时更新数据顺序
  • ✅ 支持数据过滤后的排序
  • ✅ 切换标签页时自动初始化
  • ✅ 优雅的动画效果

先看实现效果: 在这里插入图片描述

技术栈

  • Vue 3 - 渐进式 JavaScript 框架
  • Element Plus - Vue 3 组件库
  • SortableJS - 轻量级拖拽排序库
  • TypeScript - 类型安全的 JavaScript 超集

实现思路

1. 整体架构

用户拖拽表格行
    ↓
SortableJS 监听拖拽事件
    ↓
触发 onEnd 回调
    ↓
更新 Vue 响应式数据
    ↓
表格自动重新渲染

2. 关键步骤

  1. 安装依赖:引入 SortableJS 库
  2. 获取 DOM:获取表格 tbody 元素
  3. 初始化 Sortable:创建拖拽实例
  4. 处理回调:在拖拽结束时更新数据
  5. 生命周期管理:在适当时机初始化和销毁实例

代码实现

1. 安装依赖

npm install sortablejs
# 或
pnpm add sortablejs

2. 导入必要的模块

import { ref, nextTick, watch, onMounted } from "vue";
import Sortable from "sortablejs";
import { Operation } from "@element-plus/icons-vue";//图标

3. 定义数据结构

interface TypeItem {
  id: string;
  name: string;
  enabled: boolean;
  sortOrder: number;
}

const typeData = ref<TypeItem[]>([
  { id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 },
  { id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 },
  // ... 更多数据
]);

4. 模板结构

<template>
  <el-table ref="typeTableRef" :data="filteredTypeData" stripe row-key="id">
    <!-- 排序列:显示拖拽图标 -->
    <el-table-column label="排序" width="131">
      <template #default>
        <el-icon class="drag-handle">
          <Operation />
        </el-icon>
      </template>
    </el-table-column>
    
    <!-- 其他列 -->
    <el-table-column prop="name" label="名称" />
    <el-table-column prop="enabled" label="启用/禁用">
      <template #default="{ row }">
        <el-switch v-model="row.enabled" />
      </template>
    </el-table-column>
  </el-table>
</template>

5. 核心实现代码

// 表格引用
const typeTableRef = ref<InstanceType<typeof ElTable>>();

// Sortable 实例(用于后续销毁)
let sortableInstance: Sortable | null = null;

/**
 * 初始化拖拽排序功能
 */
const initSortable = () => {
  // 1. 销毁旧实例,避免重复创建
  if (sortableInstance) {
    sortableInstance.destroy();
    sortableInstance = null;
  }

  // 2. 等待 DOM 更新完成
  nextTick(() => {
    // 3. 获取表格的 tbody 元素
    const tbody = typeTableRef.value?.$el?.querySelector(
      ".el-table__body-wrapper tbody"
    );
    
    if (!tbody) return;

    // 4. 创建 Sortable 实例
    sortableInstance = Sortable.create(tbody, {
      // 指定拖拽手柄(只能通过拖拽图标来拖拽)
      handle: ".drag-handle",
      
      // 动画时长(毫秒)
      animation: 300,
      
      // 拖拽结束回调
      onEnd: ({ newIndex, oldIndex }) => {
        // 5. 更新数据顺序
        if (
          newIndex !== undefined &&
          oldIndex !== undefined &&
          filterStatus.value === "all" // 只在"全部"状态下允许排序
        ) {
          // 获取被移动的项
          const movedItem = typeData.value[oldIndex];
          
          // 从原位置删除
          typeData.value.splice(oldIndex, 1);
          
          // 插入到新位置
          typeData.value.splice(newIndex, 0, movedItem);
          
          // 更新排序字段
          typeData.value.forEach((item, index) => {
            item.sortOrder = index + 1;
          });
        }
      }
    });
  });
};

6. 生命周期管理

/**
 * 监听标签页切换,初始化拖拽
 */
const watchActiveTab = () => {
  if (activeTab.value === "type") {
    // 延迟初始化,确保表格已完全渲染
    setTimeout(() => {
      initSortable();
    }, 300);
  }
};

// 组件挂载时初始化
onMounted(() => {
  watchActiveTab();
});

// 监听标签页切换
watch(activeTab, () => {
  watchActiveTab();
});

// 监听过滤器变化,重新初始化拖拽
watch(filterStatus, () => {
  if (activeTab.value === "type") {
    setTimeout(() => {
      initSortable();
    }, 100);
  }
});

7. 样式定义

/* 拖拽手柄样式 */
.drag-handle {
  color: #909399;
  cursor: move;
  font-size: 18px;
  transition: color 0.3s;
}

.drag-handle:hover {
  color: #1890ff;
}

/* 表格样式 */
.type-table {
  margin-top: 0;
}

:deep(.type-table .el-table__header-wrapper) {
  background-color: #f9fafc;
}

:deep(.type-table .el-table th) {
  background-color: #f9fafc;
  font-size: 14px;
  font-weight: 500;
  color: #33425cfa;
  font-family: PingFang SC;
  border-bottom: 1px solid #dcdfe6;
}

核心要点

1. 实例管理

问题:如果不管理 Sortable 实例,切换标签页或过滤器时会创建多个实例,导致拖拽行为异常。

解决:使用变量保存实例引用,在创建新实例前先销毁旧实例。

let sortableInstance: Sortable | null = null;

const initSortable = () => {
  // 先销毁旧实例
  if (sortableInstance) {
    sortableInstance.destroy();
    sortableInstance = null;
  }
  // 再创建新实例
  // ...
};

2. DOM 获取时机

问题:如果直接获取 DOM,可能表格还未渲染完成,导致获取失败。

解决:使用 nextTick 等待 Vue 完成 DOM 更新,或使用 setTimeout 延迟执行。

nextTick(() => {
  const tbody = typeTableRef.value?.$el?.querySelector(
    ".el-table__body-wrapper tbody"
  );
  // ...
});

3. 拖拽手柄

问题:如果不指定拖拽手柄,整行都可以拖拽,可能与其他交互冲突(如点击编辑按钮)。

解决:使用 handle 选项指定只有拖拽图标可以触发拖拽。

Sortable.create(tbody, {
  handle: ".drag-handle", // 只允许通过 .drag-handle 元素拖拽
  // ...
});

4. 数据更新策略

问题:直接操作 DOM 顺序不会更新 Vue 的响应式数据。

解决:在 onEnd 回调中手动更新数据数组的顺序。

onEnd: ({ newIndex, oldIndex }) => {
  const movedItem = typeData.value[oldIndex];
  typeData.value.splice(oldIndex, 1);
  typeData.value.splice(newIndex, 0, movedItem);
  // 更新排序字段
  typeData.value.forEach((item, index) => {
    item.sortOrder = index + 1;
  });
}

5. 过滤状态处理

问题:当表格数据被过滤后,拖拽的索引可能不准确。

解决:只在"全部"状态下允许排序,或根据过滤后的数据计算正确的索引。

onEnd: ({ newIndex, oldIndex }) => {
  if (filterStatus.value === "all") {
    // 只在全部状态下允许排序
    // ...
  }
}

常见问题

Q1: 拖拽后数据没有更新?

A: 检查是否正确更新了响应式数据。SortableJS 只负责 DOM 操作,不会自动更新 Vue 数据。

Q2: 切换标签页后拖拽失效?

A: 需要在标签页切换时重新初始化 Sortable 实例,因为 DOM 已经重新渲染。

Q3: 拖拽时整行都可以拖,如何限制?

A: 使用 handle 选项指定拖拽手柄元素。

Q4: 拖拽动画不流畅?

A: 调整 animation 参数的值,通常 200-300ms 效果较好。

Q5: 如何保存排序结果?

A: 在 onEnd 回调中,将更新后的数据发送到后端 API。

onEnd: ({ newIndex, oldIndex }) => {
  // 更新本地数据
  // ...
  
  // 保存到后端
  saveSortOrder(typeData.value.map(item => ({
    id: item.id,
    sortOrder: item.sortOrder
  })));
}

完整示例代码

<template>
  <div class="type-setting">
    <!-- 过滤器 -->
    <div class="filter-actions">
      <el-button
        :type="filterStatus === 'all' ? 'primary' : ''"
        @click="filterStatus = 'all'"
      >
        全部
      </el-button>
      <el-button
        :type="filterStatus === 'enabled' ? 'primary' : ''"
        @click="filterStatus = 'enabled'"
      >
        启用
      </el-button>
    </div>

    <!-- 表格 -->
    <el-table
      ref="typeTableRef"
      :data="filteredTypeData"
      stripe
      row-key="id"
    >
      <el-table-column label="排序" width="131">
        <template #default>
          <el-icon class="drag-handle">
            <Operation />
          </el-icon>
        </template>
      </el-table-column>
      <el-table-column prop="name" label="名称" />
      <el-table-column prop="enabled" label="启用/禁用">
        <template #default="{ row }">
          <el-switch v-model="row.enabled" />
        </template>
      </el-table-column>
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button type="primary" link @click="handleEdit(row)">
            编辑
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, watch, onMounted } from "vue";
import { ElTable } from "element-plus";
import Sortable from "sortablejs";
import { Operation } from "@element-plus/icons-vue";

interface TypeItem {
  id: string;
  name: string;
  enabled: boolean;
  sortOrder: number;
}

const typeData = ref<TypeItem[]>([
  { id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 },
  { id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 },
  { id: "3", name: "楼宇性质3", enabled: false, sortOrder: 3 },
]);

const filterStatus = ref<"all" | "enabled" | "disabled">("all");
const typeTableRef = ref<InstanceType<typeof ElTable>>();
let sortableInstance: Sortable | null = null;

const filteredTypeData = computed(() => {
  if (filterStatus.value === "all") return typeData.value;
  if (filterStatus.value === "enabled") {
    return typeData.value.filter(item => item.enabled);
  }
  return typeData.value.filter(item => !item.enabled);
});

const initSortable = () => {
  if (sortableInstance) {
    sortableInstance.destroy();
    sortableInstance = null;
  }

  nextTick(() => {
    const tbody = typeTableRef.value?.$el?.querySelector(
      ".el-table__body-wrapper tbody"
    );
    if (!tbody) return;

    sortableInstance = Sortable.create(tbody, {
      handle: ".drag-handle",
      animation: 300,
      onEnd: ({ newIndex, oldIndex }) => {
        if (
          newIndex !== undefined &&
          oldIndex !== undefined &&
          filterStatus.value === "all"
        ) {
          const movedItem = typeData.value[oldIndex];
          typeData.value.splice(oldIndex, 1);
          typeData.value.splice(newIndex, 0, movedItem);
          typeData.value.forEach((item, index) => {
            item.sortOrder = index + 1;
          });
        }
      }
    });
  });
};

onMounted(() => {
  setTimeout(() => initSortable(), 300);
});

watch(filterStatus, () => {
  setTimeout(() => initSortable(), 100);
});
</script>

<style scoped>
.drag-handle {
  color: #909399;
  cursor: move;
  font-size: 18px;
}

.drag-handle:hover {
  color: #1890ff;
}
</style>

总结

通过本文的介绍,我们实现了一个完整的表格拖拽排序功能。关键点包括:

  1. 正确的实例管理:避免重复创建和内存泄漏
  2. 合适的初始化时机:确保 DOM 已完全渲染
  3. 数据同步更新:手动更新 Vue 响应式数据
  4. 良好的用户体验:指定拖拽手柄,添加动画效果
  5. 完善的错误处理:处理边界情况

这个方案可以轻松应用到其他需要拖拽排序的场景,如菜单管理、分类排序等。希望本文对您有所帮助!


由于vite版本不一致,导致vue组件引入报错

2025年11月14日 10:26

最近开发了一个vue3的瀑布流插件,但是发现插件在部分项目无法正常展示。

报错信息: Uncaught (in promise) TypeError: Cannot read properties of null (reading 'ce')

image.png

这个错误信息“Uncaught (in promise) TypeError: Cannot read properties of null (reading 'ce') ” 是 Vue 3 中一个常见但信息模糊的报错,通常与组件未正确挂载、异步组件加载失败、或虚拟 DOM 渲染异常有关。

'ce' 是 Vue 内部压缩后的属性名,通常指向 组件的虚拟节点(vnode)或渲染上下文,当 Vue 试图访问一个已卸载或未挂载的组件实例时,就会抛出这个错误。

✅ 常见原因与排查方向

1. 组件未正确挂载就访问其 DOM 或实例

比如你在 onMounted 之前就访问了 refthis.$el,或者组件被条件渲染(v-if)控制,导致挂载失败。

2. 异步组件加载失败或返回 null

如果你使用了 defineAsyncComponentimport(),但组件加载失败或返回了 null,Vue 会尝试渲染一个无效的 vnode。

3. 组件在 v-if 或 v-show 中频繁切换,导致卸载时访问旧实例

比如你在 onUnmountedwatchEffect 中访问了已销毁的 DOM 或组件实例。

4. 使用了不兼容的库或插件

某些第三方库(如旧版本的 vue-router, pinia, element-plus)在 Vue 3.3+ 中可能存在兼容性问题,导致内部访问失败。

5. 组件依赖的devDependencies库和项目devDependencies版本不一致

由于组件依赖的运行时库在打包的时候不会编译进入dist包,项目本地运行时双方依赖版本不一致就会导致报错。

经过排查后发现我组件的vite版本和项目的vite版本差距太大。

//项目依赖库版本
"devDependencies": {
    "@vitejs/plugin-vue": "^3.0.3",
    "vite": "^3.0.7"
 }

//组件库依赖版本
"devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "vite": "^7.2.2"
 }

解决方案:升级项目的依赖库版本即可正常展示;

image.png

vue3+vite实现瀑布流效果 vue3-waterfall-x

还在死磕模板语法?Vue渲染函数+JSX让你开发效率翻倍!

2025年11月14日 07:28
开篇:被模板限制的烦恼时刻 你是不是也遇到过这样的场景?产品经理拿着设计稿过来,说要做一个超级灵活的动态表单,每个字段的类型、验证规则、布局方式都可能随时变化。你看着那复杂的条件渲染,心里默默计算着要
昨天以前首页

前端高频面试题之Vue(高级篇)

2025年11月13日 21:00

1、说一下 Vue.js 的响应式原理

1.1 Vue2 响应式原理

核心原理就是通过 Object.defineProperty 对对象属性进行劫持,重新定义对象的 gettersetter,在 getter 取值时收集依赖,在 setter 修改值时触发依赖更新,更新页面。

Vue2 对数组和对象做了两种不同方式的处理。

监听对象变化:

针对对象来说,Vue 会循环遍历对象的每一个属性,用 defineReactive 重新定义 gettersetter


function defineReactive(target,key,value){
    observer(value);
    Object.defineProperty(target,key,{ ¸v
        get(){
            // ... 收集依赖逻辑
            return value;
        },
        set(newValue){
            if (value !== newValue) {
                value = newValue;
                observer(newValue) // 把新设置的值包装成响应式
            }
            // ...触发依赖更新逻辑
        }
    })
}
function observer(data) {
    if(typeof data !== 'object'){
        return data
    }
    for(let key in data){
        defineReactive(data,key,data[key]);
    }
}

监听数组变化:

我们都知道,数组其实也是对象,同样可以用 Object.defineProperty 劫持数组的每一项,但如果数组有100万项,那就要调用 Object.defineProperty 一百万次,这样的话性能太低了。鉴于平时我们操作数组大都是采用数组提供的原生方法,于是 Vue 对数组重写原型链,在调用7个能改变自身的原生方法(pushpopshiftunshiftsplicesortreverse)时,通知页面进行刷新,具体实现过程如下:

// 先拿到数组的原型
const oldArrayProtoMethods = Array.prototype
// 用Object.create创建一个以oldArrayProtoMethods为原型的对象
const arrayMethods = Object.create(oldArrayProtoMethods)
const methods = [
    'push',
    'pop',
    'shift',
    'unshift',
    'sort',
    'reverse',
    'splice'
]
methods.forEach(method => {
    // 给arrayMethods定义7个方法
    arrayMethods[method] = function (...args){
        // 先找到数组对应的原生方法进行调用
        const result = oldArrayProtoMethods[method].apply(this, args)
        // 声明inserted,用来保存数组新增的数据
        let inserted
        // __ob__是Observer类实例的一个属性,data中的每个对象都是一个Observer类的实例
        const ob = this.__ob__
        switch(method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
            default:
                break
        }
        // 比如有新增的数据,新增数据也要被定义为响应式
        if(inserted) ob.observeArray(inserted)
        // 通知页面更新
        ob.dep.notify()
        return result
    }
})

Object.defineProperty的缺点:

  1. 无法监听新增属性和删除属性的变化,需要通过 $set$delete 实现。
  2. 监测数组的索引性能太低,故而直接通过数组索引改值无法触发响应式。
  3. 初始化时需要一次性递归调用,性能较差。

1.2 Vue3 的响应式改进

Vue3 采用 Proxy + Reflect 配合实现响应式。能解决上述 Object.defineProperty 的所有缺陷,唯一缺点就是兼容性没有 Object.defineProperty 好。

let handler = {
  get(target, key) {
    if (typeof target[key] === "object") {
      return new Proxy(target[key], handler);
    }
    return Reflect.get(target, key);
  },
  set(target, key, value) {
    let oldValue = target[key];
    if (oldValue !== value) {
      return Reflect.set(target, key, value);
    }
    return true;
  },
};
let proxy = new Proxy(obj, handler);

2、介绍一下 Vue 中的 diff 算法?

Vue 的 diff 算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式 + 双指针的方式进行比较。

比较过程:

  1. 先比较是否是相同节点。
  2. 相同节点比较属性,并复用老节点。
  3. 比较儿子节点,考虑老节点和新节点儿子的情况。
  4. 优化比较:头头、尾尾、头尾、尾头。
  5. 比对查找进行复用。

Vue3 在这个比较过程的基础上增加了最长递增子序列实现diff算法。

  • 找出不需要移动的现有节点。
  • 只对需要移动的节点进行操作。
  • 最小化 DOM 操作次数。

3、Vue 的模板编译原理是什么?

Vue 中的模板编译就是把我们写的 template 转换为渲染函数(render function) 的过程,它主要经历3个步骤:

  1. 解析(Parse):将 template 模板转换成 ast 抽象语法树。
  2. 优化(Optimize):对静态节点做静态标记,减少 diff 过程中的比对。
  3. 生成(Generate):重新生成代码,将 ast 抽象语法数转化成可执行的渲染函数代码。

3.1 解析阶段

<div id="app">
  <p>{{ message }}</p>
</div>
  • 用 HTML 解析器将模板解析为 AST。
  • AST中用 js 对象描述模板,里面包含了元素类型、属性、子节点等信息。
  • 解析指令(v-for、v-if)和事件(@click)、插值表达式{{}}等 vue 语法。

3.2 优化阶段

  • 遍历上一步生成的 ast,标记静态节点,比如用 v-once 的节点,以及没有用到响应式数据的节点。
  • 标记静态根节点,避免不必要的渲染。

3.3 代码生成阶段

vue2 解析结果:

function render() {
  with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('p', [_v(_s(message))])])
  }
}
  • _c: 是 createElement 的别名,用于创建 VNode。
  • _v: 创建文本 VNode。
  • _s: 是 toString 的别名,用于将值转换为字符串。

vue3 解析结果:

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", { id: "app" }, [
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}
  • _openBlock: 开启一个"block"区域,用于收集动态子节点。
  • _createElementBlock: 创建一个块级虚拟 DOM 节点。
  • _createElementVNode: 创建一个普通虚拟 DOM 节点。
  • _toDisplayString: 将响应式数据 _ctx.message 转换为显示字符串,或者处理 null/undefined 等值,确保它们能正确渲染为空白字符串。

vue2在线编译:template-explorer.vuejs.org/

vue3在线编译:v2.template-explorer.vuejs.org/

运行时+编译(runtime-compiler) vs 仅运行时(runtime-only):

  1. 完整版(运行时+编译):
    • 包含编译模块,可以写 template 模版。
    • 体积较大(~30kb)。
  2. 仅运行时版本
    • 需要在打包时使用 vue-loader 进行编译。
    • 体积较小(~20kb)。

平时开发项目推荐使用仅运行时(runtime-only)版本。

编译后的特点:

  1. 虚拟DOM:渲染函数生成的是虚拟DOM节点(VNode)。
  2. 响应式绑定:渲染函数中的变量会自动建立依赖关系。
  3. 性能优化:通过静态节点标记减少不必要的更新。

4、v-show 和 v-if 的原理

简单来说,v-if 内部是通过一个三元表达式来实现的,而 v-show 则是通过控制 DOM 元素的 display 属性来实现的。

v-if 源码:

function genIfConditions (
    conditions: ASTIfConditions,
    state: CodegenState,
    altGen?: Function,
    altEmpty?: string
    ): string {
    if (!conditions.length) {
        return altEmpty || '_e()'
    }
    const condition = conditions.shift()
    if (condition.exp) {   // 如果有表达式
        return `(${condition.exp})?${ // 将表达式作为条件拼接成元素
        genTernaryExp(condition.block)
        }:${
        genIfConditions(conditions, state, altGen, altEmpty)
        }`
    } else {
        return `${genTernaryExp(condition.block)}` // 没有表达式直接生成元素 像v-else
    }

    // v-if with v-once should generate code like (a)?_m(0):_m(1)
    function genTernaryExp (el) {
        return altGen
        ? altGen(el, state)
        : el.once
            ? genOnce(el, state)
            : genElement(el, state)
    }
}

v-show 源码:

{
    bind (el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
    const originalDisplay = el.__vOriginalDisplay =
        el.style.display === 'none' ? '' : el.style.display // 获取原始显示值
        el.style.display = value ? originalDisplay : 'none' // 根据属性控制显示或者隐藏
    }  
} 

5、v-if 和 v-for 哪个优先级更高?为什么?

  • vue2 中 v-for 的优先级比 v-if 高,它们作用于一个节点上会导致先循环后对每一项进行判断,浪费性能。
  • vue3 中 v-if 的优先级比 v-for 高,这就会导致 v-if 中的条件无法访问 v-for 作用域名中定义的变量别名。
<li v-for="item in arr" v-if="item.visible">
  {{ item}}
</li>

以上代码在 vue3 的编译结果如下:

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createCommentVNode as _createCommentVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_ctx.item.visible)
    ? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(_ctx.arr, (item) => {
        return (_openBlock(), _createElementBlock("li", null, _toDisplayString(item), 1 /* TEXT */))
      }), 256 /* UNKEYED_FRAGMENT */))
    : _createCommentVNode("v-if", true)
}

可以看出 vue3 在编译时会先判断 v-if,然后再走 v-for 的循环,所以在 v-if 中自然就无法访问 v-for 作用域名中定义的变量别名。

这样的写法在 vue3 中会抛出一个警告⚠️,[Vue warn]: Property "item" was accessed during render but is not defined on instance,导致渲染失败。

以上代码在 vue2 还不能直接编译,因为 vue2 的组件需要一个根节点,所以我们在外层加一个 div

<div>
  <li v-for="item in arr" v-if="item.visible">
    {{ item}}
  </li>
</div>

其编译结果如下:

function render() {
  with(this) {
    return _c('div', _l((arr), function (item) {
      return (item.visible) ? _c('li', [_v("\n    " + _s(item) + "\n  ")]) :
        _e()
    }), 0)
  }
}

很明显是先循环 arr,然后每一项再用 item.visible 去判断的,也印证了在 vue2 中, v-for 的优先级高于 v-if

所以不管是 vue2 还是 vue3,都不推荐同时使用 v-ifv-for,更好的方案是采用计算属性,或者在外层再包裹一个容器元素,将 v-if 作用在容器元素上。

6、nextTick 的原理是什么?

6.1 Vue2 的 nextTick:

  • 首选微任务:
    • Promise.resolve().then(flushCallbacks):最常见,使用 Promise 创建微任务。
    • MutationObserver:如果 Promise 不可用,创建一个文本节点,修改其内容触发 MutationObserver 的观察器回调。
  • 回退宏任务:
    • setImmediate:如果环境支持 setImmediate,比如 node 环境,则会优先使用 setImmediate 。
    • setTimeout(flushCallbacks, 0):最后使用定时器。

这里体现了优雅降级的思想。

6.2 Vue3 的 nextTick:

  • 由于 Vue3 不再考虑 promise 的兼容性,所以 nextTick 的实现原理就是 promise.then 方法。

7、Vue.set 方法是如何实现的?

Vue2的实现:在 Vue 2 中,Vue.set 的实现主要位于 src/core/observer/index.js 中:

export function set (target: Array | Object, key: any, val: any): any {
    // 1.如果是数组 Vue.set(array,1,100); 调用我们重写的splice方法 (这样可以更新视图)
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
    }
    // 2.如果是对象本身的属性,则直接添加即可
    if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
    }
    const ob = (target: any).__ob__
    // 3.如果是响应式的也不需要将其定义成响应式属性
    if (!ob) {
        target[key] = val
        return val
    }
    // 4.将属性定义成响应式的
    defineReactive(ob.value, key, val)
    // 5.通知视图更新
    ob.dep.notify()
    return val
}

Vue3 中 set 方法已经被移除,因为 proxy 天然弥补 vue2 响应式的缺陷。

8、Vue.use 是干什么的?原理是什么?

Vue.use 是用来使用插件的,我们可以在插件中扩展全局组件、指令、原型方法等。

Vue.use 源码:

Vue.use = function (plugin: Function | Object) {
    // 插件不能重复的加载
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    if (installedPlugins.indexOf(plugin) > -1) {
        return this
    }
    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)  // install方法的第一个参数是Vue的构造函数,其他参数是Vue.use中除了第一个参数的其他参数
    if (typeof plugin.install === 'function') { // 调用插件的install方法
        plugin.install.apply(plugin, args)  Vue.install = function(Vue,args){}
    } else if (typeof plugin === 'function') { // 插件本身是一个函数,直接让函数执行
        plugin.apply(null, args) 
    }
    installedPlugins.push(plugin) // 缓存插件
    return this
}

9、介绍下 Vue 中的 mixin,Vue3 为何不再推荐使用它?

mixin 是 Vue 2 中一种复用组件逻辑的方式,允许将可复用的配置(data、methods、computed、lifecycle hooks 等)抽离成一个对象,然后通过 mixins: [] 合并到组件中。支持全局注入和局部注入。

  • 作用:抽离公共的业务逻辑
  • 原理:类似“对象的继承”,当组件初始化时会调用 mergeOptions 方法进行合并,采用策略模式针对不同的属性进行合并。如果混入的数据和本身组件中的数据冲突,会采用“就近原则”以组件的数据为准。

mixin 的优点:

  • 复用逻辑(如表单验证、权限判断)。
  • 全局注入(如日志、埋点)。
  • 减少重复代码。

mixin 中有很多缺陷:

  • 命名冲突问题:mixin 中的变量、函数名可能会与组件中的重名。
  • 依赖问题:
  • 数据来源问题:

vue3 不再推荐使用它的理由如下:

问题 说明
1. 隐式依赖 & 数据来源不明确 组件行为来自多个 mixin,难以追踪 data、methods 是从哪里来的。
2. 命名冲突 多个 mixin 可能定义同名 data、methods,合并规则复杂(同名 methods 后者覆盖前者,data 合并为对象,同名 Key 后者覆盖前者)。
3. 调试和维护困难 父组件无法知道子组件内部有哪些 mixin 注入的属性,排查 bug 和调试困难。
4. 不利于 Tree-shaking 打包时难以移除未使用的 mixin 代码。
5. 与 Composition API 理念冲突 Mixin 是“横切关注点”,而 Composition API 强调显式、可组合的逻辑。

Vue 3 推荐替代方案:Composition API + 可复用函数(Composables)。

特性 Mixin Composables
数据来源明确 隐式 显式(import)
是否有命名冲突问题
逻辑封装 全局污染 按需引入
Tree-shaking 支持
TypeScript 支持

对于全局混入(Global Mixin),Vue3 虽然提供了 app.mixin(),但不推荐,推荐使用:

  1. app.config.globalProperties
  2. app.provide 在顶层提供数据,组件通过 inject 方法消费数据。

10、介绍下 Vue.js 中的函数式组件、异步组件和递归组件

10.1 函数式组件(Functional Components)

函数式组件是一种轻量级、无状态的组件形式。它们很像纯函数:接收 props,返回 虚拟 DOM(vnode)。函数式组件在渲染过程中不会创建组件实例 (也就是说,没有 this),也不会触发常规的组件生命周期钩子。没有响应式系统、生命周期和实例的开销,函数式组件自然在渲染上更加高效和快速。

总而言之,函数式组件有无状态无this无生命周期性能更高等特点。

使用场景:适合简单、静态的 UI 元素,如列表项或包装组件。

在 Vue 2 中,通过 functional: true 声明;在 Vue 3 中,函数式组件更简单,直接返回渲染函数。

Vue2 示例:

<template functional>
  <div>{{ props.msg }}</div>
</template>

或者 js 形式:

export default {
  functional: true,
  props: ['msg'],
  render(h, { props }) {
    return h('div', props.msg);
  }
};

Vue3 示例:

<script setup>
import { h } from 'vue';

const FunctionalComp = (props) => h('div', props.msg);
</script>

<template>
  <FunctionalComp msg="Hello Functional" />
</template>

10.2 异步组件(Async Components)

异步组件是一种懒加载(Lazy Loading)机制,用于按需加载组件代码,优化初始加载时间和性能。Vue 会动态导入组件,只有在使用时才下载和渲染,常用于路由或大型组件。

特点:

  • 通过 import() 动态加载,返回 Promise。
  • 支持加载中(loading)、错误(error)和超时(timeout)处理。
  • 在 Vue 3 中,使用 defineAsyncComponent 更规范,支持与 <Suspense> 结合(Vue 3 独有,用于统一处理异步)。

Vue2 示例:

<script>
export default {
  components: {
    AsyncComp: () => import('./AsyncComp.vue')
  }
};
</script>

<template>
  <AsyncComp />
</template>

Vue3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';

const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'));
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncComp />
    </template>
    <template #fallback>加载中...</template>
  </Suspense>
</template>

10.3 递归组件(Recursive Components)

递归组件是指组件内部调用自身,用于处理树形或嵌套数据结构,如菜单、树视图或评论回复。Vue 支持组件自引用,但需注意避免无限循环(通过条件终止递归)。

特点:

  • 组件需有名称(name 选项),才能自引用。
  • 常结合 v-for 和 props 传递数据。

Vue 2 示例:

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'Tree',  // 必须有 name
  props: ['tree']
};
</script>

Vue 3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';  // 可选:异步加载避免循环

const Tree = defineAsyncComponent(() => import('./Tree.vue'));  // 自引用
defineProps(['tree']);
</script>

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

11、Vue.js 中的 vue-loader 是什么?

Vue-loader 是一个专为 Vue.js 设计的 Webpack loader(加载器),其主要作用是将 Vue 的单文件组件(Single-File Components,简称 SFC,即 .vue 文件)转换为可执行的 JavaScript 模块。 它允许开发者以一种结构化的方式编写组件,将模板(template)、脚本(script)和样式(style)封装在同一个文件中,便于管理和维护。

核心功能:

  • 解析 SFC 文件:Vue-loader 会自动处理 .vue 文件中的三个部分:
    • template 部分:编译为 Vue 的渲染函数(render function)。
    • script 部分:提取为组件的 JavaScript 逻辑,支持 ES 模块和 TypeScript。
    • style部分:处理 CSS,支持预处理器(如 Sass、Less)并可选地应用 scoped(作用域样式)或 CSS Modules。
  • 热重载(Hot Module Replacement,HMR):在开发模式下,支持组件的热更新,无需刷新页面即可看到变化,提高开发效率。
  • 自定义块(Custom Blocks):支持扩展,如添加 <docs> 或其他自定义标签,用于文档生成或其他工具集成。
  • 预处理器支持:无缝集成 Babel、PostCSS 等工具链。

12、Vue.extend 方法的作用?

Vue.extend方法可以作为基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

Vue2 示例:

var dialog = Vue.extend({
  template: "<div>{{hello}} {{world}}</div>",
  data: function () {
    return {
      hello: "hello",
      world: "world",
    };
  },
});
// 创建 dialog 实例,并手动挂载到一个元素上。
new dialog().$mount("#app");

注意:在 Vue.extend 中的 data 必须是一个函数,要不然会报错。

Vue3 示例:

Vue3 中不在使用 Vue.extend 方法,而是采用render方法进行手动渲染。

<!-- Modal.vue -->
<template>
  <div class="modal">这是一个弹窗</div>
</template>

<script>
export default {
  name: 'Modal',
}
</script>
<template>
  <div id="box"></div>
</template>

<script setup>
import { h, render, createApp, onMounted } from 'vue'
import Modal from './Modal.vue'

onMounted(() => {
  render(h(Modal), document.getElementById('box'));
})
</script>

13、keep-alive 的原理

<keep-alive> 是 Vue.js 的内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例,避免重复渲染和状态丢失,提高性能。它是一个抽象组件(abstract: true),不会渲染到 DOM,也不会出现在组件树中,而是通过插槽(slots)和自定义 render 函数实现缓存逻辑。

核心实现机制:

  1. 抽象组件与 Render 函数:
  • <keep-alive> 通过 render 函数处理包裹的内容(通常是动态组件,如 <component> 或 v-if 切换的组件)。
  • 在 render 中,它从插槽获取子组件的 VNode(虚拟节点)。如果子组件有 key(推荐使用),则用 key 作为缓存标识;否则用组件的 tag 或 cid(组件 ID)。
  • 如果组件已缓存,直接返回缓存的 VNode(设置 vnode.componentInstance.keepAlive = true 以标记缓存状态);否则,渲染新实例并存入缓存。
  1. 缓存存储:
  • 内部使用一个对象(this.cache)作为缓存 Map,以 key 为键,值为 VNode 对象(包含组件实例)。
  • 当组件首次渲染时,存入缓存;切换回时,从缓存取出,避免重新创建实例和执行 mounted 钩子。
  1. LRU 缓存算法:
  • 支持 max 属性设置最大缓存数量(Vue 2.5+)。
  • 使用 Least Recently Used(最近最少使用)算法:当缓存超出 max 时,删除最久未访问的组件(通过 this.keys 数组跟踪访问顺序)。
  • 访问组件时,将其 key 移到数组末尾(最近使用);超出时,删除数组开头的 key,并销毁对应实例(调用 $destroy)。
  1. 过滤规则(include/exclude):
  • 通过 include(白名单)和 exclude(黑名单)属性决定哪些组件缓存,支持字符串、正则、数组或函数。
  • 在 created 钩子中,监听这些 prop 的变化,并调用 pruneCache 更新缓存(移除不匹配的组件)。
  1. 生命周期钩子:
  • 缓存组件不会触发 destroyed/unmounted,而是使用 activated(激活时)和 deactivated(失活时)钩子。
  • 这允许开发者在切换时管理状态(如暂停定时器),而非完全销毁。

注意事项:

  • 只缓存一个直接子组件(插槽内容),不支持多个。
  • Vue 3 中原理类似,但优化了 VNode 处理和 Composition API 支持。
  • 潜在问题:缓存过多导致内存占用;需手动清理资源(如在 deactivated 中停止监听)。

14、Vue.js 中使用了哪些设计模式?

1. 观察者模式 (Observer Pattern)

  • 描述:Vue 的响应式系统使用观察者模式,通过 Proxy (Vue 3) 或 Object.defineProperty (Vue 2) 拦截对象属性的 get/set 操作。当数据变化时,通知订阅者(Watcher)更新视图。
  • 应用:在 reactive() 函数中,返回 Proxy 对象,get 陷阱用于依赖收集 (track),set 陷阱用于触发更新 (trigger)。
  • 优势:实现了细粒度的变更检测,避免全局重渲染。

2. 发布-订阅模式 (Publish-Subscribe Pattern)

  • 描述:Vue 的事件系统和响应式通知机制采用 Pub-Sub 模式。数据变化时发布事件,订阅者(如组件渲染函数)接收并响应。
  • 应用:在响应式系统中,trigger() 函数检索订阅者效果并调用它们;事件 API 如 emitemit 和 on 也基于此。
  • 优势:解耦了数据生产者和消费者,支持异步更新。

3. 代理模式 (Proxy Pattern)

描述:Vue 3 的响应式系统直接使用 ES6 Proxy 作为代理层,拦截对象操作,实现透明的响应式。 应用:reactive() 返回 Proxy 对象,代理目标对象的访问和修改。 优势:比 Vue 2 的 defineProperty 更强大,支持数组和 Map/Set 等类型。

4. 策略模式 (Strategy Pattern)

  • 描述:Vue 的虚拟 DOM diff 算法使用不同策略(如 key-based diff 或简单 patch)来优化更新。
  • 应用:在渲染过程中,根据节点类型选择 diff 策略。
  • 优势:最小化 DOM 操作,提高渲染效率。

5. 单例模式(Singleton Pattern)

  • 描述:整个程序中有且仅有一个实例。
  • 应用:vuex 的 store 和插件。
  • 优势:全局唯一、节约资源、便于管理。

6. 工厂模式(Factory Pattern)

  • 描述:提供了一种创建对象的方式,使得创建对象的过程与使用对象的过程分离。
  • 应用:Vue2 中的组件创建,传入参数给 createComponentInstance 就可以创建实例。
  • 优势:解藕,易于维护。

15、Vue.js 应用中常见的内存泄漏来源有哪些?

  1. 未清理的事件监听器、定时器、动画
<script setup>
import { onMounted, onUnmounted } from 'vue';

let timer = null;
let controller = null;
let raf = null;

onMounted(() => {
  // 定时器
  timer = setInterval(() => {}, 1000);
  // 动画
  raf = requestAnimationFrame(() => {});
  // 事件监听
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  clearInterval(timer);
  cancelAnimationFrame(this.raf);
  window.removeEventListener('resize', handleResize);
});
</script>
  1. 未移除的第三方库实例
<script setup>
import { onMounted, onUnmounted } from 'vue';

let chart = null;

onMounted(() => {
  chart = echarts.init(this.$refs.chart);
});

onUnmounted(() => {
  chart?.dispose();
});
</script>
  1. 事件总线(Event Bus)未解绑

vue2 用可以用 new Vue 全局创建一个事件总线实例,或者在组件中直接使用 this.$onthis.$emitthis.$off

vue3 则需要借助第三库,比如 mitt 来实现事件总线。

<script setup>
import { onMounted, onUnmounted } from 'vue';
import mitt from 'mitt';

// 创建事件总线实例
const emitter = mitt();

onMounted(() => {
  emitter.on('update', this.handler);
});

onUnmounted(() => {
  emitter.off('update', this.handler);
});
</script>

顺便提一下, vue3 为啥去掉 $on、$emit、$off 这些 API,主要有以下原因:

  1. 设计理念的调整

Vue 3 更加注重组件间通信的明确性和可维护性。$on 这类事件 API 本质上是一种 "发布 - 订阅" 模式,容易导致组件间关系模糊(多个组件可能监听同一个事件,难以追踪事件来源和流向)。Vue 3 推荐使用更明确的通信方式,如:

  • 父子组件通过 props 和 emit 通信
  • 跨组件通信使用 provide/inject 或 Pinia/Vuex 等状态管理库
  • 复杂场景可使用专门的事件总线库(如 mitt
  1. 与 Composition API 的适配

Vue 3 主推的 Composition API 强调逻辑的封装和复用,而 $on 基于选项式 API 的实例方法,与 Composition API 的函数式思维不太契合。移除后,开发者可以更自然地使用响应式变量或第三方事件库来实现类似功能。

  1. 减少潜在问题
  • $on 容易导致内存泄漏(忘记解绑事件)
  • 事件名称可能冲突(全局事件总线尤其明显)
  • 不利于 TypeScript 类型推断,难以实现类型安全

4. 未清理的 Watcher

Vue 本身不会泄漏内存,泄漏几乎都来自开发者未清理的副作用。养成“创建即清理”的习惯,使用 beforeDestroy 或者 onUnmounted 集中清理,在使用 keep-alive 的组件中,视情况在 deactivated 钩子中清理资源。

16、Vue.js 中的性能优化手段有哪些?

16.1 数据相关

  • Vue2 中数据层级不易过深,合理设置响应式数据;
  • Vue2 非响应式数据可以通过 Object.freeze()方法冻结属性;
  • 合理使用 computed,利用其缓存能力提高性能。

16.2 组件相关

  • 控制组件粒度(Vue 采用组件级更新);
  • 合适场景可使用函数式组件(函数式组件开销低);
  • 采用异步组件(借助构建工具的分包的能力,减少主包体积);
  • 在组件卸载或者非激活状态及时清除定时器、DOM事件、事件总线、三方库的实例等。
  • v-on 按需监听、使用动态 watch 和及时销毁 watch。

16.3 渲染相关

  • 合理设置 key 属性;
  • v-show 和 v-if 的选取;
  • 使用防抖、节流、分页、虚拟滚动、时间分片等策略;
  • 合理使用 keep-alive 、v-once、v-memo 进行逻辑优化。

结语

以上是整理的 Vue 高级的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vuex 和 Vue-router 相关面试题。

从 useState 到 URLState:前端状态管理的另一种思路

作者 宇余
2025年11月12日 09:09

在 React 开发中,useState 是我们最常用的状态管理工具之一。它轻量、直观,能满足大多数组件级状态管理需求。但在某些场景下,比如列表筛选、分页、多页面共享状态时,单纯使用 useState 会遇到状态丢失、刷新页面重置等问题。这时候,URLState 或许是一个更优雅的解决方案。

一、useState 的「痛点」场景

先来看一个常见的业务场景:实现一个带筛选功能的商品列表页,包含「价格区间」「分类」「排序方式」三个筛选条件。用 useState 实现的代码可能是这样的:


import { useState } from 'react';

function ProductList() {
  // 筛选状态
  const [priceRange, setPriceRange] = useState([0, 1000]);
  const [category, setCategory] = useState('all');
  const [sortBy, setSortBy] = useState('price-asc');

  // 筛选逻辑...
  return (
    <div>
      <FilterPanel 
        priceRange={priceRange}
        onPriceRangeChange={setPriceRange}
        category={category}
        onCategoryChange={setCategory}
        sortBy={sortBy}
        onSortByChange={setSortBy}
      />
      <ProductGrid />
    </div>
  );
}

这段代码看似没问题,但存在三个明显痛点:

  • 页面刷新状态丢失:用户筛选后刷新页面,所有筛选条件会重置为初始值,体验极差。

  • 状态无法共享:如果需要在其他页面(比如商品详情页)回退到筛选后的列表,无法携带筛选状态。

  • 无法书签/分享:用户想把筛选后的结果分享给同事,复制链接过去是未筛选的初始状态。

二、什么是 URLState?为什么要用它?

URLState 是将应用状态存储在 URL 查询参数(Query String)中的状态管理方式。比如上面的筛选场景,使用 URLState 后,URL 可能变成这样:


https://example.com/products?price=0-1000&category=electronics&sort=price-asc

这种方式的核心优势在于:

  • 「刷新不丢状态」:URL 是浏览器的持久化载体,刷新页面参数不会消失。

  • 「天然可共享」:复制 URL 即可分享当前状态,支持书签保存。

  • 「跨页传参简单」:不同页面间通过 URL 即可传递状态,无需依赖全局状态库。

  • 「可回溯」:浏览器的前进/后退按钮能直接回溯状态变更历史。

三、实现 URLState:自定义 useURLState Hook

其实 URLState 的实现并不复杂,核心是通过 URLSearchParams 操作查询参数,并结合 React 的状态更新机制。下面我们封装一个通用的 useURLState Hook。

3.1 核心逻辑拆解

  1. 从 URL 中解析初始状态:通过 new URLSearchParams(window.location.search) 获取查询参数。

  2. 定义状态更新函数:修改状态时,同步更新 URL(使用 history.pushState 避免页面刷新)。

  3. 监听 URL 变化:当用户通过前进/后退按钮切换历史记录时,同步更新组件状态。

3.2 完整实现代码


import { useState, useEffect, useCallback } from 'react';

function useURLState(initialState = {}) {
  // 从 URL 解析状态
  const parseURLState = useCallback(() => {
    const searchParams = new URLSearchParams(window.location.search);
    const state = {};
    
    // 遍历初始状态,从 URL 中提取对应参数
    Object.entries(initialState).forEach(([key, defaultValue]) => {
      const value = searchParams.get(key);
      if (value === null) {
        state[key] = defaultValue;
        return;
      }
      
      // 处理不同类型的默认值(数字、布尔、数组等)
      if (typeof defaultValue === 'number') {
        state[key] = Number(value);
      } else if (typeof defaultValue === 'boolean') {
        state[key] = value === 'true';
      } else if (Array.isArray(defaultValue)) {
        state[key] = value.split('-');
      } else {
        state[key] = value;
      }
    });
    
    return state;
  }, [initialState]);

  // 初始化状态:从 URL 解析或使用初始值
  const [state, setState] = useState(parseURLState());

  // 当 URL 变化时(前进/后退),同步更新状态
  useEffect(() => {
    const handlePopState = () => {
      setState(parseURLState());
    };
    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, [parseURLState]);

  // 更新状态并同步到 URL
  const setURLState = useCallback((newState) => {
    const searchParams = new URLSearchParams(window.location.search);
    const nextState = { ...state, ...newState };
    
    // 遍历新状态,更新到 searchParams
    Object.entries(nextState).forEach(([key, value]) => {
      if (value === initialState[key]) {
        // 如果值等于初始值,移除该参数(保持 URL 简洁)
        searchParams.delete(key);
      } else {
        // 数组类型用 "-" 拼接
        const paramValue = Array.isArray(value) ? value.join('-') : String(value);
        searchParams.set(key, paramValue);
      }
    });
    
    // 更新 URL(pushState 不会刷新页面)
    const searchString = searchParams.toString();
    const newUrl = searchString ? `${window.location.pathname}?${searchString}` : window.location.pathname;
    window.history.pushState({}, '', newUrl);
    
    // 更新组件状态
    setState(nextState);
  }, [state, initialState]);

  return [state, setURLState];
}

四、实战:用 URLState 重构商品列表页

有了 useURLState,我们可以轻松重构之前的商品列表页,解决 useState 带来的痛点:


import { useURLState } from './useURLState';

function ProductList() {
  // 用 useURLState 替代 useState,初始状态和之前一致
  const [state, setURLState] = useURLState({
    priceRange: [0, 1000], // 数组类型
    category: 'all',       // 字符串类型
    sortBy: 'price-asc'    // 字符串类型
  });

  const { priceRange, category, sortBy } = state;

  // 筛选条件变更时,调用 setURLState 更新
  const handlePriceChange = (newRange) => {
    setURLState({ priceRange: newRange });
  };

  const handleCategoryChange = (newCategory) => {
    setURLState({ category: newCategory });
  };

  const handleSortChange = (newSort) => {
    setURLState({ sortBy: newSort });
  };

  return (
    <div>
      <FilterPanel 
        priceRange={priceRange}
        onPriceRangeChange={handlePriceChange}
        category={category}
        onCategoryChange={handleCategoryChange}
        sortBy={sortBy}
        onSortByChange={handleSortChange}
      />
      <ProductGrid />
    </div>
  );
}

此时,用户筛选商品后,URL 会自动更新为:


https://example.com/products?priceRange=0-2000&category=electronics&sortBy=price-desc

刷新页面、复制链接分享、后退到上一个筛选状态,都能完美生效!

五、URLState 的适用场景与注意事项

5.1 适用场景

  • 列表筛选、分页、排序等「可分享」的状态。

  • 多步骤表单(如注册流程)的进度状态。

  • 单页应用中的「页面级」状态(如标签页切换)。

5.2 注意事项

  • 不要存储敏感信息:URL 参数会暴露在地址栏、浏览器历史、服务器日志中,密码、token 等敏感信息绝对不能用 URLState。

  • 参数不宜过多/过长:浏览器对 URL 长度有上限(通常 2KB-8KB),复杂状态建议用全局状态库(如 Redux)。

  • 处理特殊类型数据:对于对象等复杂类型,需要先序列化(如 JSON.stringify),但会增加 URL 长度,需谨慎使用。

六、总结

useState 是 React 状态管理的基石,但在「状态持久化」「可分享」场景下存在局限。URLState 作为一种轻量级的补充方案,通过 URL 查询参数实现状态的持久化和共享,无需引入复杂的状态管理库,就能解决很多实际业务问题。

当然,URLState 不是银弹,它和 useState、全局状态库是互补关系。在合适的场景选择合适的工具,才是高效开发的关键。

别再只会用默认插槽了!Vue插槽这些高级用法让你的组件更强大

2025年11月13日 07:25

你是不是经常遇到这样的情况:写了一个通用组件,却发现有些地方需要微调样式,有些地方需要替换部分内容,但又不想为了这点小改动就写一个新的组件?

如果你还在用默认插槽来解决所有问题,那真的有点out了。今天我要分享的Vue插槽高级用法,能让你的组件灵活度提升好几个level!

读完这篇文章,你会彻底搞懂作用域插槽和具名插槽的实战技巧,让你的组件像乐高一样可以随意组合,再也不用担心产品经理那些“稍微改一下”的需求了。

从基础开始:插槽到底是什么?

先来个简单的回忆。插槽就是Vue组件里的一个占位符,让使用组件的时候可以往里面塞自定义内容。

看个最简单的例子:

// 定义一个带插槽的组件
const MyComponent = {
  template: `
    <div class="container">
      <h2>我是组件标题</h2>
      <slot></slot>
    </div>
  `
}

// 使用这个组件
<my-component>
  <p>这里的内容会显示在slot的位置</p>
</my-component>

这个就是最基本的默认插槽。但现实开发中,我们经常遇到更复杂的需求,这时候就需要更高级的玩法了。

具名插槽:多个插槽怎么管理?

想象一下,你要做一个卡片组件,这个卡片有头部、主体、底部三个部分,每个部分都需要自定义内容。如果还用默认插槽,代码就会变得很混乱。

这时候具名插槽就派上用场了:

// 卡片组件定义
const CardComponent = {
  template: `
    <div class="card">
      <div class="card-header">
        <slot name="header"></slot>
      </div>
      <div class="card-body">
        <slot name="body"></slot>
      </div>
      <div class="card-footer">
        <slot name="footer"></slot>
      </div>
    </div>
  `
}

使用的时候,我们可以这样给不同的插槽传递内容:

<card-component>
  <template v-slot:header>
    <h3>这是卡片标题</h3>
  </template>
  
  <template v-slot:body>
    <p>这是卡片的主体内容,可以放任何你想放的东西</p>
    <button>点击我</button>
  </template>
  
  <template v-slot:footer>
    <span>底部信息</span>
    <a href="#">链接</a>
  </template>
</card-component>

看到没?每个部分都清晰明了,再也不用在默认插槽里堆砌一堆div还要用CSS来控制布局了。

这里有个小技巧,v-slot:header可以简写成#header,写起来更简洁:

<card-component>
  <template #header>
    <h3>简洁写法</h3>
  </template>
</card-component>

作用域插槽:让插槽内容访问组件数据

这才是今天的大招!作用域插槽允许插槽内容访问子组件中的数据,这让组件的灵活性达到了新的高度。

举个实际例子:我们要做一个数据列表组件,但希望使用组件的人可以自定义每行怎么显示。

先看传统的做法有什么问题:

// 传统做法 - 灵活性很差
const DataList = {
  props: ['items'],
  template: `
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }} - {{ item.price }}
      </li>
    </ul>
  `
}

这样写死的话,如果其他地方需要显示不同的字段,就得重新写一个组件。太麻烦了!

现在看作用域插槽的解决方案:

// 使用作用域插槽的灵活版本
const FlexibleList = {
  props: ['items'],
  template: `
    <ul>
      <li v-for="item in items" :key="item.id">
        <slot :item="item"></slot>
      </li>
    </ul>
  `
}

使用的时候,我们可以这样自定义每行的显示:

<flexible-list :items="productList">
  <template v-slot="slotProps">
    <div class="product-item">
      <strong>{{ slotProps.item.name }}</strong>
      <span class="price">¥{{ slotProps.item.price }}</span>
      <button @click="addToCart(slotProps.item)">加入购物车</button>
    </div>
  </template>
</flexible-list>

这里的关键在于,我们在slot上绑定了item数据,然后在父组件中通过slotProps来接收这些数据。这样,使用组件的人就可以完全控制怎么显示每个item了。

实战进阶:作用域插槽 + 具名插槽组合使用

真正强大的时候是当作用域插槽和具名插槽结合使用的时候。我们来看一个更复杂的例子:一个完整的数据表格组件。

// 高级表格组件
const AdvancedTable = {
  props: ['data', 'columns'],
  template: `
    <div class="table-wrapper">
      <table>
        <!-- 表头部分 -->
        <thead>
          <tr>
            <th v-for="col in columns" :key="col.key">
              <slot name="header" :column="col">
                {{ col.title }}
              </slot>
            </th>
          </tr>
        </thead>
        
        <!-- 表格主体 -->
        <tbody>
          <tr v-for="(row, index) in data" :key="row.id">
            <td v-for="col in columns" :key="col.key">
              <slot 
                name="cell" 
                :row="row" 
                :column="col"
                :index="index"
              >
                {{ row[col.key] }}
              </slot>
            </td>
          </tr>
        </tbody>
        
        <!-- 表格底部 -->
        <tfoot>
          <slot name="footer" :data="data"></slot>
        </tfoot>
      </table>
    </div>
  `
}

这个组件提供了极大的灵活性:

<advanced-table 
  :data="userList" 
  :columns="tableColumns"
>
  <!-- 自定义表头 -->
  <template #header="slotProps">
    <div class="custom-header">
      {{ slotProps.column.title }}
      <i 
        v-if="slotProps.column.sortable" 
        class="sort-icon"
        @click="sortTable(slotProps.column)"
      >↑↓</i>
    </div>
  </template>
  
  <!-- 自定义单元格 -->
  <template #cell="slotProps">
    <div v-if="slotProps.column.key === 'avatar'">
      <img 
        :src="slotProps.row.avatar" 
        :alt="slotProps.row.name"
        class="avatar"
      >
    </div>
    <div v-else-if="slotProps.column.key === 'status'">
      <span 
        :class="`status-badge status-${slotProps.row.status}`"
      >
        {{ getStatusText(slotProps.row.status) }}
      </span>
    </div>
    <div v-else>
      {{ slotProps.row[slotProps.column.key] }}
    </div>
  </template>
  
  <!-- 自定义底部 -->
  <template #footer="slotProps">
    <tr>
      <td :colspan="tableColumns.length">
        共 {{ slotProps.data.length }} 条数据
      </td>
    </tr>
  </template>
</advanced-table>

这样的组件既保持了统一的表格功能,又给了使用者最大的自定义空间。

实际业务场景:配置化表单生成器

我们再来看一个更贴近实际业务的例子。很多管理系统都需要动态表单,根据配置渲染不同的表单项。

// 动态表单组件
const DynamicForm = {
  props: ['fields', 'formData'],
  template: `
    <form class="dynamic-form">
      <div 
        v-for="field in fields" 
        :key="field.name"
        class="form-field"
      >
        <label>{{ field.label }}</label>
        
        <slot 
          name="field" 
          :field="field" 
          :value="formData[field.name]"
          :onChange="(val) => $emit('update:formData', {
            ...formData,
            [field.name]: val
          })"
        >
          <!-- 默认的表单渲染 -->
          <input 
            v-if="field.type === 'text'"
            :type="field.type"
            :value="value"
            @input="onChange($event.target.value)"
            :placeholder="field.placeholder"
          >
          
          <select 
            v-else-if="field.type === 'select'"
            :value="value"
            @change="onChange($event.target.value)"
          >
            <option 
              v-for="option in field.options" 
              :key="option.value"
              :value="option.value"
            >
              {{ option.label }}
            </option>
          </select>
        </slot>
        
        <!-- 错误信息插槽 -->
        <slot 
          name="error" 
          :field="field"
          :errors="fieldErrors[field.name]"
        >
          <div 
            v-if="fieldErrors[field.name]" 
            class="error-message"
          >
            {{ fieldErrors[field.name] }}
          </div>
        </slot>
      </div>
    </form>
  `
}

使用的时候,我们可以完全重写某个字段的渲染方式:

<dynamic-form 
  :fields="formConfig" 
  :form-data="formData"
  @update:form-data="handleFormUpdate"
>
  <!-- 自定义头像上传字段 -->
  <template #field="slotProps">
    <div v-if="slotProps.field.name === 'avatar'">
      <image-uploader
        :value="slotProps.value"
        @change="slotProps.onChange"
      />
    </div>
    
    <!-- 其他字段使用默认渲染 -->
    <div v-else>
      <slot></slot>
    </div>
  </template>
  
  <!-- 自定义错误提示样式 -->
  <template #error="slotProps">
    <div 
      v-if="slotProps.errors" 
      class="my-custom-error"
    >
      ❌ {{ slotProps.errors }}
    </div>
  </template>
</dynamic-form>

性能优化和最佳实践

虽然作用域插槽很强大,但也要注意一些使用技巧:

  1. 避免不必要的重新渲染

    作用域插槽每次都会创建新的作用域,如果数据没变但组件重新渲染了,可能是作用域插槽导致的。

  2. 合理使用默认内容

    给插槽提供合理的默认内容,让组件开箱即用:

    <slot name="empty">
      <div class="empty-state">
        暂无数据
      </div>
    </slot>
    
  3. 使用解构让代码更清晰

    作用域插槽的参数可以使用解构,让模板更简洁:

    <template #item="{ id, name, price }">
      <div>{{ name }} - {{ price }}</div>
    </template>
    

总结

Vue插槽的高级用法真的能让你的组件开发体验完全不同。具名插槽解决了多插槽管理的难题,作用域插槽则打破了父子组件的数据隔离,让组件既保持封装性又具备灵活性。

记住这个进阶路径:默认插槽 → 具名插槽 → 作用域插槽 → 组合使用。每掌握一个层次,你的组件设计能力就提升一个档次。

现在回头看看你项目里的那些通用组件,是不是有很多地方可以用今天学到的技巧来重构?动手试试吧,你会惊讶于组件灵活度提升带来的开发效率变化!

如果你在实战中遇到了有趣的问题或者有更好的用法,欢迎在评论区分享你的经验!

藏不住了!TinyVue 悄悄上线 Space 组件,不止按钮表格,弹性间距全搞定

2025年11月12日 10:16

本文由TinySpace组件贡献者夏雯斐同学原创。

前言

近期,TinyVue正式发布 v3.27.0版本,这次版本更新也增加了很多新特性,space组件就是其中比较重要的一个特性。
Space组件OpenTiny Vue组件库中的一个布局容器组件,用于在子元素之间提供灵活的间距控制。 它支持水平与垂直方向排列、自动换行、对齐与分布控制、以及顺序调整等功能,能帮助开发者轻松实现响应式、整齐的组件布局。

适用场景

  • 表单项或按钮组的布局
  • 列表项的水平/垂直间距
  • 工具栏元素的分布控制
  • 在响应式布局中控制间距与换行

环境准备与安装

1. 环境要求

确保已安装 Node.js 10.13+ 及包管理器 npm/pnpm/yarn。

node -v

2. 安装 OpenTiny Vue

# npm
npm install @opentiny/vue

# 或 pnpm
pnpm add @opentiny/vue

3.引入TinySpace

import { TinySpace } from '@opentiny/vue'

快速开始

基础使用

<template>
  <div id="tiny-space-all" style="padding: 16px; max-width: 600px">
    <h2>TinySpace 全功能演示</h2>

    <!-- 控制面板 -->
    <div style="margin-bottom: 16px; display: flex; flex-direction: column; gap: 12px;">
      <!-- 间距控制 -->
      <div>
        <strong>间距:</strong>
        <tiny-button @click="size = [10, 10]">[10, 10]</tiny-button>
        <tiny-button @click="size = [10, 30]">[10, 30]</tiny-button>
        <tiny-button @click="size = [20, 40]">[20, 40]</tiny-button>
      </div>

      <!-- 布局方向 -->
      <div>
        <strong>方向:</strong>
        <tiny-button-group>
          <tiny-button @click="direction = 'row'">水平 row</tiny-button>
          <tiny-button @click="direction = 'column'">垂直 column</tiny-button>
        </tiny-button-group>
      </div>

      <!-- 主轴对齐 -->
      <div>
        <strong>主轴对齐 (justify):</strong>
        <tiny-select v-model="justify" style="width: 160px">
          <tiny-option label="start" value="start" />
          <tiny-option label="center" value="center" />
          <tiny-option label="end" value="end" />
          <tiny-option label="space-between" value="space-between" />
          <tiny-option label="space-around" value="space-around" />
        </tiny-select>
      </div>

      <!-- 交叉轴对齐 -->
      <div>
        <strong>交叉轴对齐 (align):</strong>
        <tiny-select v-model="align" style="width: 160px">
          <tiny-option label="start" value="start" />
          <tiny-option label="center" value="center" />
          <tiny-option label="end" value="end" />
          <tiny-option label="baseline" value="baseline" />
        </tiny-select>
      </div>

      <!-- 自动换行 -->
      <div>
        <tiny-switch v-model="wrap" active-text="自动换行" inactive-text="不换行" />
      </div>

      <!-- 顺序控制 -->
      <div>
        <strong>顺序控制:</strong>
        <tiny-button @click="order = ['3', '1', '2']">3 → 1 → 2</tiny-button>
        <tiny-button @click="order = ['2', '3', '1']">2 → 3 → 1</tiny-button>
        <tiny-button @click="order = []">原顺序</tiny-button>
      </div>
    </div>

    <!-- Space 布局演示 -->
    <tiny-space
      class="tiny-space-demo"
      :size="size"
      :direction="direction"
      :justify="justify"
      :align="align"
      :wrap="wrap"
      :order="order"
      style="border: 1px dashed #bbb; padding: 10px; min-height: 120px;"
    >
      <tiny-button key="1" type="primary">按钮 1</tiny-button>
      <tiny-button key="2" type="success">按钮 2</tiny-button>
      <tiny-button key="3" type="warning">按钮 3</tiny-button>
      <tiny-button key="4" type="danger">按钮 4</tiny-button>
      <tiny-button key="5" type="info">按钮 5</tiny-button>
    </tiny-space>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { TinyButton, TinyButtonGroup, TinySwitch, TinySelect, TinyOption } from '@opentiny/vue'
import TinySpace from '@opentiny/vue-space'


// 响应式配置项
const size = ref([10, 20])
const direction = ref('row')
const justify = ref('start')
const align = ref('center')
const wrap = ref(true)
const order = ref([])
</script>

<style scoped>
.tiny-space-demo .tiny-button {
  min-width: 80px;
}
</style>

默认方向为 row(水平排列),默认间距为 8px

TinySpace 效果图

配置详解

1.控制间距 size

size 是最核心的属性,支持多种写法。

1.1数值写法
<tiny-space :size="20"/>

间距为 20px

1.2数组写法([rowGap, columnGap])
<tiny-space :size="[50, 50]"/>

行间距 50px,列间距 50px,方便实现不等距布局。

1.3字符串写法
<tiny-space size="100px"/>

间距为 100px

<tiny-space size="small"/>
// small | medium | large

2.控制排列方向 direction

<tiny-space :size="12" direction="row"/>

- 'row'(默认):水平排列

- 'column':垂直排列

可以通过切换 direction 实现响应式布局(如手机端竖排、PC端横排)。

3.对齐 align 与分布 justify

通过 alignjustify 控制元素对齐与分布方式。

3.1 justify
<tiny-space :size="10" justify="center"/>
3.2 aligin
<tiny-space :size="10" align="baseline"/>     
属性 说明 可选值
align 垂直对齐 start / center / end/baseline/stretch
justify 水平分布 start / center / end / space-between

4.自动换行 wrap

<tiny-space :wrap="true" :size="[10, 12]">
  <tiny-button v-for="n in 10" :key="n">按钮 {{ n }}</tiny-button>
</tiny-space>

效果: 当宽度不够时,按钮会自动换行,仍然保持间距一致。

2.JPG

5.控制顺序 order

<template>
  <tiny-space :order="order" style="border: 1px dashed #ccc">
    <tiny-button key="1">First Button</tiny-button>
    <tiny-button key="2">Second Button</tiny-button>
    <tiny-button key="3">Third Button</tiny-button>
    <tiny-button>Fourth Button</tiny-button>
  </tiny-space>
</template>
<script setup>
import { TinyButton, TinySpace } from '@opentiny/vue'
const order = ['2', '3', '1'] // 自定义顺序:第二个、第三个、然后第一个
</script>

设置 order="['2', '3', '1']" 后,渲染顺序会变为 2 → 3 → 1

适用于在响应式布局中快速调换内容顺序。

6.嵌套布局(组合使用)

<tiny-space direction="column" :size="16">
 <tiny-space direction="row" :size="8">
  <tiny-button>左</tiny-button>
  <tiny-button>中</tiny-button>
  <tiny-button>右</tiny-button>
 </tiny-space>

 <tiny-space direction="row" :size="8">
  <tiny-button>上</tiny-button>
  <tiny-button>下</tiny-button>
 </tiny-space>
</tiny-space>

7.测试与稳定性

TinySpace 已通过 Playwright E2E 自动化测试,验证:

  • 间距正确渲染(rowGap / columnGap

  • 动态数据更新响应

  • 换行与方向切换稳定

  • 兼容 Vue 2 & Vue 3 环境

8.最佳实践总结

场景 推荐配置
表单项垂直间距 <tiny-space direction="column" :size="12">
按钮组 <tiny-space :size="8" align="center">
可换行标签集合 <tiny-space :size="[8, 16]" wrap>

9.小结

TinySpace 是一个轻量级、灵活的布局组件,专为控制子元素间距而设计。 它支持:

  • 数值、预设、数组多种间距形式
  • 垂直与水平排列
  • 自动换行与对齐分布
  • 顺序控制
  • Vue 2 / Vue 3 双版本兼容

通过它,开发者可以轻松构建整洁、美观、响应式的 UI 布局。

属性一览

属性 类型 默认值 说明
size number /array / string small 设置间距大小,可为字符串、数字或数组,数组形式为 [横向间距, 纵向间距]
direction string row row | column
align string stretch 设置交叉轴上的对齐方式,对应 CSS align-items 属性
justify string start 设置主轴上的对齐方式,对应 CSS justify-content 属性
wrap boolean false 是否自动换行
order string[] - 控制子项显示顺序

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

Vue 全家桶深度探索:从语法精要到项目实战

作者 FogLetter
2025年11月12日 08:24

在现代前端开发中,Vue.js 以其渐进式框架的特性和友好的学习曲线,赢得了大量开发者的青睐。今天,就让我们一起来深入探索 Vue 全家桶的魅力所在!

一、Vue 与 React:理念的碰撞

在开始 Vue 全家桶的深度探索之前,让我们先来理解 Vue 与 React 这对"欢喜冤家"的核心差异。

1.1 设计哲学对比

React 推崇函数式编程思想,强调不可变性和单向数据流。它的核心理念是:

  • 单向数据绑定:数据 -> 视图 + 事件 -> 数据更新
  • 一切都是 JavaScript:JSX 将标记与逻辑耦合
  • 手动优化:需要开发者关注性能优化点

Vue 则更倾向于渐进式和响应式:

  • 双向数据绑定:v-model 指令简化表单处理
  • 关注点分离:模板、逻辑、样式相对独立
  • 自动优化:响应式系统自动处理依赖追踪

1.2 代码风格对比

让我们通过一个简单的计数器组件来感受两者的差异:

React 实现:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>当前计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        点击+1
      </button>
    </div>
  );
}

Vue 实现:

<template>
  <div>
    <p>当前计数: {{ count }}</p>
    <button @click="count++">
      点击+1
    </button>
  </div>
</template>

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

const count = ref(0);
</script>

可以看到,Vue 的 ref 和模板语法让代码更加简洁直观。特别是 @click="count++" 这种直接修改的方式,体现了 Vue 响应式系统的便利性。

二、Vue 3 语法精要

2.1 SFC:单文件组件的艺术

Vue 的单文件组件(Single File Component)是我最喜欢的设计之一。它将一个组件的模板、逻辑和样式封装在单个 .vue 文件中:

<template>
  <!-- 视图层 -->
  <div class="greeting">{{ message }}</div>
</template>

<script setup>
// 逻辑层
import { ref } from 'vue';

const message = ref('Hello Vue!');
</script>

<style scoped>
/* 样式层 */
.greeting {
  color: #42b983;
  font-size: 1.5rem;
}
</style>

这种组织方式让组件变得高度可维护,特别是 <style scoped> 中的样式作用域机制,完美解决了 CSS 污染问题。

2.2 模板语法:声明式渲染的魅力

Vue 的模板语法既强大又直观:

<template>
  <div>
    <!-- 文本插值 -->
    <h1>{{ title }}</h1>
    
    <!-- 属性绑定 -->
    <img :src="avatarUrl" :alt="userName">
    
    <!-- 条件渲染 -->
    <div v-if="isVisible">你看得见我!</div>
    <div v-else>现在看不见了</div>
    
    <!-- 列表渲染 -->
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
    
    <!-- 双向绑定 -->
    <input v-model="searchText" placeholder="搜索...">
  </div>
</template>

2.3 响应式系统:Vue 的灵魂

Vue 3 的响应式系统基于 Proxy,提供了 refreactive 两种 API:

<script setup>
import { ref, reactive, computed, watch } from 'vue';

// 基本类型使用 ref
const count = ref(0);
const searchField = ref('');

// 对象类型可以使用 reactive
const user = reactive({
  name: '张三',
  age: 25,
  profile: {
    level: 'VIP'
  }
});

// 计算属性
const userInfo = computed(() => {
  return `${user.name} - ${user.age}岁`;
});

// 侦听器
watch(count, (newValue, oldValue) => {
  console.log(`计数从 ${oldValue} 变为 ${newValue}`);
});

// 方法
const increment = () => {
  count.value++;
};
</script>

响应式的重要提示:

  • ref 创建的值在 JS 中访问需要使用 .value
  • reactive 创建的对象可以直接访问属性
  • 模板中都不需要 .value,Vue 会自动解包

三、Pinia:新一代状态管理

Pinia 是 Vue 官方推荐的状态管理库,相比 Vuex,它更加简洁和类型安全。

3.1 Store 的定义与使用

// store/homeStore.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { HomeTopBarItem, RecentlyViewedItem } from '@/types/home';

export const useHomeStore = defineStore('home', () => {
  // 状态定义
  const topBarState = ref<HomeTopBarItem[]>([
    {
      title: "游览&体验",
      icon: 'photo-o'
    },
    // ... 更多项
  ]);
  
  const recentlyViewedState = ref<RecentlyViewedItem[]>([
    {
      title: "曼谷 & 芭达雅景点通票",
      cover: "https://example.com/image.jpg",
      price: 173,
    },
    // ... 更多项
  ]);

  // Getter(计算属性)
  const expensiveItems = computed(() => {
    return recentlyViewedState.value.filter(item => item.price > 100);
  });

  // Action(方法)
  const addRecentlyViewed = (item: RecentlyViewedItem) => {
    recentlyViewedState.value.unshift(item);
    // 保持最近浏览不超过10个
    if (recentlyViewedState.value.length > 10) {
      recentlyViewedState.value.pop();
    }
  };

  return {
    topBarState,
    recentlyViewedState,
    expensiveItems,
    addRecentlyViewed
  };
});

3.2 在组件中使用 Store

<template>
  <div class="home">
    <van-search
      v-model="searchField"
      placeholder="请输入搜索关键词"
      show-action
      shape="round"
    >
      <template #action>
        <div class="text-white">
          <van-icon name="shopping-cart-o" size="1.25rem" />
        </div>
      </template>
    </van-search>
    
    <section class="topbar flex justify-around mb-3">
      <div 
        v-for="item in topBarState"
        :key="item.title"
        class="topbar-item"
      >
        <van-icon :name="item.icon" size="2rem" />
        <span class="text-xs">{{ item.title }}</span>
      </div>
    </section>
  </div>
</template>

<script setup lang="ts">
import { toRefs, ref, onMounted } from 'vue';
import { useHomeStore } from '@/store/homeStore';

const searchField = ref<string>('');
const homeStore = useHomeStore();

// 使用 toRefs 保持响应式
const { topBarState, recentlyViewedState } = toRefs(homeStore);

// 直接使用 action
const handleAddItem = () => {
  homeStore.addRecentlyViewed({
    title: "新景点",
    cover: "https://example.com/new.jpg",
    price: 200,
  });
};

onMounted(() => {
  // 组件挂载后可以执行初始化操作
  console.log('Home 组件已挂载');
});
</script>

为什么使用 toRefs

  • 当从 store 中解构状态时,使用 toRefs 可以保持响应式
  • 否则直接解构会失去响应式连接

四、路由管理:Vue Router 深度应用

4.1 路由配置与类型安全

// router/index.ts
import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw
} from 'vue-router';

// 使用 TypeScript 确保路由配置正确
const rootRoutes: RouteRecordRaw[] = [
  {
    path: '/home',
    component: () => import('@/views/HomePage/HomePage.vue'),
    name: 'home',
    meta: {
      title: '首页',
      requiresAuth: false
    }
  },
  {
    path: '/account',
    component: () => import('@/views/Account/Account.vue'),
    name: 'account',
    meta: {
      title: '我的账户',
      requiresAuth: true
    }
  },
  // ... 更多路由
];

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'App',
    component: () => import('@/views/TheRoot.vue'),
    redirect: '/home',
    children: rootRoutes
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/NotFound.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

// 路由守卫
router.beforeEach((to, from) => {
  // 修改页面标题
  if (to.meta.title) {
    document.title = to.meta.title as string;
  }
  
  // 身份验证检查
  if (to.meta.requiresAuth && !isLoggedIn()) {
    return { name: 'login' };
  }
});

export default router;

4.2 布局组件与路由视图

<!-- App.vue -->
<template>
  <div id="app">
    <router-view />
    <TabBar v-if="showTabBar" />
  </div>
</template>

<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import TabBar from '@/views/layout/TabBar.vue';

const route = useRoute();

// 根据当前路由决定是否显示底部导航
const showTabBar = computed(() => {
  const hiddenRoutes = ['/login', '/register'];
  return !hiddenRoutes.includes(route.path);
});
</script>
<!-- TheRoot.vue -->
<template>
  <div class="root-layout">
    <header v-if="showHeader" class="app-header">
      <van-nav-bar
        :title="currentTitle"
        left-arrow
        @click-left="router.back()"
      />
    </header>
    
    <main class="app-main">
      <router-view />
    </main>
    
    <footer class="app-footer">
      <TabBar />
    </footer>
  </div>
</template>

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

const route = useRoute();
const router = useRouter();

const currentTitle = computed(() => route.meta.title || '默认标题');
const showHeader = computed(() => !route.meta.hideHeader);
</script>

五、插槽系统:组件的灵活性之源

Vue 的插槽系统让组件具备了极高的可定制性。

5.1 基础插槽使用

<!-- BaseCard.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <!-- 具名插槽 -->
      <slot name="header">
        <!-- 默认内容 -->
        <h3>默认标题</h3>
      </slot>
    </div>
    
    <div class="card-body">
      <!-- 默认插槽 -->
      <slot>
        <p>默认内容</p>
      </slot>
    </div>
    
    <div class="card-actions">
      <!-- 作用域插槽 -->
      <slot name="actions" :item="itemData" :isFavorite="isFavorite">
        <button @click="handleDefaultAction">默认操作</button>
      </slot>
    </div>
  </div>
</template>

5.2 插槽的使用

<template>
  <BaseCard>
    <!-- 具名插槽使用 -->
    <template #header>
      <div class="custom-header">
        <van-icon name="star" />
        <h3>自定义标题</h3>
      </div>
    </template>
    
    <!-- 默认插槽内容 -->
    <p>这是卡片的主要内容...</p>
    
    <!-- 作用域插槽使用 -->
    <template #actions="{ item, isFavorite }">
      <van-button 
        :type="isFavorite ? 'primary' : 'default'"
        @click="toggleFavorite(item)"
      >
        {{ isFavorite ? '已收藏' : '收藏' }}
      </van-button>
      <van-button @click="viewDetail(item)">
        查看详情
      </van-button>
    </template>
  </BaseCard>
</template>

六、TypeScript:Vue 的完美搭档

TypeScript 为 Vue 开发带来了类型安全和更好的开发体验。

6.1 类型定义

// types/home.ts
export interface HomeTopBarItem {
  title: string;
  icon: string;
  badge?: number; // 可选属性
}

export interface RecentlyViewedItem {
  id: string;
  title: string;
  cover: string;
  price: number;
  originalPrice?: number;
  rating?: number;
}

// 泛型响应类型
export interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
  timestamp: number;
}

// 组件 Props 类型
export interface RecentlyViewedProps {
  items: RecentlyViewedItem[];
  maxDisplay?: number;
  showPrice?: boolean;
}

6.2 组合式函数与类型

// composables/useApi.ts
import { ref, type Ref } from 'vue';
import { request } from '@/utils/request';

export function useApi<T>(url: string) {
  const data: Ref<T | null> = ref(null);
  const loading = ref(false);
  const error = ref<string | null>(null);
  
  const fetchData = async (params?: Record<string, any>) => {
    loading.value = true;
    error.value = null;
    
    try {
      const response = await request<T>({
        url,
        method: 'GET',
        params
      });
      data.value = response.data;
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误';
    } finally {
      loading.value = false;
    }
  };
  
  return {
    data,
    loading,
    error,
    fetchData
  };
}

七、工具链与工程化

7.1 Vite 配置优化

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { VantResolver } from '@vant/auto-import-resolver';
import path from 'path';

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],
      dts: true, // 生成类型声明文件
    }),
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
});

7.2 请求封装与拦截器

// utils/request.ts
import axios from 'axios';
import type {
  AxiosRequestConfig, 
  AxiosResponse,
  AxiosError
} from 'axios';
import { showToast } from 'vant';

const instance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10 * 1000,
  withCredentials: true,
});

// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 添加认证 token
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
instance.interceptors.response.use(
  (response) => {
    return response;
  },
  (error: AxiosError) => {
    const status = error.response?.status;
    
    switch (status) {
      case 401:
        showToast('请先登录');
        // 跳转到登录页
        break;
      case 403:
        showToast('没有权限');
        break;
      case 500:
        showToast('服务器错误');
        break;
      default:
        showToast('网络错误');
    }
    
    return Promise.reject(error);
  }
);

// 泛型请求函数
export const request = <T = any>(
  config: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
  return instance(config);
};

// 具体的 API 请求
export const api = {
  get: <T = any>(url: string, params?: any) => 
    request<T>({ method: 'GET', url, params }),
  
  post: <T = any>(url: string, data?: any) =>
    request<T>({ method: 'POST', url, data }),
    
  put: <T = any>(url: string, data?: any) =>
    request<T>({ method: 'PUT', url, data }),
    
  delete: <T = any>(url: string) =>
    request<T>({ method: 'DELETE', url }),
};

八、样式与 Tailwind CSS

8.1 原子化 CSS 的优势

<template>
  <div class="home">
    <!-- 渐变背景 -->
    <div class="top-bg absolute h-36 -z-10 w-screen 
                bg-gradient-to-b from-orange-500 to-white">
    </div>
    
    <!-- 搜索框 -->
    <van-search
      class="mb-2 mx-4 rounded-lg shadow-sm"
      background="transparent"
    />
    
    <!-- 内容区域 -->
    <main class="flex flex-col space-y-4 px-4">
      <header class="w-[calc(100vw-2rem)] min-h-24 
                     bg-white rounded-2xl p-4 shadow-md 
                     self-center transition-all 
                     hover:shadow-lg">
        <!-- 响应式设计 -->
        <section class="topbar flex justify-around 
                        flex-wrap gap-4 mb-4 
                        md:flex-nowrap md:gap-0">
          <div 
            v-for="item in topBarState"
            :key="item.title"
            class="topbar-item flex flex-col items-center 
                   cursor-pointer transition-transform 
                   hover:scale-105 min-w-[60px]"
          >
            <div class="topbar-item__icon mb-1">
              <van-icon :name="item.icon" 
                        class="text-2xl text-orange-500" />
            </div>
            <div class="topbar-item__text text-xs 
                        text-gray-600 font-medium">
              {{ item.title }}
            </div>
          </div>
        </section>
      </header>
    </main>
  </div>
</template>

8.2 自定义样式与 Tailwind 结合

/* style.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* 自定义组件样式 */
@layer components {
  .btn-primary {
    @apply bg-orange-500 text-white px-4 py-2 
           rounded-lg hover:bg-orange-600 
           transition-colors focus:outline-none 
           focus:ring-2 focus:ring-orange-300 
           disabled:opacity-50 disabled:cursor-not-allowed;
  }
  
  .card {
    @apply bg-white rounded-xl shadow-sm 
           border border-gray-100 
           hover:shadow-md transition-shadow;
  }
}

/* 自定义工具类 */
@layer utilities {
  .text-shadow {
    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  
  .scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
  
  .scrollbar-hide::-webkit-scrollbar {
    display: none;
  }
}

九、项目架构最佳实践

9.1 目录结构设计

src/
├── assets/          # 静态资源
│   ├── images/
│   └── styles/
├── components/      # 通用组件
│   ├── ui/         # 基础UI组件
│   └── business/   # 业务组件
├── views/          # 页面组件
├── store/          # 状态管理
│   ├── modules/    # 模块化store
│   └── index.ts
├── router/         # 路由配置
├── utils/          # 工具函数
│   ├── request.ts
│   └── helpers.ts
├── types/          # 类型定义
├── composables/    # 组合式函数
├── api/            # API接口
└── main.ts

9.2 组件设计原则

<!-- 好的组件设计示例 -->
<template>
  <ProductCard
    :product="product"
    :show-price="true"
    :favorite="isFavorite"
    @favorite-toggle="handleFavoriteToggle"
    @click="handleCardClick"
  >
    <template #badge>
      <van-tag v-if="product.isNew" type="primary">
        新品
      </van-tag>
    </template>
    
    <template #actions>
      <van-button 
        size="small" 
        type="primary"
        @click.stop="handleBuy"
      >
        立即购买
      </van-button>
    </template>
  </ProductCard>
</template>

<script setup lang="ts">
// 明确的 Props 定义
interface Props {
  product: Product;
  showPrice?: boolean;
  favorite?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  showPrice: true,
  favorite: false
});

// 明确的事件定义
const emit = defineEmits<{
  'favorite-toggle': [id: string, value: boolean];
  click: [product: Product];
}>();

// 组合式函数复用
const { toggleFavorite } = useFavorite();
const { addToCart } = useCart();

const handleFavoriteToggle = async () => {
  const newValue = !props.favorite;
  await toggleFavorite(props.product.id, newValue);
  emit('favorite-toggle', props.product.id, newValue);
};

const handleCardClick = () => {
  emit('click', props.product);
};
</script>

十、性能优化与最佳实践

10.1 组件性能优化

<template>
  <!-- 虚拟滚动优化长列表 -->
  <RecycleScroller
    :items="largeList"
    :item-size="80"
    key-field="id"
    v-slot="{ item }"
    class="h-96"
  >
    <ProductItem :item="item" />
  </RecycleScroller>
  
  <!-- 图片懒加载 -->
  <img
    v-for="image in images"
    :key="image.id"
    v-lazy="image.url"
    class="product-image"
    alt="产品图片"
  />
</template>

<script setup>
import { computed, watchEffect, shallowRef } from 'vue';

// 使用 shallowRef 避免深度响应式
const largeList = shallowRef([]);

// 计算属性缓存
const filteredList = computed(() => {
  return largeList.value.filter(item => 
    item.price > 0 && item.stock > 0
  );
});

// 监听优化
watchEffect(() => {
  // 只有当依赖变化时才执行
  if (filteredList.value.length > 0) {
    updateStatistics(filteredList.value);
  }
});

// 函数记忆化
const expensiveCalculation = computed(() => {
  return heavyCalculation(filteredList.value);
});
</script>

10.2 打包优化

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-library': ['vant'],
          'utils': ['axios', 'dayjs', 'lodash-es']
        }
      }
    },
    chunkSizeWarningLimit: 1000
  }
});

总结

Vue 全家桶提供了一个完整、优雅的前端解决方案。从响应式系统到状态管理,从路由控制到构建工具,每一个环节都体现了 Vue 团队对开发者体验的深度思考。

Vue 的核心优势:

  1. 渐进式:可以根据项目需求逐步采用
  2. 响应式:自动的依赖追踪和更新
  3. 组合式:优秀的逻辑复用能力
  4. 类型友好:与 TypeScript 完美结合
  5. 生态丰富:完整的工具链和组件库

无论是初创项目还是大型企业应用,Vue 全家桶都能提供出色的开发体验和运行性能。希望这篇笔记能帮助你更好地理解和运用 Vue 全家桶,在开发路上越走越远!

《手撕类Vue2的响应式核心思想:我的学习心路历程》

2025年11月12日 00:14

一、前言:什么是响应式?

响应式的核心思想很简单:当数据发生变化时,所有依赖于这个数据的函数(例如视图渲染函数、计算函数等)会自动重新执行,从而保持数据与依赖它的事物同步。在Vue2中,这是通过Object.defineProperty实现的。今天,我们就顺着这个思路,用原生JavaScript手写一个简易的响应式Demo。

实例效果.gif

二、基础版本:手动触发更新

我们先实现一个最基础的版本。点击按钮,让obj.age增加,并同步更新页面。

<div id="age">18</div>
<button onclick="changeObj()">年龄++</button>
// 1. 定义数据对象
const obj = {
    age: 18
};

// 2. 获取DOM元素
const ageEl = document.querySelector('#age');

// 3. 定义更新DOM的函数
const updateDom = function() {
    ageEl.textContent = obj.age.toString();
    console.log('页面已更新: ', ageEl.textContent);
}

// 4. 定义改变数据的函数
const changeObj = () => {
    obj.age++; // 修改数据
    updateDom(); // 手动更新DOM
}

效果: 点击按钮,年龄增加,页面显示同步更新。

image.png

这确实复现了“响应式”的效果,但存在一个明显问题:我们需要手动调用updateDom。如果依赖obj.age的函数很多,比如有dependFn1dependFn2dependFn3,那么changeObj函数会变得非常冗长和难以维护。

const updateDom = function() {
    age.textContent = obj.age.toString()
    console.log(age.textContent);
}

const dependFn1 = ()=>{
    console.log(obj.age);
}

const dependFn2 = ()=>{
    console.log(obj.age);
}

const dependFn3 = ()=>{
    console.log(obj.age);
}

const changeObj = ()=>{
    console.log(obj.age);
    obj.age++
    updateDom()//更新dom
    dependFn1()
    dependFn2()
    dependFn3()
}

image.png

三、优化一:统一管理依赖函数

为了解决上述问题,我们可以把所有依赖函数收集到一个数组中,当数据变化时,统一遍历执行。

//创建依赖函数数组
let fnArrays = []

//创建触发依赖函数的函数
const dispatchFns = ()=>{
  fnArrays.forEach(fn => fn())
}

//收集依赖函数的函数
const gatherFns = (fn)=>{
  fnArrays.push(fn)
}

gatherFns(dependFn1)
gatherFns(dependFn2)
gatherFns(dependFn3)

const changeObj = ()=>{
  console.log(obj.age);
  obj.age++
  updateDom()//更新dom
  dispatchFns()
}

这样,我们只需要调用一次dispatchFns(),所有依赖函数都会执行。但这种方式依然不够灵活,如果多个数据对象拥有各自的依赖函数,管理起来会非常混乱。

image.png

四、优化二:引入Depend类

更好的做法是为每个响应式数据创建一个独立的依赖管理器。我们定义一个Depend类来专门负责收集和触发依赖函数。

//这时候得创建一个Depend(依赖)类
  class Depend {
      constructor(){
          this.dependFns = new Set()
      }

      //收集依赖函数
      gatherFns(fn){
          if(fn && typeof fn === 'function'){
              this.dependFns.add(fn)
          }
      }

      //统一调用依赖函数
      dispatchFns(){
          this.dependFns.forEach(fn => {
              fn()
          })
      }
  }

  const depend1 = new Depend()

  depend1.gatherFns(dependFn1)
  depend1.gatherFns(dependFn2)
  depend1.gatherFns(dependFn3)

  const changeObj = ()=>{
      console.log(obj.age);
      obj.age++
      updateDom()//更新dom
      depend1.dispatchFns()
  }

image.png

现在,每个响应式数据都有自己的Depend实例,依赖管理更加清晰。但我们仍然需要在修改数据后手动调用dispatchFns()

五、核心实现:自动依赖收集和触发

要实现真正的自动响应式,我们需要在获取数据时自动收集依赖,在修改数据时自动触发更新。这可以通过Object.defineProperty拦截数据的读取和修改操作来实现。

//创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
function myReactive(obj){
    Object.keys(obj).forEach(key => {
        const instance = new Depend() //创建一个实例对象
        let value = obj[key] //获取对应属性的值,保留等下要用

        //加上属性劫持
        Object.defineProperty(obj,key,{
            get : function(){
                return value
            },
            set : function(newValue){
                value = newValue
                //通知对应的实例执行所有依赖函数
                instance.dispatchFns()
            }
        })
    })
}

image.png

现在是解决了对象属性改变时,可以劫持使用set方法进行调用依赖函数,但是收集函数就成了一个难题,因为实例是在内部创建的,形成了闭包,外界拿不到内部的实例,所以只有一个方法了,就是设置一个全局活跃函数,当调用依赖函数时,会触发获取对象属性,触发get方法,就可以在get方法调用内部的instance可以获取外部的全局活跃函数,将其加入实例中的Set数组中!

关键思路:设置"全局活跃函数"

我们需要一个方法来标记当前正在执行的函数,这样在读取数据时,就能知道是哪个函数依赖了这个数据。

//设置活跃函数
let activeFn = null
//加入依赖的函数
const addDependFn = (fn)=>{
    activeFn = fn
    fn()
    activeFn = null
}

addDependFn(dependFn1)
addDependFn(dependFn2)
addDependFn(dependFn3)

//创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
function myReactive(obj){
    Object.keys(obj).forEach(key => {
        const instance = new Depend() //创建一个实例对象
        let value = obj[key] //获取对应属性的值,保留等下要用

        //加上属性劫持
        Object.defineProperty(obj,key,{
            get : function(){
                instance.gatherFns(activeFn)
                return value
            },
            set : function(newValue){
                value = newValue
                //通知对应的实例执行所有依赖函数
                instance.dispatchFns()
            }
        })
    })
}

image.png

其实到这里就已经实现了简单的响应式啦!哈哈哈, 完整demo代码如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>手撕类Vue2响应式demo</title>
</head>
<body>
    <div id="age">18</div>

    <button onclick="changeObj()">年龄++</button>
    <script>
        // obj对象
        const obj = {
            age : 18
        }

        //使用原生拿到元素
        const age = document.querySelector('#age')

        const updateDom = function() {
            age.textContent = obj.age.toString()
            console.log(age.textContent);
        }

        //模拟依赖函数1
        const dependFn1 = ()=>{
            console.log(obj.age);
        }

        //模拟依赖函数2
        const dependFn2 = ()=>{
            console.log(obj.age);
        }

        //模拟依赖函数3
        const dependFn3 = ()=>{
            console.log(obj.age);
        }

        //这时候得创建一个类
        class Depend {
            constructor(){
                this.dependFns = new Set()
            }

            //收集依赖函数
            gatherFns(fn){
                if(fn && typeof fn === 'function'){
                    this.dependFns.add(fn)
                }
            }

            //统一调用依赖函数
            dispatchFns(){
                this.dependFns.forEach(fn => {
                    fn()
                })
            }
        }

        const changeObj = ()=>{
            console.log(obj.age);
            obj.age++
        }

        //设置活跃函数
        let activeFn = null
        //加入依赖的函数
        const addDependFn = (fn)=>{
            activeFn = fn
            fn()
            activeFn = null
        }

        //手动执行一遍函数
        addDependFn(dependFn1)
        addDependFn(dependFn2)
        addDependFn(dependFn3)

        //创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
        function myReactive(obj){
            Object.keys(obj).forEach(key => {
                const instance = new Depend() //创建一个实例对象
                let value = obj[key] //获取对应属性的值,保留等下要用

                //加上属性劫持
                Object.defineProperty(obj,key,{
                    get : function(){
                        instance.gatherFns(activeFn)
                        return value
                    },
                    set : function(newValue){
                        value = newValue
                        //通知对应的实例执行所有依赖函数
                        instance.dispatchFns()
                    }
                })
            })
        }

        myReactive(obj) //传入对象,添加劫持
        addDependFn(updateDom) //添加活跃函数
    </script>
</body>
</html>

六、最终优化

但是到现在,其实它还有很多可以优化的地方,比如可能一个对象多次传入myReactive函数,会创建多个实例,还有实例是在myReactive函数里面创建的,属于闭包,可能出现内存泄漏,所以我们可以采用WeakMap集合来收集对象,这样子的话,就可以在myReactive函数开始那里判断一下对象是否创建过。然后每一个对象对应着一个(属性-实例)Map表,这样子的话,就可以方便的拿到实例了,就不会有强引用来泄漏内存了。

这里是优化,就不赘述了,直接上代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>手撕类Vue2响应式demo</title>
</head>
<body>
    <div id="age">18</div>

    <button onclick="changeObj()">年龄++</button>
    <script>
        // obj对象
        const obj = {
            age : 18
        }

        //使用原生拿到元素
        const age = document.querySelector('#age')

        const updateDom = function() {
            age.textContent = obj.age.toString()
            console.log(age.textContent);
        }

        //模拟依赖函数1
        const dependFn1 = ()=>{
            console.log(obj.age);
        }

        //模拟依赖函数2
        const dependFn2 = ()=>{
            console.log(obj.age);
        }

        //模拟依赖函数3
        const dependFn3 = ()=>{
            console.log(obj.age);
        }

        //这时候得创建一个类
        class Depend {
            constructor(){
                this.dependFns = new Set()
            }

            //收集依赖函数
            gatherFns(fn){
                if(fn && typeof fn === 'function'){
                    this.dependFns.add(fn)
                }
            }

            //统一调用依赖函数
            dispatchFns(){
                this.dependFns.forEach(fn => {
                    fn()
                })
            }
        }

        const changeObj = ()=>{
            console.log(obj.age);
            obj.age++
        }

        //设置活跃函数
        let activeFn = null
        //加入依赖的函数
        const addDependFn = (fn)=>{
            activeFn = fn
            fn()
            activeFn = null
        }

        //手动执行一遍函数
        addDependFn(dependFn1)
        addDependFn(dependFn2)
        addDependFn(dependFn3)

        const targetMap = new WeakMap()//创建一个集合,将响应式对象放进去

        //查找依赖实例的函数
        const getDepend = (obj,key)=>{
            let target = targetMap.get(obj)

            if(!target){
                //创建
                const propertyDependMap = new Map() //创建一张属性和对应依赖实例的对照表
                targetMap.set(obj,propertyDependMap) //将对象和对应的属性和对应依赖实例的对照表关联,可以通过对象找到对照表
                target = targetMap.get(obj)
            }

            let depend = target.get(key)
            if(!depend){
                //创建
                const instance = new Depend()
                target.set(key,instance) //将映射关系写好
                depend = instance
            }

            return depend
        }

        //创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
        function myReactive(obj){
            Object.keys(obj).forEach(key => {
                let value = obj[key] //获取对应属性的值,保留等下要用

                //加上属性劫持
                Object.defineProperty(obj,key,{
                    get : function(){
                        getDepend(obj,key).gatherFns(activeFn)
                        return value
                    },
                    set : function(newValue){
                        value = newValue
                        //通知对应的实例执行所有依赖函数
                        getDepend(obj,key).dispatchFns()
                    }
                })
            })
        }

        myReactive(obj)
        addDependFn(updateDom)
    </script>
</body>
</html>

image.png

七、总结

响应式其实就是3个重要的点

  1. 依赖收集:通过Object.defineProperty的getter拦截属性访问,收集当前正在执行的函数作为依赖
  2. 依赖触发:通过setter拦截属性修改,自动通知所有依赖函数更新
  3. 依赖管理:使用Depend类管理每个属性的依赖关系,使用WeakMapMap建立对象-属性-依赖的映射关系

这个简易实现包含了Vue2响应式系统的核心思想,但是vue封装的更加复杂!这个只是我的个人总结,如果能帮到你的话,就更好啦!如果觉得写的好的话,可以给个一键三连哈哈哈哈哈哈哈哈

还在重复造轮子?掌握这7个原则,让你的Vue组件复用性飙升!

2025年11月12日 07:25
你是不是经常遇到这样的情况:每次开始新项目,都要把之前的组件复制粘贴一遍,然后修修补补?或者在团队协作时,发现同事写的组件根本没法直接用,只能重写? 说实话,这种重复劳动真的挺浪费时间的。不过别担心,

Vue 项目上线前必查!8 个易忽略知识点,90% 开发者都踩过坑

作者 zzpper
2025年11月10日 23:42

Vue 项目上线前必查!8 个易忽略知识点,90% 开发者都踩过坑

最近最近接手了一个朋友的 Vue3 项目,改 bug 改到怀疑人生 —— 明明语法看着没毛病,页面就是不更新;父子组件传值偶尔失效;打包后样式突然错乱… 排查后发现全是些 “不起眼” 的知识点在作祟。

这些知识点不像响应式、生命周期那样被反复强调,却偏偏是面试高频考点和项目线上问题的重灾区。今天就带大家逐个拆解,每个都附代码示例和避坑方案,新手能避坑,老手能查漏,建议收藏备用!🚀

1. Scoped 样式的 “隐形泄露”,父子组件样式串味了

写组件时大家都习惯加scoped让样式局部化,但你可能遇到过:父组件的样式莫名其妙影响了子组件?这可不是 Vue 的 bug。

隐藏陷阱

Vue 为scoped样式的元素添加独特属性(如data-v-xxx)来隔离样式,但子组件的根节点会同时继承父组件和自身的 scoped 样式。比如这样的代码:

vue

<!-- 父组件 App.vue -->
<template>
  <h4>父组件标题</h4>
  <HelloWorld />
</template>
<style scoped>
h4 { color: red; }
</style>

<!-- 子组件 HelloWorld.vue -->
<template>
  <h4>子组件标题</h4> <!-- 会被父组件的red样式影响 -->
</template>
<style scoped></style>

最终子组件的 h4 也会变成红色,很多人第一次遇到都会懵圈。

避坑方案

  1. 给子组件根元素加唯一 class,避免标签选择器冲突

    vue

    <!-- 优化后 HelloWorld.vue -->
    <template>
      <div class="hello-world">
        <h4>子组件标题</h4>
      </div>
    </template>
    
  2. Vue3 支持多根节点,直接用多个根元素打破继承链

  3. 尽量用 class 选择器替代标签选择器,减少冲突概率

2. 数组 / 对象响应式失效?别再直接改索引了

这是 Vue 响应式系统的经典 “坑”,Vue3 用 Proxy 优化了不少,但某些场景依然会踩雷。

隐藏陷阱

Vue 的响应式依赖数据劫持实现,但以下两种操作无法被监听:

  1. 给对象新增未声明的属性
  2. 直接修改数组索引或长度

vue

<template>
  <div>{{ user.age }}</div>
  <div>{{ list[0] }}</div>
  <button @click="modifyData">修改数据</button>
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '张三' })
const list = reactive(['苹果'])

const modifyData = () => {
  user.age = 25 // 新增属性,页面不更新
  list[0] = '香蕉' // 直接改索引,页面不更新
}
</script>

点击按钮后,数据确实变了,但页面纹丝不动。

避坑方案

针对不同数据类型用正确姿势修改:

vue

<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '张三' })
const list = reactive(['苹果'])

const modifyData = () => {
  // 对象新增属性:直接赋值即可(Vue3 Proxy支持)
  user.age = 25 
  // 数组修改:用splice或替换数组
  list.splice(0, 1, '香蕉') 
  // 也可直接替换整个数组
  // list = ['香蕉', '橙子']
}
</script>

小贴士:Vue2 中需用this.$set(user, 'age', 25),Vue3 的 Proxy 无需额外 API,但修改数组索引仍需用数组方法。

3. setup 里的异步请求,别漏了 Suspense 配合

Vue3 的 Composition API 是趋势,但很多人在 setup 里写异步请求时,遇到过数据渲染延迟或报错的问题。

隐藏陷阱

setup 函数执行时组件还未挂载,若直接在 setup 中写 async/await,返回的 Promise 会导致组件渲染异常,因为 setup 本身不支持直接返回 Promise。

vue

<!-- 错误示例 -->
<script setup>
import axios from 'axios'
const data = ref(null)

// 直接用await会导致组件初始化异常
const res = await axios.get('/api/list') 
data.value = res.data
</script>

避坑方案

用 Vue3 内置的<Suspense>组件包裹异步组件,搭配异步 setup 使用:

vue

<!-- 父组件 -->
<template>
  <Suspense>
    <template #default>
      <DataList />
    </template>
    <template #fallback>
      <div>加载中...</div> <!-- 加载占位 -->
    </template>
  </Suspense>
</template>

<!-- DataList.vue 异步组件 -->
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const data = ref(null)

// setup可以写成async函数
const fetchData = async () => {
  const res = await axios.get('/api/list')
  data.value = res.data
}
fetchData()
</script>

这样既能正常发起异步请求,又能优雅处理加载状态,提升用户体验。

4. 非 props 属性 “悄悄继承”,DOM 多了莫名属性

给组件传了没在 props 中声明的属性(如 id、class),结果发现子组件根元素自动多了这些属性,有时会导致样式或功能冲突。

隐藏陷阱

这是 Vue 的非 props 属性继承特性,像 id、class、name 这类未被 props 接收的属性,会默认挂载到子组件的根元素上。比如:

vue

<!-- 父组件 -->
<template>
  <UserCard id="user-card" class="card-style" />
</template>

<!-- 子组件 UserCard.vue 未声明对应props -->
<template>
  <div>用户信息卡片</div> <!-- 最终会被渲染为<div id="user-card" class="card-style"> -->
</template>

若子组件根元素已有 class,会和继承的 class 合并,有时会覆盖预期样式。

避坑方案

  1. 禁止继承:用inheritAttrs: false关闭自动继承

    vue

    <script setup>
    // 关闭非props属性继承
    defineOptions({ inheritAttrs: false }) 
    </script>
    
  2. 手动控制属性位置:用$attrs将属性挂载到指定元素

    vue

    <template>
      <div>
        <div v-bind="$attrs">只给这个元素加继承属性</div>
      </div>
    </template>
    

5. 生命周期的 “顺序陷阱”,父子组件执行顺序搞反了

Vue2 升级 Vue3 后,生命周期不仅改了命名,父子组件的执行顺序也有差异,这是面试高频题,也是项目中异步逻辑出错的常见原因。

隐藏陷阱

很多人仍沿用 Vue2 的思维写 Vue3 代码,比如认为父组件的onMounted会比子组件先执行,结果 DOM 操作时报错。

阶段 Vue2 执行顺序 Vue3 执行顺序
初始化 父 beforeCreate→父 created→父 beforeMount→子 beforeCreate→子 created→子 beforeMount→子 mounted→父 mounted 父 setup→父 onBeforeMount→子 setup→子 onBeforeMount→子 onMounted→父 onMounted

避坑方案

  1. 数据初始化:Vue3 可在 setup 中直接用 async/await 发起请求,配合 Suspense

  2. DOM 操作:务必在onMounted中执行,且要清楚子组件的 mounted 会比父组件先触发

  3. 清理工作:定时器、事件监听一定要在onBeforeUnmount中清除,避免内存泄漏

    vue

    <script setup>
    import { onMounted, onBeforeUnmount } from 'vue'
    let timer = null
    onMounted(() => {
      timer = setInterval(() => {
        console.log('定时器运行中')
      }, 1000)
    })
    // 组件卸载前清除定时器
    onBeforeUnmount(() => {
      clearInterval(timer)
    })
    </script>
    

6. CSS 中用 v-bind,动态样式的正确打开方式

Vue3.2 + 支持在 CSS 中直接用 v-bind 绑定数据,这个特性很实用,但很多人不知道它的底层逻辑和使用限制。

隐藏陷阱

直接在 CSS 中绑定计算属性时,误以为修改数据后样式不会实时更新,或者担心影响性能。

vue

<template>
  <div class="text">动态颜色文本</div>
  <button @click="changeColor">切换颜色</button>
</template>
<script setup>
import { ref, computed } from 'vue'
const primaryColor = ref('red')
const textColor = computed(() => primaryColor.value)
const changeColor = () => {
  primaryColor.value = primaryColor.value === 'red' ? 'blue' : 'red'
}
</script>
<style>
.text {
  color: v-bind(textColor);
}
</style>

避坑方案

  1. 无需担心性能:v-bind 会被编译成 CSS 自定义属性,通过内联样式应用到组件,数据变更时仅更新自定义属性
  2. 支持多种数据类型:可绑定 ref、reactive、computed,甚至是 props 传递的值
  3. 与 scoped 兼容:动态样式同样支持局部作用域,不会污染全局

7. ref 获取元素,别在 onMounted 前急着用

用 ref 获取 DOM 元素是基础操作,但新手常犯的错是在 DOM 未挂载完成时就调用元素方法。

隐藏陷阱

setuponBeforeMount中获取 ref,结果拿到undefined

vue

<template>
  <input ref="inputRef" type="text" />
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
const inputRef = ref(null)

onBeforeMount(() => {
  inputRef.value.focus() // 报错:Cannot read property 'focus' of null
})
</script>

避坑方案

  1. 基础用法:在onMounted中操作 ref 元素,此时 DOM 已完全挂载

    vue

    <script setup>
    import { ref, onMounted } from 'vue'
    const inputRef = ref(null)
    
    onMounted(() => {
      inputRef.value.focus() // 正常生效
    })
    </script>
    
  2. 动态元素:若 ref 绑定在 v-for 渲染的元素上,inputRef 会变成数组,需通过索引访问

  3. 组件 ref:获取子组件实例时,子组件需用defineExpose暴露属性和方法

8. watch 监听数组 / 对象,深度监听别写错了

watch 是 Vue 中处理响应式数据变化的核心 API,但监听复杂数据类型时,很容易出现 “监听不到变化” 的问题。

隐藏陷阱

直接监听数组或对象时,默认只监听引用变化,对内部属性的修改无法触发监听。

vue

<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })

// 错误:监听不到age的变化
watch(user, (newVal) => {
  console.log('用户信息变了', newVal)
})

const changeAge = () => {
  user.value.age = 25 // 仅修改内部属性,不触发监听
}
</script>

避坑方案

根据 Vue 版本选择正确的监听方式:

  1. Vue3 监听 ref 包裹的对象:开启深度监听

    vue

    watch(user, (newVal) => {
      console.log('用户信息变了', newVal)
    }, { deep: true }) // 开启深度监听
    
  2. 精准监听单个属性:用函数返回值的方式,性能更优

    vue

    // 只监听age变化,无需深度监听
    watch(() => user.value.age, (newAge) => {
      console.log('年龄变了', newAge)
    })
    

最后总结

Vue 这些易忽略的知识点,本质上都是对底层原理理解不透彻导致的。很多时候我们只顾着实现功能,却忽略了这些细节,等到项目上线出现 bug 才追悔莫及。

以上 8 个知识点,建议结合代码逐个实操验证。如果本文帮你避开了坑,欢迎点赞收藏,也可以在评论区分享你踩过的 Vue 神坑,一起避雷成长!💪

Vue组件通信不再难!这8种方式让你彻底搞懂父子兄弟传值

2025年11月11日 07:29
你是不是经常遇到这样的场景?父组件的数据要传给子组件,子组件的事件要通知父组件,兄弟组件之间要共享状态...每次写Vue组件通信都觉得头大,不知道用哪种方式最合适? 别担心!今天我就带你彻底搞懂Vue
❌
❌