Vue2 高频面试题

Vue2 高频面试题

1. Vue2 的响应式原理是什么?

Vue2 的响应式核心是 Object.defineProperty ,通过 劫持对象属性的 getter/setter 来实现依赖收集和派发更新。

三个核心角色:

角色 职责 类比
Observer(观察者) 递归遍历对象,给每个属性添加 getter/setter 安装监控摄像头
Dep(依赖收集器) 每个属性对应一个 Dep,收集谁在用这个属性 登记册(记录谁在关注)
Watcher(订阅者) 组件渲染 watcher / 用户 watcher / computed watcher 关注者(数据变了要通知的人)
objectivec 复制代码
数据变化 → setter 触发 → dep.notify() → 通知所有 watcher → 组件重新渲染
javascript 复制代码
// ========== 简化版响应式原理 ==========
class Dep {
  constructor() {
    this.subs = []  // 存放所有 watcher
  }
  addSub(watcher) { this.subs.push(watcher) }
  depend() {
    if (Dep.target) this.addSub(Dep.target)  // 收集当前 watcher
  }
  notify() {
    this.subs.forEach(watcher => watcher.update())  // 通知所有 watcher 更新
  }
}

function defineReactive(obj, key, val) {
  const dep = new Dep()
  // 递归处理嵌套对象
  observe(val)
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) dep.depend()  // 依赖收集:谁读了我,就记住谁
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      observe(newVal)   // 新值也要变成响应式
      dep.notify()      // 派发更新:通知所有依赖方
    }
  })
}

function observe(data) {
  if (typeof data !== 'object' || data === null) return
  Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}

⚠️ Vue2 响应式的局限性:

javascript 复制代码
export default {
  data() {
    return {
      user: { name: '张三' },
      list: [1, 2, 3]
    }
  },
  methods: {
    // ❌ 无法检测到的变化
    addProperty() {
      this.user.age = 25           // ❌ 新增属性不触发更新
      delete this.user.name        // ❌ 删除属性不触发更新
      this.list[0] = 'new'         // ❌ 通过下标修改数组不触发
      this.list.length = 1         // ❌ 修改数组长度不触发
    },
    // ✅ 正确的做法
    addPropertyCorrect() {
      this.$set(this.user, 'age', 25)        // ✅ Vue.set
      this.$delete(this.user, 'name')         // ✅ Vue.delete
      this.$set(this.list, 0, 'new')          // ✅ 数组用 $set
      this.list.splice(0, 1, 'new')           // ✅ 用变异方法
    }
  }
}

// Vue2 重写了 7 个数组方法使其具有响应式:
// push、pop、shift、unshift、splice、sort、reverse

💡 面试加分点: Vue2 对数组的处理是面试高频考点------出于性能考虑,Vue2 没有对数组下标使用 defineProperty(数组可能很长),而是重写了 7 个能改变原数组的方法。面试时提到 $set 的原理(内部调用 defineReactive + dep.notify())会加分。


2. Vue2 的生命周期详解

复制代码
创建阶段:  beforeCreate → created
挂载阶段:  beforeMount → mounted
更新阶段:  beforeUpdate → updated
销毁阶段:  beforeDestroy → destroyed
缓存组件:  activated / deactivated
错误捕获:  errorCaptured
生命周期 能做什么 不能做什么
beforeCreate 初始化事件和生命周期 ❌ 访问不了 data/methods/computed
created ✅ 可访问 data/methods,✅ 发起异步请求 ❌ 访问不了 DOM($el)
beforeMount 模板编译完成,还没挂载 ❌ 访问不了真实 DOM
mounted ✅ DOM 已挂载,✅ 操作 DOM、初始化第三方库 SSR 中不执行
beforeUpdate 数据已更新,DOM 还没更新 避免在此修改数据(可能死循环)
updated ✅ DOM 已更新 避免在此修改数据(可能死循环)
beforeDestroy ✅ 清理定时器、事件监听、取消订阅 -
destroyed 组件已销毁,所有绑定和监听已移除 -
javascript 复制代码
export default {
  data() {
    return { timer: null, chart: null }
  },
  created() {
    // ✅ 最佳请求时机(SSR 兼容)
    this.fetchData()
  },
  mounted() {
    // ✅ 操作 DOM / 初始化第三方库
    this.chart = new ECharts(this.$refs.chartDom)
    this.timer = setInterval(this.polling, 5000)
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy() {
    // ✅ 必须清理!否则内存泄漏
    clearInterval(this.timer)
    this.chart?.dispose()
    window.removeEventListener('resize', this.handleResize)
  }
}

父子组件生命周期执行顺序:

复制代码
挂载阶段:
父 beforeCreate → 父 created → 父 beforeMount
→ 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted
→ 父 mounted

更新阶段(父传子 props 变化):
父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated

销毁阶段:
父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed

💡 面试加分点: 请求数据应该放在 created 还是 mounted?推荐 created------更早发起请求且 SSR 兼容。mounted 中无法保证子组件也挂载完成,需要 this.$nextTick。父子组件生命周期顺序是高频考题,记住"子组件先完成"的规律。


3. Vue2 的 computed、watch、methods 区别?

特性 computed watch methods
缓存 ✅ 依赖不变不重新计算 ❌ 每次调用都执行
异步 ❌ 必须同步返回 ✅ 可以异步 ✅ 可以异步
返回值 ✅ 必须有返回值 ❌ 不需要 可有可无
参数 ❌ 不接受参数 ✅ 新旧值 ✅ 任意参数
用途 派生数据 监听变化执行副作用 事件处理/通用方法
javascript 复制代码
export default {
  data() {
    return {
      firstName: '张',
      lastName: '三',
      searchQuery: '',
    }
  },
  // ✅ computed:派生数据(有缓存)
  computed: {
    fullName() {
      console.log('computed 执行了')  // 只有 firstName/lastName 变化才重新执行
      return this.firstName + this.lastName
    },
    // 可写的 computed
    fullNameWritable: {
      get() { return this.firstName + this.lastName },
      set(val) {
        this.firstName = val.slice(0, 1)
        this.lastName = val.slice(1)
      }
    }
  },
  // ✅ watch:监听变化做副作用
  watch: {
    // 简写
    searchQuery(newVal, oldVal) {
      this.debouncedSearch(newVal)
    },
    // 完整写法
    searchQuery: {
      handler(newVal, oldVal) {
        this.debouncedSearch(newVal)
      },
      immediate: true,  // 立即执行一次
      deep: false        // 深度监听
    },
    // 监听嵌套属性
    'user.name'(newVal) {
      console.log('用户名变了:', newVal)
    },
    // 深度监听对象
    user: {
      handler(newVal) { console.log('user 变了') },
      deep: true  // 深度监听,对象内部任何属性变化都触发
    }
  }
}

💡 面试加分点: computed 本质是一个 lazy watcher ------只有在被访问时才计算,并且会缓存结果,依赖不变时直接返回缓存值。watchdeep: true 性能开销大(递归遍历对象),能用 'obj.key' 精确监听就不要用 deep


4. Vue2 的组件通信方式(全面总结)

方式 方向 适用场景
props / $emit 父 → 子 / 子 → 父 最基本的父子通信
parent/parent / parent/children 父 ↔ 子 直接访问(不推荐,耦合高)
$refs 父 → 子 访问子组件实例/DOM
provide / inject 祖先 → 后代 跨层级(非响应式,除非传 ref)
EventBus 任意组件 兄弟/跨层级通信
Vuex 全局 复杂状态管理
attrs/attrs / attrs/listeners 父 → 孙 属性透传(中间组件不需要声明)
.sync 修饰符 父 ↔ 子 双向绑定的语法糖
javascript 复制代码
// ========== 1. props / $emit ==========
// 父组件
// <Child :title="title" @update="handleUpdate" />

// 子组件
export default {
  props: {
    title: { type: String, required: true }
  },
  methods: {
    notify() {
      this.$emit('update', newValue)
    }
  }
}

// ========== 2. .sync 修饰符(Vue2 特有的双向绑定语法糖) ==========
// 父组件
// <Child :visible.sync="dialogVisible" />
// 等价于:
// <Child :visible="dialogVisible" @update:visible="val => dialogVisible = val" />

// 子组件
export default {
  props: ['visible'],
  methods: {
    close() {
      this.$emit('update:visible', false)  // 固定格式:update:propName
    }
  }
}

// ========== 3. $attrs / $listeners(属性透传) ==========
// 祖父组件
// <Parent :name="name" :age="age" @click="handleClick" />

// 父组件(不需要 name 和 age,透传给孙组件)
export default {
  inheritAttrs: false,  // 不自动绑定到根元素
  // <GrandChild v-bind="$attrs" v-on="$listeners" />
}

// ========== 4. provide / inject ==========
// 祖先组件
export default {
  provide() {
    return {
      theme: this.theme,       // ⚠️ 非响应式!
      getTheme: () => this.theme  // ✅ 通过函数间接实现响应式
    }
  }
}

// 后代组件
export default {
  inject: ['theme', 'getTheme'],
  // 或者带默认值
  inject: {
    theme: { default: 'light' }
  }
}

// ========== 5. EventBus(事件总线) ==========
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()

// 组件 A:发送事件
import { EventBus } from './event-bus'
EventBus.$emit('user-login', { name: '张三' })

// 组件 B:监听事件
import { EventBus } from './event-bus'
export default {
  created() {
    EventBus.$on('user-login', this.handleLogin)
  },
  beforeDestroy() {
    EventBus.$off('user-login', this.handleLogin)  // ⚠️ 必须手动销毁!
  }
}

// ========== 6. $refs ==========
// <Child ref="childComp" />
this.$refs.childComp.childMethod()   // 调用子组件方法
this.$refs.childComp.childData       // 访问子组件数据

💡 面试加分点: .sync 是 Vue2 特有的(Vue3 中被 v-model:xxx 取代)。EventBus 必须在 beforeDestroy 中手动 $off,否则会内存泄漏。Vue2 的 provide/inject 默认不是响应式的(Vue3 中传入 ref 就是响应式的)。


5. Vuex 核心概念和使用

Vuex 是 Vue2 官方的状态管理库,采用集中式存储管理应用的所有组件状态。

scss 复制代码
┌─────────────────────────────────────┐
│              Vuex Store              │
│                                     │
│  State ←── Getters                  │
│    ↑                                │
│  Mutations ←── Actions              │
│              (异步操作)              │
│                                     │
│  Component                          │
│    dispatch → Actions               │
│    commit  → Mutations              │
│    读取    ← State / Getters        │
└─────────────────────────────────────┘
概念 作用 类比
State 数据源(单一状态树) 数据库
Getters 派生数据(带缓存) computed
Mutations 同步修改 State(唯一途径) 事务(必须同步)
Actions 异步操作,提交 Mutation 业务逻辑层
Modules 分模块管理 按业务拆分
javascript 复制代码
// ========== store/index.js ==========
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export default new Vuex.Store({
  // State:数据源
  state: {
    count: 0,
    userInfo: null,
    token: localStorage.getItem('token') || '',
    cartList: []
  },

  // Getters:派生数据(类似 computed)
  getters: {
    isLoggedIn: state => !!state.token,
    cartTotal: state => state.cartList.reduce((sum, item) => sum + item.price * item.qty, 0),
    cartCount: state => state.cartList.length,
    // getter 接收其他 getter 作为第二个参数
    cartSummary: (state, getters) => `${getters.cartCount} 件商品,共 ¥${getters.cartTotal}`
  },

  // Mutations:同步修改 State(唯一方式)
  mutations: {
    // 常量风格命名(推荐)
    SET_COUNT(state, payload) {
      state.count = payload
    },
    SET_USER_INFO(state, userInfo) {
      state.userInfo = userInfo
    },
    SET_TOKEN(state, token) {
      state.token = token
      localStorage.setItem('token', token)
    },
    ADD_TO_CART(state, product) {
      const item = state.cartList.find(i => i.id === product.id)
      if (item) {
        item.qty++
      } else {
        state.cartList.push({ ...product, qty: 1 })
      }
    },
    REMOVE_FROM_CART(state, productId) {
      state.cartList = state.cartList.filter(i => i.id !== productId)
    }
  },

  // Actions:异步操作(可以包含异步、多个 mutations)
  actions: {
    async login({ commit }, credentials) {
      try {
        const { data } = await api.login(credentials)
        commit('SET_TOKEN', data.token)
        commit('SET_USER_INFO', data.userInfo)
        return data
      } catch (error) {
        throw error
      }
    },
    async logout({ commit }) {
      await api.logout()
      commit('SET_TOKEN', '')
      commit('SET_USER_INFO', null)
      localStorage.removeItem('token')
    },
    // action 可以调用其他 action
    async fetchAndAddToCart({ commit, dispatch }, productId) {
      const product = await api.getProduct(productId)
      commit('ADD_TO_CART', product)
    }
  }
})

// ========== 组件中使用 ==========
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'

export default {
  computed: {
    // 方式1:直接访问
    count() { return this.$store.state.count },
    // 方式2:mapState 辅助函数
    ...mapState(['count', 'userInfo', 'token']),
    ...mapState({ userName: state => state.userInfo?.name }),
    // mapGetters
    ...mapGetters(['isLoggedIn', 'cartTotal', 'cartCount'])
  },
  methods: {
    // mapMutations
    ...mapMutations(['SET_COUNT', 'ADD_TO_CART']),
    // mapActions
    ...mapActions(['login', 'logout']),
    // 使用
    handleLogin() {
      this.login({ username: 'admin', password: '123' })
        .then(() => this.$router.push('/'))
        .catch(err => this.$message.error(err.message))
    }
  }
}

💡 面试加分点: 面试常问"为什么 mutations 必须是同步的?"------因为 devtools 需要在每个 mutation 前后记录状态快照(time-travel debugging),异步操作无法确定状态变化的时机。Actions 可以异步,最终还是通过 commit 同步修改 state。


6. Vuex 的 Module 模块化

当应用变大时,store 会变得臃肿。Module 可以将 store 分割成模块,每个模块有自己的 state、getters、mutations、actions。

javascript 复制代码
// ========== store/modules/user.js ==========
export default {
  namespaced: true,  // ✅ 开启命名空间(避免命名冲突)
  state: () => ({
    userInfo: null,
    token: ''
  }),
  getters: {
    isLoggedIn: state => !!state.token,
    // 访问根 state 和根 getters
    fullInfo: (state, getters, rootState, rootGetters) => ({
      ...state.userInfo,
      appName: rootState.appName
    })
  },
  mutations: {
    SET_USER(state, user) { state.userInfo = user },
    SET_TOKEN(state, token) { state.token = token }
  },
  actions: {
    async login({ commit, dispatch, rootState }, credentials) {
      const data = await api.login(credentials)
      commit('SET_TOKEN', data.token)
      commit('SET_USER', data.user)
      // 调用根 store 的 action
      dispatch('app/setLastLogin', Date.now(), { root: true })
    }
  }
}

// ========== store/modules/cart.js ==========
export default {
  namespaced: true,
  state: () => ({
    items: []
  }),
  getters: {
    total: state => state.items.reduce((sum, i) => sum + i.price * i.qty, 0),
    count: state => state.items.length
  },
  mutations: {
    ADD_ITEM(state, product) { state.items.push({ ...product, qty: 1 }) },
    CLEAR(state) { state.items = [] }
  },
  actions: {
    async checkout({ state, commit, rootGetters }) {
      if (!rootGetters['user/isLoggedIn']) throw new Error('请先登录')
      await api.checkout(state.items)
      commit('CLEAR')
    }
  }
}

// ========== store/index.js ==========
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import cart from './modules/cart'

Vue.use(Vuex)
export default new Vuex.Store({
  state: { appName: 'MyApp' },
  modules: { user, cart }
})

// ========== 组件中使用(命名空间模块) ==========
import { mapState, mapGetters, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('user', ['userInfo', 'token']),
    ...mapGetters('user', ['isLoggedIn']),
    ...mapGetters('cart', ['total', 'count']),
    // 或者直接访问
    userName() { return this.$store.state.user.userInfo?.name }
  },
  methods: {
    ...mapActions('user', ['login', 'logout']),
    ...mapActions('cart', ['checkout'])
  }
}

