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 ------只有在被访问时才计算,并且会缓存结果,依赖不变时直接返回缓存值。watch的deep: true性能开销大(递归遍历对象),能用'obj.key'精确监听就不要用deep。
4. Vue2 的组件通信方式(全面总结)
| 方式 | 方向 | 适用场景 |
|---|---|---|
| props / $emit | 父 → 子 / 子 → 父 | 最基本的父子通信 |
| parent/children | 父 ↔ 子 | 直接访问(不推荐,耦合高) |
| $refs | 父 → 子 | 访问子组件实例/DOM |
| provide / inject | 祖先 → 后代 | 跨层级(非响应式,除非传 ref) |
| EventBus | 任意组件 | 兄弟/跨层级通信 |
| Vuex | 全局 | 复杂状态管理 |
| 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-model:v-model:name、v-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和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 的异步组件工厂函数可以配置
loading、error、delay、timeout四个选项,这是实现"骨架屏"和"超时降级"的利器。Vue3 用defineAsyncComponent()+<Suspense>取代了这一模式。
20. Vue2 → Vue3 迁移要点
| Vue2 特性 | Vue3 变化 | 迁移方案 |
|---|---|---|
| Options API | Composition API(兼容 Options) | 逐步重构,优先重构复杂组件 |
| Vuex | Pinia(官方推荐) | 新项目用 Pinia,旧项目 Vuex 4 兼容 |
| filters | 移除 | 用 computed 或全局方法替代 |
| on/off/$once | 移除(EventBus 不可用) | 用 mitt 或 tiny-emitter 替代 |
| $children | 移除 | 用 $refs 或 provide/inject 替代 |
| v-model | 改为 modelValue + update:modelValue |
重写组件的 v-model |
| .sync | 移除,用 v-model:xxx 替代 |
替换为 v-model:propName |
| v-for/v-if 优先级 | v-if 优先级高于 v-for |
检查同时使用的地方 |
| 生命周期 | beforeDestroy → beforeUnmount |
重命名 |
| 全局 API | Vue.xxx → app.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-if、v-for、v-model、v-bind、v-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-if、v-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> 可以让样式只作用于当前组件,不影响其他组件。
实现机制:
- 给当前组件的所有 DOM 元素添加一个唯一属性
data-v-xxxxxxxx(8 位哈希值) - 通过 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 的模板编译经过 三个阶段 :parse → optimize → generate。
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中可以访问props和methods,computed可以依赖data。如果面试官追问"为什么beforeCreate中访问不了data"------因为initState在beforeCreate之后才执行。另外,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 移除了实例事件方法,推荐用mitt或Pinia替代。
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 更新------本质是"收集 + 去重 + 异步批量执行"。