2016年,Vue.js 2.0发布了,我也开始上手Vue.js 2.0。

说起来,最早接触Vue.js,是在2015年,那时候Vue.js还是1.0版本,比较小众,用的人不多。但是,它的简单易学、轻量高效,给我留下了很深的印象。

2016年,Vue.js 2.0发布了,做了很多改进,性能更好,功能更强,API也更完善。而且,这一年,Vue.js越来越流行,社区越来越活跃,很多公司开始用Vue.js做项目。

正好,我们有一个新项目,需要选一个前端框架,对比了React、Angular、Vue.js之后,我们选择了Vue.js 2.0,因为它简单易学,上手快,适合我们团队的情况。

于是,我开始上手Vue.js 2.0,把它用到了项目中。但是,在上手的过程中,我踩了很多坑,有些问题,甚至让我熬夜排查。

今天,就来聊聊Vue.js 2.0上手踩坑记,那些让我熬夜的问题。

一、Vue.js是什么

先简单介绍一下Vue.js是什么。

Vue.js(读音 /vjuː/,类似于 view)是一套用于构建用户界面的渐进式JavaScript框架。与其它大型框架不同的是,Vue被设计为可以自底向上逐层应用。Vue的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。

Vue.js的特点:

  • 渐进式:可以逐步采用,不需要一开始就用全部功能
  • 简单易学:API简单,文档清晰,上手快
  • 轻量高效:核心库小,性能好,虚拟DOM优化
  • 响应式:数据驱动视图,数据变化视图自动更新
  • 组件化:组件化开发,代码复用,可维护性好
  • 生态丰富:有Vuex(状态管理)、Vue Router(路由)、Vue CLI(脚手架)等官方工具

Vue.js 2.0相比1.0,做了很多改进:

  • 性能更好,渲染速度提升了2-4倍
  • 虚拟DOM重写,更高效
  • 支持服务端渲染(SSR)
  • 组件API更统一
  • 支持JSX
  • 更好的TypeScript支持

二、响应式原理

Vue.js最核心的特性就是响应式,数据变化,视图自动更新。但是,响应式也是最容易踩坑的地方。

1. 响应式原理

Vue.js 2.0的响应式,是通过Object.defineProperty实现的。在初始化的时候,Vue会遍历data对象的所有属性,用Object.defineProperty把它们转为getter/setter,在getter中收集依赖,在setter中触发更新。

当数据变化时,setter被触发,通知依赖(Watcher),Watcher触发重新渲染,更新视图。

这个原理,看起来简单,但是,有几个限制,也是容易踩坑的地方。

2. 无法检测对象属性的添加和删除

由于Object.defineProperty的限制,Vue无法检测对象属性的添加和删除。

比如:

data() {
    return {
        user: {
            name: 'test'
        }
    };
},
methods: {
    addAge() {
        this.user.age = 18; // 这个属性不是响应式的,视图不会更新
    }
}

这里,给user对象添加了age属性,但是,这个属性不是响应式的,因为Vue在初始化的时候,只把user.name转为了getter/setter,age是后来添加的,没有被转为getter/setter,所以,数据变化,视图不会更新。

解决方法:

  • 用Vue.set(object, key, value)或者this.$set(object, key, value)
  • 或者,用Object.assign创建一个新对象,替换原对象
// 方法1:用Vue.set
this.$set(this.user, 'age', 18);

// 方法2:用Object.assign
this.user = Object.assign({}, this.user, { age: 18 });

3. 无法检测数组索引的修改和长度修改

同样,Vue也无法检测数组索引的修改和长度修改。

比如:

data() {
    return {
        list: ['a', 'b', 'c']
    };
},
methods: {
    updateItem() {
        this.list[0] = 'x'; // 这个修改不是响应式的,视图不会更新
        this.list.length = 2; // 这个修改也不是响应式的
    }
}

解决方法:

  • 用Vue.set(array, index, value)或者this.$set(array, index, value)
  • 或者,用数组的splice方法
  • 或者,替换整个数组