💡 面试加分点: namespaced: true 是实际项目中必须开启 的------不开启时所有模块的 mutations/actions 会注册到全局,容易命名冲突。模块内访问根状态用 rootState,调用根 action 用 { root: true }


7. Vue2 的 mixins 混入

mixins 是 Vue2 中复用逻辑的主要方式(Vue3 中被 Composition API 的 composable 取代)。

javascript 复制代码
// ========== mixins/pagination.js ==========
export const paginationMixin = {
  data() {
    return {
      currentPage: 1,
      pageSize: 10,
      total: 0,
      list: [],
      loading: false
    }
  },
  computed: {
    totalPages() {
      return Math.ceil(this.total / this.pageSize)
    }
  },
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const { data, total } = await this.fetchApi({
          page: this.currentPage,
          pageSize: this.pageSize
        })
        this.list = data
        this.total = total
      } finally {
        this.loading = false
      }
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}

// ========== 组件中使用 ==========
import { paginationMixin } from '@/mixins/pagination'

export default {
  mixins: [paginationMixin],
  methods: {
    // 必须实现 fetchApi 方法
    fetchApi(params) {
      return api.getUserList(params)
    }
  }
}

⚠️ mixins 的问题(面试重点):

问题 说明
命名冲突 多个 mixin 有同名属性/方法时,组件的优先级最高,但容易混乱
来源不明 看组件代码时不知道某个属性/方法来自哪个 mixin
隐式依赖 mixin 依赖组件中的某个方法(如上面的 fetchApi),但没有显式声明
数据合并 data 合并策略复杂,生命周期钩子都会执行(mixin 先于组件)
javascript 复制代码
// ❌ mixins 的典型问题
const mixinA = { data() { return { name: 'A' } } }
const mixinB = { data() { return { name: 'B' } } }

export default {
  mixins: [mixinA, mixinB],  // name 到底是 'A' 还是 'B'?→ 'B'(后面的覆盖前面的)
  data() { return { name: 'C' } }  // 如果组件也定义了呢?→ 'C'(组件优先)
}

// ✅ Vue3 Composition API 完美解决(对比)
// composables/usePagination.js
export function usePagination(fetchFn) {
  const currentPage = ref(1)
  const pageSize = ref(10)
  const list = ref([])
  const loading = ref(false)
  // 来源清晰、无命名冲突、显式依赖
  return { currentPage, pageSize, list, loading }
}

💡 面试加分点: mixins 是 Vue2 时代的"权宜之计",Vue3 用 Composition API 替代了它。面试中对比 mixins 和 composable 的优劣是加分项:composable 来源清晰(import)、无命名冲突(变量名自定义)、显式依赖(参数传入)、TypeScript 友好。


8. Vue2 Router 的使用和配置

javascript 复制代码
// ========== router/index.js ==========
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    name: 'Home',
    component: () => import('@/views/Home.vue'),  // 路由懒加载
    meta: { title: '首页', requiresAuth: false }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue')
  },
  // 动态路由
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue'),
    props: true  // ✅ 将 params 作为 props 传递给组件(推荐)
  },
  // 嵌套路由
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta: { requiresAuth: true, roles: ['admin'] },
    children: [
      { path: '', name: 'Dashboard', component: () => import('@/views/admin/Dashboard.vue') },
      { path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/Users.vue') },
      { path: 'settings', name: 'AdminSettings', component: () => import('@/views/admin/Settings.vue') }
    ]
  },
  // 404
  { path: '*', name: 'NotFound', component: () => import('@/views/NotFound.vue') }
]

const router = new VueRouter({
  mode: 'history',  // 'history' | 'hash'(默认)
  base: process.env.BASE_URL,
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition    // 浏览器后退时恢复位置
    if (to.hash) return { selector: to.hash }  // 锚点定位
    return { x: 0, y: 0 }                      // 滚动到顶部
  }
})

export default router

路由模式对比:

特性 Hash 模式 History 模式
URL 格式 /#/user/123 /user/123
原理 window.onhashchange history.pushState
服务器配置 ❌ 不需要 ✅ 需要 fallback 到 index.html
SEO ❌ 不友好 ✅ 友好
兼容性 好(IE9+) HTML5(IE10+)

9. Vue2 Router 的导航守卫(完整)

javascript 复制代码
// ========== 完整的导航解析流程 ==========
// 1. 触发导航
// 2. 在失活组件里调用 beforeRouteLeave
// 3. 调用全局 beforeEach
// 4. 在重用组件里调用 beforeRouteUpdate
// 5. 在路由配置里调用 beforeEnter
// 6. 解析异步路由组件
// 7. 在被激活组件里调用 beforeRouteEnter
// 8. 调用全局 beforeResolve
// 9. 导航确认
// 10. 调用全局 afterEach
// 11. 触发 DOM 更新
// 12. 调用 beforeRouteEnter 的 next 回调

// ========== 全局守卫 ==========
// 前置守卫(权限控制)
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')

  if (to.meta.requiresAuth && !token) {
    // 未登录,跳转登录页,并记录来源
    next({ name: 'Login', query: { redirect: to.fullPath } })
  } else if (to.meta.roles && !to.meta.roles.includes(userRole)) {
    // 无权限
    next({ name: 'Forbidden' })
  } else {
    next()  // ✅ 必须调用 next()
  }
})

// 后置钩子
router.afterEach((to, from) => {
  document.title = to.meta.title || '默认标题'
  // 关闭加载状态
  NProgress.done()
})

// 全局解析守卫
router.beforeResolve((to, from, next) => {
  // 在 beforeRouteEnter 之后、afterEach 之前调用
  next()
})

// ========== 路由独享守卫 ==========
const routes = [{
  path: '/admin',
  component: Admin,
  beforeEnter: (to, from, next) => {
    if (!isAdmin()) {
      next('/403')
    } else {
      next()
    }
  }
}]

// ========== 组件内守卫 ==========
export default {
  // 进入路由前(组件实例还没创建,不能用 this)
  beforeRouteEnter(to, from, next) {
    // ⚠️ 唯一不能用 this 的守卫
    next(vm => {
      // 通过 vm 访问组件实例
      vm.fetchData(to.params.id)
    })
  },
  // 路由参数变化时(组件被复用,如 /user/1 → /user/2)
  beforeRouteUpdate(to, from, next) {
    this.fetchData(to.params.id)  // ✅ 可以用 this
    next()
  },
  // 离开路由前
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      const answer = window.confirm('有未保存的更改,确定离开吗?')
      if (!answer) return next(false)  // 取消导航
    }
    next()
  }
}

💡 面试加分点: beforeRouteEnter 是唯一不能访问 this 的守卫(因为组件实例还没创建),需要通过 next(vm => {}) 的回调来访问。导航守卫的完整流程有 12 步,面试时能说出大致顺序就很加分。


10. Vue2 的 $nextTick 原理

$nextTick 将回调延迟到 DOM 更新之后执行 。Vue 的 DOM 更新是异步批量的------同一事件循环内多次数据修改只会触发一次 DOM 更新。

javascript 复制代码
export default {
  data() { return { message: 'Hello' } },
  methods: {
    updateMessage() {
      this.message = 'World'
      // ❌ DOM 还没更新
      console.log(this.$refs.msgEl.textContent)  // 'Hello'

      // ✅ DOM 更新后执行
      this.$nextTick(() => {
        console.log(this.$refs.msgEl.textContent)  // 'World'
      })

      // ✅ 也可以用 await
      // await this.$nextTick()
    },

    // ✅ 实际应用场景
    async showInput() {
      this.isEditing = true
      await this.$nextTick()
      this.$refs.input.focus()  // 等 input 渲染后再聚焦
    }
  }
}

// ========== $nextTick 的降级策略(Vue2 源码) ==========
// 优先级:Promise.then → MutationObserver → setImmediate → setTimeout
// Vue2 会自动选择当前环境中最快的微/宏任务

💡 面试加分点: $nextTick 本质是利用微任务 (Promise.then)来延迟回调执行。Vue 把 DOM 更新也放在微任务中,而 $nextTick 的回调在 DOM 更新之后的微任务中执行,所以能保证 DOM 已经更新。


11. Vue2 的 v-model 原理

html 复制代码
<!-- v-model 是语法糖 -->
<!-- <input v-model="message" /> -->
<!-- 等价于 -->
<input :value="message" @input="message = $event.target.value" />

