1. 组件到底解决了什么问题——先想清楚再动手我最早做前端的时候页面里到处是复制粘贴的代码。一个商品卡片列表页放一份搜索页再复制一份活动页又改一版改了几次之后所有地方的颜色、间距、字段全都不一样了。后来接触 Vue2 的组件系统才真正意识到“创建组件”这件事的本质把可复用的 UI 结构、逻辑行为和样式封装成一个独立单元让页面变成积木的拼装结果。组件化并不是 Vue 提出的新鲜事但 Vue2 把它做得很轻、很好上手这也是当年大量团队选择它的原因。Vue2 的组件本质上是一个拥有预定义选项的 Vue 实例组件可以复用、可以嵌套、可以通过 props 接收外部数据、可以通过事件对外沟通。这种设计让“组件”这个词不再停留在概念层面而是落地成了.vue单文件组件里的一段template、一份script、一套style。很多刚接触 Vue2 的同学把这个过程分成“创建组件”和“使用组件”两步这当然没错但我会建议再多想一层组件创建时的接口设计决定了它能不能被真正用起来。一个组件写得再漂亮如果 props 定义不清、事件名随意、样式全部写死那它就只能服务于最初那个页面换个地方就得重写这就失去了组件的意义。那组件化到底解决了哪些具体问题我这几年实际项目里感受最深的几点一致性同一个按钮、弹窗、表单控件只要团队都引用同一个组件视觉和交互天然统一。不需要设计师去盯着每个页面的每个角落。复用性写一次到处用。尤其后台管理系统里表格、分页、搜索表单这些场景高度相似组件能省掉大量重复劳动。可维护性业务逻辑被拆到各自的组件里代码定位变快。出问题的时候不需要在几万行的单文件里大海捞针。可测试性独立组件可以单独验证输入 props、检查渲染结果和 emit 事件比整个页面一起测要容易得多。我见过一个比较极端的例子公司内部有一个运营后台早期所有代码都堆在一个两百多行的模板里后来换了人接手光是把表格列和弹窗表单对齐就花了两周。我接手后把页面拆成“筛选区”“表格区”“编辑弹窗”三个组件改动立刻变得可控。这不是技术炫技是组件化最朴素的收益。不过有一点要提醒组件不是拆得越细越好。粒度太细会让目录爆炸、通信成本上升、可读性下降。我自己心里的标准是“这个片段是否会被复用这个片段是否逻辑独立”如果两个答案都是否那就不要单独拆。组件化是手段不是目的这一点在 Vue2 的实战里尤其重要。创建组件之前先在脑里过一遍“我为什么要拆它、谁会用它、接口怎么定”后面所有步骤都会顺畅很多。2. 创建组件的三种方式以及 data 为什么必须是一个函数Vue2 创建组件最常见的路径有三种全局注册、局部注册、单文件组件。很多人只写其中一种但实际项目里三种都会用到最好都吃透。2.1 全局注册简单直接但要克制全局注册组件是在入口文件通常是main.js里通过Vue.component(componentName, options)完成注册注册之后任何组件模板里都能直接使用。import Vue from vue import App from ./App.vue // 全局注册一个简单的计数按钮 Vue.component(counter-button, { data () { return { count: 0 } }, template: button clickcount stylefont-size: 18px; 点击了 {{ count }} 次 /button }) new Vue({ el: #app, render: h h(App) })这种方式的好处是省事组件能在任意页面直接用不需要到处 import。坏处也明显组件一旦全局注册即使某个页面永远用不到它也照样会打进最终构建包里增加资源体积。项目越大越心疼。我的建议是全局注册只留给那些全站通用、确实到处要用的基础组件比如BaseButton、BaseDialog、BaseTable这一类“设计系统层”的组件。业务型组件尽量别全局注册否则很难追溯一个组件到底被谁改了、被谁影响了。2.2 局部注册用得最多也最好管理局部注册是把组件定义写在某个父组件的components选项里只在当前组件及其模板里可见。这也是我在做业务功能时最常用的一种方式。template div classuser-panel user-avatar :srcavatarUrl :size40 / span{{ userName }}/span /div /template script import UserAvatar from ./UserAvatar.vue export default { name: UserPanel, components: { // 注意这里用了 ES6 属性简写等价于 UserAvatar: UserAvatar UserAvatar }, data () { return { avatarUrl: /images/avatar.png, userName: 张三 } } } /script局部注册的核心优势是“按需引入、作用域隔离”。UserAvatar只服务于UserPanel不会污染全局命名空间Webpack 在做打包分析时也能更精确地知道什么组件被哪个模块引用了。副作用一眼就能看清后续维护时改一个组件不会波及全站。2.3 单文件组件工程化的基石如果项目里还没有引入构建工具链那么上面两种用字符串template的方式还是可以考虑的但一旦项目进入了 Webpack/Vite .vue单文件组件的工程化阶段完整写法就应该是模板、脚本、样式三者分离的单文件组件template div classstatus-card :classstatus-card--${type} span classstatus-card__icon{{ iconText }}/span p classstatus-card__title{{ title }}/p slot / /div /template script export default { name: StatusCard, props: { type: { type: String, default: info }, title: { type: String, required: true } }, computed: { iconText () { const map { success: ✓, error: ✗, warning: ! } return map[this.type] || i } } } /script style scoped .status-card { border-radius: 6px; padding: 16px; background: #f5f7fa; } .status-card--success { border-left: 4px solid #52c41a; } .status-card--error { border-left: 4px solid #ff4d4f; } .status-card--warning { border-left: 4px solid #faad14; } /style单文件组件之所以叫“单文件”是因为它把组件所需的 HTML 结构、JS 逻辑、CSS 样式集中在同一个文件里职责内聚、IDE 天然有高亮和语法提示配合 Vue Loader 还可以做热更新状态保留。我们平时说的“Vue 组件开发”默认就是这个形态。2.4 data 必须是一个函数不是强迫症是语言机制这个问题在面试里基本属于必考题但在实际开发里踩坑的也不少。Vue2 里组件选项中的data必须声明为一个返回对象的函数而根实例里的data可以是一个对象。这两者的差异到底体现在哪原因在于组件是要被复用的同一个组件在页面里可能同时存在多个实例。如果data直接写成一个对象那么每个组件实例会共享同一份数据引用。一个实例改了count其他实例的count也会跟着变这就是典型的“组件状态污染”。// 错误多个实例会共享 data 对象 Vue.component(bad-counter, { data: { count: 0 }, template: button clickcount{{ count }}/button }) // 正确每个实例都通过函数返回一份新对象 Vue.component(good-counter, { data () { return { count: 0 } }, template: button clickcount{{ count }}/button })为什么根实例没这个限制因为页面通常只有一个根实例不存在“多个根实例共享同一份 data”的问题。你可以把函数式的 data 理解成“为每个组件实例单独 clone 一份初始状态”这样各实例互不干扰。这个设计在 Vue 官方文档里写得很明白但真正写代码的时候尤其是不小心把data: { count: 0 }写成对象时报错提示也很直接“Thedataoption should be a function that returns a per-instance value in component definitions.” 看到这个报错第一反应应该是去把 data 改成函数而不是怀疑 Vue 坏了。3. 父子组件通信props、$emit 与 v-model 这套基本功组件创建出来之后紧接着面对的问题就是通信。Vue2 里组件与组件之间的关系通常分三种父子、兄弟、跨层级。其中父子通信是地基也是热搜词里反复被提到的“父传子、子传父”的核心场景。3.1 父传子props 的声明、校验与命名细节父组件给子组件传数据靠的是 props。子组件通过props选项声明它能接收哪些字段父组件在模板里像写 HTML 属性一样传值。!-- 子组件TaskItem.vue -- template div classtask-item input typecheckbox :checkedtask.done change$emit(toggle, task.id) span{{ task.title }}/span /div /template script export default { name: TaskItem, props: { task: { type: Object, required: true } } } /script!-- 父组件TaskList.vue -- template ul task-item v-fortask in tasks :keytask.id :tasktask togglehandleToggle / /ul /template script import TaskItem from ./TaskItem.vue export default { name: TaskList, components: { TaskItem }, data () { return { tasks: [ { id: 1, title: 学习 Vue2 组件, done: false }, { id: 2, title: 实践组件通信, done: false } ] } }, methods: { handleToggle (id) { const target this.tasks.find(item item.id id) if (target) { target.done !target.done } } } } /scriptprops 尽量带类型校验别偷懒。比如task这个 prop 加了type: Object, required: true如果哪天父组件忘记传或者传成了字符串开发模式下控制台会直接告警比运行时突然报错好查得多。Vue2 还支持default、validator等字段适合对格式有严格要求的场景。还有一个很容易忽略的点props 是单向数据流。子组件不应该直接修改 props这是 Vue2 的设计约定也是避免数据流混乱的基础。如果子组件确实要基于某个 prop 做本地修改正确做法是把它复制到 data 或 computed 里props: { initialCount: { type: Number, default: 0 } }, data () { return { localCount: this.initialCount } }如果直接this.task.done !this.task.done去改 props 里的对象属性代码“看起来能跑”但它破坏了单向数据流且 Vue2 对嵌套属性修改不会给出警告。等父组件一更新覆盖掉子组件的修改bug 就冒出来了。记住一句话谁的数据谁负责改。子组件想改变父组件的数据靠事件不靠改写 props。3.2 子传父$emit 就是子组件对外喊话子组件把消息告诉父组件走$emit。父组件在模板里用event-namehandler监听。想要携带多个参数$emit的第二个参数开始都是载荷父组件对应的处理函数接收它们即可。上面 TaskItem 里的change$emit(toggle, task.id)就是一个标准示例。子组件不负责改父组件的数据只负责“喊一声”用户勾选了任务 ID 为 1 的那一项。至于父组件要不要改数据、怎么改是父组件自己的事。这里分享一个我自己早期踩过坑的点事件名最好用 kebab-case短横线命名不要用 camelCase。原因在于 HTML 属性不区分大小写toggleTask很容易被写成toggle-task而匹配不到。虽然模板编译器会做一定兼容但为了避免心智负担事件名直接统一成短横线风格比如toggle-task、save-success。3.3 v-model 在自定义组件上的工作逻辑Vue2 里v-model本质上是一段语法糖input v-modelmsg !-- 等价于 -- input :valuemsg inputmsg $event.target.value放到自定义组件上语法糖会变成base-input v-modelsearchText / !-- 等价于 -- base-input :valuesearchText inputsearchText $event /所以想要让自定义组件支持v-model就必须满足两个约定接收 propvalue对外触发事件input并把新值作为事件参数传出。template input :valuevalue inputhandleInput /template script export default { name: BaseInput, props: { value: { type: String, default: } }, methods: { handleInput (e) { this.$emit(input, e.target.value) } } } /script如果你不想用固定的value这个名字Vue2 还支持model选项自定义属性名和事件名template input :checkedchecked changehandleChange /template script export default { name: BaseCheckbox, model: { prop: checked, event: change }, props: { checked: Boolean }, methods: { handleChange (e) { this.$emit(change, e.target.checked) } } } /script实际业务里弹窗、输入框、日期选择这类表单型组件做一次v-model支持能省掉大量模板代码属于“组件通用性”的标配能力。3.4 兄弟与跨层级EventBus、$refs 与 Vuex 的取舍父子通信之外兄弟组件通信和跨层级通信也是业务里绕不开的。Vue2 的通用解法大概有三种我分别说一下我的使用建议。EventBus事件总线适合跨多级、且数据量不太大的临时通信。// bus.js import Vue from vue export default new Vue() // 组件 A 中发送 import bus from ./bus export default { methods: { notify () { bus.$emit(user-updated, { id: 1 }) } } } // 组件 B 中监听 import bus from ./bus export default { created () { bus.$on(user-updated, this.handleUserUpdated) }, beforeDestroy () { // 注意离开时一定要注销监听否则组件销毁后依然会收到事件 bus.$off(user-updated, this.handleUserUpdated) }, methods: { handleUserUpdated (user) { console.log(user) } } }EventBus 用起来方便但它有一个经典陷阱组件销毁时忘了$off事件回调继续执行轻则报错重则内存泄漏。所以我在项目里只会拿它做“一次性通知”比如刷新列表、关闭弹窗这类信号型通信一旦有多个组件共享同一份状态我会直接换 Vuex。$refs父组件直接调用子组件实例的方法或读取其属性。适合“父主动叫子做事”的场景。template child refchildRef / /template script export default { mounted () { // 调用子组件暴露的方法 this.$refs.childRef.reset() } } /script但注意$refs只是拿到实例引用不是响应式系统的一部分所以不要用它来做“根据子组件数据变化而自动更新 UI”这类逻辑那是 props 和 computed 的职责。Vuex多个页面或组件共享同一份全局状态数据量大、更新频繁、需要追踪变更时用 Vuex 是更稳的选择。它让状态变更通过 mutation 收口虽然写起来啰嗦但可追溯性远远好于 EventBus 满天飞。我的实践经验是能用父子 props $emit 解决的绝不上 Vuex能用局部变量解决的绝不全局共享。数据流越收敛代码越容易维护。4. 插槽与作用域插槽做一个能随便往里塞内容的壳子props 和事件解决了“数据传递”但有时候最自然的用法是“往里塞模板”。比如一个卡片组件不同页面希望卡片头部、内容区、操作栏完全由自己决定。这时候就轮到插槽出场。4.1 默认插槽最基本的占位template div classbase-card div classbase-card__body slot / /div /div /template使用方在base-card标签内部写的内容会被填充进slot /的位置base-card p这里是卡片内容/p /base-card很多新人会把“标签内部的内容”误当成 props。要厘清一点props 传的是数据插槽传的是视图。如果需要的是数据拼接、逻辑控制用 props如果需要的是结构片段用插槽。4.2 具名插槽一个组件开多个口当组件不止一个可插入位置时用name区分不同的插槽。!-- PageLayout.vue -- template div classpage-layout header classpage-layout__header slot nameheader默认标题/slot /header main classpage-layout__main !-- 默认插槽放置未指定 name 的内容 -- slot / /main footer classpage-layout__footer slot namefooter / /footer /div /template使用方通过v-slot:header这种指令式写法指定内容插入到哪个插槽page-layout template v-slot:header h1页面头部区域/h1 /template p这是中间主体内容自动落入默认插槽/p template v-slot:footer p版权信息在这里/p /template /page-layoutVue2 的插槽语法需要特别注意版本差异Vue 2.6.0 之后推荐使用v-slot旧的slotheader写法依然可用但是在 Vue 2.7 里会被废弃警告。建议新代码统一v-slot。4.3 作用域插槽让父组件拿子组件的数据来拼模板作用域插槽是插槽体系里面最强大的部分。它能让你在父组件编写插槽内容时访问到子组件内部的数据。!-- ItemList.vue -- template ul li v-foritem in items :keyitem.id slot :itemitem :indexindex !-- 默认渲染内容如果父组件不提供插槽内容就显示这段 -- {{ item.name }} /slot /li /ul /template script export default { name: ItemList, data () { return { items: [ { id: 1, name: 苹果, price: 5 }, { id: 2, name: 香蕉, price: 3 } ] } } } /script父组件通过v-slot{ item, index }解构拿到子组件的数据item-list template v-slot:default{ item, index } span{{ index 1 }}. {{ item.name }} —— ¥{{ item.price }}/span /template /item-list作用域插槽最大的价值在于把“数据来源”和“展示方式”解耦。组件负责提供数据表格数据、菜单项、步骤项父组件负责决定这些数据长什么样。同一个Table组件在不同的业务页面里单元格可以渲染成文本、链接、按钮、自定义样式组件完全不需要 Table 自身知道业务形态。很多高级组件库的 Column 自定义渲染底层就是作用域插槽。实际项目里我建议把“作用域插槽”当成一种降低组件耦合度的设计工具而不是炫技。当你发现组件里要加一堆if/else去判断“这个字段是文本还是标签还是图片”时就应该考虑用作用域插槽把决策权交还给调用方。这叫“把变的部分留给使用者”是组件封装的高级思路。5. 动态组件与 keep-alive怎么做到“同一个位置按条件切换组件且不丢状态”业务里经常遇到“同一个容器区域根据状态切换显示不同组件”的需求。比如 Tab 切换、步骤条每一步显示不同表单。Vue2 里直接通过component :is动态组件和keep-alive缓存机制来搞定。5.1 动态组件template div button clickcurrentTab Home首页/button button clickcurrentTab Profile个人中心/button button clickcurrentTab Settings设置/button component :iscurrentTab / /div /template script import Home from ./Home.vue import Profile from ./Profile.vue import Settings from ./Settings.vue export default { name: TabContainer, components: { Home, Profile, Settings }, data () { return { currentTab: Home } } } /script:is接收组件名或组件选项对象。动态组件的底层实现是通过创建新的组件实例来切换视图。默认情况下切换到别的组件后原组件的实例会被销毁实例状态比如表单里输入的内容会丢失。再次切回来组件会重新走 created、mounted 生命周期。如果这不是你想要的行为——比如用户先在“设置”里填写表单临时切到“个人中心”再切回来表单数据却清空了体验就非常糟糕。这时候要用 keep-alive。5.2 keep-alive 缓存组件状态keep-alive是 Vue2 内置组件它不会渲染到 DOM 里作用是在组件切换时把被缓存的组件实例保留下来而不是销毁。template keep-alive component :iscurrentTab / /keep-alive /template被keep-alive包裹的组件会多出两个生命周期钩子activated和deactivated。首次加载进缓存时顺序是created - mounted - activated切走时触发deactivated不会触发destroyed再次切回来只触发activated不会重新走created/mounted。这个差异在做“每次进入页面刷新数据”的时候要特别注意你的初始化逻辑放在created里可能再也不会执行第二次了应该放到activated里。keep-alive 也可以配合include/exclude精确控制哪些组件需要缓存、哪些不需要否则所有组件都缓存也可能造成内存占用过高keep-alive :include[Home, Profile] component :iscurrentTab / /keep-alive5.3 异步组件与按需加载组件如果体积较大或者某些组件只在一个冷门路由里用到直接全部打包进主 bundle 会让首屏加载变慢。Vue2 官方支持异步组件最常用的形式是配合 Webpack 的代码分割const Home () import(./Home.vue) const Profile () import(./Profile.vue) export default { components: { Home, Profile } }这种写法会让这几个组件被单独拆出 chunk只有渲染到这个组件时才加载对应文件。动态组件 异步组件是一个经典组合用于页面内部较重模块的按需加载。异步组件的报错处理和加载状态控制在新版本路由和组件库里有更完善的方案但在 Vue2 原生层面你可以这样写const AsyncComp () ({ component: import(./HeavyWidget.vue), loading: LoadingComponent, error: ErrorComponent, delay: 200, // 延迟 200ms 再显示 loading避免闪烁 timeout: 3000 // 超过 3s 则切换到 error 组件 })实际开发里渲染长列表、图表、富文本编辑器这类重量级模块一定要考虑异步按需加载否则主包体积会一路膨胀。我见过一个后台项目首屏 bundle 有 4MB把五个页面级组件改造成异步加载之后首屏直接少了 1.5MB体感加载时间从 6 秒降到 3 秒这个改造非常划算。6. scoped 样式与样式穿透改子组件样式不变先从这里找原因Vue2 单文件组件里style scoped会通过>/* 你写的 */ .title { color: red; } /* 编译后 */ .title[data-v-1a2b3c] { color: red; }这样两个组件里都写了.title也不会互相影响。scoped 带来的隔离能力确实极大降低了样式冲突但也带来了一个常见困惑父组件想改子组件内部元素的样式发现写了半天的样式根本不生效。比如父组件用了第三方 UI 库的el-dialog想调整对话框内某个内部类的宽度template el-dialog classmy-dialog :visible.syncvisible ... /el-dialog /template style scoped .my-dialog .el-dialog__body { padding: 30px; } /style这通常不会生效因为el-dialog内部元素的>style scoped langscss .my-dialog { ::v-deep .el-dialog__body { padding: 30px; } } /style这段样式编译后会变成.my-dialog[data-v-1a2b3c] .el-dialog__body { padding: 30px; }此时>template div classcarousel :style{ height: height px } mouseenterpause mouseleaveresume div classcarousel__viewport div classcarousel__track :styletrackStyle :class{ is-animating: animating } !-- 核心通过插槽让外部决定每一页长什么样 -- div v-for(item, index) in items :keygetKey(item, index) classcarousel__slide slot :itemitem :indexindex img :srcitem classcarousel__image altcarousel slide /slot /div /div /div div classcarousel__dots span v-for(item, index) in items :keyindex classcarousel__dot :class{ is-active: index currentIndex } clickgoTo(index) / /div /div /template script export default { name: BaseCarousel, props: { items: { type: Array, default: () [] }, height: { type: Number, default: 300 }, autoplay: { type: Boolean, default: true }, interval: { type: Number, default: 3000 }, duration: { type: Number, default: 400 }, initialIndex: { type: Number, default: 0 } }, data () { return { currentIndex: this.initialIndex, animating: false, timer: null } }, computed: { trackStyle () { return { width: ${this.items.length * 100}%, transform: translateX(-${(this.currentIndex / this.items.length) * 100}%), transition: this.animating ? transform ${this.duration}ms ease : none } } }, mounted () { this.resume() }, beforeDestroy () { this.pause() }, methods: { getKey (item, index) { // 如果 item 是字符串直接用值做 key如果 item 是对象用 id 或 index 兜底 return typeof item string ? item : (item.id || index) }, goTo (index) { if (index 0 || index this.items.length || index this.currentIndex) { return } this.animating true this.currentIndex index this.$emit(change, index) setTimeout(() { this.animating false }, this.duration) }, next () { this.goTo((this.currentIndex 1) % this.items.length) }, pause () { window.clearInterval(this.timer) this.timer null }, resume () { if (this.autoplay) { this.pause() this.timer window.setInterval(() { this.next() }, this.interval) } } } } /script style scoped langscss .carousel { position: relative; width: 100%; overflow: hidden; __viewport { width: 100%; height: 100%; } __track { display: flex; height: 100%; } __slide { flex-shrink: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } __image { width: 100%; height: 100%; object-fit: cover; } __dots { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; z-index: 2; } __dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255, 255, 255, 0.5); cursor: pointer; transition: background-color 0.2s; .is-active { background: #fff; } } } /style7.3 这个组件里的关键设计点拆解这个轮播图麻雀虽小但几乎把组件设计的核心原则都装进去了第一关于动画实现。我没有用索引绝对值去计算位移而是用了“百分比 transition”的思路。track宽度被设置成items.length * 100%每一张 slide 宽度是容器宽度的100%所以translateX(-index%)实质上就是移动 index 张幻灯片的距离。这样不管容器多宽动画位移都能自适应不需要监听容器的 resize 去换算像素值。在很多业务项目里这种“用百分比代替像素计算”的技巧能省掉大量兼容代码。第二props 的默认值处理。items的 default 我用了函数返回空数组items: { type: Array, default: () [] }这跟 data 必须是函数是同一个道理。如果写成default: []当多个实例共用这个默认值时理论上存在共享同一个空数组引用的风险一旦有代码不小心 push 数据进去所有实例都会被影响。Vue 对 props 的 default 如果是数组或对象类型也要求必须用工厂函数返回。第三插槽的合理使用。默认插槽内容是一张图片这是最通用的兜底渲染。但调用方如果想展示“图 标题 按钮”的组合卡片可以直接写base-carousel :itemsbanners :height320 template v-slot{ item } div classbanner-item img :srcitem.image alt p{{ item.title }}/p button clickhandleClick(item)查看详情/button /div /template /base-carousel组件完全不关心调用方怎么渲染它只负责轮播逻辑。这就是“数据与展示分离”的具体落地。第四生命周期与资源清理。自动播放的定时器在mounted里启动、在beforeDestroy里清理这是每个组件都该有的自觉。挂在window上的事件、定时器、观察器组件销毁时一定要清理否则就是性能杀手。pause和resume提供给了鼠标进入/离开事件也暴露给了外部调用方可以在跳转路由时暂停播放。第五边界处理。goTo方法里对越界索引做了拦截避免currentIndex跑飞。像这类组件在写的时候就要把异常输入当成默认前提不能假设使用方永远传合法的值。items为空数组时组件也会渲染出一个空 track不会报错这种容错是组件库级别的组件必须具备的素质。8. 踩坑记录与 Vue2/Vue3 过渡期的组件思考最后一节聊点不写进官方文档但一定会遇到的坑以及站在 Vue3 已经普及的今天怎么回看 Vue2 的组件设计。8.1 组件状态不更新的几个经典原因很多人在 Vue2 里写过这种代码点击按钮后数据变了页面却不刷新。排查下来大部分原因集中在以下三个第一个在 data 之外新增属性。Vue2 的响应式系统基于Object.defineProperty对组件实例创建时已有的属性做 getter/setter 劫持。如果你直接给对象新增一个属性this.form.user { name: 李四 } // 页面上不响应这个属性并没有经过响应式转化Vue 检测不到它的变化。正确做法是先用Vue.set或配合this.$set新增属性this.$set(this.form, user, { name: 李四 })或者干脆在 data 初始化时就把user: null声明好再整体赋值。第二个直接通过索引修改数组元素。arr[0] newValue同样不触发视图更新Vue2 不能侦测到数组索引赋值的变化。用this.$set(this.arr, 0, newValue)或者splice才能触发响应式更新。第三个误用普通赋值替代事件更新。在子组件里直接修改 props 对象内部的嵌套属性父组件不感知子组件自身因为 props 是响应式的所以偶尔能“骗”过用户但这种代码一旦遇到父组件重新渲染就会被父组件的旧数据覆盖表现成“数据莫名其妙回退了”。排查这类问题我一般按顺序做三件事在变更处打 console 看数据到底变没变用 Vue Devtools 查看组件树里对应组件的 props/data 实际值检查变更方式是否涉及数组索引、对象新增属性、异步回调里修改数据等绕过响应式机制的操作。别急着怀疑框架绝大多数是使用方式不对。8.2 组件里用第三方库如何避免影响其他页面热搜词里有一条“vue2中vue-konva只在一个页面使用不影响其他页面”这个场景我太熟了。解决办法有两个层面。第一个层面是按需加载。用异步组件把用到 Konva 的页面或组件单独拆包只有进入该页面时才加载相关代码这样其他页面永远不会被动加载这个大库。代码层面就用前面提到的动态 importconst CanvasEditor () import(./CanvasEditor.vue)第二个层面是实例生命周期管理。第三方库通常会在页面挂载时创建自己的实例比如 Konva 的 Stage如果这个实例不销毁即使组件已经被v-if移除它的内存、事件监听、DOM 引用还挂在后台。标准做法是在组件的beforeDestroy钩子里主动执行销毁beforeDestroy () { if (this.stage) { this.stage.destroy() this.stage null } }这个问题在图表组件ECharts、G2、Canvas 库Konva、Pixi、地图库Leaflet、Mapbox里特别常见因为它们的实例往往持有 WebGL / Canvas 上下文不释放的话浏览器显存和内存都会被持续占用。写组件时给自己立一个规矩在 mounted 里创建的全局性资源必须在 beforeDestroy 里回收。8.3 组件职责划分与命名规范组件写多了我反而越来越重视“少而精”的组件设计。单个组件只做一件事但要把这件事做到可复用。给组件命名时我建议遵循“基础组件用 Base 前缀业务组件用业务名词前缀”的习惯。比如BaseButton、BaseDialog、BaseTable负责通用 UIUserCard、OrderForm、GoodsSelector负责业务封装。前缀一区分一个项目里哪些是公共资产、哪些是业务局部代码一目了然。职责划分上我的底线是组件不要同时承担“数据获取”和“纯展示”两个职责。一个组件如果既要在 created 里发请求又要根据 props 渲染复杂视图它的复用性天然会差。推荐的做法是外层“容器组件”负责取数内层“展示组件”负责渲染。取数逻辑变了只改容器展示样式变了只改内层。就像轮播图这个实战案例一样BaseCarousel 内部不关心数据从哪来它只负责把 items 轮播出来。8.4 从 Vue2 转向 Vue3组件创建和使用有哪些变化虽然标题是 Vue2但当下很多项目已经在做 Vue3 迁移我简单提几个组件相关的重要差异方便你站在更高的视角理解 Vue2 的设计选择组合式 APIComposition API成为主流。Vue3 的组件仍然由 props、emit、slots 这些概念组成但逻辑组织方式从“选项分组”变成了“功能聚合”。同一个功能的状态和函数写在一起比在data/methods/computed三个区块里来回跳跃要直观得多。v-model 的 model 选项被彻底改造。Vue3 里自定义组件的 v-model 可以指定多个不再局限在valueinput而是通过modelValueupdate:modelValue实现更加灵活但初学时容易不习惯。响应式系统换成 Proxy。Vue3 用 Proxy 重写了响应式底层新增属性、数组索引修改不再需要$set了这是迁移时最让人松一口气的变化。碎片组件成为可能。Vue3 组件模板不再要求单一根节点template里可以写多个顶层元素静态变量通过>