// 方法1:用Vue.set
this.$set(this.list, 0, 'x');

// 方法2:用splice
this.list.splice(0, 1, 'x');
this.list.splice(2); // 截取前2个,相当于length=2

// 方法3:替换数组
this.list = ['x', 'b', 'c'];

Vue对数组的以下方法做了包裹,这些方法是响应式的:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

用这些方法修改数组,视图会自动更新。

4. 异步更新队列

Vue更新DOM是异步的,只要侦听到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher被多次触发,只会被推入到队列中一次。

比如:

methods: {
    update() {
        this.message = 'new message';
        console.log(this.$el.textContent); // 这里还是旧的message,因为DOM还没更新
    }
}

这里,修改了message之后,立即读取DOM的textContent,还是旧的值,因为DOM更新是异步的,还没执行。

解决方法:

  • 用Vue.nextTick(callback)或者this.$nextTick(callback),在DOM更新后执行回调
methods: {
    update() {
        this.message = 'new message';
        this.$nextTick(function() {
            console.log(this.$el.textContent); // 这里是新的message,DOM已经更新
        });
    }
}

这个坑,我刚上手的时候踩过,修改了数据,立即操作DOM,发现不对,调试了半天,才发现是异步更新的问题。

三、组件通信

组件是Vue.js的核心概念,组件通信也是容易踩坑的地方。

1. props传递数据

父组件通过props向子组件传递数据:

<!-- 父组件 -->
<template>
    <child :message="parentMessage"></child>
</template>

<!-- 子组件 -->
<template>
    <div>{{ message }}</div>
</template>
<script>
export default {
    props: ['message']
};
</script>

这里,有几个容易踩坑的地方:

  • props是单向数据流:父组件的数据变化,会流向子组件,但是,子组件不能直接修改props,否则会警告。如果子组件需要修改,应该用data或computed。
  • props命名:在模板中,props用kebab-case(短横线分隔),在JS中用camelCase(驼峰)。
  • props验证:可以对props进行验证,指定类型、是否必填、默认值、自定义验证函数。
props: {
    message: {
        type: String,
        required: true,
        default: 'default',
        validator: function(value) {
            return value.length > 0;
        }
    }
}

2. 事件通信

子组件通过$emit向父组件发送事件,父组件通过v-on监听事件:

<!-- 子组件 -->
<button @click="sendMessage">发送</button>
<script>
export default {
    methods: {
        sendMessage() {
            this.$emit('child-event', 'hello from child');
        }
    }
};
</script>

<!-- 父组件 -->
<child @child-event="handleEvent"></child>
<script>
export default {
    methods: {
        handleEvent(message) {
            console.log(message); // 'hello from child'
        }
    }
};
</script>

这里,容易踩坑的地方:

  • 事件名也是kebab-case(在模板中),camelCase(在JS中)
  • $emit的第一个参数是事件名,后面的参数是传递的数据
  • 父组件监听事件,用v-on或@,回调函数的参数就是子组件传递的数据

3. 兄弟组件通信

兄弟组件之间通信,有几种方式:

  • 通过父组件中转:子组件A发送事件给父组件,父组件修改数据,通过props传给子组件B
  • 通过事件总线(EventBus):创建一个空的Vue实例作为事件总线,组件之间通过事件总线通信
  • 通过Vuex:用Vuex做状态管理,共享状态

事件总线的方式:

// event-bus.js
import Vue from 'vue';
export const EventBus = new Vue();

// 组件A发送事件
import { EventBus } from './event-bus.js';
EventBus.$emit('event', 'data');

// 组件B监听事件
import { EventBus } from './event-bus.js';
EventBus.$on('event', function(data) {
    console.log(data);
});

这里,容易踩坑的地方:

  • 事件总线的事件,需要在组件销毁时移除监听,否则会导致内存泄漏,重复触发
  • 用$on监听,用$off移除