<!-- 不同表单元素的 v-model 行为不同 -->
<!-- input/textarea:value + input 事件 -->
<!-- checkbox:checked + change 事件 -->
<!-- radio:checked + change 事件 -->
<!-- select:value + change 事件 -->
javascript 复制代码
// ========== 组件上的 v-model(Vue2) ==========
// 默认使用 value prop + input 事件
// <CustomInput v-model="message" />
// 等价于:
// <CustomInput :value="message" @input="message = $event" />

// 子组件
export default {
  props: ['value'],
  methods: {
    handleInput(e) {
      this.$emit('input', e.target.value)
    }
  }
  // template: <input :value="value" @input="handleInput" />
}

// ========== 自定义 model 选项(Vue2 特有) ==========
// 有时候 value 属性已经有其他用途(如 checkbox)
export default {
  model: {
    prop: 'checked',     // 自定义 prop 名
    event: 'change'      // 自定义事件名
  },
  props: { checked: Boolean },
  methods: {
    toggle() {
      this.$emit('change', !this.checked)
    }
  }
}
// 使用:<MyCheckbox v-model="isAgree" />
// 等价于:<MyCheckbox :checked="isAgree" @change="isAgree = $event" />

// ========== 修饰符 ==========
// <input v-model.lazy="msg" />   → 使用 change 事件而非 input
// <input v-model.number="age" /> → 自动转为数字
// <input v-model.trim="name" />  → 自动去除首尾空格

💡 面试加分点: Vue2 的 v-model 只能在组件上使用一个(一个组件只有一个 model 选项)。如果需要多个双向绑定,要用 .sync 修饰符。Vue3 完全改变了这一设计------支持多个 v-modelv-model:namev-model:age


12. Vue2 的过滤器(filter)

过滤器是 Vue2 特有的功能,Vue3 已移除。 用于文本格式化。

javascript 复制代码
// ========== 全局过滤器 ==========
Vue.filter('currency', (value, symbol = '¥') => {
  if (!value && value !== 0) return ''
  return `${symbol}${Number(value).toFixed(2)}`
})

Vue.filter('dateFormat', (value, format = 'YYYY-MM-DD') => {
  if (!value) return ''
  const date = new Date(value)
  const map = {
    'YYYY': date.getFullYear(),
    'MM': String(date.getMonth() + 1).padStart(2, '0'),
    'DD': String(date.getDate()).padStart(2, '0'),
    'HH': String(date.getHours()).padStart(2, '0'),
    'mm': String(date.getMinutes()).padStart(2, '0'),
    'ss': String(date.getSeconds()).padStart(2, '0')
  }
  return Object.entries(map).reduce((str, [key, val]) => str.replace(key, val), format)
})

Vue.filter('truncate', (value, length = 20) => {
  if (!value) return ''
  return value.length > length ? value.slice(0, length) + '...' : value
})

// ========== 局部过滤器 ==========
export default {
  filters: {
    uppercase(value) {
      return value.toUpperCase()
    }
  }
}
html 复制代码
<!-- 使用过滤器(管道符) -->
<p>价格:{{ price | currency }}</p>
<p>价格:{{ price | currency('$') }}</p>
<p>日期:{{ createTime | dateFormat('YYYY/MM/DD') }}</p>
<p>简介:{{ description | truncate(50) }}</p>

<!-- 过滤器可以串联 -->
<p>{{ message | uppercase | truncate(20) }}</p>

<!-- v-bind 中也能用 -->
<div :title="title | truncate(30)">...</div>
javascript 复制代码
// ✅ Vue3 的替代方案:使用 computed 或全局方法
// 方案1:computed
const formattedPrice = computed(() => `¥${price.value.toFixed(2)}`)

// 方案2:全局方法
app.config.globalProperties.$filters = {
  currency: (value, symbol = '¥') => `${symbol}${Number(value).toFixed(2)}`
}
// 模板中:{{ $filters.currency(price) }}

💡 面试加分点: Vue3 移除过滤器的原因是------过滤器的语法 {{ value | filter }} 与 JavaScript 的位运算符冲突,且其功能完全可以用 computed 或全局方法替代。面试时提到这个设计决策的理由会加分。


13. Vue2 的自定义指令

javascript 复制代码
// ========== 全局注册 ==========
Vue.directive('focus', {
  inserted(el) {
    el.focus()  // inserted 相当于 Vue3 的 mounted
  }
})

// ========== 指令的完整钩子 ==========
Vue.directive('permission', {
  bind(el, binding, vnode) {
    // 指令第一次绑定到元素时(类似 beforeMount)
  },
  inserted(el, binding, vnode) {
    // 元素插入父节点时(类似 mounted)
    const { value } = binding
    const roles = store.getters['user/roles']
    if (!roles.includes(value)) {
      el.parentNode?.removeChild(el)
    }
  },
  update(el, binding, vnode, oldVnode) {
    // VNode 更新时(类似 updated)
  },
  componentUpdated(el, binding) {
    // 组件 VNode 及其子 VNode 全部更新后
  },
  unbind(el) {
    // 指令与元素解绑时(类似 unmounted),清理
  }
})

// ========== 局部注册 ==========
export default {
  directives: {
    focus: { inserted(el) { el.focus() } },
    // 简写:只需要 bind 和 update 时
    color(el, binding) {
      el.style.color = binding.value
    }
  }
}

// ========== 实用指令示例 ==========
// 防抖点击
Vue.directive('debounce-click', {
  inserted(el, binding) {
    let timer = null
    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => {
        binding.value()
      }, binding.arg || 300)  // v-debounce-click:500="handleClick"
    })
  }
})

// 图片懒加载
Vue.directive('lazy', {
  inserted(el, binding) {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        el.src = binding.value
        observer.unobserve(el)
      }
    })
    observer.observe(el)
  }
})

// 点击外部关闭
Vue.directive('click-outside', {
  bind(el, binding) {
    el._clickOutside = (event) => {
      if (!el.contains(event.target)) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el._clickOutside)
  },
  unbind(el) {
    document.removeEventListener('click', el._clickOutside)
  }
})
html 复制代码
<input v-focus />
<button v-permission="'admin'">删除</button>
<button v-debounce-click:500="handleSubmit">提交</button>
<img v-lazy="imageUrl" />
<div v-click-outside="closeMenu">菜单</div>

💡 面试加分点: Vue2 和 Vue3 的指令钩子名称不同------Vue2 用 bind/inserted/update/unbind,Vue3 统一为与生命周期一致的 created/mounted/updated/unmounted。面试时自定义指令是实战能力的体现,v-permission(权限控制)和 v-click-outside(点击外部关闭)是最常见的业务指令。


14. Vue2 的 keep-alive 和动态组件

html 复制代码
<!-- keep-alive:缓存不活动的组件实例 -->
<keep-alive include="Home,List" exclude="Detail" :max="10">
  <router-view />
</keep-alive>

<!-- 动态组件 -->
<keep-alive>
  <component :is="currentTabComponent" />
</keep-alive>
javascript 复制代码
export default {
  name: 'Home',  // ⚠️ 必须有 name,keep-alive 通过 name 匹配
  data() { return { scrollTop: 0 } },
  // keep-alive 特有的生命周期
  activated() {
    // 组件从缓存中被激活
    this.fetchLatestData()
    window.scrollTo(0, this.scrollTop)  // 恢复滚动位置
  },
  deactivated() {
    // 组件被缓存
    this.scrollTop = document.documentElement.scrollTop  // 保存滚动位置
  }
}

// include/exclude 支持多种格式:
// 字符串(逗号分隔): include="Home,List"
// 正则: :include="/^(Home|List)$/"
// 数组: :include="['Home', 'List']"

// max 使用 LRU 策略:超出限制时销毁最久没被使用的组件

💡 面试加分点: keep-alive 缓存的组件不再走 mounted/destroyed,而是走 activated/deactivated。组件必须有 name 选项才能被 include/exclude 匹配。max 使用 LRU(最近最少使用)算法管理缓存。


15. Vue2 的性能优化

优化手段 说明 影响
路由懒加载 () => import('./views/xxx.vue') 减少首屏体积
组件懒加载 Vue.component('async', () => import('./Comp.vue')) 按需加载
v-show vs v-if 频繁切换用 v-show,不常显示用 v-if 减少渲染开销
v-for 加 key 使用唯一 id,不要用 index 优化 diff 算法
Object.freeze() 冻结大量只读数据 跳过响应式处理
computed 缓存 用 computed 替代复杂的模板表达式 避免重复计算
keep-alive 缓存高频切换组件 避免重复创建销毁
函数式组件 无状态的展示组件 减少实例创建开销
v-once 只渲染一次的内容 跳过后续 diff
第三方库按需引入 babel-plugin-component 减小打包体积
javascript 复制代码
// ========== Object.freeze 优化大列表 ==========
export default {
  data() {
    return { list: [] }
  },
  async created() {
    const data = await api.getList()
    this.list = Object.freeze(data)  // ✅ 冻结后不会添加响应式(大量只读数据时性能提升明显)
  }
}

// ========== 函数式组件(Vue2 写法) ==========
// 无状态、无实例、无生命周期,渲染更快
Vue.component('smart-item', {
  functional: true,
  props: { item: Object },
  render(h, context) {
    const { item } = context.props
    return h('div', item.name)
  }
})

// 或者 template 写法
// <template functional>
//   <div>{{ props.item.name }}</div>
// </template>

// ========== 事件销毁(避免内存泄漏) ==========
export default {
  mounted() {
    window.addEventListener('scroll', this.handleScroll)
    this.timer = setInterval(this.poll, 5000)
    this.eventBus.$on('refresh', this.handleRefresh)
  },
  beforeDestroy() {
    // ⚠️ 必须全部清理!
    window.removeEventListener('scroll', this.handleScroll)
    clearInterval(this.timer)
    this.eventBus.$off('refresh', this.handleRefresh)
  }
}

// ========== 图片懒加载 ==========
// npm install vue-lazyload
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
  preLoad: 1.3,
  loading: '/img/loading.gif',
  error: '/img/error.gif'
})
// <img v-lazy="imageUrl" />

💡 面试加分点: Object.freeze() 是 Vue2 中优化大列表的经典手段------Vue2 对 Object.freeze() 的对象不会添加响应式(源码中有判断 Object.isFrozen()),适合展示型的大量数据。函数式组件在 Vue3 中已经不需要特殊声明(普通组件已经足够快)。


16. Vue2 的 v-for 和 v-if 优先级问题

在 Vue2 中,v-for 的优先级高于 v-if 这意味着 v-if 会在每次循环中重复执行,造成性能浪费。

html 复制代码
<!-- ❌ Vue2 中不推荐:v-for 和 v-if 同时使用 -->
<li v-for="item in list" :key="item.id" v-if="item.active">
  {{ item.name }}
</li>
<!-- 即使只有 1 个 active,也会遍历整个 list -->

<!-- ✅ 方案1:使用 computed 过滤(推荐) -->
<li v-for="item in activeList" :key="item.id">
  {{ item.name }}
</li>
<script>
export default {
  computed: {
    activeList() {
      return this.list.filter(item => item.active)
    }
  }
}
</script>

<!-- ✅ 方案2:用 template 包裹 v-if -->
<template v-for="item in list">
  <li v-if="item.active" :key="item.id">
    {{ item.name }}
  </li>
</template>

<!-- ✅ 方案3:隐藏整个列表(在外层判断) -->
<ul v-if="shouldShow">
  <li v-for="item in list" :key="item.id">{{ item.name }}</li>
</ul>

💡 面试加分点: Vue2 中 v-for 优先级高于 v-if,Vue3 中相反(v-if 优先级高于 v-for)。 这是 Vue2 → Vue3 的一个破坏性变更,迁移时需要注意。不管哪个版本,最佳实践都是避免二者同时使用。


17. Vue2 的 slot 插槽(含作用域插槽)

html 复制代码
<!-- ========== 默认插槽 ========== -->
<!-- MyCard.vue -->
<template>
  <div class="card">
    <div class="card-body">
      <slot>默认内容</slot>
    </div>
  </div>
</template>

<!-- 使用 -->
<MyCard>
  <p>自定义内容</p>
</MyCard>

<!-- ========== 具名插槽 ========== -->
<!-- MyCard.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">默认标题</slot>
    </div>
    <div class="card-body">
      <slot></slot>
    </div>
    <div class="card-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<!-- Vue2.6+ 使用 v-slot(简写 #) -->
<MyCard>
  <template v-slot:header>
    <h2>自定义标题</h2>
  </template>
  <template #footer>
    <button>确定</button>
  </template>
  <p>默认插槽内容</p>