export default {
    created() {
        EventBus.$on('event', this.handleEvent);
    },
    beforeDestroy() {
        EventBus.$off('event', this.handleEvent);
    },
    methods: {
        handleEvent(data) {
            console.log(data);
        }
    }
};

这个坑,我踩过,组件切换的时候,事件没有移除,导致事件被触发多次,调试了很久才发现。

4. 父组件访问子组件

父组件可以通过ref访问子组件实例:

<child ref="childRef"></child>
<script>
export default {
    methods: {
        accessChild() {
            this.$refs.childRef.someMethod(); // 调用子组件的方法
            console.log(this.$refs.childRef.someData); // 访问子组件的数据
        }
    }
};
</script>

这里,容易踩坑的地方:

  • ref在DOM渲染后才可用,不能在created或beforeMount中访问,应该在mounted或之后访问
  • 如果ref用在v-for中,$refs是一个数组
  • 尽量不要用ref直接操作子组件,应该用props和事件通信,ref只在必要时使用

四、生命周期

Vue组件有生命周期钩子,也是容易踩坑的地方。

Vue 2.0的生命周期:

  1. beforeCreate:实例初始化之后,数据观测和事件配置之前调用
  2. created:实例创建完成后调用,数据观测、属性和方法运算、事件回调都已完成,但是DOM还没生成,$el还不可用
  3. beforeMount:挂载开始之前调用,render函数首次被调用
  4. mounted:挂载完成后调用,DOM已生成,$el可用,但是不保证所有子组件都挂载完成
  5. beforeUpdate:数据更新时调用,发生在虚拟DOM重新渲染和打补丁之前
  6. updated:数据更新后调用,组件DOM已更新,但是不保证所有子组件都重绘完成
  7. beforeDestroy:实例销毁之前调用,实例仍然完全可用
  8. destroyed:实例销毁后调用,所有事件监听器被移除,子实例被销毁

容易踩坑的地方:

  • created vs mounted:created中数据已可用,但是DOM还没生成,不能操作DOM;mounted中DOM已生成,可以操作DOM。
  • mounted不保证子组件挂载完成:如果需要等所有子组件挂载完成,用this.$nextTick。
  • updated不保证子组件重绘完成:同理,用this.$nextTick。
  • 定时器和事件监听器要在beforeDestroy中清理:否则会导致内存泄漏。
export default {
    data() {
        return {
            timer: null
        };
    },
    mounted() {
        this.timer = setInterval(() => {
            console.log('timer');
        }, 1000);
        window.addEventListener('resize', this.handleResize);
    },
    beforeDestroy() {
        clearInterval(this.timer); // 清理定时器
        window.removeEventListener('resize', this.handleResize); // 移除事件监听器
    },
    methods: {
        handleResize() {
            console.log('resize');
        }
    }
};

这个坑,我也踩过,组件销毁了,定时器还在跑,导致内存泄漏,页面越来越卡,后来才发现是定时器没清理。

五、Vuex状态管理

当应用复杂的时候,组件之间的状态共享变得困难,这时候就需要Vuex。

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

Vuex的核心概念:

  • State:状态,存储数据
  • Getter:计算属性,从state派生出状态
  • Mutation:修改状态的方法,必须是同步的
  • Action:提交mutation,可以包含异步操作
  • Module:模块,分割store

容易踩坑的地方:

1. Mutation必须是同步的

Mutation必须是同步的,不能在mutation中做异步操作,否则devtools无法追踪状态变化。

异步操作应该放在action中,action提交mutation。

// 错误:mutation中做异步操作
mutations: {
    updateData(state) {
        setTimeout(() => {
            state.data = 'new data';
        }, 1000);
    }
}

// 正确:action中做异步操作,提交mutation
mutations: {
    updateData(state, data) {
        state.data = data;
    }
},
actions: {
    updateDataAsync({ commit }) {
        setTimeout(() => {
            commit('updateData', 'new data');
        }, 1000);
    }
}

2. 组件中提交mutation和dispatch action