</MyCard>

<!-- 旧语法(Vue2.6 之前,已废弃) -->
<MyCard>
  <h2 slot="header">标题</h2>
  <button slot="footer">确定</button>
</MyCard>

<!-- ========== 作用域插槽(子组件向父组件传数据) ========== -->
<!-- UserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot name="item" :user="user" :index="index">
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>

<!-- 新语法(Vue2.6+) -->
<UserList :users="users">
  <template #item="{ user, index }">
    <span>{{ index + 1 }}. {{ user.name }} - {{ user.age }}岁</span>
  </template>
</UserList>

<!-- 旧语法(Vue2.6 之前,slot-scope) -->
<UserList :users="users">
  <template slot="item" slot-scope="{ user, index }">
    <span>{{ index + 1 }}. {{ user.name }} - {{ user.age }}岁</span>
  </template>
</UserList>

💡 面试加分点: Vue2 的插槽语法经历了三个阶段:slot/slot-scope(2.6 前)→ v-slot(2.6+)→ # 简写。作用域插槽是组件库的核心设计模式------Element UI 的 el-table-column 的自定义模板就是作用域插槽。


18. Vue2 的 set和set 和 set和delete 原理

javascript 复制代码
// ========== $set 的原理 ==========
// Vue.set(target, propertyName, value) / this.$set(target, propertyName, value)
// 源码简化:
function set(target, key, val) {
  // 1. 数组处理:调用重写的 splice
  if (Array.isArray(target)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)  // splice 被 Vue 重写,会触发更新
    return val
  }
  // 2. 已存在的属性:直接赋值(已经是响应式的)
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  // 3. 新属性:用 defineReactive 变成响应式 + 通知更新
  const ob = target.__ob__  // Observer 实例
  defineReactive(target, key, val)
  ob.dep.notify()  // 手动触发更新
  return val
}

// ========== 使用示例 ==========
export default {
  data() {
    return {
      user: { name: '张三' },
      items: ['a', 'b', 'c']
    }
  },
  methods: {
    addAge() {
      // ❌ 不触发更新
      this.user.age = 25
      // ✅ 触发更新
      this.$set(this.user, 'age', 25)
    },
    updateItem() {
      // ❌ 不触发更新
      this.items[0] = 'new'
      // ✅ 触发更新
      this.$set(this.items, 0, 'new')
      // 或者
      this.items.splice(0, 1, 'new')
    },
    deleteKey() {
      // ❌ 不触发更新
      delete this.user.name
      // ✅ 触发更新
      this.$delete(this.user, 'name')
    }
  }
}

💡 面试加分点: $set 的原理是面试高频考点------对数组用 splice,对对象用 defineReactive + dep.notify()。Vue3 使用 Proxy 后完全不需要 $set,因为 Proxy 能拦截属性的添加和删除。


19. Vue2 的异步组件和错误处理

javascript 复制代码
// ========== 异步组件 ==========
// 基本用法
Vue.component('async-component', (resolve, reject) => {
  import('./MyComponent.vue').then(resolve)
})

// 工厂函数(推荐,带加载状态和错误处理)
const AsyncComponent = () => ({
  component: import('./HeavyComponent.vue'),
  loading: LoadingComponent,     // 加载中显示的组件
  error: ErrorComponent,         // 加载失败显示的组件
  delay: 200,                    // 延迟显示 loading(毫秒)
  timeout: 10000                 // 超时时间
})

// 路由懒加载(最常用的异步组件场景)
const routes = [
  {
    path: '/dashboard',
    component: () => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue')
  }
]

// ========== 错误处理 ==========
// 1. 全局错误处理
Vue.config.errorHandler = (err, vm, info) => {
  console.error('全局错误:', err)
  console.error('组件:', vm)
  console.error('信息:', info)  // 如 "render" "watcher" "lifecycle hook"
  // 上报到监控平台
}

// 2. 全局警告处理(开发环境)
Vue.config.warnHandler = (msg, vm, trace) => {
  console.warn('Vue 警告:', msg)
}

// 3. 组件级错误捕获
export default {
  errorCaptured(err, childComponent, info) {
    console.error('子组件错误:', err)
    this.hasError = true
    return false  // 返回 false 阻止错误继续向上传播
  }
}

💡 面试加分点: Vue2 的异步组件工厂函数可以配置 loadingerrordelaytimeout 四个选项,这是实现"骨架屏"和"超时降级"的利器。Vue3 用 defineAsyncComponent() + <Suspense> 取代了这一模式。


20. Vue2 → Vue3 迁移要点

Vue2 特性 Vue3 变化 迁移方案
Options API Composition API(兼容 Options) 逐步重构,优先重构复杂组件
Vuex Pinia(官方推荐) 新项目用 Pinia,旧项目 Vuex 4 兼容
filters 移除 computed 或全局方法替代
on/on/ on/off/$once 移除(EventBus 不可用) mitttiny-emitter 替代
$children 移除 $refsprovide/inject 替代
v-model 改为 modelValue + update:modelValue 重写组件的 v-model
.sync 移除,用 v-model:xxx 替代 替换为 v-model:propName
v-for/v-if 优先级 v-if 优先级高于 v-for 检查同时使用的地方
生命周期 beforeDestroybeforeUnmount 重命名
全局 API Vue.xxxapp.xxx Vue.use()app.use()
key 在 template <template v-for> 的 key 放在 template 上 调整 key 位置
函数式组件 不再需要 functional: true 删除 functional 选项
javascript 复制代码
// ========== 全局 API 迁移 ==========
// Vue2
import Vue from 'vue'
Vue.component('my-comp', { ... })
Vue.directive('focus', { ... })
Vue.mixin({ ... })
Vue.use(VueRouter)
Vue.prototype.$http = axios

// Vue3
import { createApp } from 'vue'
const app = createApp(App)
app.component('my-comp', { ... })
app.directive('focus', { ... })
app.mixin({ ... })  // 不推荐,用 composable 替代
app.use(router)
app.config.globalProperties.$http = axios

// ========== 推荐的迁移工具 ==========
// 1. @vue/compat(兼容层,Vue3 但兼容大部分 Vue2 API)
// 2. gogocode-plugin-vue(自动化代码转换)
// 3. eslint-plugin-vue(检查不兼容的用法)

💡 面试加分点: 实际迁移建议采用"渐进式"策略------先升级到 Vue 2.7(backport 了 <script setup>defineComponent 等),再使用 @vue/compat 兼容层逐步迁移到 Vue3。新功能用 Composition API 写,旧代码保持 Options API 不变。


21. 谈谈你对 Vue 的理解?

Vue 是一套用于构建用户界面的渐进式 JavaScript 框架 ,核心思想是数据驱动视图

核心特性

特性 说明
渐进式 可以只用核心库(视图层),也可以按需引入路由、状态管理、构建工具,逐步增强
数据驱动 采用 MVVM 模式,通过数据绑定自动更新 DOM,开发者无需手动操作 DOM
组件化 将 UI 拆分成独立可复用的组件,每个组件包含自己的模板、逻辑和样式
指令系统 内置 v-ifv-forv-modelv-bindv-on 等指令,声明式地操作 DOM
虚拟 DOM 通过 JS 对象描述 DOM 结构,diff 后最小化更新真实 DOM,提升性能

Vue 的优缺点

dart 复制代码
✅ 优点:
  ┌─ 轻量级框架,学习曲线平缓,中文文档完善
  ├─ 双向数据绑定,减少 DOM 操作
  ├─ 组件化开发,代码可复用性和可维护性强
  ├─ 丰富的生态(Vue Router、Vuex/Pinia、Vue CLI/Vite)
  ├─ 单页面应用 SPA,页面切换流畅
  └─ 第三方 UI 库丰富(Element UI、Ant Design Vue、Vant 等)

❌ 缺点:
  ┌─ 不利于 SEO(SPA 的通病,可通过 SSR/预渲染解决)
  ├─ 首屏加载速度较慢(需要下载整个应用的 JS)
  ├─ Vue2 响应式有局限(Object.defineProperty 的限制)
  └─ 不适合超大型项目(Vue3 + TypeScript 已大幅改善)
javascript 复制代码
// ========== 渐进式的体现 ==========

// 1. 最简单的方式:直接引入 CDN,操作 DOM
// <script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
new Vue({
  el: '#app',
  data: { message: 'Hello Vue!' }
})

// 2. 加入组件化
import MyComponent from './MyComponent.vue'
new Vue({
  components: { MyComponent },
  template: '<MyComponent />'
})

// 3. 加入路由
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({ routes: [...] })

// 4. 加入状态管理
import Vuex from 'vuex'
Vue.use(Vuex)
const store = new Vuex.Store({ state: {...} })

// 5. 加入构建工具(Vue CLI / Vite)
// vue create my-project
// npm create vue@latest

// 每一步都是可选的,按需引入 ------ 这就是"渐进式"

Vue vs jQuery

对比项 Vue jQuery
定位 渐进式框架 DOM 操作工具库
核心思想 数据驱动视图(MVVM) 直接操作 DOM
开发方式 操作数据 → 自动更新视图 选择器获取 DOM → 手动操作
数据绑定 ✅ 双向数据绑定 ❌ 手动同步
组件化 ✅ 原生支持 ❌ 需要手动封装
适用场景 中大型 SPA 应用 简单页面交互、快速原型
javascript 复制代码
// ========== Vue vs jQuery 实现同一功能 ==========

// jQuery:手动操作 DOM
$('#btn').click(function() {
  const name = $('#input').val()
  $('#display').text('Hello, ' + name)
})

// Vue:操作数据,视图自动更新
new Vue({
  el: '#app',
  data: { name: '' },
  // 模板中:<p>Hello, {{ name }}</p>
  // <input v-model="name" />
  // 只需要修改 name,视图自动同步
})

💡 面试加分点: "渐进式"是 Vue 区别于 Angular(全家桶式框架)的核心理念。面试时可以提到 Vue 的设计哲学------"核心只做视图层,其他功能通过独立的库扩展"。相比 React 需要自己选择路由和状态管理方案,Vue 的官方全家桶更加统一。


22. MVVM 和 MVC 的区别是什么?

对比项 MVC MVVM
全称 Model-View-Controller Model-View-ViewModel
通信方式 单向通信 双向数据绑定
核心角色 Controller(控制器) ViewModel(视图模型)
数据流 View → Controller → Model → View View ↔ ViewModel ↔ Model
DOM 操作 Controller 中需要手动操作 DOM ViewModel 自动同步,极少操作 DOM
代表 Spring MVC、Express Vue、Angular、WPF
scss 复制代码
MVC 架构:
┌─────────┐    用户操作    ┌──────────────┐    更新数据    ┌─────────┐
│  View   │ ───────────→ │  Controller  │ ───────────→ │  Model  │
│ (视图)   │              │  (控制器)     │              │ (数据)   │
└─────────┘ ←─────────── └──────────────┘ ←─────────── └─────────┘
              更新视图                         通知变化
              (手动操作 DOM)

MVVM 架构:
┌─────────┐    双向绑定    ┌──────────────┐    数据交互    ┌─────────┐
│  View   │ ←──────────→ │  ViewModel   │ ←──────────→ │  Model  │
│ (视图)   │   自动同步    │  (视图模型)    │              │ (数据)   │
└─────────┘              └──────────────┘              └─────────┘
                          Vue 就是 ViewModel
                          负责 DOM 监听 + 数据绑定
javascript 复制代码
// ========== MVC 模式示例(Express) ==========
// Controller 需要手动处理数据和视图的关联
app.get('/users', (req, res) => {
  // Controller: 接收请求
  const users = UserModel.findAll()  // 从 Model 获取数据
  res.render('users', { users })     // 手动将数据传给 View 渲染
})

// ========== MVVM 模式示例(Vue) ==========
// ViewModel(Vue 实例)自动处理 View 和 Model 的同步
new Vue({
  el: '#app',
  data() {
    return {
      users: []  // Model(数据层)
    }
  },
  async created() {
    // 数据变化 → View 自动更新(数据绑定)
    this.users = await fetchUsers()
  }
  // View(模板)中 v-for="user in users" 自动渲染
  // 用户操作(如输入)→ 通过 v-model 自动更新 data(DOM 监听)
})