组件中,用this.$store.commit提交mutation,用this.$store.dispatch分发action。

// 提交mutation
this.$store.commit('updateData', 'data');

// 分发action
this.$store.dispatch('updateDataAsync', 'data');

也可以用mapMutations和mapActions辅助函数:

import { mapMutations, mapActions } from 'vuex';

export default {
    methods: {
        ...mapMutations(['updateData']),
        ...mapActions(['updateDataAsync'])
    }
};

3. 模块化的命名空间

当store模块化的时候,如果开启了命名空间(namespaced: true),那么commit、dispatch、getter都需要加上命名空间前缀。

// 模块
const userModule = {
    namespaced: true,
    state: { name: 'test' },
    mutations: {
        updateName(state, name) {
            state.name = name;
        }
    }
};

// 组件中
this.$store.commit('user/updateName', 'new name'); // 加上命名空间前缀
this.$store.state.user.name; // 访问模块的state

这个坑,我刚用Vuex的时候踩过,没加命名空间前缀,mutation不生效,调试了半天。

六、Vue Router路由

Vue Router是Vue.js的官方路由管理器,用于构建单页应用(SPA)。

容易踩坑的地方:

1. 路由模式

Vue Router有两种模式:

  • hash模式:URL带#,比如http://example.com/#/user,默认模式,兼容性好
  • history模式:URL不带#,比如http://example.com/user,需要后端配置支持,否则刷新页面会404

用history模式,需要后端配置,把所有路由都指向index.html:

# Nginx配置
location / {
    try_files $uri $uri/ /index.html;
}

这个坑,我踩过,用了history模式,但是后端没配置,刷新页面404,排查了很久。

2. 路由跳转

路由跳转,有几种方式:

  • 声明式:<router-link :to="{ path: '/user' }">用户</router-link>
  • 编程式:this.$router.push('/user')this.$router.replace('/user')this.$router.go(-1)

容易踩坑的地方:

  • push会向history添加新记录,replace不会添加新记录,替换当前记录
  • 路由参数变化,组件会复用,不会重新创建,生命周期钩子不会重新调用,如果需要响应参数变化,用watch监听$route,或者用beforeRouteUpdate导航守卫
// 监听路由参数变化
watch: {
    '$route'(to, from) {
        // 路由变化,重新加载数据
        this.loadData(to.params.id);
    }
}

这个坑,我踩过,路由参数变了,但是组件没更新,数据还是旧的,后来才发现是组件复用的问题。

3. 导航守卫

Vue Router有导航守卫,用于在路由跳转时做一些操作,比如登录验证。

  • 全局守卫:beforeEach、beforeResolve、afterEach
  • 路由独享守卫:beforeEnter
  • 组件内守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave

比如,登录验证:

router.beforeEach((to, from, next) => {
    if (to.meta.requiresAuth && !isLoggedIn()) {
        next('/login'); // 未登录,跳转到登录页
    } else {
        next(); // 登录了,继续
    }
});

容易踩坑的地方:

  • 守卫中必须调用next(),否则路由不会继续
  • next()可以传参数,比如next('/login')跳转到指定路由,next(false)中断导航
  • beforeRouteEnter中不能访问this,因为组件还没创建,可以用next(vm => {})访问组件实例

七、其他常见踩坑

除了上面说的,还有一些其他常见的踩坑。

1. v-for的key

用v-for渲染列表的时候,必须加key,而且key要唯一,稳定,不要用index作为key(如果列表会变化的话)。

<!-- 正确 -->
<div v-for="item in list" :key="item.id">{{ item.name }}</div>

<!-- 不推荐(如果列表会变化) -->
<div v-for="(item, index) in list" :key="index">{{ item.name }}</div>

用index作为key,如果列表中间插入或删除元素,后面的元素的index都会变,导致key变化,组件会重新渲染,性能差,而且可能导致状态错乱。

2. v-if和v-for不要一起用