💡 面试加分点: Vue 并不是严格的 MVVM------严格的 MVVM 要求 View 和 Model 不能直接通信,但 Vue 提供了 $refs 让你可以直接访问 DOM。尤雨溪本人也说过 Vue 只是"受 MVVM 启发",并非完全遵循。面试时提到这一点说明你理解足够深入。


23. Vue 常用修饰符有哪些?

修饰符是 Vue 中以 . 开头的特殊后缀,用于指示指令应以特殊方式绑定。

事件修饰符

修饰符 作用 等价代码
.stop 阻止事件冒泡 event.stopPropagation()
.prevent 阻止默认行为 event.preventDefault()
.capture 使用事件捕获模式 addEventListener(..., true)
.self 只在事件源是自身时触发 if (event.target === event.currentTarget)
.once 事件只触发一次 addEventListener(..., { once: true })
.passive 不会调用 preventDefault 提升移动端滚动性能
.native 监听组件根元素的原生事件 Vue2 特有,Vue3 已移除
html 复制代码
<!-- ========== 事件修饰符示例 ========== -->

<!-- .stop:阻止冒泡 -->
<div @click="parentClick">
  <button @click.stop="childClick">点击不冒泡</button>
</div>

<!-- .prevent:阻止默认行为 -->
<form @submit.prevent="handleSubmit">
  <button type="submit">提交</button>
</form>

<!-- .self:只有点击自身(不包括子元素)才触发 -->
<div @click.self="handleClick" class="overlay">
  <div class="modal">点击 modal 不触发 overlay 的 click</div>
</div>

<!-- .once:只触发一次(常用于支付按钮防重复) -->
<button @click.once="handlePay">立即支付</button>

<!-- .native:监听组件根元素的原生事件(Vue2 特有) -->
<ElButton @click.native="handleClick">按钮</ElButton>

<!-- 修饰符可以链式使用 -->
<a @click.stop.prevent="handleClick" href="#">阻止冒泡 + 阻止默认</a>

按键修饰符

html 复制代码
<!-- 常用按键修饰符 -->
<input @keyup.enter="submit" />       <!-- Enter 键 -->
<input @keyup.esc="cancel" />         <!-- Esc 键 -->
<input @keyup.tab="nextField" />      <!-- Tab 键 -->
<input @keyup.delete="remove" />      <!-- Delete / Backspace 键 -->
<input @keyup.space="toggle" />       <!-- 空格键 -->

<!-- 组合键 -->
<input @keyup.ctrl.enter="submitForm" />  <!-- Ctrl + Enter -->
<div @click.ctrl="selectMultiple" />      <!-- Ctrl + 鼠标点击 -->

<!-- 自定义按键码(Vue2 特有) -->
<script>
Vue.config.keyCodes.f1 = 112  // 自定义 f1 键
</script>
<input @keyup.f1="showHelp" />

v-model 修饰符

html 复制代码
<!-- .lazy:change 事件替代 input 事件(失去焦点时才同步) -->
<input v-model.lazy="message" />

<!-- .number:自动转为数字类型 -->
<input v-model.number="age" type="number" />

<!-- .trim:自动去除首尾空格 -->
<input v-model.trim="username" />

.sync 修饰符(Vue2 特有的双向绑定语法糖)

vue 复制代码
<!-- 父组件 -->
<template>
  <div>
    <p>弹窗状态: {{ dialogVisible }}</p>
    <p>当前值: {{ value }}</p>
    <!-- .sync 是双向绑定的语法糖 -->
    <MyDialog :visible.sync="dialogVisible" :title.sync="dialogTitle" />
    <!-- 等价于 -->
    <!-- <MyDialog
      :visible="dialogVisible"
      @update:visible="val => dialogVisible = val"
      :title="dialogTitle"
      @update:title="val => dialogTitle = val"
    /> -->
  </div>
</template>

<script>
export default {
  data() {
    return {
      dialogVisible: false,
      dialogTitle: '默认标题'
    }
  }
}
</script>
vue 复制代码
<!-- 子组件 MyDialog.vue -->
<template>
  <div v-if="visible" class="dialog">
    <h3>{{ title }}</h3>
    <input :value="title" @input="$emit('update:title', $event.target.value)" />
    <button @click="close">关闭</button>
  </div>
</template>

<script>
export default {
  props: {
    visible: Boolean,
    title: String
  },
  methods: {
    close() {
      // 固定格式:update:属性名
      this.$emit('update:visible', false)
    }
  }
}
</script>

💡 面试加分点: .sync 修饰符是 Vue2 特有的(Vue3 中被 v-model:xxx 取代),它和 v-model 的区别在于:v-model 一个组件只能用一次,.sync 可以同时绑定多个属性。.native 修饰符在 Vue3 中也被移除了------Vue3 中组件默认可以监听原生事件,无需特殊声明。


24. v-show 和 v-if 有什么区别?使用场景是什么?

对比项 v-show v-if
实现方式 切换 display: none CSS 属性 动态创建/销毁 DOM 元素
初始渲染 无论条件真假都会渲染 条件为 false 时不渲染
切换开销 小(只改 CSS) 大(创建/销毁组件实例和 DOM)
初始开销 大(总是渲染) 小(条件为 false 不渲染)
是否触发生命周期 ❌ 不触发 ✅ 创建时触发 created/mounted,销毁时触发 destroyed
配合 v-else ❌ 不支持 ✅ 支持 v-else-ifv-else
适用场景 频繁切换 条件很少改变
html 复制代码
<!-- ========== v-show vs v-if 行为差异 ========== -->

<!-- v-show:DOM 始终存在,只是 display 切换 -->
<div v-show="isVisible" class="panel">
  面板内容(DOM 元素始终存在,隐藏时 style="display: none")
</div>
<!-- 渲染结果(隐藏时): <div style="display: none" class="panel">...</div> -->

<!-- v-if:DOM 根据条件创建/销毁 -->
<div v-if="isVisible" class="panel">
  面板内容(条件为 false 时 DOM 不存在)
</div>
<!-- 渲染结果(隐藏时): <!-- -->(注释节点占位)-->
vue 复制代码
<template>
  <div>
    <!-- ✅ v-show 适合:频繁切换的场景 -->
    <!-- 比如 Tab 切换,内容组件频繁显隐 -->
    <div class="tabs">
      <button @click="activeTab = 'basic'">基本信息</button>
      <button @click="activeTab = 'detail'">详细信息</button>
      <button @click="activeTab = 'log'">操作日志</button>
    </div>
    <div v-show="activeTab === 'basic'">基本信息内容...</div>
    <div v-show="activeTab === 'detail'">详细信息内容...</div>
    <div v-show="activeTab === 'log'">操作日志内容...</div>

    <!-- ✅ v-if 适合:条件很少改变的场景 -->
    <!-- 比如权限判断,登录后显示不同内容 -->
    <template v-if="isAdmin">
      <AdminPanel />  <!-- 只有管理员才创建这个组件 -->
    </template>
    <template v-else-if="isVip">
      <VipPanel />
    </template>
    <template v-else>
      <NormalPanel />
    </template>

    <!-- ✅ v-if 适合:初始条件为 false 且不常改变时(惰性渲染节省首屏开销) -->
    <HeavyChart v-if="showChart" />

    <!-- ❌ 不推荐:在 v-for 中使用 v-if -->
    <!-- <li v-for="item in list" v-if="item.active">❌</li> -->
  </div>
</template>

<script>
export default {
  data() {
    return {
      activeTab: 'basic',
      isAdmin: false,
      showChart: false
    }
  }
}
</script>

生命周期的差异演示:

javascript 复制代码
// 子组件 ToggleComp.vue
export default {
  name: 'ToggleComp',
  created()  { console.log('created') },
  mounted()  { console.log('mounted') },
  destroyed(){ console.log('destroyed') }
}

// 父组件中:
// <ToggleComp v-show="flag" />
// flag 从 true → false:不触发任何生命周期(只是 display:none)
// flag 从 false → true:不触发任何生命周期(只是 display 恢复)

// <ToggleComp v-if="flag" />
// flag 从 true → false:触发 beforeDestroy → destroyed
// flag 从 false → true:触发 beforeCreate → created → beforeMount → mounted

💡 面试加分点: 面试时的选择口诀是------"频繁切换用 v-show,条件稳定用 v-if" 。另外,v-show 不支持 <template> 元素(因为 <template> 不会渲染到 DOM 中,无法设置 display),也不支持 v-else。如果一个组件初始化开销很大(比如图表组件),用 v-if 配合惰性渲染可以加速首屏。


25. 为什么 Vue 组件中的 data 必须是一个函数?

这是 Vue2 的经典面试题,考察你对组件实例化和引用类型的理解。

核心原因: 每个组件实例需要维护独立的数据副本 。如果 data 是一个对象,所有实例会共享同一个引用,导致数据互相污染。

javascript 复制代码
// ========== ❌ data 写成对象(错误示范) ==========
// 假设 Vue 允许 data 是对象:
const ComponentA = {
  data: {
    count: 0  // 所有实例共享同一个对象引用
  }
}

// 创建两个实例
const vm1 = new Vue(ComponentA)
const vm2 = new Vue(ComponentA)

vm1.count = 10
console.log(vm2.count)  // 10 ❌ vm2 也被影响了!因为它们指向同一个对象


// ========== ✅ data 写成函数(正确做法) ==========
const ComponentB = {
  data() {
    return {
      count: 0  // 每次调用函数,返回一个全新的对象
    }
  }
}

// 创建两个实例
const vm3 = new Vue(ComponentB)  // data() 返回 { count: 0 }
const vm4 = new Vue(ComponentB)  // data() 返回另一个 { count: 0 }

vm3.count = 10
console.log(vm4.count)  // 0 ✅ vm4 不受影响


// ========== 用原生 JS 理解原理 ==========
// 对象是引用类型
const obj = { count: 0 }
const a = obj
const b = obj
a.count = 5
console.log(b.count)  // 5 ← 共享引用

// 函数每次返回新对象
function createData() {
  return { count: 0 }
}
const c = createData()
const d = createData()
c.count = 5
console.log(d.count)  // 0 ← 各自独立
vue 复制代码
<!-- ========== 实际场景:复用组件 ========== -->
<template>
  <div>
    <!-- 同一个组件用了三次,各自计数互不影响 -->
    <Counter />  <!-- count: 0 -->
    <Counter />  <!-- count: 0 -->
    <Counter />  <!-- count: 0 -->
  </div>
</template>

<!-- Counter.vue -->
<template>
  <button @click="count++">点击了 {{ count }} 次</button>
</template>

<script>
export default {
  // ✅ data 是函数,每个 Counter 实例有独立的 count
  data() {
    return { count: 0 }
  }
}
</script>

注意: Vue 的根实例(new Vue({}))可以直接写成对象,因为根实例只有一个,不存在复用和共享的问题。但组件(Vue.component)必须用函数。

💡 面试加分点: 从源码角度解释------Vue 在实例化组件时会调用 data() 函数获取返回值,然后用 observe() 将其变为响应式。如果 data 是对象,多个实例的 observe 会作用在同一个对象上,setter 触发时所有实例都会更新。Vue 内部会检测 data 的类型,如果组件的 data 不是函数会直接报警告。


26. 怎么理解 Vue 的单向数据流?

单向数据流是 Vue 组件通信的核心原则 :数据从父组件流向子组件(通过 props),子组件不能直接修改父组件传来的数据。

bash 复制代码
单向数据流:
  父组件 ──── props ────→ 子组件
  父组件 ←── $emit 事件 ── 子组件

  数据向下流动,事件向上传递
规则 说明
props 向下 父级 prop 更新会单向流动到子组件
子组件不能直接修改 props 直接修改会报警告
修改的正确方式 通过 $emit 派发事件,由父组件决定是否修改
意义 保证数据流可追踪、可预测,方便调试
vue 复制代码
<!-- ========== ❌ 错误:子组件直接修改 props ========== -->
<!-- Child.vue -->
<script>
export default {
  props: ['title'],
  methods: {
    changeTitle() {
      this.title = '新标题'  // ❌ 报警告!Avoid mutating a prop directly
    }
  }
}
</script>


<!-- ========== ✅ 正确做法1:$emit 通知父组件修改 ========== -->
<!-- Parent.vue -->
<template>
  <Child :title="title" @update-title="title = $event" />
</template>

<!-- Child.vue -->
<template>
  <div>
    <h2>{{ title }}</h2>
    <button @click="$emit('update-title', '新标题')">修改标题</button>
  </div>