v-if和v-for不要用在同一个元素上,因为v-for的优先级比v-if高,所以v-if会在每个v-for循环中都执行,性能差,而且逻辑混乱。

如果需要条件渲染列表,应该用computed先过滤列表,或者把v-if放在外层元素。

<!-- 不推荐 -->
<div v-for="item in list" v-if="item.visible">{{ item.name }}</div>

<!-- 推荐:用computed过滤 -->
<div v-for="item in visibleList" :key="item.id">{{ item.name }}</div>

<script>
computed: {
    visibleList() {
        return this.list.filter(item => item.visible);
    }
}
</script>

3. 表单输入绑定

用v-model绑定表单输入,有几个修饰符:

  • .lazy:在change事件后同步,而不是input事件
  • .number:自动转为数字
  • .trim:自动过滤首尾空格
<input v-model.lazy="message">
<input v-model.number="age" type="number">
<input v-model.trim="name">

checkbox和radio的绑定,也需要注意:

  • checkbox:单个checkbox绑定布尔值,多个checkbox绑定数组
  • radio:绑定value,选中时v-model的值等于value

4. 样式作用域

用<style scoped>,样式只作用于当前组件,不会污染全局。但是,scoped样式,对子组件的根元素有效,对子组件的内部元素无效。

如果需要修改子组件内部的样式,需要用深度选择器(>>>或/deep/):

<style scoped>
/* 只作用于当前组件 */
.title {
    color: red;
}

/* 深度选择器,作用于子组件内部 */
>>> .child-title {
    color: blue;
}
</style>

5. 组件命名

组件命名,有两种方式:

  • PascalCase(大驼峰):MyComponent
  • kebab-case(短横线):my-component

在模板中,用PascalCase或kebab-case都可以,但是,在DOM模板中(直接写在HTML里),只能用kebab-case,因为HTML不区分大小写。

注册组件的时候,用PascalCase:

Vue.component('my-component', {
    // ...
});

八、最佳实践

最后,总结一些Vue.js 2.0的最佳实践。

1. 组件化

  • 组件尽量小,职责单一,一个组件只做一件事
  • 通用组件抽离,复用代码
  • 组件命名清晰,见名知意
  • props和事件定义清晰,组件接口明确

2. 状态管理

  • 简单的状态,用组件data和props/events
  • 复杂的跨组件状态,用Vuex
  • 不要把所有状态都放Vuex,只放需要共享的状态
  • Vuex的mutation要纯函数,同步;action处理异步

3. 性能优化

  • 合理使用v-if和v-show,频繁切换用v-show,不频繁切换用v-if
  • 列表渲染加key,key唯一稳定
  • 大数据列表用虚拟滚动
  • 合理使用computed缓存计算结果
  • 组件懒加载,路由懒加载
  • 生产环境构建,压缩代码

4. 代码规范

  • 遵循Vue风格指南
  • 组件data必须是函数
  • props定义尽量详细,指定类型、默认值、验证
  • 模板简洁,复杂逻辑放computed或methods
  • 样式用scoped,避免污染全局

5. 调试工具

  • 用Vue Devtools调试,查看组件树、状态、事件、路由
  • 用Vue Devtools的时间旅行调试,追踪状态变化
  • 控制台报警告和错误,及时修复

九、写在最后

Vue.js 2.0上手踩坑记:那些让我熬夜的问题。

Vue.js是一个优秀的前端框架,简单易学,轻量高效,但是,在上手的过程中,还是会踩很多坑,特别是响应式原理、组件通信、生命周期、Vuex、Vue Router这些核心概念,需要深入理解,才能用好。

我踩过的这些坑,希望能帮助大家少走弯路,更快地上手Vue.js,写出优雅高效的代码。

Vue.js还在不断发展,新的特性不断加入,我们也要不断学习,跟上技术的步伐。

最后,用一句话结尾:

"框架是工具,不是目的。理解框架的原理,才能真正用好框架。"

愿大家都能用好Vue.js,构建出优秀的前端应用。