</template>

<script>
export default {
  props: ['title']
  // 不直接修改 props,而是 $emit 事件让父组件修改
}
</script>


<!-- ========== ✅ 正确做法2:用本地 data 接收 props ========== -->
<!-- 适合只需要初始值,后续独立变化的场景 -->
<script>
export default {
  props: ['initialTitle'],
  data() {
    return {
      localTitle: this.initialTitle  // 用 props 初始化本地数据
    }
  },
  methods: {
    changeTitle() {
      this.localTitle = '新标题'  // ✅ 修改的是本地数据,不影响 props
    }
  }
}
</script>


<!-- ========== ✅ 正确做法3:用 computed 转换 props ========== -->
<!-- 适合需要对 props 做转换处理的场景 -->
<script>
export default {
  props: ['size'],
  computed: {
    normalizedSize() {
      return this.size.trim().toLowerCase()  // ✅ computed 不修改原 props
    }
  }
}
</script>

为什么需要单向数据流?

javascript 复制代码
// 如果允许子组件直接修改父组件的数据:
// 1. 数据来源不可追踪 ------ 不知道是谁改了数据
// 2. 多个子组件同时修改 ------ 数据竞态,难以调试
// 3. 组件耦合度高 ------ 子组件依赖父组件的数据结构

// 单向数据流的好处:
// 1. 数据流向清晰 ------ 数据只能从上往下流
// 2. 修改有迹可循 ------ 通过 $emit 事件可以追踪谁在什么时候修改了数据
// 3. 组件更独立 ------ 子组件只关心 props 和 emit,不关心父组件内部

💡 面试加分点: 虽然 Vue 是单向数据流,但提供了 .sync 修饰符和 v-model 来实现"伪双向绑定"------本质上还是子组件 $emit + 父组件接收更新,只是语法糖简化了写法。另外,如果 props 传的是引用类型 (对象/数组),子组件修改其内部属性虽然不会报错,但这是反模式,应该避免。


27. scoped 属性的原理?如何进行样式穿透?

scoped 原理

<style scoped> 可以让样式只作用于当前组件,不影响其他组件。

实现机制:

  1. 给当前组件的所有 DOM 元素添加一个唯一属性 data-v-xxxxxxxx(8 位哈希值)
  2. 通过 PostCSS 在 CSS 选择器末尾 追加属性选择器 [data-v-xxxxxxxx]
html 复制代码
<!-- 编译前 -->
<template>
  <div class="container">
    <p class="title">标题</p>
  </div>
</template>
<style scoped>
.container { padding: 20px; }
.title { color: red; }
</style>

<!-- 编译后 -->
<div class="container" data-v-7ba5bd90>
  <p class="title" data-v-7ba5bd90>标题</p>
</div>
<style>
.container[data-v-7ba5bd90] { padding: 20px; }
.title[data-v-7ba5bd90] { color: red; }
</style>

样式穿透

当需要在父组件中修改子组件的内部样式时(如修改 Element UI 组件的样式),scoped 会阻止样式生效。此时需要"样式穿透"。

穿透原理: 将属性选择器从选择器末尾 移到穿透标记的前面,让穿透标记后面的选择器不带 scoped 属性限制。

语法 适用预处理器 说明
>>> 原生 CSS 深度选择器(部分预处理器不识别)
/deep/ Sass/Less 别名,等价于 >>>
::v-deep 所有(Vue2.7+/Vue3 推荐) 推荐写法
vue 复制代码
<template>
  <div class="wrapper">
    <el-input v-model="value" placeholder="请输入" />
  </div>
</template>

<style scoped>
/* ❌ 不生效:scoped 后 .el-input__inner 也要求有 data-v 属性 */
.wrapper .el-input__inner {
  border-color: red;
}
/* 编译后: .wrapper .el-input__inner[data-v-xxx] → el-input 内部元素没有 data-v-xxx */

/* ✅ 样式穿透:>>> (原生 CSS) */
.wrapper >>> .el-input__inner {
  border-color: red;
}
/* 编译后: .wrapper[data-v-xxx] .el-input__inner → 属性选择器在 >>> 前面 */

/* ✅ 样式穿透:/deep/ (Sass/Less) */
.wrapper /deep/ .el-input__inner {
  border-color: red;
}

/* ✅ 样式穿透:::v-deep (推荐,所有场景通用) */
.wrapper ::v-deep .el-input__inner {
  border-color: red;
}

/* Vue3 推荐写法 */
.wrapper :deep(.el-input__inner) {
  border-color: red;
}
</style>
vue 复制代码
<!-- ========== 实际场景:修改 Element UI 弹窗样式 ========== -->
<style scoped>
/* 修改 el-dialog 的内容区域 */
.my-dialog ::v-deep .el-dialog__body {
  padding: 10px 20px;
  max-height: 400px;
  overflow-y: auto;
}

/* 修改 el-table 的表头样式 */
.my-table ::v-deep .el-table__header th {
  background-color: #f5f7fa;
  color: #333;
}

/* 修改 el-form 的必填星号颜色 */
.my-form ::v-deep .el-form-item__label::before {
  color: #f56c6c;
}
</style>

💡 面试加分点: scoped 并不是真正的"CSS 隔离"------它只是通过属性选择器提高了选择器的特异性(specificity),全局样式如果特异性更高仍然会覆盖 scoped 样式。Vue3 还新增了 ::v-slotted()(穿透插槽内容)和 ::v-global()(在 scoped 中定义全局样式)两个伪选择器。


28. 什么是虚拟 DOM?diff 算法的原理?

虚拟 DOM(Virtual DOM)

虚拟 DOM 是用 JavaScript 对象 来描述真实 DOM 结构的一种抽象表示。

javascript 复制代码
// 真实 DOM
// <div id="app" class="container">
//   <h1>标题</h1>
//   <p>内容</p>
// </div>

// 虚拟 DOM(VNode 对象)
const vnode = {
  tag: 'div',
  data: { attrs: { id: 'app', class: 'container' } },
  children: [
    { tag: 'h1', children: [{ text: '标题' }] },
    { tag: 'p',  children: [{ text: '内容' }] }
  ]
}

为什么需要虚拟 DOM?

优势 说明
减少 DOM 操作 不直接操作 DOM,先在 JS 中 diff,最后一次性更新
批量更新 同一事件循环内多次数据修改,只触发一次 DOM 更新
跨平台 虚拟 DOM 是 JS 对象,可以渲染到 DOM、Canvas、Native 等
可预测 给定相同的状态,总是产生相同的 VNode 树

diff 算法原理

Vue2 使用的是 同层比较 + 双端指针 的 diff 算法(源码中的 updateChildren 方法)。

markdown 复制代码
核心策略:
1. 只比较同层节点(不跨层级比较)
2. 标签不同 → 直接替换(不再比较子节点)
3. 标签相同 → 更新属性,继续比较子节点
4. 子节点比较使用双端指针算法

双端比较(updateChildren):

css 复制代码
旧子节点:  [A]  [B]  [C]  [D]
             ↑                ↑
          oldStart          oldEnd

新子节点:  [D]  [B]  [A]  [C]
             ↑                ↑
          newStart          newEnd

比较顺序(4 种对比):
  ① 头头比:oldStart vs newStart
  ② 尾尾比:oldEnd vs newEnd
  ③ 头尾比:oldStart vs newEnd
  ④ 尾头比:oldEnd vs newStart

如果 ①②③④ 都不匹配:
  → 在旧子节点中查找 newStart 对应的 key
  → 找到就移动,找不到就创建新节点
javascript 复制代码
// ========== diff 过程示例 ==========
// 旧列表:A B C D
// 新列表:D B A C

// 第 1 轮:
// ① 头头(A vs D) ❌  ② 尾尾(D vs C) ❌  ③ 头尾(A vs C) ❌  ④ 尾头(D vs D) ✅
// → 将 D 移到最前面
// 旧:A B C [D已处理]  → 新:[D已处理] B A C

// 第 2 轮:
// ① 头头(A vs B) ❌  ② 尾尾(C vs C) ✅
// → C 位置不变
// 旧:A B [C已处理]  → 新:B A [C已处理]

// 第 3 轮:
// ① 头头(A vs B) ❌  ② 尾尾(B vs A) ❌  ③ 头尾(A vs A) ✅
// → 将 A 移到 B 后面
// 旧:[A已处理] B  → 新:B [A已处理]

// 第 4 轮:
// ① 头头(B vs B) ✅  → 位置不变
// 最终结果:D B A C ✅


// ========== key 的作用 ==========
// 有 key:通过 key 快速找到对应节点,移动 DOM 而非重新创建
// 无 key:采用"就地复用"策略,只更新内容,可能导致状态错乱

// 示例:列表删除中间项
// 旧:[1:A] [2:B] [3:C]
// 新:[1:A] [3:C]

// 有 key(id):直接删除 B 节点 ← 最优
// 无 key:B 变成 C,C 被删除 ← 多余操作

// ❌ 不要用 index 作为 key(删除/排序时索引会变,导致错误复用)
vue 复制代码
<!-- ========== key 导致的 bug 示例 ========== -->
<template>
  <div>
    <!-- ❌ 用 index 作为 key -->
    <div v-for="(item, index) in list" :key="index">
      <input type="text" />  <!-- 输入内容后删除第一项,input 内容会错位! -->
      <span>{{ item.name }}</span>
      <button @click="list.splice(index, 1)">删除</button>
    </div>

    <!-- ✅ 用唯一 id 作为 key -->
    <div v-for="item in list" :key="item.id">
      <input type="text" />  <!-- 删除任何项,input 内容都正确 -->
      <span>{{ item.name }}</span>
      <button @click="removeItem(item.id)">删除</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: 'Apple' },
        { id: 2, name: 'Banana' },
        { id: 3, name: 'Cherry' }
      ]
    }
  },
  methods: {
    removeItem(id) {
      this.list = this.list.filter(item => item.id !== id)
    }
  }
}
</script>

💡 面试加分点: Vue2 的 diff 算法是 O(n) 复杂度(同层比较),而不是传统树 diff 的 O(n³)。核心优化有三点:① 只做同层比较不跨级;② 双端指针减少遍历;③ 通过 key 建立映射表快速定位。Vue3 对 diff 做了进一步优化------通过静态标记(PatchFlags)跳过静态节点,以及最长递增子序列算法减少 DOM 移动次数。


29. Vue 的 template 编译过程?new Vue 做了什么?

template 编译过程

Vue 的模板编译经过 三个阶段parseoptimizegenerate

arduino 复制代码
template 字符串
    ↓  parse(解析)
AST 抽象语法树
    ↓  optimize(优化)
标记静态节点的 AST
    ↓  generate(生成)
render 函数字符串
    ↓  new Function()
render 函数
    ↓  执行
VNode 虚拟 DOM
    ↓  patch
真实 DOM
阶段 作用 说明
parse 将 template 解析成 AST 通过正则匹配标签、属性、指令、文本,构建树形结构
optimize 标记静态节点和静态根节点 diff 时跳过静态节点,减少不必要的比较
generate 将 AST 转换为 render 函数代码 生成 _c()_v()_s() 等渲染辅助函数调用
javascript 复制代码
// ========== 编译过程示例 ==========

// 原始 template
// <div id="app">
//   <p>{{ message }}</p>
//   <span>静态文本</span>
// </div>

// 1. parse 阶段 → 生成 AST
const ast = {
  tag: 'div',
  attrs: [{ name: 'id', value: 'app' }],
  children: [
    {
      tag: 'p',
      children: [{ type: 2, expression: '_s(message)', text: '{{ message }}' }]
    },
    {
      tag: 'span',
      static: true,  // optimize 标记为静态
      children: [{ type: 3, text: '静态文本' }]
    }
  ]
}

// 2. optimize 阶段 → 标记静态节点
// <span>静态文本</span> 被标记为 static: true
// 后续 diff 会直接跳过这个节点

// 3. generate 阶段 → 生成 render 函数
// _c = createElement, _v = createTextVNode, _s = toString
const render = function() {
  return _c('div', { attrs: { id: 'app' } }, [
    _c('p', [_v(_s(message))]),       // 动态节点
    _c('span', [_v('静态文本')])       // 静态节点(diff 时跳过)
  ])
}

// render 函数执行后返回 VNode(虚拟 DOM 树)

new Vue 做了什么?

javascript 复制代码
// new Vue() 的执行流程(简化版)
function Vue(options) {
  // 1. 初始化
  this._init(options)
}

Vue.prototype._init = function(options) {
  const vm = this

  // 2. 合并配置项
  vm.$options = mergeOptions(Vue.options, options)

  // 3. 初始化生命周期、事件、渲染函数
  initLifecycle(vm)    // 初始化 $parent, $children, $refs 等
  initEvents(vm)       // 初始化事件系统
  initRender(vm)       // 初始化 $createElement(即 h 函数)

  callHook(vm, 'beforeCreate')  // ← beforeCreate 钩子

  // 4. 初始化注入和状态
  initInjections(vm)   // 初始化 inject(在 data/props 之前)
  initState(vm)        // ★ 初始化 props → methods → data → computed → watch
  initProvide(vm)      // 初始化 provide(在 data/props 之后)

  callHook(vm, 'created')       // ← created 钩子

  // 5. 挂载(如果有 el 选项)
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)   // → 编译模板 → 生成 render → 首次渲染
  }
}

// $mount 的流程:
// 1. 如果没有 render 函数 → 将 template 编译成 render 函数
// 2. callHook('beforeMount')
// 3. 创建渲染 Watcher → 执行 render 函数 → 生成 VNode → patch 到真实 DOM
// 4. callHook('mounted')
javascript 复制代码
// ========== 实际使用示例 ==========
new Vue({
  el: '#app',              // 5. 自动挂载
  data() {                 // 4. initState 阶段处理
    return { count: 0 }
  },
  computed: {              // 4. initState 阶段处理(创建 computed watcher)
    doubleCount() {
      return this.count * 2
    }
  },
  watch: {                 // 4. initState 阶段处理(创建 user watcher)
    count(newVal) {
      console.log('count changed:', newVal)
    }
  },
  created() {              // 4 之后执行
    console.log(this.count)       // ✅ 0(data 已初始化)
    console.log(this.$el)         // ❌ undefined(还没挂载)
  },
  mounted() {              // 5 之后执行
    console.log(this.$el)         // ✅ DOM 元素
  }
})

💡 面试加分点: initState 的初始化顺序是 props → methods → data → computed → watch,这意味着 data 中可以访问 propsmethodscomputed 可以依赖 data。如果面试官追问"为什么 beforeCreate 中访问不了 data"------因为 initStatebeforeCreate 之后才执行。另外,Vue2 会在首次挂载时判断是否有 render 函数,没有的话才走 template → compile → render 的编译流程(runtime + compiler 版本),纯 runtime 版本不支持 template 编译。


30. 如何重置 Vue 组件的 data?SPA 单页面 vs MPA 多页面?

重置 data

有时需要将组件的 data 恢复到初始状态(比如表单重置、弹窗关闭时清空数据)。

javascript 复制代码
export default {
  data() {
    return {
      form: {
        name: '',
        age: '',
        email: '',
        address: ''
      },
      selectedList: [],
      searchQuery: ''
    }
  },
  methods: {
    // ========== 方式1:Object.assign 重置全部 data(推荐) ==========
    resetAllData() {
      // this.$data          → 当前 data 对象
      // this.$options.data() → 执行 data 函数,获取初始状态
      // ⚠️ 注意:必须传 this,否则 data() 中引用 this.xxx 会报错
      Object.assign(this.$data, this.$options.data.call(this))
    },

    // ========== 方式2:只重置部分数据 ==========
    resetForm() {
      // 只重置 form 字段
      const initialData = this.$options.data.call(this)
      this.form = initialData.form
    },

    // ========== 方式3:手动重置(简单场景) ==========
    resetManual() {
      this.form = { name: '', age: '', email: '', address: '' }
      this.selectedList = []
      this.searchQuery = ''
    }
  }
}
vue 复制代码
<!-- ========== 实际场景:弹窗表单重置 ========== -->
<template>
  <el-dialog :visible.sync="visible" @close="handleClose">
    <el-form ref="form" :model="form" :rules="rules">
      <el-form-item label="姓名" prop="name">
        <el-input v-model="form.name" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </el-form>
    <span slot="footer">
      <el-button @click="handleClose">取消</el-button>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </span>
  </el-dialog>
</template>

<script>
export default {
  props: { visible: Boolean },
  data() {
    return {
      form: { name: '', email: '' }
    }
  },
  methods: {
    handleClose() {
      // ✅ 关闭弹窗时重置表单数据和验证状态
      this.$refs.form.resetFields()  // Element UI 的表单重置(重置到初始值+清除验证)
      Object.assign(this.$data, this.$options.data.call(this))  // 重置所有 data
      this.$emit('update:visible', false)
    },
    async handleSubmit() {
      await this.$refs.form.validate()
      // 提交逻辑...
      this.handleClose()
    }
  }
}
</script>

SPA 单页面 vs MPA 多页面

对比项 SPA(单页面应用) MPA(多页面应用)
定义 只有一个 HTML 页面,通过 JS 动态切换内容 每个页面都是独立的 HTML 文件
页面切换 路由切换,不刷新页面 window.location.href 跳转,整页刷新
首屏加载 ❌ 较慢(需加载所有 JS/CSS) ✅ 较快(只加载当前页面资源)
切换体验 ✅ 流畅,无白屏 ❌ 有白屏闪烁(重新加载)
SEO ❌ 不友好(内容靠 JS 渲染) ✅ 友好(服务端返回完整 HTML)
状态共享 ✅ 容易(全局 Store) ❌ 困难(需要 URL/Storage/Cookie)
技术栈 Vue/React/Angular 传统后端模板(JSP/PHP/Thymeleaf)
适用场景 后台管理系统(B 端) 官网、营销活动页(C 端)
代表 Vue + Vue Router Nuxt.js 的多页面模式
javascript 复制代码
// ========== SPA 路由跳转(无刷新) ==========
// Vue Router
this.$router.push('/about')
// 本质:通过 history.pushState 修改 URL,
//       Vue Router 监听 URL 变化,切换对应组件
//       不会发起新的 HTTP 请求(除了 API 请求)

// ========== MPA 页面跳转(整页刷新) ==========
window.location.href = '/about.html'
// 本质:浏览器向服务器发起新的 HTTP 请求
//       服务器返回新的 HTML 文件
//       浏览器重新解析、渲染整个页面


// ========== SPA 的 SEO 解决方案 ==========
// 1. SSR 服务端渲染(Nuxt.js / Next.js)
// 2. SSG 静态站点生成(预渲染)
// 3. 预渲染插件(prerender-spa-plugin)
// 4. Headless Chrome 预渲染服务

💡 面试加分点: 现代项目越来越多采用混合方案 ------核心页面(首页、SEO 要求高的页面)用 SSR 或 SSG,内部管理页面用 SPA。Nuxt.js 就是 Vue 生态中 SSR 方案的代表,它支持三种渲染模式:universal(SSR)、spa(纯客户端)和 static(SSG)。面试时提到"根据业务场景选择渲染模式"说明你有全局视野。


31. Vue2 中 key 的作用和原理?

核心作用

key 是 Vue 在 diff 算法中用来识别节点身份的唯一标识,决定了节点是"复用"还是"重建"。

场景 有 key 无 key(或用 index)
列表新增/删除 ✅ 精确找到节点,最小化 DOM 操作 ❌ 就地复用(可能导致状态错乱)
列表排序 ✅ 移动 DOM 节点 ❌ 逐个 patch,效率低
强制重建组件 ✅ 更换 key 即可重建 无法实现

为什么不能用 index 做 key?

html 复制代码
<!-- ❌ 用 index 做 key 的 Bug 场景 -->
<!-- 原始列表:[A, B, C],对应 index:[0, 1, 2] -->
<!-- 删除 B 后:[A, C],对应 index:[0, 1] -->
<!-- Vue 认为 index=1 的节点从 B 变成了 C,直接 patch 内容 -->
<!-- 如果 B 有输入框/选中状态等,会"串"到 C 上! -->

<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      <span>{{ item.name }}</span>
      <input type="text" />  <!-- ❌ 删除中间项后,输入框内容会错位 -->
    </div>
    <button @click="removeMiddle">删除中间项</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
        { id: 3, name: 'Charlie' }
      ]
    }
  },
  methods: {
    removeMiddle() {
      this.list.splice(1, 1)  // 删除 Bob
      // 用 index 做 key:Vue 认为 key=1 从 Bob→Charlie,patch 文本
      // 输入框保留了 Bob 的内容 → 状态错乱!

      // 用 id 做 key:Vue 直接移除 key=2 的 DOM → 正确!
    }
  }
}
</script>

diff 中 key 的匹配过程

javascript 复制代码
// ========== Vue2 patch 中 key 的核心逻辑(简化) ==========

// 1. 有 key 时:通过 key 建立映射表,O(1) 查找
function createKeyToOldIdx(children, beginIdx, endIdx) {
  const map = {}
  for (let i = beginIdx; i <= endIdx; i++) {
    const key = children[i].key
    if (key !== undefined) map[key] = i
  }
  return map
}

// 2. 新旧节点比较时:
// sameVnode(a, b) 判断标准:key 相同 && tag 相同
// key 相同 → 复用 DOM,只 patch 差异
// key 不同 → 销毁旧节点,创建新节点

// ========== key 的实际应用 ==========

// 场景1:强制重建组件(重置状态)
// <UserForm :key="userId" />
// userId 变化 → key 变化 → 组件销毁重建(不是复用 patch)

// 场景2:列表渲染(用唯一 id)
// <item v-for="item in list" :key="item.id" />

// 场景3:切换组件时强制重建
// <transition>
//   <component :is="currentComp" :key="currentComp" />
// </transition>

💡 面试加分点: 面试官最爱问的就是"用 index 做 key 会出什么问题"------答案是:当列表有中间插入/删除/排序操作,且列表项有自身状态(输入框、选中态、动画等)时,会导致状态错乱 。纯展示列表用 index 问题不大,但最佳实践是始终使用唯一业务 id。key 的本质作用是给 diff 算法提供"这是同一个节点"的判断依据。


32. 手写 EventBus(发布/订阅模式)

EventBus 的本质

EventBus 就是一个发布/订阅模式 (观察者模式的变体),Vue2 中常用 new Vue() 作为事件总线实现任意组件间通信。

javascript 复制代码
// ========== 方式一:用 Vue 实例做 EventBus(Vue2 常用) ==========
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()

// 组件 A(发布事件)
EventBus.$emit('user-login', { name: 'Tom', role: 'admin' })

// 组件 B(订阅事件)
export default {
  created() {
    EventBus.$on('user-login', this.handleLogin)
  },
  beforeDestroy() {
    EventBus.$off('user-login', this.handleLogin)  // ⚠️ 必须手动销毁!
  },
  methods: {
    handleLogin(user) {
      console.log('用户登录了:', user.name)
    }
  }
}

手写完整的 EventBus

javascript 复制代码
// ========== 手写 EventBus(面试必备) ==========
class EventBus {
  constructor() {
    this.events = {}  // { eventName: [callback1, callback2, ...] }
  }

  // 订阅事件
  $on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []
    }
    this.events[event].push(callback)
    return this  // 支持链式调用
  }

  // 发布事件
  $emit(event, ...args) {
    const callbacks = this.events[event]
    if (callbacks) {
      // 用 slice 复制一份,防止在回调中 $off 导致数组变化
      callbacks.slice().forEach(cb => cb(...args))
    }
    return this
  }

  // 取消订阅
  $off(event, callback) {
    if (!event) {
      // 不传参数:清除所有事件
      this.events = {}
      return this
    }
    const callbacks = this.events[event]
    if (!callbacks) return this

    if (!callback) {
      // 只传事件名:清除该事件的所有回调
      delete this.events[event]
    } else {
      // 传了回调:只移除指定回调
      const index = callbacks.indexOf(callback)
      if (index > -1) callbacks.splice(index, 1)
      if (callbacks.length === 0) delete this.events[event]
    }
    return this
  }

  // 只订阅一次(触发后自动取消)
  $once(event, callback) {
    const wrapper = (...args) => {
      callback(...args)
      this.$off(event, wrapper)  // 执行后立即取消
    }
    wrapper._original = callback  // 保存原始引用,方便手动 $off
    this.$on(event, wrapper)
    return this
  }
}

// ========== 使用示例 ==========
const bus = new EventBus()

// 订阅
bus.$on('data-loaded', (data) => console.log('收到数据:', data))

// 只订阅一次
bus.$once('init', () => console.log('只执行一次'))

// 发布
bus.$emit('data-loaded', { list: [1, 2, 3] })  // 收到数据: { list: [1, 2, 3] }
bus.$emit('init')   // 只执行一次
bus.$emit('init')   // 无输出(已自动取消)

// 取消订阅
bus.$off('data-loaded')

EventBus 的问题和替代方案

javascript 复制代码
// ========== EventBus 的缺点 ==========
// 1. 事件名是字符串,没有类型提示,容易拼错
// 2. 必须手动 $off,否则会内存泄漏
// 3. 事件流难以追踪(不知道谁在发,谁在收)
// 4. 多个组件监听同一事件,调试困难

// ========== Vue2 中的替代方案 ==========
// ✅ 简单共享:Vuex(推荐)
// ✅ 父子通信:props / $emit
// ✅ 跨层级:provide / inject
// ✅ 兄弟通信:状态提升到公共父组件

// ========== Vue3 中的替代 ==========
// Vue3 移除了 $on/$off/$once,不再支持 EventBus 模式
// 推荐方案:
// ✅ mitt 库(极简版 EventBus,300B)
// ✅ Pinia 状态管理
// ✅ provide / inject
import mitt from 'mitt'
const emitter = mitt()
emitter.on('event', handler)
emitter.emit('event', data)
emitter.off('event', handler)

💡 面试加分点: 手写 EventBus 考察的是发布/订阅设计模式 的理解。重点注意:1)$once 的实现要用 wrapper 包装一层;2)$emit 中要先复制数组再遍历,防止回调中 $off 改变数组长度;3)$off 要处理三种情况(无参/仅事件名/事件名+回调)。Vue3 移除了实例事件方法,推荐用 mittPinia 替代。


33. Vue2 的 computed 缓存原理(lazy watcher)

computed 为什么有缓存?

computed 本质上是一个 lazy watcher (惰性求值的观察者),通过 dirty 标志位控制是否重新计算。

objectivec 复制代码
初始化 → 创建 watcher(lazy: true, dirty: true)
         ↓
第一次读取 computed → dirty 为 true → 执行 getter 求值 → 缓存结果 → dirty = false
         ↓
再次读取 computed → dirty 为 false → 直接返回缓存值(不重新计算)
         ↓
依赖的数据变化 → 触发 watcher.update() → dirty = true(标记为脏)
         ↓
下次读取 computed → dirty 为 true → 重新执行 getter → 更新缓存 → dirty = false

源码级理解

javascript 复制代码
// ========== Vue2 computed 核心实现(简化版) ==========

// 1. initComputed:为每个 computed 创建一个 lazy watcher
function initComputed(vm, computed) {
  const watchers = vm._computedWatchers = {}

  for (const key in computed) {
    const getter = typeof computed[key] === 'function'
      ? computed[key]
      : computed[key].get

    // ✅ 关键:lazy: true,不会立即求值
    watchers[key] = new Watcher(vm, getter, noop, { lazy: true })

    // 在 vm 上定义 computed 属性
    defineComputed(vm, key, computed[key])
  }
}

// 2. defineComputed:给 computed 属性定义 getter
function defineComputed(target, key, userDef) {
  Object.defineProperty(target, key, {
    get: createComputedGetter(key),
    set: userDef.set || noop
  })
}

// 3. createComputedGetter:核心缓存逻辑
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers[key]
    if (watcher) {
      // ✅ 关键:只有 dirty 为 true 才重新求值
      if (watcher.dirty) {
        watcher.evaluate()  // 执行 getter,设置 dirty = false
      }
      // 收集依赖(让渲染 watcher 也订阅 computed 的依赖)
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value  // 返回缓存值
    }
  }
}

// 4. Watcher 类中的关键方法
class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.lazy = !!options.lazy
    this.dirty = this.lazy   // lazy watcher 初始时 dirty = true
    this.getter = expOrFn
    this.value = this.lazy ? undefined : this.get()  // lazy 不立即求值
  }

  evaluate() {
    this.value = this.get()  // 执行 getter 求值
    this.dirty = false        // ✅ 求值后标记为"干净"
  }

  update() {
    if (this.lazy) {
      this.dirty = true  // ✅ 依赖变化时只标记为"脏",不立即求值
    } else {
      queueWatcher(this)  // 普通 watcher 进入异步更新队列
    }
  }

  depend() {
    // 让当前的 Dep.target(渲染 watcher)也订阅 computed 的依赖
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

computed vs watch vs methods 的底层对比

javascript 复制代码
// ========== 三者的本质区别 ==========

// computed:lazy watcher + dirty 缓存
// - 有缓存,依赖不变不重新计算
// - 必须 return 值,用于模板中
// - 不能执行异步操作
computed: {
  fullName() { return this.firstName + ' ' + this.lastName }
  // 底层:new Watcher(vm, getter, noop, { lazy: true })
}

// watch:user watcher,监听变化执行副作用
// - 无缓存,依赖变化就执行
// - 不需要 return,用于副作用(请求、日志等)
// - 可以执行异步操作
watch: {
  searchQuery(newVal) { this.fetchResults(newVal) }
  // 底层:new Watcher(vm, 'searchQuery', callback)
}

// methods:普通函数,每次调用都执行
// - 无缓存,模板每次渲染都重新调用
// - 适合事件处理
methods: {
  getFullName() { return this.firstName + ' ' + this.lastName }
  // 底层:就是一个绑定了 vm 作为 this 的普通函数
}

💡 面试加分点: computed 的缓存原理就三句话:1)创建时 lazy: true,不立即求值 ;2)依赖变化时只把 dirty 设为 true (不重新计算);3)下次读取时发现 dirty 为 true 才重新求值 。这就是"惰性求值"------只有真正需要用到的时候才计算。另外 computed 的 depend() 方法会让渲染 watcher 也订阅 computed 的依赖,这样依赖变化时才能触发组件重新渲染。


34. Vue2 的异步更新和批量更新机制

为什么需要异步更新?

javascript 复制代码
// ========== 如果是同步更新 ==========
export default {
  data() { return { count: 0 } },
  methods: {
    increment() {
      this.count = 1  // 触发 setter → 更新 DOM
      this.count = 2  // 又触发 setter → 又更新 DOM
      this.count = 3  // 又触发 setter → 又更新 DOM
      // 同步更新:3 次无意义的 DOM 操作!
    }
  }
}
// Vue 的解决方案:异步批量更新
// 把 3 次修改收集起来,最终只更新 1 次 DOM

异步更新队列原理

scss 复制代码
数据变化 → setter → dep.notify() → watcher.update()
                                         ↓
                              不立即更新 DOM!
                              而是把 watcher 推入队列
                                         ↓
                              queueWatcher(watcher)
                                         ↓
                          同一个 watcher 只入队一次(去重)
                                         ↓
                     用 nextTick(微任务)异步执行 flushSchedulerQueue
                                         ↓
                          遍历队列,依次执行 watcher.run()
                                         ↓
                              更新 DOM(只更新一次!)
javascript 复制代码
// ========== Vue2 异步更新核心源码(简化版) ==========
const queue = []         // watcher 队列
let has = {}             // watcher 去重用的 hash 表
let waiting = false      // 是否已经安排了 flush
let flushing = false     // 是否正在 flush

function queueWatcher(watcher) {
  const id = watcher.id

  // ✅ 关键1:去重 ------ 同一个 watcher 只入队一次
  if (has[id] != null) return
  has[id] = true

  if (!flushing) {
    queue.push(watcher)
  } else {
    // 如果正在 flush,按 id 顺序插入(保证父→子的执行顺序)
    let i = queue.length - 1
    while (i > index && queue[i].id > watcher.id) {
      i--
    }
    queue.splice(i + 1, 0, watcher)
  }

  // ✅ 关键2:只安排一次异步 flush
  if (!waiting) {
    waiting = true
    nextTick(flushSchedulerQueue)
  }
}

function flushSchedulerQueue() {
  flushing = true
  // ✅ 关键3:按 id 排序(父组件 watcher id < 子组件)
  // 保证更新顺序:父 → 子
  queue.sort((a, b) => a.id - b.id)

  for (let i = 0; i < queue.length; i++) {
    const watcher = queue[i]
    has[watcher.id] = null
    watcher.run()  // 真正执行更新
  }

  // 重置状态
  resetSchedulerState()
}

function resetSchedulerState() {
  queue.length = 0
  has = {}
  waiting = false
  flushing = false
}

nextTick 的降级策略

javascript 复制代码
// ========== Vue2 nextTick 源码中的降级策略 ==========
// 优先使用微任务(更快),实在不行才用宏任务

// 优先级1:Promise.then(微任务)✅ 推荐
if (typeof Promise !== 'undefined') {
  const p = Promise.resolve()
  timerFunc = () => { p.then(flushCallbacks) }
}
// 优先级2:MutationObserver(微任务)
else if (typeof MutationObserver !== 'undefined') {
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, { characterData: true })
  timerFunc = () => { counter = (counter + 1) % 2; textNode.data = String(counter) }
}
// 优先级3:setImmediate(宏任务,IE/Node)
else if (typeof setImmediate !== 'undefined') {
  timerFunc = () => { setImmediate(flushCallbacks) }
}
// 优先级4:setTimeout(宏任务,兜底)
else {
  timerFunc = () => { setTimeout(flushCallbacks, 0) }
}

面试常考场景

javascript 复制代码
// ========== 经典面试题:连续修改数据,DOM 更新几次? ==========
export default {
  data() { return { a: 1, b: 2, c: 3 } },
  methods: {
    handleClick() {
      this.a = 10
      this.b = 20
      this.c = 30
      // 问:DOM 更新了几次?
      // 答:1 次!
      // 原因:三次赋值触发三次 setter → 三次 dep.notify()
      // → 但组件只有一个渲染 watcher → queueWatcher 去重
      // → 最终只执行一次 watcher.run() → 一次 DOM 更新

      console.log(this.$el.textContent)  // 还是旧值!(DOM 还没更新)

      this.$nextTick(() => {
        console.log(this.$el.textContent)  // 新值!(DOM 已更新)
      })
    }
  }
}

// ========== 为什么要父→子的顺序更新? ==========
// 场景:父组件传 props 给子组件
// 如果子组件先更新 → 使用的还是旧 props → 错误!
// 正确顺序:父组件先更新(新 props 传下来)→ 子组件再更新
// 实现方式:按 watcher.id 排序(父组件的 id 更小)

// ========== 和 React 的批量更新对比 ==========
// Vue2:基于响应式 + watcher 去重,在任何地方修改数据都自动批量
// React 17:只在事件处理函数中批量更新,setTimeout/Promise 中不批量
// React 18:createRoot 后在所有地方都自动批量(Automatic Batching)

💡 面试加分点: Vue2 的异步更新机制总结:1)数据变化不会立即更新 DOM ,而是把 watcher 推入异步队列;2)同一个 watcher 只入队一次 (去重,避免重复更新);3)用 nextTick(微任务)在当前事件循环末尾统一 flush ;4)flush 时按 watcher id 排序(保证父→子顺序)。这就是为什么修改多个数据只触发一次 DOM 更新------本质是"收集 + 去重 + 异步批量执行"。

相关推荐
禁止摆烂_才浅1 小时前
前端性能优化面试题
前端·面试·性能优化
ClouGence1 小时前
文件上传也能录制回放了:CueCast UI 自动化测试更新
前端·测试
禁止摆烂_才浅1 小时前
Vue3 高频面试题
前端·vue.js·面试
天才熊猫君1 小时前
从“改自己”到“改布局”——拖拽调整的通用设计思路
前端·javascript
何时梦醒1 小时前
CSS 三栏布局与 BFC 深度解析:Float、Flex、Grid 三种方案一网打尽
前端·css·面试
橘子星1 小时前
别只会用 Pick/Omit/Partial!从拼多多一道面试题搞懂 TS 全部内置工具类型
前端
嘟嘟07171 小时前
TypeScript 工具类型通关:Pick、Omit、Partial、Record 区别与 Omit 等价实现一次讲清
设计模式·面试·typescript
PC2005_cloud1 小时前
DSH 沙箱机制拆解:受限令牌为何会掐断 HTTPS 请求
前端·程序员
众人皆醒我独醉1 小时前
MLOps 流水线:从训练到推理的 CI/CD
面试·llm·gpu