<?xml version="1.0" encoding="utf-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"><channel><title>code前端网</title><link>https://www.codeqd.com/</link><description>学技术，来code前端开发网</description><item><title>做Vue3项目要不要用专门的动画库？哪个更适合小白+进阶开发者？</title><link>https://www.codeqd.com/post/20261022121.html</link><description>&lt;p&gt;要不要用专门的Vue3动画库,其实可以拆成两个前提场景来看：如果你的项目只需要淡入淡出、上下滑动这种原生Vue自带Transition/TransitionGroup完全能hold住的简单动画，那完全没必要，自带API写起来顺手还轻量；但如果要做复杂点的交互——比如3D轮播、列表拖拽排序的丝滑过渡、多元素联动的时序动画、手势触发的弹性反馈，甚至模拟物理世界的抛物/弹跳效果，那原生API要么写起来非常繁琐（要自己写CSS变量控制、加requestAnimationFrame循环、处理边界条件），要么根本实现不了（比如物理引擎相关的），这时候选个适配好的Vue3动画库就是明智之举，能帮你省至少80%的重复逻辑开发时间，还能保证动画在各种设备上的流畅度和兼容性。&lt;/p&gt;
&lt;h2&gt;先理理Vue3原生过渡到底够不够用？&lt;/h2&gt;
&lt;p&gt;在对比动画库之前,得先对「原生过渡的天花板」有个清晰认知，避免盲目选库增加项目负担。&lt;/p&gt;
&lt;p&gt;Vue3的过渡系统其实已经做了不错的升级：把Vue2里的transition-group组件的v-move钩子内置优化了，用FLIP（First, Last, Invert, Play）算法自动处理列表元素位置变化时的过渡，不用像以前那样手动加transform和过渡时间；还增加了onBeforeEnter/onEnter/onAfterEnter这类纯JS钩子，对需要动态控制DOM样式或者调用第三方非Vue适配动画库（比如GSAP的基础部分）的场景也留了接口；甚至支持CSS变量和SCSS/Less变量混合写，动画参数的复用性比以前好很多。&lt;/p&gt;
&lt;p&gt;但它的局限也很明显：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;时序控制能力弱&lt;/strong&gt;：原生Transition只能通过transition-delay硬排多个元素的入场顺序，一旦要做“第一个元素入场一半第二个再进，同时第三个从侧边滑入补位”这种非线性、条件触发的时序，就得手动监听onAfterEnter事件写嵌套逻辑，代码会变得又臭又长，后期改个时间差都要找半天。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂动画实现成本高&lt;/strong&gt;：比如要做一个“点击卡片后，卡片翻转放大到全屏、内容从底部滑入、顶部导航栏透明渐变隐藏”的多状态联动，原生需要写好几个CSS类、嵌套两层Transition、加一堆state来控制各个过渡的触发时机，可能要花一下午，还容易出现过渡卡顿或者元素重叠的问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有物理模拟和手势绑定&lt;/strong&gt;：原生根本不支持物理引擎，做不出像手机端小红书那种下拉刷新的橡皮筋效果，也做不出购物车添加商品时的抛物飞入效果；至于手势绑定（比如滑动删除、捏合缩放），虽然可以自己结合touch事件写，但要兼容不同设备的触摸频率、处理惯性滑动的边界、避免和浏览器默认手势冲突，工作量不亚于重新开发一个小插件。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SVG和Canvas动画支持差&lt;/strong&gt;：原生Transition主要针对DOM元素，要做路径动画、粒子效果这种SVG/Canvas专属的，基本只能靠纯JS写requestAnimationFrame，或者直接用第三方非Vue适配的库，那还不如一开始就选个能直接整合SVG/Canvas的Vue3动画库。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;现在主流的Vue3动画库有哪些？分别适合什么场景？&lt;/h2&gt;
&lt;p&gt;我整理了目前GitHub星数较高、更新频率稳定、社区活跃度不错的5个Vue3动画库，从「学习成本」「功能覆盖」「性能表现」「社区支持」四个维度给大家做了对比，小白和进阶开发者可以根据自己的项目需求直接挑。&lt;/p&gt;
&lt;h3&gt;GSAP for Vue3（官方名是GSAP Vue Plugin）&lt;/h3&gt;
&lt;h4&gt;核心特点&lt;/h4&gt;
&lt;p&gt;功能最全、性能最好、适用场景最广的“动画界瑞士军刀”，不管是DOM/SVG/Canvas动画，还是3D（结合Three.js）、物理模拟（结合GSAP自带的Physics2D/PhysicsProps插件）、手势触发，它都能完美搞定；时序控制能力是所有动画库中最强的，可以用timeline轻松实现复杂的非线性、嵌套、条件触发动画；而且它的优化做得非常极致——自动处理GPU加速、避免layout thrashing（布局抖动）、兼容所有主流浏览器（包括IE11，如果你的项目还要兼容老IE的话）。&lt;/p&gt;
&lt;h4&gt;学习成本&lt;/h4&gt;
&lt;p&gt;小白入门可能会有点懵,因为GSAP不是专门为Vue设计的，虽然有官方的Vue Plugin简化了和Transition/TransitionGroup的结合，但核心API还是GSAP自己的，需要花1-2天时间看官方文档学基础语法（比如to()/from()/fromTo()三个核心方法，timeline的用法，各种属性的简写）；不过一旦学会了，不管是做Vue项目还是React项目，甚至是纯原生项目，都能用，性价比极高。&lt;/p&gt;
&lt;h4&gt;适合场景&lt;/h4&gt;
&lt;p&gt;所有复杂动画场景：电商平台的商品展示3D轮播、金融APP的K线图动画、教育平台的交互式课件、短视频APP的特效预览、需要兼容老IE的企业级项目。&lt;/p&gt;
&lt;h4&gt;上手小技巧&lt;/h4&gt;
&lt;p&gt;不用一开始就学所有插件,先学核心的to()/from()/fromTo()和timeline，还有Vue Plugin的useGSAP()组合式函数；如果是做Vue3的单元素过渡，可以直接在Transition的onEnter钩子中写gsap.to()，不用配置CSS类；如果是做列表过渡，可以用TransitionGroup结合gsap.fromTo()和FLIP插件，比原生的FLIP效果更丝滑。&lt;/p&gt;
&lt;h3&gt;Framer Motion for Vue3（官方名是Motion One）&lt;/h3&gt;
&lt;h4&gt;核心特点&lt;/h4&gt;
&lt;p&gt;专为现代前端框架设计的动画库,学习成本极低，小白看10分钟官方文档就能上手；语法非常简洁，用声明式的写法（比如给元素加animate属性就能定义动画状态），不用写复杂的JS逻辑；自带手势绑定（点击、滑动、捏合、拖拽）和物理模拟（弹性、惯性、摩擦），不用额外装插件；性能也不错，自动处理GPU加速，动画帧率稳定在60fps；社区活跃度很高，有很多现成的动画组件可以直接用。&lt;/p&gt;
&lt;h4&gt;学习成本&lt;/h4&gt;
&lt;p&gt;几乎为零,因为它的声明式写法和Vue3的模板语法非常契合，比如给div加&lt;code&gt;&amp;lt;motion.div animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 50 }} transition={{ duration: 0.5 }}&amp;gt;&lt;/code&gt;就能实现一个从下方50px淡入的动画；手势绑定更简单，加个drag=&quot;x&quot;属性就能让元素横向拖拽，加个whileHover={{ scale: 1.1 }}属性就能实现 hover 时放大的效果。&lt;/p&gt;
&lt;h4&gt;适合场景&lt;/h4&gt;
&lt;p&gt;交互性强的C端项目：手机端APP的H5页面、小程序（注意：Motion One有专门的小程序版本）、个人博客、电商平台的商品详情页、需要快速上线的MVP项目；不适合需要兼容老IE的项目（最低支持Chrome 80+、Firefox 75+、Safari 14+），也不适合特别复杂的3D或时序动画（虽然timeline功能也有，但比GSAP弱很多）。&lt;/p&gt;
&lt;h4&gt;上手小技巧&lt;/h4&gt;
&lt;p&gt;Motion One的官网有一个“动画 playground”，可以在上面实时调整动画参数，看到效果后直接复制代码到自己的项目里；如果是做列表过渡，可以用&lt;code&gt;&amp;lt;motion-group&amp;gt;&lt;/code&gt;组件，加个staggerChildren属性就能轻松实现列表元素的依次入场；如果是做手势触发的动画，可以结合dragConstraints属性限制拖拽范围，结合onDragEnd事件做惯性滑动。&lt;/p&gt;
&lt;h3&gt;VueUse Motion&lt;/h3&gt;
&lt;h4&gt;核心特点&lt;/h4&gt;
&lt;p&gt;VueUse官方推出的动画库,完全基于Vue3的组合式API设计，和VueUse的其他函数（比如useMouse、useScroll、useDark）结合起来非常顺手；学习成本比GSAP低，比Motion One稍高，因为它是函数式的写法，不是声明式的；自带基础的过渡、时序控制、手势绑定、物理模拟功能；轻量得离谱，核心包只有2KB左右（gzip压缩后）；社区活跃度也不错，因为是VueUse官方的，更新频率有保证。&lt;/p&gt;
&lt;h4&gt;学习成本&lt;/h4&gt;
&lt;p&gt;中等,需要先熟悉Vue3的组合式API（比如ref、reactive、watchEffect）；核心API只有几个：useMotion、useSpring、useScroll、useTimeline；比如用useSpring实现一个弹性动画：&lt;code&gt;const { x } = useSpring({ x: 0 }, { stiffness: 300, damping: 20 });&lt;/code&gt;，然后把x绑定到div的style.transform上，就能实现点击按钮后x轴方向的弹性移动。&lt;/p&gt;
&lt;h4&gt;适合场景&lt;/h4&gt;
&lt;p&gt;已经在使用VueUse的项目、追求轻量的项目、需要和Vue的响应式数据深度结合的项目；不适合特别复杂的3D或SVG/Canvas高级动画，也不适合需要兼容老IE的项目。&lt;/p&gt;
&lt;h4&gt;上手小技巧&lt;/h4&gt;
&lt;p&gt;VueUse Motion的官网有很多和VueUse其他函数结合的示例，比如结合useScroll实现滚动触发的视差动画，结合useMouse实现跟随鼠标移动的粒子效果；如果是做单元素过渡，可以结合Transition组件的onBeforeEnter钩子，把useMotion的初始状态设为隐藏，在onEnter钩子中触发动画；如果是做列表过渡，可以结合TransitionGroup组件和useTimeline，用timeline的add()方法依次添加每个列表元素的动画。&lt;/p&gt;
&lt;h3&gt;Anime.js for Vue3（非官方，但有不错的Vue3适配器）&lt;/h3&gt;
&lt;h4&gt;核心特点&lt;/h4&gt;
&lt;p&gt;轻量、灵活、语法简洁的动画库，核心包只有17KB左右（gzip压缩后）；支持DOM/SVG/JS对象动画，自带基础的时序控制和路径动画功能；学习成本中等，小白看1-2小时官方文档就能上手；社区活跃度曾经很高，但最近几年更新频率有所下降；不支持物理模拟和手势绑定，需要额外装插件。&lt;/p&gt;
&lt;h4&gt;学习成本&lt;/h4&gt;
&lt;p&gt;中等,核心API只有anime()这一个构造函数，通过配置参数定义动画；比如用anime()实现一个路径动画：&lt;code&gt;anime({ targets: &#039;.ball&#039;, translateX: anime.path(&#039;.path&#039;), duration: 3000, loop: true, easing: &#039;linear&#039; });&lt;/code&gt;，就能让小球沿着指定的SVG路径无限循环运动。&lt;/p&gt;
&lt;h4&gt;适合场景&lt;/h4&gt;
&lt;p&gt;需要做路径动画的项目、已经在使用Anime.js的老项目升级到Vue3、追求轻量但不需要物理模拟和手势绑定的项目；不适合交互性强的C端项目，也不适合需要兼容老IE的项目（最低支持Chrome 60+、Firefox 55+、Safari 12+）。&lt;/p&gt;
&lt;h4&gt;上手小技巧&lt;/h4&gt;
&lt;p&gt;虽然Anime.js没有官方的Vue3适配器，但可以用VueUse的useEventListener函数监听Transition/TransitionGroup的钩子，在钩子中调用anime()；也可以直接在setup()函数中用ref获取DOM元素，然后作为targets传给anime()；Anime.js的官网有很多现成的SVG路径动画示例，可以直接拿来用。&lt;/p&gt;
&lt;h3&gt;Lottie for Vue3（官方名是lottie-web-vue）&lt;/h3&gt;
&lt;h4&gt;核心特点&lt;/h4&gt;
&lt;p&gt;专门用于播放After Effects（AE）导出的JSON格式动画的库，不需要写任何动画代码，只要让UI设计师用AE做动画，然后用Bodymovin插件导出JSON文件，再用lottie-web-vue组件播放就行；支持所有主流浏览器（包括IE11），性能也不错，自动处理GPU加速；社区活跃度很高，有很多现成的Lottie动画可以在LottieFiles、Icons8等平台免费下载。&lt;/p&gt;
&lt;h4&gt;学习成本&lt;/h4&gt;
&lt;p&gt;几乎为零,只要会引入组件、传入JSON文件的路径就行；比如&lt;code&gt;&amp;lt;lottie-web-vue :animationData=&quot;animationData&quot; loop autoplay /&amp;gt;&lt;/code&gt;就能播放一个循环自动播放的Lottie动画。&lt;/p&gt;
&lt;h4&gt;适合场景&lt;/h4&gt;
&lt;p&gt;需要做复杂的、视觉效果好的、但不需要交互的动画的项目：APP的启动页、加载页、空状态页、电商平台的节日活动页；不适合需要交互的动画（虽然Lottie也支持基础的交互，比如点击触发某个片段，但实现起来比较麻烦，需要UI设计师在AE中设置好标记点），也不适合需要动态修改动画参数的项目（除非重新导出JSON文件）。&lt;/p&gt;
&lt;h4&gt;上手小技巧&lt;/h4&gt;
&lt;p&gt;LottieFiles平台有很多免费的、高质量的Lottie动画，可以按关键词搜索，下载后直接用；如果需要基础的交互，可以让UI设计师在AE中用Bodymovin插件设置好片段的标记点，然后用lottie-web-vue组件的goToAndPlay()方法播放指定片段；如果需要修改Lottie动画的颜色，可以用lottie-web-vue组件的rendererSettings属性中的preserveAspectRatio和colorFilters参数，不用重新导出JSON文件。&lt;/p&gt;
&lt;h2&gt;小白和进阶开发者分别应该怎么选？&lt;/h2&gt;
&lt;h3&gt;小白开发者&lt;/h3&gt;
&lt;p&gt;如果你的项目是交互性强的C端项目（比如H5页面、个人博客），或者需要快速上线的MVP项目，选&lt;strong&gt;Motion One&lt;/strong&gt;，学习成本几乎为零，声明式的写法和Vue3的模板语法非常契合，能让你快速做出好看的动画效果；如果你的项目需要做复杂的、视觉效果好的、但不需要交互的动画，选&lt;strong&gt;Lottie for Vue3&lt;/strong&gt;，不用写任何动画代码，只要让UI设计师配合就行；如果你的项目已经在使用VueUse，或者追求轻量，选&lt;strong&gt;VueUse Motion&lt;/strong&gt;，和VueUse的其他函数结合起来非常顺手。&lt;/p&gt;
&lt;h3&gt;进阶开发者&lt;/h3&gt;
&lt;p&gt;如果你的项目是需要做所有复杂动画场景的企业级项目（比如电商平台、金融APP），或者需要兼容老IE，选&lt;strong&gt;GSAP for Vue3&lt;/strong&gt;，功能最全、性能最好、性价比最高；如果你的项目需要做路径动画，或者已经在使用Anime.js的老项目升级到Vue3，选&lt;strong&gt;Anime.js for Vue3&lt;/strong&gt;，轻量、灵活、语法简洁；如果你的项目需要做复杂的、视觉效果好的、但不需要交互的动画，选&lt;strong&gt;Lottie for Vue3&lt;/strong&gt;，不用写任何动画代码。&lt;/p&gt;
&lt;h2&gt;最后给大家几个使用Vue3动画库的小建议&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不要盲目选库&lt;/strong&gt;：先看原生过渡能不能hold住，能hold住就不用选，增加项目负担；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;选更新频率稳定、社区活跃度高的库&lt;/strong&gt;：避免选那种已经停止维护的库，后期出了问题没人解决；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意动画的性能&lt;/strong&gt;：尽量用transform和opacity做动画，避免用width、height、margin、padding等会触发layout的属性；自动处理GPU加速（大部分动画库都会自动处理，但也要注意不要过度使用GPU加速，会导致内存占用过高）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意动画的时长和缓动函数&lt;/strong&gt;：动画的时长一般控制在0.3-0.5秒之间，太长会让用户觉得不耐烦，太短会让用户看不清；缓动函数尽量用自然一点的，比如ease-out、ease-in-out，不要用linear（太生硬）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意动画的可访问性&lt;/strong&gt;：给动画元素加prefers-reduced-motion媒体查询，当用户在系统设置中关闭了动画时，自动禁用不必要的动画；给有交互的动画元素加ARIA属性，方便视障用户使用。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;做Vue3项目要不要用专门的动画库,取决于你的项目需求；选哪个动画库，取决于你的学习成本和项目场景，希望这篇文章能帮到大家！&lt;/p&gt;</description><pubDate>Thu, 08 Oct 2026 21:58:32 +0800</pubDate></item><item><title>Vue3自定义指令用在哪？怎么比组件更轻量高效？新手也能快速上手吗？</title><link>https://www.codeqd.com/post/20261022120.html</link><description>&lt;h2&gt;先别急着学，得搞懂Vue3自定义指令的「正确打开场景」&lt;/h2&gt;
&lt;p&gt;很多人学Vue3，要么跳过directive直奔Composition API和组件库，要么学了点皮毛就到处乱用，反而把代码搞乱了，其实Vue官方文档里明确提过：自定义指令主要是用来封装&lt;strong&gt;纯DOM操作逻辑&lt;/strong&gt;的，不能替代组件的职责——组件管「视图结构复用」「业务逻辑关联」，指令管「和DOM节点强绑定、通用的、小粒度的交互或效果」。&lt;/p&gt;
&lt;p&gt;举几个你天天写代码都可能遇到但没意识到能靠指令解决的场景：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;表单输入自动聚焦&lt;/strong&gt;：虽然原生input有autofocus属性，但它只在页面首次加载时生效，像弹窗打开、条件渲染后的input，autofocus就没用了，这时候写一个v-focus指令,比每次写onMounted获取DOM聚焦要简洁10倍。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图片懒加载+占位图+错误降级&lt;/strong&gt;：很多UI组件库有图片组件，但如果你项目里没用大库，或者只需要懒加载几个核心位置的图，单独写个组件太冗余，v-lazy就是完美选择。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文本溢出省略+鼠标悬停显示完整内容&lt;/strong&gt;：同样，UI库有tooltip，但如果只是想给表格里的某几列文本加这个功能，不需要复杂的tooltip样式，v-ellipsis-tooltip就能搞定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无限滚动&lt;/strong&gt;：监听scroll事件是个力气活，每次都要写节流、判断距离底部高度、执行回调，封装成v-infinite-scroll,一个指令绑定容器就能跑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;权限控制DOM显示隐藏/禁用&lt;/strong&gt;：比如管理员才能看到的「删除按钮」，普通用户只能看不能点的「提交按钮」，用v-permission比v-if判断一堆权限码要直观，而且权限逻辑统一管理,改起来方便。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;要是反过来，把带复杂视图结构的功能（比如一个下拉菜单、一个弹窗）封装成指令，那就完全走偏了——指令没法方便地传递插槽、复杂props，也没法和Vue的响应式系统深度结合,最后只会写出一堆难维护的代码。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;为什么说Vue3自定义指令比「原生JS操作」「轻量组件」更轻量高效？&lt;/h2&gt;
&lt;h3&gt;（一）比原生JS操作：不用手动管理DOM的生命周期&lt;/h3&gt;
&lt;p&gt;原生JS操作DOM，最头疼的就是&lt;strong&gt;生命周期绑定&lt;/strong&gt;：比如你写了一个无限滚动的监听函数，当组件销毁的时候，必须手动移除这个事件监听器，不然就会内存泄漏；条件渲染的input,每次渲染都要重新写focus的代码。&lt;/p&gt;
&lt;p&gt;但Vue3的自定义指令自带&lt;strong&gt;钩子函数&lt;/strong&gt;，完全贴合Vue组件的生命周期，能自动帮你处理这些麻烦事，举个简单的v-focus指令的钩子对比：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原生JS实现条件渲染input自动聚焦：要写在父组件的onMounted和onUpdated里，还要用ref获取DOM，代码散、重复率高；&lt;/li&gt;
&lt;li&gt;Vue3 v-focus指令实现：只需要在directive的mounted钩子（组件挂载后）和updated钩子（组件更新后，但注意只有绑定的值或者指令的参数变化才会触发，Vue3还有个更细的钩子叫componentUpdated，看后面的API介绍）里写一行el.focus()就行，Vue会自动在合适的时机调用，不用你管什么时候销毁、什么时候重新获取。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;（二）比轻量组件：减少虚拟DOM的开销&lt;/h3&gt;
&lt;p&gt;不管是组件还是原生HTML标签，在Vue里都会先生成虚拟DOM，然后再比对、渲染到真实DOM上，轻量组件虽然代码不多，但也会多生成一层虚拟DOM节点——比如把文本溢出省略做成一个轻量组件，可能生成的真实DOM是&lt;code&gt;&amp;lt;div class=&quot;ellipsis-wrapper&quot;&amp;gt;&amp;lt;span&amp;gt;文本&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;，但如果做成v-ellipsis指令，直接在原来的&lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;标签上绑定就行，真实DOM还是一层,虚拟DOM也少了一层。&lt;/p&gt;
&lt;p&gt;别小看这一层虚拟DOM的开销，如果你项目里有几百上千个表格单元格都用这个组件，那虚拟DOM的比对、渲染速度就会明显变慢；但用指令的话,几乎不会增加额外的开销。&lt;/p&gt;
&lt;h3&gt;（三）复用性和可维护性更高&lt;/h3&gt;
&lt;p&gt;原生JS的逻辑散在各个组件里，改一个效果要找遍所有用的地方；轻量组件虽然复用性好，但如果逻辑太细，组件库就会变得很臃肿,找起来也麻烦。&lt;/p&gt;
&lt;p&gt;自定义指令可以&lt;strong&gt;全局注册&lt;/strong&gt;，也可以&lt;strong&gt;局部注册&lt;/strong&gt;，逻辑统一放在一个文件里，改起来只要动一处，而且指令的参数、修饰符、绑定值都可以灵活配置，比如v-permission可以传&lt;code&gt;[&#039;admin&#039;, &#039;editor&#039;]&lt;/code&gt;数组，也可以传单个&lt;code&gt;&#039;admin&#039;&lt;/code&gt;字符串，还可以加&lt;code&gt;.disable&lt;/code&gt;修饰符表示禁用而不是隐藏，比组件的props配置更轻量、更直观。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;新手别怕！Vue3自定义指令的核心API超简单，5分钟就能写一个能用的&lt;/h2&gt;
&lt;p&gt;Vue3的自定义指令API和Vue2有一些区别，但比Vue2更简洁、更贴合Composition API的风格，核心就是&lt;strong&gt;5个钩子函数&lt;/strong&gt;和&lt;strong&gt;4个钩子参数&lt;/strong&gt;，我们一个一个说，最后再写3个实用的指令示例,保证你看完就能动手。&lt;/p&gt;
&lt;h3&gt;（一）核心钩子函数：别死记硬背，理解生命周期就行&lt;/h3&gt;
&lt;p&gt;Vue3的自定义指令钩子函数和组件的生命周期钩子是一一对应的，甚至名字都差不多,记起来很容易：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;created&lt;/strong&gt;：在绑定元素的属性或事件监听器被应用之前调用，这个钩子用得不多,一般用来做一些不需要DOM的初始化工作。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;beforeMount&lt;/strong&gt;：在绑定元素被挂载到DOM之前调用，这个钩子也用得少,因为还没法操作真实DOM。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mounted&lt;/strong&gt;：在绑定元素被挂载到DOM之后调用，这个钩子是最常用的，比如v-focus的聚焦、v-lazy的初始化占位图、v-infinite-scroll的绑定scroll事件,都可以写在这里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;beforeUpdate&lt;/strong&gt;：在包含绑定元素的组件更新之前调用，但注意&lt;strong&gt;不会在绑定元素自身的属性更新时调用&lt;/strong&gt;（除非绑定的值或参数变了）,这个钩子用得也不多。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;updated&lt;/strong&gt;：在包含绑定元素的组件更新之后调用，同样要配合绑定值或参数变化，Vue3还新增了一个&lt;strong&gt;componentUpdated&lt;/strong&gt;钩子，区别不大？其实componentUpdated是&lt;strong&gt;在包含绑定元素的VNode及其子VNode更新之后调用&lt;/strong&gt;，updated是&lt;strong&gt;在包含绑定元素的VNode更新之后、子VNode更新之前调用&lt;/strong&gt;——不过一般新手用componentUpdated就行,不容易出错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;beforeUnmount&lt;/strong&gt;：在绑定元素被卸载之前调用，这个钩子也很常用，比如v-infinite-scroll的移除scroll事件监听器、v-lazy的取消图片预加载，都可以写在这里,防止内存泄漏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;unmounted&lt;/strong&gt;：在绑定元素被卸载之后调用，这个钩子用得很少,因为beforeUnmount已经能处理大部分清理工作了。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;（二）核心钩子参数：看懂这4个，指令就能灵活配置了&lt;/h3&gt;
&lt;p&gt;每个钩子函数都接收4个参数,按顺序是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;el&lt;/strong&gt;：绑定指令的真实DOM元素，直接用原生JS操作它就行，比如el.style.color = &#039;red&#039;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;binding&lt;/strong&gt;：一个对象，包含指令的所有信息，这是最核心的参数：&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;name&lt;/strong&gt;：指令的名字，不带v-前缀，比如v-focus的name就是&#039;focus&#039;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;value&lt;/strong&gt;：指令的绑定值，比如v-permission=&quot;[&#039;admin&#039;]&quot;的value就是[&#039;admin&#039;]数组，v-ellipsis:2=&quot;true&quot;的value就是true布尔值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;oldValue&lt;/strong&gt;：指令的上一个绑定值，只有beforeUpdate和updated/componentUpdated钩子才有,用来判断绑定值有没有变化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;arg&lt;/strong&gt;：指令的参数，比如v-ellipsis:2的arg就是&#039;2&#039;（字符串哦，记得转成数字），v-on:click的arg就是&#039;click&#039;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;modifiers&lt;/strong&gt;：一个对象，包含指令的所有修饰符，比如v-permission.disable的modifiers就是{ disable: true }，v-on.stop.prevent的modifiers就是{ stop: true, prevent: true }。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;instance&lt;/strong&gt;：使用该指令的组件实例，Vue3里这个是Proxy对象，新手尽量少直接操作它,除非你真的知道自己在做什么。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dir&lt;/strong&gt;：指令定义对象本身,新手也用得少。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;vnode&lt;/strong&gt;：绑定元素对应的虚拟DOM节点,新手一般不用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;prevVnode&lt;/strong&gt;：上一个虚拟DOM节点，只有beforeUpdate和updated/componentUpdated钩子才有,新手也不用。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;（三）新手必练的3个实用自定义指令示例&lt;/h3&gt;
&lt;h4&gt;v-focus：条件渲染/弹窗后的自动聚焦&lt;/h4&gt;
&lt;p&gt;这个是最简单但最常用的指令，我们先写全局注册的版本，再写局部注册的版本。
全局注册的话，在main.js/main.ts里写：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// main.js
import { createApp } from &amp;#39;vue&amp;#39;
import App from &amp;#39;./App.vue&amp;#39;
const app = createApp(App)
// 全局注册v-focus指令
app.directive(&amp;#39;focus&amp;#39;, {
  // 挂载后聚焦
  mounted(el) {
    el.focus()
  },
  // 组件更新后（比如条件渲染显示了）重新聚焦
  componentUpdated(el, binding) {
    // 只有绑定值为true的时候才聚焦，不然每次更新都聚焦会很烦
    if (binding.value) {
      el.focus()
    }
  }
})
app.mount(&amp;#39;#app&amp;#39;)&lt;/pre&gt;
&lt;p&gt;然后在组件里用：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;button @click=&amp;quot;showInput = !showInput&amp;quot;&amp;gt;切换输入框&amp;lt;/button&amp;gt;
    &amp;lt;!-- 只有showInput为true的时候才显示并聚焦 --&amp;gt;
    &amp;lt;input v-if=&amp;quot;showInput&amp;quot; v-focus=&amp;quot;showInput&amp;quot; type=&amp;quot;text&amp;quot; placeholder=&amp;quot;自动聚焦哦&amp;quot;&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
const showInput = ref(false)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;局部注册的话,直接在组件的script里定义directives对象就行：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { ref, directive } from &amp;#39;vue&amp;#39; // Vue3.2+可以直接用directive()函数定义局部指令
const showInput = ref(false)
// 局部注册v-focus指令，用directive()函数或者directives对象都可以
const vFocus = {
  mounted(el) {
    el.focus()
  },
  componentUpdated(el, binding) {
    if (binding.value) el.focus()
  }
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;v-lazy：图片懒加载+占位图+错误降级&lt;/h4&gt;
&lt;p&gt;这个稍微复杂一点，但也很实用，我们需要用到Intersection Observer API（比监听scroll事件性能好太多，不会卡顿）。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 先在src/directives/lazy.js里定义全局指令
export default {
  mounted(el, binding) {
    // 初始化：用占位图替换真实src，把真实src存到data-src里
    const placeholder = binding.arg || &amp;#39;https://via.placeholder.com/300x200?text=加载中...&amp;#39;
    const errorImg = binding.modifiers.error ? &amp;#39;https://via.placeholder.com/300x200?text=加载失败&amp;#39; : placeholder
    el.src = placeholder
    el.dataset.src = binding.value || &amp;#39;&amp;#39;
    el.dataset.error = errorImg
    // 创建Intersection Observer实例
    const observer = new IntersectionObserver((entries) =&amp;gt; {
      entries.forEach(entry =&amp;gt; {
        // 如果元素进入视口
        if (entry.isIntersecting) {
          const img = entry.target
          // 加载真实图片
          const realSrc = img.dataset.src
          if (realSrc) {
            img.src = realSrc
            // 图片加载成功后取消观察
            img.onload = () =&amp;gt; {
              observer.unobserve(img)
            }
            // 图片加载失败后用错误图，然后取消观察
            img.onerror = () =&amp;gt; {
              img.src = img.dataset.error
              observer.unobserve(img)
            }
          }
        }
      })
    }, {
      // 可以配置rootMargin，提前一点加载，比如100px
      rootMargin: &amp;#39;100px 0px&amp;#39;,
      threshold: 0.1 // 元素进入视口10%就触发
    })
    // 观察当前元素
    observer.observe(el)
    // 把observer实例存到el._lazyObserver里，方便卸载的时候取消观察
    el._lazyObserver = observer
  },
  beforeUnmount(el) {
    // 卸载前取消观察，防止内存泄漏
    if (el._lazyObserver) {
      el._lazyObserver.unobserve(el)
      el._lazyObserver = null
    }
  }
}&lt;/pre&gt;
&lt;p&gt;然后在main.js里全局注册：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import vLazy from &amp;#39;./directives/lazy.js&amp;#39;
app.directive(&amp;#39;lazy&amp;#39;, vLazy)&lt;/pre&gt;
&lt;p&gt;在组件里用：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;img-list&amp;quot;&amp;gt;
    &amp;lt;!-- 循环渲染100张图片，真实src用picsum的随机图 --&amp;gt;
    &amp;lt;img 
      v-for=&amp;quot;i in 100&amp;quot; 
      :key=&amp;quot;i&amp;quot; 
      v-lazy.error=&amp;quot;`https://picsum.photos/300/200?random=${i}`&amp;quot; 
      :arg=&amp;quot;&amp;#39;https://via.placeholder.com/300x200?text=我的占位图&amp;#39;&amp;quot;
      alt=&amp;quot;懒加载图片&amp;quot;
    &amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
.img-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 20px;
}
.img-list img {
  width: 300px;
  height: 200px;
  object-fit: cover;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;注意哦，这里的arg我用了&lt;code&gt;arg&lt;/code&gt;的方式传，因为默认arg是字符串，没法传变量，这种写法是Vue3支持的动态指令参数,非常实用。&lt;/p&gt;
&lt;h4&gt;v-permission：权限控制DOM显示隐藏/禁用&lt;/h4&gt;
&lt;p&gt;这个是企业级项目里常用的，我们需要先有一个全局的权限管理机制，比如从localStorage或者Vuex/Pinia里获取当前用户的权限码。
先假设我们有一个Pinia store叫usePermissionStore，里面有一个currentPermissions数组,存的是当前用户的权限码：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// src/stores/permission.js
import { defineStore } from &amp;#39;pinia&amp;#39;
export const usePermissionStore = defineStore(&amp;#39;permission&amp;#39;, {
  state: () =&amp;gt; ({
    currentPermissions: [&amp;#39;admin&amp;#39;, &amp;#39;editor&amp;#39;] // 模拟当前用户是管理员和编辑
  }),
  getters: {
    hasPermission: (state) =&amp;gt; (permission) =&amp;gt; {
      if (!permission) return true
      if (Array.isArray(permission)) {
        return permission.some(p =&amp;gt; state.currentPermissions.includes(p))
      }
      return state.currentPermissions.includes(permission)
    }
  }
})&lt;/pre&gt;
&lt;p&gt;然后定义v-permission指令：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// src/directives/permission.js
import { usePermissionStore } from &amp;#39;../stores/permission.js&amp;#39;
export default {
  mounted(el, binding) {
    const permissionStore = usePermissionStore()
    const hasPermission = permissionStore.hasPermission(binding.value)
    const modifiers = binding.modifiers
    if (!hasPermission) {
      // 如果有disable修饰符，就禁用元素
      if (modifiers.disable) {
        el.disabled = true
        el.style.opacity = 0.5
        el.style.cursor = &amp;#39;not-allowed&amp;#39;
        // 还要阻止点击事件哦，不然光禁用样式没用
        el.addEventListener(&amp;#39;click&amp;#39;, (e) =&amp;gt; e.stopPropagation(), true)
        el._preventClick = true
      } else {
        // 否则就隐藏元素，用display: none比v-if轻量，而且不会影响虚拟DOM的位置
        el.style.display = &amp;#39;none&amp;#39;
      }
    }
  },
  beforeUnmount(el) {
    // 卸载前移除阻止点击事件的监听器
    if (el._preventClick) {
      el.removeEventListener(&amp;#39;click&amp;#39;, (e) =&amp;gt; e.stopPropagation(), true)
      el._preventClick = false
    }
  }
}&lt;/pre&gt;
&lt;p&gt;全局注册后在组件里用：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;permission-demo&amp;quot;&amp;gt;
    &amp;lt;button v-permission=&amp;quot;[&amp;#39;admin&amp;#39;]&amp;quot;&amp;gt;只有管理员能看到的删除按钮&amp;lt;/button&amp;gt;
    &amp;lt;button v-permission=&amp;quot;[&amp;#39;admin&amp;#39;, &amp;#39;editor&amp;#39;]&amp;quot;&amp;gt;管理员和编辑能看到的编辑按钮&amp;lt;/button&amp;gt;
    &amp;lt;button v-permission.disable=&amp;quot;[&amp;#39;superadmin&amp;#39;]&amp;quot;&amp;gt;只有超级管理员能点的提交按钮&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;写Vue3自定义指令的几个避坑指南&lt;/h2&gt;
&lt;p&gt;虽然Vue3自定义指令简单，但新手还是容易踩几个坑,这里总结一下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不要滥用指令&lt;/strong&gt;：开头说了，指令只能封装纯DOM操作逻辑,不能替代组件。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意钩子函数的触发时机&lt;/strong&gt;：比如created钩子还没法操作真实DOM，mounted钩子是最合适的；beforeUnmount钩子一定要做清理工作，比如移除事件监听器、取消Intersection Observer观察,防止内存泄漏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动态指令参数要用&lt;code&gt;arg&lt;/code&gt;的方式传&lt;/strong&gt;：默认的arg是静态字符串，比如v-ellipsis:2的arg是&#039;2&#039;，如果要传变量，比如v-ellipsis:[lineCount]，lineCount是响应式的，那lineCount变化的时候，钩子函数的arg也会变，这时候就可以在updated/componentUpdated钩子函数里重新计算文本溢出的行数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;binding.value是响应式的，但指令本身不会自动监听响应式数据的变化&lt;/strong&gt;：除非响应式数据变化导致包含绑定元素的VNode更新，这时候beforeUpdate和updated/componentUpdated钩子才会触发，如果要让指令主动监听响应式数据的变化，可以在mounted钩子函数里用watch/watchEffect,不过要记得在beforeUnmount钩子函数里停止监听。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;全局注册的指令名字不要和原生HTML属性或事件冲突&lt;/strong&gt;：比如不要叫v-class、v-style、v-on,不然会覆盖Vue内置的指令。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;企业级项目里最好把所有自定义指令放在单独的目录里统一管理&lt;/strong&gt;：比如src/directives，然后在main.js里批量全局注册,方便维护和复用。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;p&gt;Vue3自定义指令虽然不是必须学的，但学会了之后能帮你&lt;strong&gt;简化代码&lt;/strong&gt;、&lt;strong&gt;提升性能&lt;/strong&gt;、&lt;strong&gt;提高复用性和可维护性&lt;/strong&gt;，核心就是记住它的「正确打开场景」——纯DOM操作逻辑，然后掌握5个核心钩子函数和4个核心钩子参数，再练几个实用的指令示例,新手也能快速上手。&lt;/p&gt;
&lt;p&gt;最后再提一句，如果你项目里用了Element Plus、Ant Design Vue这样的UI组件库，其实它们已经内置了很多实用的自定义指令，比如Element Plus的v-loading、v-infinite-scroll、v-permission（Pro版本才有），可以直接用，不用自己重复造轮子，但如果内置的指令满足不了你的需求，或者你项目里没用大库,那自己写自定义指令就是最好的选择。&lt;/p&gt;</description><pubDate>Thu, 08 Oct 2026 16:24:22 +0800</pubDate></item><item><title>Vue3动画怎么实现 新手常用方法有哪些坑</title><link>https://www.codeqd.com/post/20261022119.html</link><description>&lt;p&gt;很多刚学Vue3的前端新手都会问,动画明明CSS3就能写，为什么还要用Vue3的内置组件或者组合式API？其实CSS3处理简单的入场退场、 hover效果没问题，但遇到列表元素动态排序、组件切换过渡链、复杂的基于状态的连续动画这些场景，纯CSS3就有点力不从心了——要么得手动加一堆class、定时器，要么代码耦合度高，维护起来头疼，Vue3刚好把这些复杂场景的动画逻辑封装好了，只要按规则用，就能写出优雅、易维护的动画。&lt;/p&gt;
&lt;h2&gt;先从最简单的单元素/组件过渡讲起&lt;/h2&gt;
&lt;h3&gt;过渡的核心是什么？&lt;/h3&gt;
&lt;p&gt;Vue3的单元素/组件过渡，本质是在元素进入、离开DOM的特定时机自动添加/删除预设的class，当你用&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;包裹一个带&lt;code&gt;v-if&lt;/code&gt;、&lt;code&gt;v-show&lt;/code&gt;、&lt;code&gt;is&lt;/code&gt;动态组件切换的元素时，Vue会在这个过程中触发6个关键的时间点，给它加对应的过渡类名。&lt;/p&gt;
&lt;h3&gt;那6个类名是什么？记不住怎么办？&lt;/h3&gt;
&lt;p&gt;不用死记硬背所有细节,先把基础的3组核心概念搞清楚就行：进入（enter）和离开（leave）、起始（start）、激活（active）、结束（to），简单拆解的话，每组过程（enter/leave）都是从“加起始类→激活动画→删起始类加结束类→动画完成后删结束类和激活类”这么个流程。
举个例子，用自定义过渡名&lt;code&gt;fade&lt;/code&gt;的话，对应的类名就是&lt;code&gt;fade-enter&lt;/code&gt;（进入前状态）、&lt;code&gt;fade-enter-active&lt;/code&gt;（进入动画激活，整个过程都存在，用来定义transition属性）、&lt;code&gt;fade-enter-to&lt;/code&gt;（进入后状态）；离开类名是一样的逻辑，把enter换成leave就行。
如果没给过渡名，默认是&lt;code&gt;v-enter&lt;/code&gt;这些前缀。&lt;/p&gt;
&lt;h3&gt;能不能写个最基础的淡入淡出例子？&lt;/h3&gt;
&lt;p&gt;当然可以,就拿一个带&lt;code&gt;v-if&lt;/code&gt;的按钮切换的div来说：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;button @click=&amp;quot;isShow = !isShow&amp;quot;&amp;gt;切换淡入淡出效果&amp;lt;/button&amp;gt;
  &amp;lt;!-- 这里用自定义过渡名fade --&amp;gt;
  &amp;lt;transition name=&amp;quot;fade&amp;quot;&amp;gt;
    &amp;lt;div v-if=&amp;quot;isShow&amp;quot; class=&amp;quot;box&amp;quot;&amp;gt;我是淡入淡出的内容&amp;lt;/div&amp;gt;
  &amp;lt;/transition&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
const isShow = ref(true)
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.box {
  width: 200px;
  height: 100px;
  background-color: #42b983;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  border-radius: 8px;
}
/* 进入激活和离开激活都要加transition属性，因为入场退场都是用这个类控制动画时长、曲线 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease-in-out;
}
/* 淡入前透明，淡入后不透明 */
.fade-enter-from {
  opacity: 0;
}
/* 注意哦！Vue3把Vue2的v-enter和v-leave改成了v-enter-from和v-leave-from！很多新手会踩这个坑！ */
.fade-leave-to {
  opacity: 0;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;对了,刚才提了Vue2到Vue3的第一个大坑：过渡类名的前缀变了！Vue2里进入前是&lt;code&gt;v-enter&lt;/code&gt;，离开前是&lt;code&gt;v-leave&lt;/code&gt;，Vue3统一加了&lt;code&gt;-from&lt;/code&gt;后缀，变成&lt;code&gt;v-enter-from&lt;/code&gt;、&lt;code&gt;v-leave-from&lt;/code&gt;了，很多新手直接抄Vue2的代码，结果动画没反应，找半天bug才发现这里改了。&lt;/p&gt;
&lt;h2&gt;什么时候用CSS动画代替CSS过渡？&lt;/h2&gt;
&lt;p&gt;刚才的淡入淡出是“从一个状态到另一个状态”的简单变化，用CSS transition没问题，但如果是需要多帧循环的动画（比如抖动、旋转、弹跳入场），就得用CSS keyframes动画了。&lt;/p&gt;
&lt;h3&gt;CSS动画和过渡的类名使用有啥不一样？&lt;/h3&gt;
&lt;p&gt;CSS transition类名里的&lt;code&gt;*-active&lt;/code&gt;主要用来加&lt;code&gt;transition&lt;/code&gt;属性，而CSS keyframes里的&lt;code&gt;*-active&lt;/code&gt;主要用来绑定&lt;code&gt;animation&lt;/code&gt;属性（包括动画名、时长、次数这些）；起始类&lt;code&gt;*-enter-from&lt;/code&gt;和结束类&lt;code&gt;*-leave-to&lt;/code&gt;一般可以不用写，因为keyframes里已经定义好了起始和结束帧的状态。&lt;/p&gt;
&lt;h3&gt;写个弹跳入场的例子试试？&lt;/h3&gt;
&lt;p&gt;比如一个表单提交成功的提示框,点击显示时弹跳入场，点击关闭时直接消失：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;button @click=&amp;quot;showToast = !showToast&amp;quot;&amp;gt;显示成功提示&amp;lt;/button&amp;gt;
  &amp;lt;transition name=&amp;quot;bounce&amp;quot;&amp;gt;
    &amp;lt;div v-if=&amp;quot;showToast&amp;quot; class=&amp;quot;toast&amp;quot; @click=&amp;quot;showToast = false&amp;quot;&amp;gt;🎉 提交成功！&amp;lt;/div&amp;gt;
  &amp;lt;/transition&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
const showToast = ref(false)
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.toast {
  position: fixed;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  padding: 12px 24px;
  background-color: #333;
  color: white;
  border-radius: 20px;
  font-size: 16px;
  z-index: 9999;
}
.bounce-enter-active {
  animation: bounce-in 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* 离开时直接透明度变0，不用弹跳 */
.bounce-leave-active {
  transition: opacity 0.3s;
}
.bounce-leave-to {
  opacity: 0;
}
@keyframes bounce-in {
  0% {
    transform: translateX(-50%) translateY(-100%);
    opacity: 0;
  }
  50% {
    transform: translateX(-50%) translateY(20px);
    opacity: 1;
  }
  70% {
    transform: translateX(-50%) translateY(-10px);
  }
  100% {
    transform: translateX(-50%) translateY(0);
  }
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里用到了cubic-bezier贝塞尔曲线，让弹跳更自然，新手可以直接用预设的ease-out-bounce这些值吗？Vue3内置组件默认没有预设这些，但可以自己封装成工具类，或者用一些第三方动画库。&lt;/p&gt;
&lt;h2&gt;刚才说的第三方动画库，Vue3推荐用啥？&lt;/h2&gt;
&lt;p&gt;首推Animate.css！它是一个纯CSS的、免费开源的动画库，里面有上百种现成的动画效果，比如flip、zoomIn、slideOutLeft这些，不用自己写keyframes，直接拿过来用就行。&lt;/p&gt;
&lt;h3&gt;Animate.css和Vue3的&lt;transition&gt;怎么结合？&lt;/h3&gt;
&lt;p&gt;结合的时候要注意两个属性：&lt;code&gt;enter-active-class&lt;/code&gt;和&lt;code&gt;leave-active-class&lt;/code&gt;，这两个属性可以覆盖默认的&lt;code&gt;*-enter-active&lt;/code&gt;和&lt;code&gt;*-leave-active&lt;/code&gt;类名，直接绑定Animate.css提供的动画激活类（Animate.css的激活类是&lt;code&gt;animate__animated&lt;/code&gt;，动画类是&lt;code&gt;animate__zoomIn&lt;/code&gt;这种）。&lt;/p&gt;
&lt;h3&gt;有没有具体的结合例子？&lt;/h3&gt;
&lt;p&gt;没问题,还是刚才的提示框，换成Animate.css的zoomInDown和zoomOutUp动画：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;!-- 先引入Animate.css，这里用CDN，也可以npm install animate.css --&amp;gt;
  &amp;lt;link rel=&amp;quot;stylesheet&amp;quot; href=&amp;quot;https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css&amp;quot;&amp;gt;
  &amp;lt;button @click=&amp;quot;showToast = !showToast&amp;quot;&amp;gt;显示Animate.css提示&amp;lt;/button&amp;gt;
  &amp;lt;transition
    name=&amp;quot;animate__animated&amp;quot;
    enter-active-class=&amp;quot;animate__animated animate__zoomInDown&amp;quot;
    leave-active-class=&amp;quot;animate__animated animate__zoomOutUp&amp;quot;
  &amp;gt;
    &amp;lt;div v-if=&amp;quot;showToast&amp;quot; class=&amp;quot;toast&amp;quot; @click=&amp;quot;showToast = false&amp;quot;&amp;gt;✨ 使用Animate.css超简单！&amp;lt;/div&amp;gt;
  &amp;lt;/transition&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
const showToast = ref(false)
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
/* toast的样式和之前一样，这里就不重复写了 */
.toast {
  position: fixed;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  padding: 12px 24px;
  background-color: #ff6b6b;
  color: white;
  border-radius: 20px;
  font-size: 16px;
  z-index: 9999;
  cursor: pointer;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里有个新手容易忽略的细节：不管是&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;还是后面要讲的&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;，结合Animate.css的时候，必须同时加&lt;code&gt;animate__animated&lt;/code&gt;这个激活类，不然动画不会生效！因为Animate.css的所有动画类，都必须在&lt;code&gt;animate__animated&lt;/code&gt;的包裹下才有作用——它给元素加了&lt;code&gt;animation-fill-mode: both&lt;/code&gt;这些基础属性，控制动画播放前后的状态。&lt;/p&gt;
&lt;h2&gt;列表元素要排序、要增删动画怎么办？&lt;/h2&gt;
&lt;p&gt;刚才的&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;只能处理单个元素/组件的过渡，如果是列表（比如v-for渲染的数组），就得用&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;了。&lt;/p&gt;
&lt;h3&gt;&lt;transition-group&gt;和&lt;transition&gt;有啥区别？&lt;/h3&gt;
&lt;p&gt;主要有3个区别：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;渲染方式&lt;/strong&gt;：&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;本身不会渲染任何DOM元素，只是作为过渡的“容器”；&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;默认会渲染成一个&lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;元素，不过可以用&lt;code&gt;tag&lt;/code&gt;属性改成其他标签，比如&lt;code&gt;ul&lt;/code&gt;、&lt;code&gt;div&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;适用场景&lt;/strong&gt;：&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;处理单个或多个互斥的元素/组件（比如用&lt;code&gt;v-if&lt;/code&gt;和&lt;code&gt;v-else-if&lt;/code&gt;的几个div，同一时间只能显示一个）；&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;处理多个同时存在、可以动态增删或排序的元素（比如v-for的数组元素，同一时间可能显示多个）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;新增的过渡类名&lt;/strong&gt;：&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;除了有&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;的6个enter/leave类名，还有一个&lt;code&gt;*-move&lt;/code&gt;类名，用来处理列表元素移动时的过渡（比如排序、删除中间元素后，后面的元素往前挪的过程）。&lt;h3&gt;举个带增删和排序的列表动画例子&lt;/h3&gt;
&lt;p&gt;比如一个待办事项列表,点击添加按钮加事项，点击事项删除它，点击排序按钮按字母顺序排序，所有操作都有动画：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;
&amp;lt;template&amp;gt;
&amp;lt;div class=&amp;quot;todo-container&amp;quot;&amp;gt;
 &amp;lt;h2&amp;gt;待办事项动画列表&amp;lt;/h2&amp;gt;
 &amp;lt;div class=&amp;quot;input-group&amp;quot;&amp;gt;
   &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;newTodo&amp;quot; placeholder=&amp;quot;输入待办事项&amp;quot; @keyup.enter=&amp;quot;addTodo&amp;quot;&amp;gt;
   &amp;lt;button @click=&amp;quot;addTodo&amp;quot;&amp;gt;添加&amp;lt;/button&amp;gt;
   &amp;lt;button @click=&amp;quot;sortTodos&amp;quot;&amp;gt;按字母排序&amp;lt;/button&amp;gt;
 &amp;lt;/div&amp;gt;
 &amp;lt;!-- transition-group默认渲染span，这里改成ul --&amp;gt;
 &amp;lt;transition-group name=&amp;quot;todo-list&amp;quot; tag=&amp;quot;ul&amp;quot; class=&amp;quot;todo-list&amp;quot;&amp;gt;
   &amp;lt;!-- 每个列表项必须有唯一的key，不能用index！这是transition-group最大的坑！ --&amp;gt;
   &amp;lt;li v-for=&amp;quot;todo in todos&amp;quot; :key=&amp;quot;todo.id&amp;quot; @click=&amp;quot;removeTodo(todo.id)&amp;quot; class=&amp;quot;todo-item&amp;quot;&amp;gt;
     {{ todo.content }}
   &amp;lt;/li&amp;gt;
 &amp;lt;/transition-group&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;script setup&gt;
import { ref } from &#039;vue&#039;
// 用uuid模拟生成唯一的id，实际开发可以用nanoid或者后端返回的id
let idCounter = 0
const newTodo = ref(&#039;&#039;)
const todos = ref([
  { id: idCounter++, content: &#039;买牛奶&#039; },
  { id: idCounter++, content: &#039;写代码&#039; },
  { id: idCounter++, content: &#039;跑步&#039; },
])
const addTodo = () =&gt; {
  if (newTodo.value.trim()) {
    todos.value.push({ id: idCounter++, content: newTodo.value.trim() })
    newTodo.value = &#039;&#039;
  }
}
const removeTodo = (id) =&gt; {
  const index = todos.value.findIndex(todo =&gt; todo.id === id)
  if (index !== -1) {
    todos.value.splice(index, 1)
  }
}
const sortTodos = () =&gt; {
  todos.value.sort((a, b) =&gt; a.content.localeCompare(b.content, &#039;zh-CN&#039;))
}
&lt;/script&gt;
&lt;style scoped&gt;
.todo-container {
  max-width: 600px;
  margin: 40px auto;
  padding: 0 20px;
}
.input-group {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
}
.input-group input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
}
.input-group button {
  padding: 10px 20px;
  border: none;
  border-radius: 6px;
  background-color: #42b983;
  color: white;
  font-size: 16px;
  cursor: pointer;
}
.input-group button:last-child {
  background-color: #35495e;
}
.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}
.todo-item {
  padding: 12px 16px;
  margin-bottom: 8px;
  background-color: #f5f5f5;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.3s;
}
.todo-item:hover {
  background-color: #eee;
}
/* 入场动画：从左滑入，高度从0到auto，背景色渐变 */
.todo-list-enter-active,
.todo-list-leave-active {
  transition: all 0.4s ease-in-out;
}
.todo-list-enter-from {
  opacity: 0;
  transform: translateX(-50px);
  height: 0;
  padding: 0 16px;
  margin-bottom: 0;
  overflow: hidden;
}
.todo-list-leave-to {
  opacity: 0;
  transform: translateX(50px);
  height: 0;
  padding: 0 16px;
  margin-bottom: 0;
  overflow: hidden;
}
/* 移动动画：让排序或删除中间元素后的移动更平滑，这里的transition要单独加，不然会和入场退场冲突吗？不会，因为move类名只在移动时存在 */
.todo-list-move {
  transition: transform 0.4s ease-in-out;
}
&lt;/style&gt;
```
这里一定要重点说`&lt;transition-group&gt;`的最大新手坑：**每个列表项的key必须是唯一的、稳定的，绝对不能用v-for的index作为key！** 为什么？因为当你增删或排序列表时，index会跟着变化，Vue就无法正确识别哪个是原来的元素，哪个是新增/删除/移动的，这样动画就会乱掉——比如删除中间的元素，后面的元素可能直接跳过来，没有move动画；排序的时候，元素的内容和索引绑定，也不会有正确的过渡，刚才的例子用了自增的idCounter作为key，实际开发中最好用后端返回的UUID或者nanoid生成的唯一字符串。
&lt;h2&gt;除了CSS类名，还有没有更灵活的方式？比如用JS控制动画&lt;/h2&gt;
&lt;p&gt;有的！Vue3的&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;和&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;都支持JS钩子函数，这些钩子函数可以让你用原生JS、GSAP、Velocity.js等第三方JS动画库来控制动画，适合处理非常复杂的、基于状态的连续动画（比如数字滚动、SVG路径动画、复杂的物理动画）。&lt;/p&gt;
&lt;h3&gt;JS钩子函数有哪些？&lt;/h3&gt;
&lt;p&gt;JS钩子函数和CSS过渡的时间点是一一对应的,主要有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@before-enter&lt;/code&gt;：进入前触发，对应CSS的&lt;code&gt;*-enter-from&lt;/code&gt;类名被添加之前&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@enter&lt;/code&gt;：进入触发，对应CSS的&lt;code&gt;*-enter-from&lt;/code&gt;被删除、&lt;code&gt;*-enter-to&lt;/code&gt;被添加的时机，这里必须传入一个&lt;code&gt;done&lt;/code&gt;回调函数，动画完成后调用&lt;code&gt;done()&lt;/code&gt;告诉Vue动画结束了&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@after-enter&lt;/code&gt;：进入完成后触发&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@enter-cancelled&lt;/code&gt;：进入动画被取消时触发（比如在动画还没完成时就切换了v-if/v-show）&lt;/li&gt;
&lt;li&gt;离开的钩子函数是一样的逻辑：&lt;code&gt;@before-leave&lt;/code&gt;、&lt;code&gt;@leave&lt;/code&gt;（必须传done）、&lt;code&gt;@after-leave&lt;/code&gt;、&lt;code&gt;@leave-cancelled&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果是&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;，还有移动相关的钩子：&lt;code&gt;@before-move&lt;/code&gt;、&lt;code&gt;@move&lt;/code&gt;、&lt;code&gt;@after-move&lt;/code&gt;&lt;h3&gt;用GSAP写个数字滚动的JS钩子动画例子&lt;/h3&gt;
&lt;p&gt;GSAP是一个非常强大的JS动画库,性能比CSS动画还好，尤其是处理复杂动画的时候，先npm install gsap，然后写一个数字从0滚动到目标值的组件：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;
&amp;lt;template&amp;gt;
&amp;lt;div class=&amp;quot;counter-container&amp;quot;&amp;gt;
  &amp;lt;h2&amp;gt;GSAP数字滚动动画&amp;lt;/h2&amp;gt;
  &amp;lt;div class=&amp;quot;counter&amp;quot;&amp;gt;当前业绩：¥&amp;lt;span ref=&amp;quot;numberSpan&amp;quot;&amp;gt;0&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;button @click=&amp;quot;isShowCounter = !isShowCounter&amp;quot;&amp;gt;{{ isShowCounter ? &amp;#39;重置数字&amp;#39; : &amp;#39;显示数字&amp;#39; }}&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;script setup&gt;
import { ref, watch } from &#039;vue&#039;
import gsap from &#039;gsap&#039;
const isShowCounter = ref(false)
const numberSpan = ref(null)
// 当isShowCounter变化时触发动画
watch(isShowCounter, (newVal) =&gt; {
  if (newVal) {
    // 显示时用GSAP从0滚动到123456
    gsap.to(numberSpan.value, {
      innerText: 123456,
      duration: 2,
      ease: &#039;power4.out&#039;,
      snap: { innerText: 1 }, // 让数字滚动时只显示整数
    })
  } else {
    // 重置时用GSAP从123456滚动回0
    gsap.to(numberSpan.value, {
      innerText: 0,
      duration: 1.5,
      ease: &#039;power4.out&#039;,
      snap: { innerText: 1 },
    })
  }
})
&lt;/script&gt;
&lt;style scoped&gt;
/* 这里用transition其实也行，但用JS钩子更灵活，比如可以控制滚动的曲线、是否加千分位分隔符 */
.counter-container {
  max-width: 600px;
  margin: 60px auto;
  padding: 0 20px;
  text-align: center;
}
.counter {
  font-size: 32px;
  font-weight: bold;
  color: #35495e;
  margin: 30px 0;
}
.counter button {
  padding: 12px 32px;
  border: none;
  border-radius: 8px;
  background-color: #ff6b6b;
  color: white;
  font-size: 18px;
  cursor: pointer;
}
&lt;/style&gt;
```
刚才的例子用了watch监听状态变化，但如果是单个组件的入场退场，其实更适合用`&lt;transition&gt;`的JS钩子函数，比如用JS钩子函数写一个GSAP的弹跳入场动画：
```html
&lt;template&gt;
  &lt;div class=&quot;js-transition-container&quot;&gt;
    &lt;h2&gt;JS钩子结合GSAP的过渡动画&lt;/h2&gt;
    &lt;button @click=&quot;isShowBox = !isShowBox&quot;&gt;切换GSAP盒子&lt;/button&gt;
    &lt;transition
      @before-enter=&quot;beforeEnter&quot;
      @enter=&quot;enter&quot;
      @before-leave=&quot;beforeLeave&quot;
      @leave=&quot;leave&quot;
      :css=&quot;false&quot;
    &gt;
      &lt;!-- :css=&quot;false&quot;这个属性很重要！如果用纯JS控制动画，一定要加这个，告诉Vue不要添加CSS过渡类名，避免冲突 --&gt;
      &lt;div v-if=&quot;isShowBox&quot; ref=&quot;boxRef&quot; class=&quot;js-box&quot;&gt;我是GSAP控制的盒子！&lt;/div&gt;
    &lt;/transition&gt;
  &lt;/div&gt;
&lt;/template&gt;
&lt;script setup&gt;
import { ref } from &#039;vue&#039;
import gsap from &#039;gsap&#039;
const isShowBox = ref(false)
const boxRef = ref(null)
const beforeEnter = (el) =&gt; {
  // 进入前设置初始状态
  gsap.set(el, { opacity: 0, scale: 0.5, y: -50 })
}
const enter = (el, done) =&gt; {
  // 进入时播放动画，完成后调用done()
  gsap.to(el, {
    opacity: 1,
    scale: 1,
    y: 0,
    duration: 0.6,
    ease: &#039;back.out(1.7)&#039;,
    onComplete: done,
  })
}
const beforeLeave = (el) =&gt; {
  // 离开前可以设置一些状态，不过这里直接在leave里设置结束状态就行
}
const leave = (el, done) =&gt; {
  // 离开时播放动画，完成后调用done()
  gsap.to(el, {
    opacity: 0,
    scale: 0.5,
    y: 50,
    duration: 0.4,
    ease: &#039;back.in(1.7)&#039;,
    onComplete: done,
  })
}
&lt;/script&gt;
&lt;style scoped&gt;
.js-transition-container {
  max-width: 600px;
  margin: 60px auto;
  padding: 0 20px;
  text-align: center;
}
.js-box {
  width: 200px;
  height: 100px;
  background-color: #42b983;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  border-radius: 8px;
  margin: 30px auto;
}
&lt;/style&gt;
```
这里又有一个新手容易踩的坑：**如果用纯JS钩子控制动画，一定要给`&lt;transition&gt;`或`&lt;transition-group&gt;`加`:css=&quot;false&quot;`属性！** 不然Vue还是会自动添加CSS过渡类名，如果你的元素有相关的CSS transition或animation，就会和JS动画冲突，导致动画效果混乱。
&lt;h2&gt;有没有什么通用的Vue3动画优化技巧？&lt;/h2&gt;
&lt;p&gt;当然有,优化动画可以让网页更流畅，尤其是在手机等性能较弱的设备上：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;尽量使用transform和opacity做动画&lt;/strong&gt;：这两个属性不会触发浏览器的重排（reflow），只会触发重绘（repaint）和合成（composite），性能最好，尽量不要用width、height、margin、padding、top、left这些属性做动画，因为它们会触发重排，导致整个页面的布局重新计算，非常消耗性能。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;开启GPU加速&lt;/strong&gt;：可以给做动画的元素加&lt;code&gt;transform: translateZ(0)&lt;/code&gt;、&lt;code&gt;transform: rotate3d(0,0,0,0)&lt;/code&gt;或者&lt;code&gt;will-change: transform, opacity&lt;/code&gt;，让浏览器把这个元素单独放到一个合成层，用GPU来渲染，提升性能，不过要注意不要滥用will-change，不然会占用太多GPU内存，反而适得其反——一般只给即将做动画的元素加，动画完成后可以去掉。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;合理控制动画时长&lt;/strong&gt;：一般入场退场动画的时长控制在0.3s-0.8s之间，太长会让用户觉得网页慢，太短会让用户看不清动画效果。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动画曲线要自然&lt;/strong&gt;：入场动画一般用ease-out或者back.out（有弹性效果），退场动画一般用ease-in或者back.in，循环动画可以用linear，新手可以用贝塞尔曲线生成工具（比如cubic-bezier.com）来调整合适的曲线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;避免在动画期间做复杂的计算&lt;/strong&gt;：比如在&lt;code&gt;@enter&lt;/code&gt;钩子函数里做大量的DOM操作、数组遍历、网络请求，这些都会阻塞主线程，导致动画卡顿。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用v-show代替v-if做频繁切换的动画&lt;/strong&gt;：因为v-if会把元素从DOM中完全删除和添加，而v-show只是切换元素的display属性，性能更好，过渡效果也更平滑。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;总结一下Vue3动画的常用方法和避坑点&lt;/h2&gt;
&lt;p&gt;Vue3动画主要有3种实现方法：纯CSS过渡/动画、结合第三方CSS动画库（比如Animate.css）、结合JS钩子函数和第三方JS动画库（比如GSAP），新手刚开始可以从纯CSS过渡/动画入手，掌握基础的类名和&lt;code&gt;&amp;lt;transition&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;的使用；然后可以尝试结合Animate.css，快速实现各种酷炫的效果；最后如果遇到复杂的场景，再用JS钩子函数和GSAP。
最后再强调一下3个最容易踩的坑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Vue3的过渡类名前缀加了&lt;code&gt;-from&lt;/code&gt;，把&lt;code&gt;v-enter&lt;/code&gt;改成了&lt;code&gt;v-enter-from&lt;/code&gt;，&lt;code&gt;v-leave&lt;/code&gt;改成了&lt;code&gt;v-leave-from&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;transition-group&amp;gt;&lt;/code&gt;的每个列表项必须有唯一的、稳定的key，不能用index；&lt;/li&gt;
&lt;li&gt;用纯JS钩子控制动画时,一定要加&lt;code&gt;css=&quot;false&quot;&lt;/code&gt;属性，避免和CSS动画冲突。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;希望这篇文章能帮到刚学Vue3动画的你,如果你有其他问题，欢迎在评论区留言讨论！&lt;/p&gt;</description><pubDate>Thu, 08 Oct 2026 16:21:54 +0800</pubDate></item><item><title>Vue3动态ref怎么写才不会踩坑？列表/循环组件/表单元素通用吗？</title><link>https://www.codeqd.com/post/20261022118.html</link><description>&lt;h2&gt;为什么要用动态ref？静态ref不够用吗？&lt;/h2&gt;
&lt;p&gt;刚开始接触Vue3的同学,可能一开始只会用静态ref——给固定的DOM或者组件起个名字，比如&lt;code&gt;&amp;lt;input ref=&quot;usernameInput&quot; /&amp;gt;&lt;/code&gt;，然后在setup里声明同名的ref变量，直接点&lt;code&gt;.value&lt;/code&gt;就能拿到节点实例，但实际开发里，固定的元素/组件占比真的不高：你要渲染3个到N个完全一样的商品卡片组件，每个卡片可能有自己的“加入购物车”弹窗触发按钮；你要做动态表单，用户点“添加一行”就出来一组新的姓名、手机号输入框，还要单独获取每行的表单值或者校验结果；甚至你要做轮播图，需要索引精准拿到每一张slide的DOM节点，这时候静态ref就完全不够用了——你总不能硬编码&lt;code&gt;card1&lt;/code&gt;、&lt;code&gt;card2&lt;/code&gt;……到一百个吧？动态ref就是为了解决这种&lt;strong&gt;多个同类节点/组件需要批量、精准获取实例&lt;/strong&gt;的场景而生的。&lt;/p&gt;
&lt;h2&gt;Vue3和Vue2动态ref写法完全不一样？区别在哪？&lt;/h2&gt;
&lt;p&gt;对,别直接照搬Vue2的经验！这是新手踩的第一个大雷，Vue2里动态ref是通过字符串拼接实现的，比如&lt;code&gt;&amp;lt;div v-for=&quot;(item, idx) in list&quot; :ref=&quot;&#039;item-&#039; + idx&quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;，然后在代码里通过&lt;code&gt;this.$refs[&#039;item-&#039; + idx]&lt;/code&gt;或者&lt;code&gt;this.$refs[&#039;item-&#039; + idx][0]&lt;/code&gt;（当只渲染一个同名动态ref时会自动包数组，多个也是数组）来取。&lt;/p&gt;
&lt;p&gt;但Vue3彻底重构了响应式系统和ref的绑定逻辑,字符串拼接的动态ref失效了——哪怕你写了同样的&lt;code&gt;ref=&quot;&#039;item-&#039; + idx&quot;&lt;/code&gt;，在setup里声明的&lt;code&gt;const item1 = ref(null)&lt;/code&gt;根本不会自动绑定到对应节点，Vue3的官方解决方案是&lt;strong&gt;函数式ref绑定&lt;/strong&gt;或者&lt;strong&gt;使用ref数组/对象来收集&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;Vue3动态ref的三种安全写法&lt;/h2&gt;
&lt;p&gt;接下来给大家说三种完全不会踩坑的写法,分别覆盖不同的开发习惯和场景，都是经过实际项目验证的。&lt;/p&gt;
&lt;h3&gt;ref数组绑定（最常用、适合循环渲染同类型元素）&lt;/h3&gt;
&lt;p&gt;这种写法最省心,适合所有通过&lt;code&gt;v-for&lt;/code&gt;循环渲染出来的同类型DOM或组件，不管是固定长度还是动态增删的列表都能用。&lt;/p&gt;
&lt;p&gt;你需要在setup里先初始化一个&lt;strong&gt;空的ref数组&lt;/strong&gt;，注意数组本身也要用ref包裹，不然数组的变化不会触发响应式（虽然获取节点本身不需要响应式，但有时候你要监听列表更新后节点的重新绑定，或者数组长度的变化，还是包ref更稳妥）。&lt;/p&gt;
&lt;p&gt;在&lt;code&gt;v-for&lt;/code&gt;循环的节点上，绑定一个&lt;strong&gt;动态的回调函数作为ref属性&lt;/strong&gt;，这个回调函数会接收两个参数：第一个是当前节点的实例（DOM元素或者组件实例），第二个是渲染该节点时的&lt;code&gt;key&lt;/code&gt;值（注意，这个key必须是唯一的，比如item.id，别用索引，否则列表增删时索引会变，收集的节点就乱了！），然后我们把当前节点实例存到数组的对应位置或者直接push？不对，直接push的话如果列表重新渲染，原来的节点实例可能会变成null，数组里会混进脏数据，正确的做法是&lt;strong&gt;用key作为数组的索引？或者用对象存？哦对，或者用ref数组配合key来确保位置唯一，但其实更推荐的是用函数式ref里的判断逻辑：如果实例存在，就存到数组的对应位置；如果不存在（说明节点被销毁了），就把对应位置设为undefined或者过滤掉&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不过还有更简单的官方推荐小技巧：直接给ref数组赋值一个回调函数的简化版？不对，等下，实际开发中用得最多的是：在&lt;code&gt;v-for&lt;/code&gt;里写&lt;code&gt;ref=&quot;el =&amp;gt; { if (el) itemRefs[idx] = el }&quot;&lt;/code&gt;？不，刚才说了别用索引当数组索引，应该用对象！哦对，我刚才差点说漏嘴，新手用索引当数组的存储位置踩的坑比字符串拼接还多——比如用户删除了列表的第2个元素，原来的第3个元素变成第2个，索引变了，对应的ref数组位置也变了，但实例还在原来的位置，最后取出来的都是错的。&lt;/p&gt;
&lt;p&gt;所以调整一下,用&lt;strong&gt;ref对象&lt;/strong&gt;配合&lt;strong&gt;唯一key&lt;/strong&gt;：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// setup 里
import { ref, onMounted } from &amp;#39;vue&amp;#39;;
export default {
  setup() {
    // 用ref包裹空对象，用来存键值对：key是唯一标识，value是节点/组件实例
    const dynamicRefs = ref({});
    const list = ref([
      { id: 1, name: &amp;#39;商品A&amp;#39; },
      { id: 2, name: &amp;#39;商品B&amp;#39; },
      { id: 3, name: &amp;#39;商品C&amp;#39; },
    ]);
    // 比如点击按钮获取指定id的商品卡片组件的某个方法
    const openProductModal = (productId) =&amp;gt; {
      const productCard = dynamicRefs.value[productId];
      if (productCard) {
        // 调用子组件的方法，或者拿DOM节点的属性
        productCard.openModal();
      }
    };
    onMounted(() =&amp;gt; {
      // 打印一下看看有没有收集到所有实例
      console.log(dynamicRefs.value);
    });
    return { dynamicRefs, list, openProductModal };
  }
};&lt;/pre&gt;
&lt;p&gt;然后模板里这样写：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- 模板里 --&amp;gt;
&amp;lt;div v-for=&amp;quot;item in list&amp;quot; :key=&amp;quot;item.id&amp;quot;&amp;gt;
  &amp;lt;!-- 假设product-card是子组件，有openModal方法 --&amp;gt;
  &amp;lt;product-card 
    :ref=&amp;quot;el =&amp;gt; { if (el) dynamicRefs[item.id] = el }&amp;quot; 
    :product=&amp;quot;item&amp;quot;
  /&amp;gt;
  &amp;lt;button @click=&amp;quot;openProductModal(item.id)&amp;quot;&amp;gt;查看详情&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;&lt;/pre&gt;
&lt;p&gt;对,这里的回调函数逻辑很重要：&lt;code&gt;if (el)&lt;/code&gt;——因为Vue在渲染节点时会调用一次ref回调（传真实实例），在销毁节点时会再调用一次（传null），加个判断就能避免在对象里存null这种脏数据。&lt;/p&gt;
&lt;h3&gt;变量名动态拼接的ref数组绑定？（仅限非常固定的、不会动态增删的场景）&lt;/h3&gt;
&lt;p&gt;刚才说了Vue3不支持直接字符串拼接同名静态ref,但有没有办法间接实现类似的效果？比如有时候你有三个完全固定的步骤条，不想用对象存，就想单独用step1、step2、step3的ref，然后用索引动态取？&lt;/p&gt;
&lt;p&gt;可以,但这种写法只能用在&lt;strong&gt;不会动态增删、key完全固定、索引不会变&lt;/strong&gt;的极端场景，而且官方不太推荐，但确实能用，你可以在setup里先把所有固定的ref变量放到一个普通数组里（注意是普通数组，不是ref数组，因为变量名本身是静态的，只是要动态访问数组元素），然后在模板里用回调函数把实例存到对应索引：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// setup 里
import { ref } from &amp;#39;vue&amp;#39;;
export default {
  setup() {
    // 先声明三个固定的ref
    const step1 = ref(null);
    const step2 = ref(null);
    const step3 = ref(null);
    // 放到普通数组里
    const stepRefs = [step1, step2, step3];
    const steps = [&amp;#39;填写信息&amp;#39;, &amp;#39;上传资料&amp;#39;, &amp;#39;确认提交&amp;#39;];
    const goToStep = (stepIdx) =&amp;gt; {
      const targetStep = stepRefs[stepIdx].value;
      if (targetStep) {
        targetStep.scrollIntoView({ behavior: &amp;#39;smooth&amp;#39; });
      }
    };
    return { stepRefs, steps, goToStep };
  }
};&lt;/pre&gt;
&lt;p&gt;模板里：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;div v-for=&amp;quot;(step, idx) in steps&amp;quot; :key=&amp;quot;idx&amp;quot;&amp;gt;
  &amp;lt;div :ref=&amp;quot;el =&amp;gt; { if (el) stepRefs[idx].value = el }&amp;quot;&amp;gt;
    {{ idx + 1 }}. {{ step }}
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;button @click=&amp;quot;goToStep(1)&amp;quot;&amp;gt;跳转到第二步&amp;lt;/button&amp;gt;&lt;/pre&gt;
&lt;p&gt;这种写法虽然能用,但真的别乱用，只要步骤条变成动态的（比如根据用户权限去掉第二步），索引一乱，stepRefs里的对应元素就存错了。&lt;/p&gt;
&lt;h3&gt;单个动态ref绑定（适合条件渲染的单个元素）&lt;/h3&gt;
&lt;p&gt;还有一种情况：不是循环，是条件渲染的单个元素，比如根据&lt;code&gt;isShowModal&lt;/code&gt;来渲染弹窗，每次弹窗的内容不一样，需要获取当前弹窗里的某个输入框，这时候也可以用函数式ref，不过不需要数组或对象，直接用一个单独的ref变量就行：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// setup 里
import { ref, watch } from &amp;#39;vue&amp;#39;;
export default {
  setup() {
    const isShowModal = ref(false);
    const currentModalType = ref(&amp;#39;login&amp;#39;);
    const modalInput = ref(null); // 单个动态ref
    // 监听弹窗打开，自动聚焦输入框
    watch(isShowModal, (newVal) =&amp;gt; {
      if (newVal &amp;amp;&amp;amp; modalInput.value) {
        modalInput.value.focus();
      }
    });
    return { isShowModal, currentModalType, modalInput };
  }
};&lt;/pre&gt;
&lt;p&gt;模板里：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;div v-if=&amp;quot;isShowModal&amp;quot;&amp;gt;
  &amp;lt;div v-if=&amp;quot;currentModalType === &amp;#39;login&amp;#39;&amp;quot;&amp;gt;
    &amp;lt;label&amp;gt;账号：&amp;lt;/label&amp;gt;
    &amp;lt;input :ref=&amp;quot;el =&amp;gt; modalInput = el&amp;quot; type=&amp;quot;text&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div v-else&amp;gt;
    &amp;lt;label&amp;gt;邮箱：&amp;lt;/label&amp;gt;
    &amp;lt;input :ref=&amp;quot;el =&amp;gt; modalInput = el&amp;quot; type=&amp;quot;email&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/pre&gt;
&lt;p&gt;这种写法很简单,条件渲染时，Vue会自动销毁旧节点（调用ref回调传null给modalInput），然后渲染新节点（传新实例给modalInput），不需要额外的判断逻辑——不过如果你想在旧节点销毁时做一些清理工作，也可以加个if-else。&lt;/p&gt;
&lt;h2&gt;这三个动态ref踩坑点一定要避开！&lt;/h2&gt;
&lt;p&gt;刚才说了一些,现在集中总结新手90%会踩的坑，避坑指南收好：&lt;/p&gt;
&lt;h3&gt;踩坑点1：继续用Vue2的字符串拼接动态ref&lt;/h3&gt;
&lt;p&gt;比如写&lt;code&gt;ref=&quot;&#039;item-&#039; + idx&quot;&lt;/code&gt;，然后在setup里声明&lt;code&gt;const item1 = ref(null)&lt;/code&gt;，结果&lt;code&gt;item1.value&lt;/code&gt;永远是null——Vue3已经完全抛弃了这种通过模板字符串匹配setup里同名变量的机制，改成了更灵活的函数式绑定，一定要改过来。&lt;/p&gt;
&lt;h3&gt;踩坑点2：用v-for的索引当ref数组/对象的存储位置&lt;/h3&gt;
&lt;p&gt;比如前面的写法一,如果改成&lt;code&gt;key=&quot;idx&quot;&lt;/code&gt;，然后&lt;code&gt;ref=&quot;el =&amp;gt; { if (el) dynamicRefs[idx] = el }&quot;&lt;/code&gt;，当用户删除列表的第0个元素，原来的第1个元素变成第0个，模板重新渲染时，会把原来的第1个元素的实例存到dynamicRefs[0]，但原来的第0个元素的实例还在dynamicRefs[0]吗？不，会先调用原来第0个元素的ref回调传null，然后调用新第0个元素的ref回调传实例，但如果是列表重新排序呢？比如从[0,1,2]变成[2,1,0]，索引对应的元素全变了，dynamicRefs里的内容也全乱了，所以&lt;strong&gt;v-for一定要用唯一的key（比如item.id、item.uuid），然后用这个唯一key当ref对象的属性名&lt;/strong&gt;，这样不管列表怎么增删、排序，每个实例都能精准对应到自己的唯一标识。&lt;/p&gt;
&lt;h3&gt;踩坑点3：ref回调里没有加if (el)的判断&lt;/h3&gt;
&lt;p&gt;刚才说了,Vue在渲染和销毁节点时都会调用ref回调，渲染传真实实例，销毁传null，如果你不加判断，直接&lt;code&gt;ref=&quot;el =&amp;gt; dynamicRefs[item.id] = el&quot;&lt;/code&gt;，当节点被销毁后，dynamicRefs[item.id]就会变成null，下次再取的时候就会报错（比如调用子组件的方法时，Cannot read properties of null），所以&lt;strong&gt;一定要加if (el)的判断，只存真实的实例&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;动态ref取出来的是DOM还是组件实例？怎么区分？&lt;/h2&gt;
&lt;p&gt;这是新手常问的另一个问题：同样的写法，为什么有时候取到的是HTMLDivElement，有时候是Proxy？
很简单，看你绑定的是普通HTML标签还是Vue组件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果绑定的是&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;这种普通HTML标签，取出来的就是真实的DOM元素，可以直接调用DOM API（比如&lt;code&gt;.focus()&lt;/code&gt;、&lt;code&gt;.scrollIntoView()&lt;/code&gt;、&lt;code&gt;.style&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;如果绑定的是&lt;code&gt;&amp;lt;product-card&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;my-modal&amp;gt;&lt;/code&gt;这种自定义Vue组件，取出来的是组件的&lt;strong&gt;公开实例Proxy&lt;/strong&gt;，只能访问组件里通过&lt;code&gt;defineExpose&lt;/code&gt;暴露出来的属性和方法——这是Vue3的另一个重要变化：Vue2里组件实例的所有属性和方法都能通过&lt;code&gt;$refs&lt;/code&gt;访问，Vue3里默认是私有的，必须显式暴露！&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如刚才的product-card子组件，如果要让父组件能调用openModal方法，必须在子组件的setup里用&lt;code&gt;defineExpose&lt;/code&gt;：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// product-card 子组件的setup里
import { ref, defineExpose } from &amp;#39;vue&amp;#39;;
export default {
  props: [&amp;#39;product&amp;#39;],
  setup(props) {
    const isModalOpen = ref(false);
    const openModal = () =&amp;gt; {
      isModalOpen.value = true;
    };
    // 必须显式暴露，父组件才能通过dynamicRefs[productId].openModal()调用
    defineExpose({ openModal });
    return { isModalOpen, openModal };
  }
};&lt;/pre&gt;
&lt;p&gt;如果你没加&lt;code&gt;defineExpose&lt;/code&gt;，父组件取到的Proxy里只有&lt;code&gt;$el&lt;/code&gt;、&lt;code&gt;$parent&lt;/code&gt;这些内置属性，找不到你自己写的openModal方法。&lt;/p&gt;
&lt;h2&gt;什么时候该用动态ref？什么时候该用其他方案？&lt;/h2&gt;
&lt;p&gt;动态ref虽然好用,但不是万能的，有些场景用其他方案更优雅、更符合Vue的响应式理念：&lt;/p&gt;
&lt;h3&gt;应该用动态ref的场景&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;需要调用DOM API：比如自动聚焦输入框、滚动到指定位置、获取元素的宽高/位置（虽然也可以用CSS变量或响应式数据，但有时候直接获取DOM更准确）；&lt;/li&gt;
&lt;li&gt;需要调用子组件的&lt;strong&gt;非响应式触发方法&lt;/strong&gt;：比如打开弹窗、播放视频、清空画布这些不需要通过props改变就能触发的方法；&lt;/li&gt;
&lt;li&gt;需要批量操作多个同类型节点：比如批量禁用一组输入框、批量设置一组轮播图slide的样式。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;不该用动态ref的场景&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;传递数据：父子组件传递数据应该用props和emits，或者pinia/vuex，不要通过ref直接修改子组件的属性；&lt;/li&gt;
&lt;li&gt;触发子组件的响应式更新：比如子组件的某个显示状态，应该通过props传值，不要通过ref调用子组件的方法来修改；&lt;/li&gt;
&lt;li&gt;监听子组件的事件：应该用&lt;code&gt;v-on&lt;/code&gt;或者来监听子组件的emits事件，不要通过ref在父组件里手动绑定事件监听器。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Vue3动态ref的核心是&lt;strong&gt;函数式绑定&lt;/strong&gt;，配合&lt;strong&gt;唯一key&lt;/strong&gt;和&lt;strong&gt;ref对象&lt;/strong&gt;使用，就能解决所有循环、条件渲染场景下的节点/组件获取问题，记住三个安全写法、三个避坑点、什么时候用什么时候不用，就能彻底掌握动态ref，再也不会踩坑。&lt;/p&gt;
&lt;p&gt;最后再提一句,如果你用的是&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;语法糖，写法完全一样，只是不需要写&lt;code&gt;export default { setup() { ... } }&lt;/code&gt;这个壳子，直接在&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;里写就行，&lt;code&gt;defineExpose&lt;/code&gt;也不需要额外导入，直接用就行——&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;真的是Vue3的神器，强烈推荐大家使用。&lt;/p&gt;</description><pubDate>Thu, 08 Oct 2026 15:58:49 +0800</pubDate></item><item><title>Vue3父子组件生命周期执行顺序是什么？附setup、onMounted等钩子实战演示</title><link>https://www.codeqd.com/post/20260922117.html</link><description>&lt;p&gt;你有没有刚学Vue3就碰到这种情况？明明父组件里写了请求接口的代码，想把拿到的数据传给子组件，结果子组件的页面上一片空白，打开控制台一看数据undefined——大概率就是没搞懂父子组件生命周期的执行先后，接口还没返回就渲染子组件了，今天咱们就彻底捋顺这个问题，不管你用组合式还是选项式API，看完这篇都能直接用。&lt;/p&gt;
&lt;h2&gt;先把Vue3的核心生命周期钩子分清楚&lt;/h2&gt;
&lt;p&gt;不管是父子还是单个组件,首先得把Vue3本身支持的两类钩子搞明白：一类是大家可能从Vue2过渡来的选项式API（Options API）钩子，另一类是现在Vue3主推的组合式API（Composition API）钩子，得先对应上两类钩子的功能和触发顺序关系，不然会乱。&lt;/p&gt;
&lt;p&gt;选项式的钩子大家应该熟一点,比如beforeCreate、created、beforeMount、mounted这些，组合式的话，setup相当于beforeCreate+created的结合体，但是执行时机稍微有点不一样——Vue3官方说setup在beforeCreate钩子&lt;strong&gt;之前&lt;/strong&gt;运行，实例里的data、methods还没初始化，所以不能在setup里用this，其他组合式的钩子都是在选项式钩子的&lt;strong&gt;同一时间点&lt;/strong&gt;触发，只是写法不同，比如组合式的onBeforeMount对应选项式的beforeMount，onMounted对应mounted，后面我们就把功能一致的放在一起说顺序，方便对比。&lt;/p&gt;
&lt;h2&gt;单个Vue3组件的生命周期触发顺序（基础）&lt;/h2&gt;
&lt;p&gt;讲父子之前必须先过一遍单个组件的,不然顺序就串不起来，不管用组合式还是选项式，单个组件的完整加载、更新、卸载流程是固定的，我们只说&lt;strong&gt;最常用的加载和卸载流程&lt;/strong&gt;，更新的话后面单独提一句父子场景。&lt;/p&gt;
&lt;h3&gt;加载流程（最关键！占父子问题的90%以上）&lt;/h3&gt;
&lt;p&gt;按时间先后排：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;组件的setup函数（组合式专属，但不管用不用组合式，只要是Vue3组件，setup都会先于beforeCreate跑，选项式组件如果没写setup，这步就跳过）&lt;/li&gt;
&lt;li&gt;选项式的beforeCreate钩子&lt;/li&gt;
&lt;li&gt;选项式的created钩子&lt;/li&gt;
&lt;li&gt;组合式的onBeforeMount / 选项式的beforeMount（注意是同一时间点，不是先后）&lt;/li&gt;
&lt;li&gt;组合式的onMounted / 选项式的mounted（同上）&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;简单验证单个组件的钩子顺序&lt;/h3&gt;
&lt;p&gt;你可以新建一个Vue3项目,在App.vue里写个空的子组件占位，然后把这段代码放到App.vue的script setup里：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { onBeforeMount, onMounted, onBeforeUnmount, onUnmounted } from &amp;#39;vue&amp;#39;
console.log(&amp;#39;App setup开始&amp;#39;)
// 模拟setup里的逻辑
const test = &amp;#39;test&amp;#39;
console.log(&amp;#39;App setup结束&amp;#39;)
onBeforeMount(() =&amp;gt; {
  console.log(&amp;#39;App onBeforeMount&amp;#39;)
})
onMounted(() =&amp;gt; {
  console.log(&amp;#39;App onMounted&amp;#39;)
})
onBeforeUnmount(() =&amp;gt; {
  console.log(&amp;#39;App onBeforeUnmount&amp;#39;)
})
onUnmounted(() =&amp;gt; {
  console.log(&amp;#39;App onUnmounted&amp;#39;)
})&lt;/pre&gt;
&lt;p&gt;然后再写点选项式的代码对比,把setup拆成script和script setup（如果用Vite创建的项目可能需要设置允许双script），控制台打印的结果肯定是先setup的两条，再onBeforeMount，最后onMounted。&lt;/p&gt;
&lt;h2&gt;进入正题：Vue3父子组件加载、更新、卸载的完整顺序&lt;/h2&gt;
&lt;p&gt;终于到大家最关心的地方了！父子组件的生命周期，核心原则记住一句话就行：&lt;strong&gt;先父组件初始化到挂载前，再子组件从初始化到完全挂载，最后父组件完全挂载&lt;/strong&gt;——更新和卸载也有类似的“父先启动、子先完成、父再收尾”的逻辑，下面分开细讲。&lt;/p&gt;
&lt;h3&gt;父子组件首次加载（从无到有渲染出来）&lt;/h3&gt;
&lt;p&gt;这个是最容易踩数据传递坑的场景,刚才开头说的接口返回晚子组件拿不到数据，就是踩在这里，我们假设父组件叫Parent，子组件叫Child，Child在Parent的template里直接用，没有v-if/v-for条件渲染（条件渲染的情况后面单独加个小补充）。&lt;/p&gt;
&lt;p&gt;按时间先后的完整顺序（组合式和选项式同功能钩子放同一层，前面加Parent/Child区分）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Parent setup开始 → Parent setup结束&lt;/strong&gt;（组合式专属，没写就跳过）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent beforeCreate&lt;/strong&gt;（选项式）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent created&lt;/strong&gt;（选项式）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent onBeforeMount / Parent beforeMount&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child setup开始 → Child setup结束&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child beforeCreate&lt;/strong&gt;（选项式）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child created&lt;/strong&gt;（选项式）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child onBeforeMount / Child beforeMount&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child onMounted / Child mounted&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent onMounted / Parent mounted&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;哦对了！这里一定要注意第10步——&lt;strong&gt;只有所有子组件都完全挂载到DOM上之后，父组件的onMounted/mounted才会触发&lt;/strong&gt;！这就解释了开头的问题：如果你在Parent的setup或者created里发异步请求接口（比如axios.get），接口返回是异步的，不会等数据回来再往下走生命周期，这时候Child已经开始初始化甚至挂载完了，拿到的就是初始的空数据或者undefined。&lt;/p&gt;
&lt;p&gt;那怎么解决这个问题呢？简单提两个常用方法，不算跑题，都是围绕生命周期的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一个是给Child加v-if，父组件拿到数据之后再把v-if设为true，这样Child会等数据回来再重新走一遍从setup到mounted的流程；&lt;/li&gt;
&lt;li&gt;第二个是在Child里用watch或者watchEffect监听父组件传过来的props,数据变了就重新处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;父子组件首次加载带条件渲染（v-if/v-for）&lt;/h3&gt;
&lt;p&gt;刚才的补充里提到了v-if，这里单独说下顺序的变化：假设Parent里有个v-if=&quot;showChild&quot;，初始showChild是false，Parent首次加载的时候showChild还没改，那Parent的加载流程就会到第4步（Parent onBeforeMount/beforeMount）之后，直接跳第10步（Parent onMounted/mounted），完全不会触发Child的任何钩子；等后来通过点击按钮或者接口返回把showChild改成true，才会单独触发Child从第5步到第9步的加载流程，这时候Parent不会再重新走钩子。&lt;/p&gt;
&lt;p&gt;v-for的话，如果是动态数组（初始为空，后来push数据），每push一个Child对应的项，就会单独触发一个新的Child的加载流程，和上面v-if的单个触发逻辑一样。&lt;/p&gt;
&lt;h3&gt;父子组件的更新流程&lt;/h3&gt;
&lt;p&gt;更新流程也遵循“父先启动，子先完成，父再收尾”的原则，这里说的更新是指&lt;strong&gt;props、data、computed等响应式数据变化导致的DOM更新&lt;/strong&gt;，同样假设Parent有个响应式数据count，传给了Child当props：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Parent的响应式数据count变了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent onBeforeUpdate / Parent beforeUpdate&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Child的响应式props count变了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child onBeforeUpdate / Child beforeUpdate&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Child的DOM更新完成&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child onUpdated / Child updated&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Parent的DOM更新完成&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent onUpdated / Parent updated&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;父子组件的卸载流程&lt;/h3&gt;
&lt;p&gt;卸载流程和加载流程是&lt;strong&gt;完全反过来的&lt;/strong&gt;！也是“父先启动，子先完成，父再收尾”：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Parent onBeforeUnmount / Parent beforeUnmount&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child onBeforeUnmount / Child beforeUnmount&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Child的组件实例和DOM被销毁&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Child onUnmounted / Child unmounted&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Parent的组件实例和DOM被销毁&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parent onUnmounted / Parent unmounted&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;用一个完整的实战案例验证所有加载顺序&lt;/h2&gt;
&lt;p&gt;光说理论可能记不住,咱们写一个同时包含组合式和选项式钩子的Parent+Child案例，直接看控制台打印结果。&lt;/p&gt;
&lt;h3&gt;先写组合式的父组件（Parent.vue）&lt;/h3&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;parent-box&amp;quot;&amp;gt;
    &amp;lt;h2&amp;gt;我是父组件&amp;lt;/h2&amp;gt;
    &amp;lt;button @click=&amp;quot;count++&amp;quot;&amp;gt;增加父组件count&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&amp;quot;showChild = !showChild&amp;quot;&amp;gt;切换子组件显示&amp;lt;/button&amp;gt;
    &amp;lt;Child v-if=&amp;quot;showChild&amp;quot; :parentCount=&amp;quot;count&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from &amp;#39;vue&amp;#39;
import Child from &amp;#39;./Child.vue&amp;#39;
console.log(&amp;#39;Parent setup开始&amp;#39;)
const count = ref(0)
const showChild = ref(true)
console.log(&amp;#39;Parent setup结束，count初始值：&amp;#39;, count.value)
onBeforeMount(() =&amp;gt; {
  console.log(&amp;#39;Parent onBeforeMount&amp;#39;)
})
onMounted(() =&amp;gt; {
  console.log(&amp;#39;Parent onMounted&amp;#39;)
})
onBeforeUpdate(() =&amp;gt; {
  console.log(&amp;#39;Parent onBeforeUpdate&amp;#39;)
})
onUpdated(() =&amp;gt; {
  console.log(&amp;#39;Parent onUpdated&amp;#39;)
})
onBeforeUnmount(() =&amp;gt; {
  console.log(&amp;#39;Parent onBeforeUnmount&amp;#39;)
})
onUnmounted(() =&amp;gt; {
  console.log(&amp;#39;Parent onUnmounted&amp;#39;)
})
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.parent-box {
  border: 2px solid #42b983;
  padding: 20px;
  margin: 20px;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;h3&gt;再写选项式+组合式混合的子组件（Child.vue）&lt;/h3&gt;
&lt;p&gt;故意混合写是为了验证同功能钩子的触发时间是否一致。&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;child-box&amp;quot;&amp;gt;
    &amp;lt;h3&amp;gt;我是子组件&amp;lt;/h3&amp;gt;
    &amp;lt;p&amp;gt;父组件传过来的count：{{ parentCount }}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script&amp;gt;
export default {
  props: [&amp;#39;parentCount&amp;#39;],
  beforeCreate() {
    console.log(&amp;#39;Child beforeCreate（选项式）&amp;#39;)
  },
  created() {
    console.log(&amp;#39;Child created（选项式）&amp;#39;)
  },
  beforeMount() {
    console.log(&amp;#39;Child beforeMount（选项式）&amp;#39;)
  },
  mounted() {
    console.log(&amp;#39;Child mounted（选项式）&amp;#39;)
  },
  beforeUpdate() {
    console.log(&amp;#39;Child beforeUpdate（选项式）&amp;#39;)
  },
  updated() {
    console.log(&amp;#39;Child updated（选项式）&amp;#39;)
  },
  beforeUnmount() {
    console.log(&amp;#39;Child beforeUnmount（选项式）&amp;#39;)
  },
  unmounted() {
    console.log(&amp;#39;Child unmounted（选项式）&amp;#39;)
  }
}
&amp;lt;/script&amp;gt;
&amp;lt;script setup&amp;gt;
import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from &amp;#39;vue&amp;#39;
console.log(&amp;#39;Child setup开始&amp;#39;)
console.log(&amp;#39;Child setup结束，parentCount初始值：&amp;#39;, props.parentCount) // 这里props不用显式defineProps吗？哦对，混合写的时候script里的props会自动暴露给script setup，不过建议还是显式写，这里为了验证混合才这么写
onBeforeMount(() =&amp;gt; {
  console.log(&amp;#39;Child onBeforeMount（组合式）&amp;#39;)
})
onMounted(() =&amp;gt; {
  console.log(&amp;#39;Child onMounted（组合式）&amp;#39;)
})
onBeforeUpdate(() =&amp;gt; {
  console.log(&amp;#39;Child onBeforeUpdate（组合式）&amp;#39;)
})
onUpdated(() =&amp;gt; {
  console.log(&amp;#39;Child onUpdated（组合式）&amp;#39;)
})
onBeforeUnmount(() =&amp;gt; {
  console.log(&amp;#39;Child onBeforeUnmount（组合式）&amp;#39;)
})
onUnmounted(() =&amp;gt; {
  console.log(&amp;#39;Child onUnmounted（组合式）&amp;#39;)
})
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.child-box {
  border: 2px solid #35495e;
  padding: 20px;
  margin-top: 20px;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;h3&gt;看控制台的打印结果&lt;/h3&gt;
&lt;p&gt;把Parent.vue引入到App.vue里，运行项目：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;首次加载（showChild初始为true）：
Parent setup开始 → Parent setup结束 → Parent beforeCreate（这里Parent没写选项式的beforeCreate和created，所以没有）→ Parent onBeforeMount → Child setup开始 → Child setup结束 → Child beforeCreate（选项式）→ Child created（选项式）→ Child beforeMount（选项式）和Child onBeforeMount（组合式）（顺序可能随机？但几乎同时）→ Child mounted（选项式）和Child onMounted（组合式）（同上）→ Parent onMounted&lt;/li&gt;
&lt;li&gt;点击“增加父组件count”：
Parent onBeforeUpdate → Child beforeUpdate（选项式）和Child onBeforeUpdate（组合式）→ Child updated（选项式）和Child onUpdated（组合式）→ Parent onUpdated&lt;/li&gt;
&lt;li&gt;点击“切换子组件显示”隐藏Child：
Parent onBeforeUpdate → Child beforeUnmount（选项式）和Child onBeforeUnmount（组合式）→ Child unmounted（选项式）和Child onUnmounted（组合式）→ Parent onUpdated&lt;/li&gt;
&lt;li&gt;再次点击“切换子组件显示”显示Child：
Parent onBeforeUpdate → Child setup开始 → ... → Child mounted → Parent onUpdated&lt;/li&gt;
&lt;li&gt;刷新页面或者直接关闭标签（相当于卸载App.vue，Parent作为App的子组件也会卸载）：
App的onBeforeUnmount（如果App.vue写了的话）→ Parent onBeforeUnmount → Child beforeUnmount（如果还在显示）→ Child unmounted → Parent onUnmounted → App的onUnmounted&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;总结一些实用的小技巧&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;发异步接口请求全局/父组件数据：尽量放在&lt;strong&gt;Parent的setup或者created里&lt;/strong&gt;，但别等返回再渲染，可以用v-if控制子组件，或者子组件监听props；&lt;/li&gt;
&lt;li&gt;发异步接口请求子组件专属数据：放在&lt;strong&gt;Child的setup或者created里&lt;/strong&gt;，或者Child的onMounted里（如果需要DOM元素的话）；&lt;/li&gt;
&lt;li&gt;操作子组件的DOM元素：必须放在&lt;strong&gt;Parent的onMounted里&lt;/strong&gt;，因为只有这时候Child才完全挂载；&lt;/li&gt;
&lt;li&gt;混合写组合式和选项式API：同功能钩子的触发时间几乎一致，没有绝对的先后，尽量只用一种，方便维护。
就到这里，你可以自己把上面的代码复制下来跑一遍，印象会更深刻，如果还有其他Vue3的问题，欢迎在评论区留言！&lt;/li&gt;
&lt;/ol&gt;</description><pubDate>Fri, 18 Sep 2026 08:01:25 +0800</pubDate></item><item><title>Vue3项目里选哪个富文本编辑器？选了又怎么踩坑避坑？</title><link>https://www.codeqd.com/post/20260922116.html</link><description>&lt;h2&gt;先别急着搜“Vue3富文本编辑器Top10”，得先搞清楚你要做啥场景&lt;/h2&gt;
&lt;p&gt;最近很多刚上手Vue3的朋友来找我聊，第一句就是甩个链接甩个需求：“我要做个论坛，用哪个富文本编辑器？给个快速上手的！”“我是电商商家后台，要加商品详情，求推荐无bug能加自定义组件的！”其实根本没有所谓的“通用Top1”，不同场景、不同技术栈深浅、不同团队预算和维护成本，选出来的编辑器天差地别，我整理了下身边朋友、同事，还有之前帮客户接项目时用到的高频Vue3富文本编辑器，得先从「你要解决什么问题」这个核心切入分类。&lt;/p&gt;
&lt;h3&gt;如果你是做&lt;strong&gt;轻量级展示/评论区/私信草稿&lt;/strong&gt;的项目&lt;/h3&gt;
&lt;p&gt;这类场景有几个共性：不需要太复杂的排版功能，字号加粗斜体下划线引用加个表情加个小图片（还可能限制格式和大小）、最多来个有序无序列表就行；加载速度要快，毕竟评论区或者私信框可能在移动端或者多弹窗的后台，太大会拖慢页面首屏或者交互响应；开发成本低，不用自己折腾太多配置或者二次封装，拿来就能用；出错率要低，毕竟不是人人都是专业排版师，输错了或者操作失误别直接崩了页面就行。
那选什么？Quill的Vue3封装版VueQuillEditor绝对是第一梯队，它原生的编辑器就很轻，Gzip压缩后大概只有20KB左右，连Markdown编辑器都可能比它大，而且配置超级简单，npm或者yarn装一下，然后在Vue3的组件里import注册一下，加个v-model双向绑定内容，一行div加class就能跑起来，表情的话有专门的emoji插件，不用自己一个个画；图片上传如果只需要本地展示或者上传到简单的服务器，自己写个handler函数替换掉默认的base64就行——默认base64会把图片直接塞到HTML里，评论存数据库太占空间，但拿来练手或者给内部小工具用没问题。
这里要提个小细节：轻量级场景也别太看不起配置，如果是新手，别一开始就加一堆插件把编辑器弄成大杂烩，插件多了不仅加载慢，还容易出兼容性问题，比如Quill的Delta格式，如果不是要做协同编辑，完全不用特意去研究，直接用getHTML()和setHTML()就行，和以前用的UEditor TinyMCE差不多逻辑,上手更快。&lt;/p&gt;
&lt;h3&gt;如果你是做&lt;strong&gt;内容创作类项目（公众号同款排版、CMS后台、在线文档轻量版）&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;这类场景就要进阶一点了：需要支持表格、插入音视频（最好能直接解析B站、抖音这种第三方链接）、自定义段落样式（比如首行缩进、行间距调整不是只有默认的那几个）、导出为PDF或者Word（哪怕只是基础版）、可能还要有撤销重做的历史记录更流畅的版本；二次封装的空间要大，比如要加个“一键生成标题图占位符”“一键插入电商商品链接卡片”这种业务相关的组件；社区生态要好，遇到问题比如在Vue3的setup语法糖里怎么绑定toolbar的自定义按钮，怎么处理移动端的触摸输入延迟，随便搜搜就能找到解决方案，不用自己啃源码啃好几天。
这个梯队的选择就多了，但我最推荐的是TinyMCE的Vue3封装版，很多人可能觉得TinyMCE太老了，但是新版本（TinyMCE 6.x）已经完全重构了，用的是TypeScript写的，和Vue3的组合式API适配得特别好，Gzip压缩后加上常用插件大概也只有100KB左右，不算太大。
为什么推荐它不推荐Slate或者Draft.js的Vue3封装版？Slate和Draft.js的好处是完全可定制，UI都能自己画，但问题是太重了，二次封装的成本极高，没有个前端团队专门维护根本搞不定，小公司或者个人项目用的话，很容易因为某个UI细节改不出来或者某个插件没人写就卡壳，而TinyMCE 6.x的官方UI已经很现代化了，移动端也做了响应式优化，工具栏可以折叠，还能根据不同用户角色设置不同的工具栏权限——这个对内容创作类项目超级重要，比如普通作者只能用基础排版，主编才能加表格音视频和导出。
还有一个备选是CKEditor 5的Vue3封装版，它的模块化做得比TinyMCE还要好，你可以按需引入插件，比如不需要导出PDF就可以不装这个模块，加载速度可能更快一点，但CKEditor 5的文档有时候会有点绕，新手可能要花点时间才能搞懂配置，而且它的商业版功能比免费版全很多，比如免费版的导出PDF功能是有水印的,如果是做收费项目可能要考虑预算。&lt;/p&gt;
&lt;h3&gt;如果你是做&lt;strong&gt;专业协同编辑、在线协作文档（比如飞书文档轻量版）&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;这类场景的要求就非常高了：必须支持多人实时协同编辑，冲突处理要流畅；要有完整的文档版本控制，能回退到任意版本；要支持Markdown实时预览、语法高亮、LaTeX公式（学术类项目）；可以插入复杂的自定义组件，比如流程图、思维导图、甘特图；技术栈要和Vue3深度适配，最好是用Vue3写的底层。
这个时候Quill、TinyMCE、CKEditor 5的免费版可能就不太够用了，因为它们的协同编辑要么是商业版才有的，要么是用WebRTC或者Socket.io自己搭的，冲突处理逻辑很难写好，那选什么？可以试试BlockSuite或者Vditor的高级协同版。
BlockSuite是AFFiNE（一个开源的在线协作文档工具）的底层编辑器，完全用TypeScript和Lit写的，但是提供了专门的Vue3组件库BlockSuite Vue，和Vue3的组合式API、Pinia都适配得很好，它的核心是“块”（Block）架构，每个段落、每个图片、每个表格都是一个独立的块，这样不仅冲突处理起来更简单，插入自定义组件也超级方便——你可以把自己写的任何Vue3组件封装成一个BlockSuite的块，直接插入到编辑器里，而且BlockSuite的协同编辑是免费开源的，用的是CRDT（无冲突复制数据类型），没有中心服务器的话也能在本地协同，有中心服务器的话可以用Y.js作为后端，部署起来也不难。
Vditor的高级协同版是基于Monaco Editor和Y.js做的，支持Markdown实时双向渲染，语法高亮做得非常好，学术类项目用的话LaTeX公式支持也很全，但是Vditor的高级协同版是收费的，个人项目如果预算有限的话，可以试试BlockSuite，如果你公司有充足的预算和技术团队，也可以自己用Slate或者Draft.js加上Y.js搭一个，但那样成本太高了,除非你的项目核心就是编辑器。&lt;/p&gt;
&lt;h2&gt;选好了编辑器，Vue3里这些坑90%的人都会踩，提前避坑能省好几天时间&lt;/h2&gt;
&lt;p&gt;选型是第一步，踩坑才是常态，我整理了下最近半年帮朋友和客户解决的Vue3富文本编辑器的问题，挑几个最常见的、大家踩得最多的来说。&lt;/p&gt;
&lt;h3&gt;坑1：setup语法糖里用v-model绑定编辑器内容，要么不更新，要么更新乱跳&lt;/h3&gt;
&lt;p&gt;这个坑绝对是新手入门踩的第一个坑，不管是用VueQuillEditor、TinyMCE Vue还是CKEditor 5 Vue，都可能遇到，为什么会这样？因为有些编辑器的Vue3封装版没有完全适配组合式API的响应式系统，或者你绑定的不是正确的响应式数据。
比如用VueQuillEditor的时候,很多人会直接写：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;VueQuillEditor v-model=&amp;quot;content&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import { VueQuillEditor } from &amp;#39;@vueup/vue-quill&amp;#39;
import &amp;#39;@vueup/vue-quill/dist/vue-quill.snow.css&amp;#39;
const content = ref(&amp;#39;&amp;#39;)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;看起来好像没问题，但有时候你在编辑器里输入内容，content的值会更新，但有时候不会，或者你手动修改content的值，编辑器里的内容会乱跳。
怎么解决？其实很简单，VueQuillEditor的官方文档里其实有写，setup语法糖里要用v-model:content或者用v-model加contentRef，不过我更推荐用v-model:content,因为更符合Vue3的组合式API的习惯：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;VueQuillEditor v-model:content=&amp;quot;content&amp;quot; contentType=&amp;quot;html&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import { VueQuillEditor } from &amp;#39;@vueup/vue-quill&amp;#39;
import &amp;#39;@vueup/vue-quill/dist/vue-quill.snow.css&amp;#39;
const content = ref(&amp;#39;&amp;lt;p&amp;gt;这是初始内容&amp;lt;/p&amp;gt;&amp;#39;)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;哦对了，还要加contentType=&quot;html&quot;，如果你不加的话，默认绑定的是Quill的Delta格式，新手如果不熟悉Delta格式的话，拿到数据存到数据库或者展示的时候都会有问题。
那TinyMCE Vue和CKEditor 5 Vue呢？TinyMCE Vue 6.x已经完全适配setup语法糖了，直接用v-model绑定一个ref或者reactive里的属性就行，不会有问题，CKEditor 5 Vue的话，如果你用的是ClassicEditor或者InlineEditor，直接用v-model也没问题，但如果你用的是DecoupledEditor（分离式编辑器，工具栏和编辑区是分开的），可能需要手动绑定editor.model.document.on(&#039;change:data&#039;)来更新数据,不过官方文档里也有详细的例子。&lt;/p&gt;
&lt;h3&gt;坑2：图片上传默认用base64，存数据库太占空间，换自定义上传又报错&lt;/h3&gt;
&lt;p&gt;这个坑也是轻量级和内容创作类项目都会遇到的，特别是电商后台的商品详情，一张商品图可能就有几MB，用base64存的话，一条商品详情可能就有几十MB，数据库很快就满了，加载速度也会特别慢。
那怎么换自定义上传？以VueQuillEditor为例，很多人会直接在toolbar里加个image按钮，然后在组件里写个uploadHandler函数，但写了之后要么图片上传成功编辑器里不显示，要么显示的是损坏的图片。
为什么会这样？因为Quill的uploadHandler函数需要返回一个Promise，Promise的resolve值必须是一个对象，对象里要有一个url属性，指向上传后的图片地址，不能直接返回字符串。
正确的写法应该是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;VueQuillEditor 
    v-model:content=&amp;quot;content&amp;quot; 
    contentType=&amp;quot;html&amp;quot;
    :options=&amp;quot;editorOptions&amp;quot;
  /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import { VueQuillEditor } from &amp;#39;@vueup/vue-quill&amp;#39;
import &amp;#39;@vueup/vue-quill/dist/vue-quill.snow.css&amp;#39;
const content = ref(&amp;#39;&amp;lt;p&amp;gt;这是初始内容&amp;lt;/p&amp;gt;&amp;#39;)
// 自定义上传函数
const uploadImage = async (file) =&amp;gt; {
  // 这里可以加文件类型和大小的校验
  if (!file.type.startsWith(&amp;#39;image/&amp;#39;)) {
    alert(&amp;#39;请上传图片文件！&amp;#39;)
    return
  }
  if (file.size &amp;gt; 5 * 1024 * 1024) {
    alert(&amp;#39;图片大小不能超过5MB！&amp;#39;)
    return
  }
  // 这里替换成你自己的图片上传接口
  const formData = new FormData()
  formData.append(&amp;#39;file&amp;#39;, file)
  const res = await fetch(&amp;#39;https://your-api.com/upload/image&amp;#39;, {
    method: &amp;#39;POST&amp;#39;,
    body: formData
  })
  const data = await res.json()
  // 必须返回一个有url属性的对象
  return {
    url: data.url
  }
}
const editorOptions = ref({
  modules: {
    toolbar: {
      container: [
        [&amp;#39;bold&amp;#39;, &amp;#39;italic&amp;#39;, &amp;#39;underline&amp;#39;, &amp;#39;strike&amp;#39;],
        [&amp;#39;blockquote&amp;#39;, &amp;#39;code-block&amp;#39;],
        [{ &amp;#39;header&amp;#39;: 1 }, { &amp;#39;header&amp;#39;: 2 }],
        [{ &amp;#39;list&amp;#39;: &amp;#39;ordered&amp;#39;}, { &amp;#39;list&amp;#39;: &amp;#39;bullet&amp;#39; }],
        [{ &amp;#39;script&amp;#39;: &amp;#39;sub&amp;#39;}, { &amp;#39;script&amp;#39;: &amp;#39;super&amp;#39; }],
        [{ &amp;#39;indent&amp;#39;: &amp;#39;-1&amp;#39;}, { &amp;#39;indent&amp;#39;: &amp;#39;+1&amp;#39; }],
        [{ &amp;#39;direction&amp;#39;: &amp;#39;rtl&amp;#39; }],
        [{ &amp;#39;size&amp;#39;: [&amp;#39;small&amp;#39;, false, &amp;#39;large&amp;#39;, &amp;#39;huge&amp;#39;] }],
        [{ &amp;#39;header&amp;#39;: [1, 2, 3, 4, 5, 6, false] }],
        [{ &amp;#39;color&amp;#39;: [] }, { &amp;#39;background&amp;#39;: [] }],
        [{ &amp;#39;font&amp;#39;: [] }],
        [{ &amp;#39;align&amp;#39;: [] }],
        [&amp;#39;link&amp;#39;, &amp;#39;image&amp;#39;, &amp;#39;video&amp;#39;, &amp;#39;formula&amp;#39;],
        [&amp;#39;clean&amp;#39;]
      ],
      handlers: {
        image: uploadImage
      }
    }
  }
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;那TinyMCE Vue呢？TinyMCE Vue的自定义图片上传更简单，直接在editorInit里加images_upload_handler属性就行，也是返回一个Promise,resolve值是图片的url字符串：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const editorInit = ref({
  selector: &amp;#39;textarea&amp;#39;, // 不用管，Vue封装版会自动处理
  plugins: &amp;#39;image link code&amp;#39;,
  toolbar: &amp;#39;image link code&amp;#39;,
  images_upload_handler: async (blobInfo, success, failure) =&amp;gt; {
    try {
      const formData = new FormData()
      formData.append(&amp;#39;file&amp;#39;, blobInfo.blob())
      const res = await fetch(&amp;#39;https://your-api.com/upload/image&amp;#39;, {
        method: &amp;#39;POST&amp;#39;,
        body: formData
      })
      const data = await res.json()
      success(data.url)
    } catch (error) {
      failure(&amp;#39;图片上传失败！&amp;#39;)
    }
  }
})&lt;/pre&gt;
&lt;p&gt;这里要注意，TinyMCE Vue的images_upload_handler有两种写法，一种是用success和failure回调，另一种是返回Promise，推荐用Promise,更符合现代JavaScript的习惯。&lt;/p&gt;
&lt;h3&gt;坑3：编辑器里的内容在展示页面乱码或者样式丢失&lt;/h3&gt;
&lt;p&gt;这个坑主要出现在内容创作类项目，比如你在TinyMCE里写了一篇文章，设置了首行缩进、行间距、自定义字体，但是在展示页面打开的时候，要么这些样式都没了，要么文字乱码。
为什么会乱码？乱码的问题比较简单，主要是因为数据库的字符集不是utf8mb4，或者后端返回数据的时候没有设置正确的Content-Type（text/html; charset=utf-8），解决方法就是把数据库的字符集改成utf8mb4，后端返回数据的时候加上正确的Content-Type。
为什么会样式丢失？这个问题就比较复杂了，主要有三个原因：
第一个原因是编辑器自带的样式没有引入到展示页面，比如VueQuillEditor的snow主题和bubble主题都有自己的CSS，你在展示页面也要引入对应的CSS，不然引用、代码块、有序无序列表这些样式都会没了，TinyMCE的话，官方提供了content.css，你可以在展示页面引入，或者自己提取需要的样式。
第二个原因是你用了自定义的CSS类或者内联样式，但是展示页面的全局样式覆盖了这些样式，比如你在编辑器里给某个段落加了font-size: 16px，但是展示页面的全局CSS里有p { font-size: 14px !important; }，那这个段落的字号就会变成14px，解决方法就是给展示页面的编辑器内容容器加一个特定的类名，然后把编辑器的样式或者自定义样式写在这个类名下面,提高CSS的优先级：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;editor-content&amp;quot; v-html=&amp;quot;content&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 提高优先级，避免被全局样式覆盖 */
.editor-content :deep(p) {
  font-size: 16px !important;
}
.editor-content :deep(.ql-indent-1) {
  padding-left: 3em;
}
.editor-content :deep(.ql-indent-2) {
  padding-left: 6em;
}
/* 引入VueQuillEditor的snow主题样式，scoped里引入的话要加:deep()或者去掉scoped，推荐去掉scoped */
&amp;lt;/style&amp;gt;
&amp;lt;style&amp;gt;
@import &amp;#39;@vueup/vue-quill/dist/vue-quill.snow.css&amp;#39;;
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;哦对了，Vue的scoped样式是通过给元素加data-v-xxx属性来实现的，而v-html渲染出来的内容是没有这个属性的，所以如果编辑器的样式是写在scoped里的，必须加:deep()才能生效，或者直接去掉scoped样式——不过去掉scoped样式的时候要注意，给内容容器加一个非常独特的类名，避免影响页面的其他元素。
第三个原因是你用了第三方的图片或者视频链接，但是展示页面的CSP（内容安全策略）禁止加载这些资源，解决方法就是修改CSP，允许加载这些第三方资源,或者把第三方资源下载到自己的服务器上。&lt;/p&gt;
&lt;h3&gt;坑4：在弹窗里使用编辑器，编辑器不显示或者编辑区无法输入&lt;/h3&gt;
&lt;p&gt;这个坑也是很多人会遇到的，特别是后台管理系统，经常会把编辑器放在弹窗里，为什么会这样？主要有两个原因：
第一个原因是弹窗是异步渲染的，编辑器初始化的时候弹窗还没有显示，编辑区的高度或者宽度是0，所以编辑器不显示，解决方法就是等弹窗完全显示之后再初始化编辑器，或者给编辑器的编辑区设置一个最小高度和最小宽度。
比如用Element Plus的el-dialog组件的时候，可以用@opened事件来初始化编辑器：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;el-dialog v-model=&amp;quot;visible&amp;quot; title=&amp;quot;编辑文章&amp;quot; @opened=&amp;quot;initEditor&amp;quot; width=&amp;quot;80%&amp;quot;&amp;gt;
    &amp;lt;VueQuillEditor ref=&amp;quot;editorRef&amp;quot; v-model:content=&amp;quot;content&amp;quot; contentType=&amp;quot;html&amp;quot; style=&amp;quot;min-height: 400px;&amp;quot; /&amp;gt;
  &amp;lt;/el-dialog&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, nextTick } from &amp;#39;vue&amp;#39;
import { VueQuillEditor } from &amp;#39;@vueup/vue-quill&amp;#39;
import &amp;#39;@vueup/vue-quill/dist/vue-quill.snow.css&amp;#39;
import { ElDialog } from &amp;#39;element-plus&amp;#39;
const visible = ref(false)
const content = ref(&amp;#39;&amp;#39;)
const editorRef = ref(null)
const openDialog = () =&amp;gt; {
  visible.value = true
}
const initEditor = () =&amp;gt; {
  nextTick(() =&amp;gt; {
    // 如果编辑器还没完全初始化，可以在这里做一些操作
    if (editorRef.value) {
      console.log(&amp;#39;编辑器初始化成功&amp;#39;)
    }
  })
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;第二个原因是弹窗的z-index比编辑器的工具栏或者下拉菜单的z-index低，所以编辑器的工具栏或者下拉菜单被弹窗盖住了，看起来像是编辑区无法输入或者不显示，解决方法就是修改编辑器的工具栏或者下拉菜单的z-index，让它比弹窗的z-index高。
比如VueQuillEditor的snow主题的下拉菜单的z-index默认是1000，而Element Plus的el-dialog组件的z-index默认是2000，所以需要把VueQuillEditor的下拉菜单的z-index改成比2000高：&lt;/p&gt;
&lt;pre class=&quot;brush:css;toolbar:false&quot;&gt;.ql-snow .ql-picker-options {
  z-index: 3000 !important;
}&lt;/pre&gt;
&lt;p&gt;TinyMCE的话，可以在editorInit里加z_index属性，把编辑器的z-index改成比弹窗高：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const editorInit = ref({
  z_index: 3000,
  // 其他配置
})&lt;/pre&gt;
&lt;h2&gt;进阶玩法：Vue3富文本编辑器怎么插入自定义业务组件？&lt;/h2&gt;
&lt;p&gt;刚才在选型的时候提到了，专业协同编辑类项目需要插入复杂的自定义业务组件，其实内容创作类项目也经常需要，比如电商后台的“一键插入商品链接卡片”“一键插入活动海报占位符”，内容创作平台的“一键插入投票组件”“一键插入问卷组件”。
那怎么插入自定义业务组件？以BlockSuite Vue为例，因为它的块架构最适合插入自定义组件，你需要安装BlockSuite Vue和相关的依赖：&lt;/p&gt;
&lt;pre class=&quot;brush:bash;toolbar:false&quot;&gt;npm install @blocksuite/vue @blocksuite/presets @blocksuite/store&lt;/pre&gt;
&lt;p&gt;你可以把自己写的任何Vue3组件封装成一个BlockSuite的块，比如你要写一个“商品链接卡片”组件：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- ProductCardBlock.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;product-card&amp;quot;&amp;gt;
    &amp;lt;img :src=&amp;quot;product.image&amp;quot; alt=&amp;quot;product.name&amp;quot; class=&amp;quot;product-image&amp;quot; /&amp;gt;
    &amp;lt;div class=&amp;quot;product-info&amp;quot;&amp;gt;
      &amp;lt;h3 class=&amp;quot;product-name&amp;quot;&amp;gt;{{ product.name }}&amp;lt;/h3&amp;gt;
      &amp;lt;p class=&amp;quot;product-price&amp;quot;&amp;gt;¥{{ product.price }}&amp;lt;/p&amp;gt;
      &amp;lt;a :href=&amp;quot;product.link&amp;quot; target=&amp;quot;_blank&amp;quot; class=&amp;quot;product-link&amp;quot;&amp;gt;立即购买&amp;lt;/a&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { defineProps } from &amp;#39;vue&amp;#39;
const props = defineProps({
  product: {
    type: Object,
    default: () =&amp;gt; ({
      name: &amp;#39;示例商品&amp;#39;,
      price: &amp;#39;99.00&amp;#39;,
      image: &amp;#39;https://via.placeholder.com/150&amp;#39;,
      link: &amp;#39;#&amp;#39;
    })
  }
})
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.product-card {
  display: flex;
  gap: 16px;
  padding: 16px;
  border: 1px solid #eee;
  border-radius: 8px;
  margin: 16px 0;
}
.product-image {
  width: 150px;
  height: 150px;
  object-fit: cover;
  border-radius: 4px;
}
.product-info {
  flex: 1;
}
.product-name {
  margin: 0 0 8px 0;
  font-size: 18px;
  font-weight: bold;
}
.product-price {
  margin: 0 0 12px 0;
  font-size: 20px;
  color: #f00;
}
.product-link {
  display: inline-block;
  padding: 8px 16px;
  background-color: #007bff;
  color: #fff;
  text-decoration: none;
  border-radius: 4px;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;你需要注册这个块到BlockSuite的编辑器里：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { defineVueBlockComponent } from &amp;#39;@blocksuite/vue&amp;#39;
import ProductCardBlock from &amp;#39;./ProductCardBlock.vue&amp;#39;
// 定义块的schema
const ProductCardSchema = defineVueBlockComponent({
  tag: &amp;#39;product-card&amp;#39;,
  component: ProductCardBlock,
  props: {
    product: {
      type: Object,
      default: () =&amp;gt; ({
        name: &amp;#39;示例商品&amp;#39;,
        price: &amp;#39;99.00&amp;#39;,
        image: &amp;#39;https://via.placeholder.com/150&amp;#39;,
        link: &amp;#39;#&amp;#39;
      })
    }
  }
})&lt;/pre&gt;
&lt;p&gt;你需要在编辑器里添加一个按钮,点击按钮就可以插入这个块：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;blocksuite-editor&amp;quot;&amp;gt;
    &amp;lt;div class=&amp;quot;toolbar&amp;quot;&amp;gt;
      &amp;lt;button @click=&amp;quot;insertProductCard&amp;quot;&amp;gt;插入商品卡片&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;affine-page ref=&amp;quot;pageRef&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, onMounted } from &amp;#39;vue&amp;#39;
import { AffinePage } from &amp;#39;@blocksuite/presets&amp;#39;
import { createEmptyDoc, Workspace } from &amp;#39;@blocksuite/store&amp;#39;
import ProductCardSchema from &amp;#39;./ProductCardSchema&amp;#39;
const pageRef = ref(null)
const workspace = new Workspace({ id: &amp;#39;my-workspace&amp;#39; })
const doc = createEmptyDoc(workspace)
onMounted(() =&amp;gt; {
  // 注册块的schema
  doc.schema.register(ProductCardSchema)
  // 加载文档
  pageRef.value.doc = doc
})
const insertProductCard = async () =&amp;gt; {
  // 模拟从后端获取商品信息
  const product = await fetch(&amp;#39;https://your-api.com/product/1&amp;#39;).then(res =&amp;gt; res.json())
  // 插入商品卡片块
  const block = doc.addBlock(&amp;#39;product-card&amp;#39;, { product }, doc.root)
  // 选中插入的块
  doc.setSelection({
    type: &amp;#39;block&amp;#39;,
    blockId: block.id
  })
}
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.blocksuite-editor {
  width: 100%;
  max-width: 800px;
  margin: 0 auto;
  padding: 24px;
}
.toolbar {
  margin-bottom: 16px;
  padding: 8px;
  border: 1px solid #eee;
  border-radius: 4px;
}
.toolbar button {
  padding: 8px 16px;
  background-color: #007bff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;是不是超级简单？BlockSuite的块架构真的是为插入自定义组件而生的，你可以把任何Vue3组件封装成一个块，直接插入到编辑器里，而且支持多人实时协同编辑，回退到任意版本。
如果你用的是TinyMCE或者CKEditor 5，也可以插入自定义组件，但要麻烦很多，需要自己写插件，处理组件的渲染、编辑、保存等逻辑,没有BlockSuite那么方便。&lt;/p&gt;
&lt;h2&gt;总结一下Vue3富文本编辑器的选型和避坑要点&lt;/h2&gt;
&lt;p&gt;选型的核心是「场景优先」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;轻量级场景（展示/评论区/私信草稿）：选VueQuillEditor创作类场景（公众号同款排版/CMS后台/在线文档轻量版）：选TinyMCE 6.x Vue或者CKEditor 5 Vue&lt;/li&gt;
&lt;li&gt;专业协同编辑场景（在线协作文档）：选BlockSuite Vue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;避坑的核心是「提前看官方文档」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;setup语法糖里绑定内容要注意格式，用v-model:content或者v-model加正确的响应式数据&lt;/li&gt;
&lt;li&gt;图片上传要返回正确的Promise格式，不要用base64存大图片&lt;/li&gt;
&lt;li&gt;展示页面要引入编辑器的样式，提高CSS优先级，注意CSP&lt;/li&gt;
&lt;li&gt;弹窗里使用编辑器要等弹窗完全显示之后再初始化，调整z-index&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;进阶玩法的核心是「选择合适的编辑器架构」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果需要插入大量自定义业务组件，选块架构的编辑器（比如BlockSuite Vue）&lt;/li&gt;
&lt;li&gt;如果只是偶尔插入一两个简单的自定义组件，可以用TinyMCE或者CKEditor 5的插件功能&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我想说的是，没有最好的富文本编辑器，只有最适合你的富文本编辑器，在选型的时候，不要只看网上的Top10榜单，要先搞清楚自己的项目场景、技术栈深浅、团队预算和维护成本，然后再做选择，在开发的时候，遇到问题不要急着去搜别人的解决方案，先去看官方文档，官方文档一般都有最详细、最准确的解决方案。&lt;/p&gt;</description><pubDate>Thu, 17 Sep 2026 20:35:49 +0800</pubDate></item><item><title>Vue3父组件怎么调用子组件方法？一文说清ref、defineExpose、ts类型支持全方案</title><link>https://www.codeqd.com/post/20260922115.html</link><description>&lt;h2&gt;Vue3和Vue2父调子有什么不一样的底层逻辑&lt;/h2&gt;
&lt;p&gt;刚从Vue2转来的朋友,第一反应肯定是找$refs对吧？但你会发现，直接用模板里的ref绑定，父组件拿到的子组件实例居然是空的！这可不是bug，是Vue3 Composition API的一个设计优化——默认关闭了子组件内部方法的对外暴露，避免了父组件滥用子组件私有逻辑的问题，让组件的封装性更强。
简单说，Vue2是“默认公开，靠自觉不瞎用”，Vue3是“默认保密，你要啥我主动给你啥”，这个转变很重要，是组件化开发更规范的体现，得先记牢。&lt;/p&gt;
&lt;h2&gt;普通JS项目用ref+defineExpose，最基础的通用版&lt;/h2&gt;
&lt;p&gt;不管你是用&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;还是传统的&lt;code&gt;setup()&lt;/code&gt;函数，这个组合都是最常用的，先拿大家用得最多的&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;来举例子吧——毕竟这个语法糖现在是Vue3的官方推荐了。
假设我们做了一个子组件，叫&lt;code&gt;UserForm.vue&lt;/code&gt;，里面有两个功能：一个是“重置表单”的&lt;code&gt;resetForm&lt;/code&gt;方法，一个是“获取表单数据但不提交”的&lt;code&gt;previewFormData&lt;/code&gt;方法，一开始父组件肯定拿不到这些，对吧？
首先处理子组件：得用Vue3提供的&lt;code&gt;defineExpose&lt;/code&gt;宏（注意这个是&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;里的专属宏，不需要导入哦），把需要对外暴露的东西包裹进去，比如我们只允许父组件调用&lt;code&gt;resetForm&lt;/code&gt;和获取&lt;code&gt;formRef.value&lt;/code&gt;（因为有时候父组件可能需要直接触发表单的validate方法），那&lt;code&gt;previewFormData&lt;/code&gt;就别暴露，让它当子组件的私有逻辑。&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- UserForm.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;el-form ref=&amp;quot;formRef&amp;quot; :model=&amp;quot;form&amp;quot; label-width=&amp;quot;80px&amp;quot;&amp;gt;
    &amp;lt;el-form-item label=&amp;quot;姓名&amp;quot; prop=&amp;quot;name&amp;quot;&amp;gt;
      &amp;lt;el-input v-model=&amp;quot;form.name&amp;quot;&amp;gt;&amp;lt;/el-input&amp;gt;
    &amp;lt;/el-form-item&amp;gt;
    &amp;lt;el-form-item label=&amp;quot;邮箱&amp;quot; prop=&amp;quot;email&amp;quot;&amp;gt;
      &amp;lt;el-input v-model=&amp;quot;form.email&amp;quot;&amp;gt;&amp;lt;/el-input&amp;gt;
    &amp;lt;/el-form-item&amp;gt;
  &amp;lt;/el-form&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, reactive } from &amp;#39;vue&amp;#39;
const formRef = ref(null)
const form = reactive({ name: &amp;#39;&amp;#39;, email: &amp;#39;&amp;#39; })
const resetForm = () =&amp;gt; {
  formRef.value?.resetFields() // 重置表单并清除校验
  form.name = &amp;#39;&amp;#39;
  form.email = &amp;#39;&amp;#39;
}
const previewFormData = () =&amp;gt; {
  console.log(&amp;#39;当前表单内容是&amp;#39;, JSON.parse(JSON.stringify(form)))
}
// 核心步骤：暴露需要对外的内容
defineExpose({
  resetForm,
  formRef
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;接下来是父组件：在模板里给&lt;code&gt;UserForm&lt;/code&gt;加一个ref属性，比如叫&lt;code&gt;userFormRef&lt;/code&gt;，然后在&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;里用&lt;code&gt;ref(null)&lt;/code&gt;声明一个同名的变量（注意大小写要完全一致！这点很多新手容易踩坑），拿到的就是子组件的暴露实例了，最后在父组件的按钮点击事件里直接调用就行。&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- Parent.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;h2&amp;gt;用户管理页面&amp;lt;/h2&amp;gt;
    &amp;lt;UserForm ref=&amp;quot;userFormRef&amp;quot; /&amp;gt;
    &amp;lt;div style=&amp;quot;margin-top: 20px;&amp;quot;&amp;gt;
      &amp;lt;el-button type=&amp;quot;primary&amp;quot; @click=&amp;quot;submitForm&amp;quot;&amp;gt;提交表单&amp;lt;/el-button&amp;gt;
      &amp;lt;el-button @click=&amp;quot;resetUserForm&amp;quot;&amp;gt;重置表单&amp;lt;/el-button&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import UserForm from &amp;#39;./UserForm.vue&amp;#39;
// 声明子组件的ref，注意模板里的ref和这里的变量名必须完全一致
const userFormRef = ref(null)
const submitForm = async () =&amp;gt; {
  // 调用暴露出来的formRef的validate方法
  const valid = await userFormRef.value?.formRef.validate()
  if (valid) {
    alert(&amp;#39;表单校验通过，准备提交&amp;#39;)
    // 这里写提交逻辑
    userFormRef.value?.resetForm() // 提交成功后重置
  }
}
const resetUserForm = () =&amp;gt; {
  // 直接调用暴露出来的resetForm方法
  userFormRef.value?.resetForm()
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;对了,加个可选链很有必要，因为组件可能还没挂载完成，这时候&lt;code&gt;userFormRef.value&lt;/code&gt;是null，直接调用会报错，如果是在挂载完成之后才会触发的方法里调用，也可以不用，但加了更安全。&lt;/p&gt;
&lt;h2&gt;传统setup()函数怎么用？不用宏但原理一样&lt;/h2&gt;
&lt;p&gt;有些老项目可能还没完全切换到&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;，或者开发者习惯用传统的&lt;code&gt;setup()&lt;/code&gt;函数，那对应的方案是什么呢？其实原理完全一样，只是写法有点区别——&lt;code&gt;defineExpose&lt;/code&gt;变成了&lt;code&gt;context.expose&lt;/code&gt;，模板里的ref也不需要和变量名完全一致，不过保持一致还是方便点。
还是拿刚才的&lt;code&gt;UserForm.vue&lt;/code&gt;改一下，看看传统写法：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- 传统写法的UserForm.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;!-- 模板和刚才一样 --&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script&amp;gt;
import { ref, reactive } from &amp;#39;vue&amp;#39;
export default {
  setup(props, context) {
    const formRef = ref(null)
    const form = reactive({ name: &amp;#39;&amp;#39;, email: &amp;#39;&amp;#39; })
    const resetForm = () =&amp;gt; {
      formRef.value?.resetFields()
      form.name = &amp;#39;&amp;#39;
      form.email = &amp;#39;&amp;#39;
    }
    const previewFormData = () =&amp;gt; {
      console.log(&amp;#39;当前表单内容是&amp;#39;, JSON.parse(JSON.stringify(form)))
    }
    // 传统写法的暴露：context.expose
    context.expose({
      resetForm,
      formRef
    })
    // 注意：传统setup()必须return模板里用到的东西
    return {
      formRef,
      form
    }
  }
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;父组件的传统写法也是类似的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- 传统写法的Parent.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;!-- 模板和刚才一样，ref还是userFormRef --&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import UserForm from &amp;#39;./UserForm.vue&amp;#39;
export default {
  components: { UserForm },
  setup() {
    const userFormRef = ref(null)
    const submitForm = async () =&amp;gt; {
      const valid = await userFormRef.value?.formRef.validate()
      if (valid) {
        alert(&amp;#39;表单校验通过，准备提交&amp;#39;)
        userFormRef.value?.resetForm()
      }
    }
    const resetUserForm = () =&amp;gt; {
      userFormRef.value?.resetForm()
    }
    return {
      userFormRef,
      submitForm,
      resetUserForm
    }
  }
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h2&gt;TypeScript项目怎么加类型支持？再也不用担心报错点错了&lt;/h2&gt;
&lt;p&gt;这是很多Vue3 TypeScript项目用户的刚需了——刚才的普通写法虽然能用，但在父组件里调用&lt;code&gt;userFormRef.value&lt;/code&gt;的时候，TS会提示&lt;code&gt;unknown&lt;/code&gt;类型，不能自动补全方法和属性，手滑打错字也不会报错，开发体验很差，那怎么解决呢？
这里有个官方推荐的最佳实践：先在子组件里定义一个&lt;code&gt;ExposeType&lt;/code&gt;接口，把要暴露的内容写进去，然后用&lt;code&gt;typeof expose&lt;/code&gt;赋值给它（或者直接写接口），再在父组件里用&lt;code&gt;Ref&amp;lt;ExposeType | null&amp;gt;&lt;/code&gt;或者用&lt;code&gt;InstanceType&amp;lt;typeof UserForm&amp;gt;&lt;/code&gt;——不过用&lt;code&gt;InstanceType&lt;/code&gt;的前提是子组件必须是用&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;或者&lt;code&gt;defineComponent&lt;/code&gt;包裹的正确组件，而且暴露的内容会被包含在实例类型里吗？不对不对，&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;里默认&lt;code&gt;InstanceType&lt;/code&gt;拿到的是空类型，因为默认不暴露，所以得用专门的类型定义方式。
还是拿刚才的&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;版本来加TS类型支持，这个是最常用的场景：
首先在子组件里，把要暴露的内容单独提出来，或者先写一个接口，然后用&lt;code&gt;defineExpose&lt;/code&gt;的同时（或者之后）导出这个类型：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- TS版本的UserForm.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;!-- 模板还是一样 --&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { ref, reactive, type FormInstance } from &amp;#39;element-plus&amp;#39; // 假设用的是Element Plus，引入它的FormInstance类型
// 定义要暴露的内容的接口
export interface UserFormExpose {
  resetForm: () =&amp;gt; void
  formRef: FormInstance | null
}
const formRef = ref&amp;lt;FormInstance | null&amp;gt;(null)
const form = reactive({ name: &amp;#39;&amp;#39;, email: &amp;#39;&amp;#39; })
const resetForm = () =&amp;gt; {
  formRef.value?.resetFields()
  form.name = &amp;#39;&amp;#39;
  form.email = &amp;#39;&amp;#39;
}
const previewFormData = () =&amp;gt; {
  console.log(&amp;#39;当前表单内容是&amp;#39;, JSON.parse(JSON.stringify(form)))
}
同时这里可以不用显式给defineExpose加类型，因为后面导出了UserFormExpose
defineExpose({
  resetForm,
  formRef
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;然后在父组件里,导入刚才的&lt;code&gt;UserFormExpose&lt;/code&gt;接口，给声明的&lt;code&gt;userFormRef&lt;/code&gt;加类型：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- TS版本的Parent.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;!-- 模板还是一样 --&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { ref, type Ref } from &amp;#39;vue&amp;#39;
import UserForm, { type UserFormExpose } from &amp;#39;./UserForm.vue&amp;#39;
// 给ref加类型：Ref&amp;lt;UserFormExpose | null&amp;gt;
const userFormRef: Ref&amp;lt;UserFormExpose | null&amp;gt; = ref(null)
const submitForm = async () =&amp;gt; {
  // 这里用可选链之后，还可以用非空断言! 不过要确保是在组件挂载完成后调用，或者加if判断
  // 先加if判断更安全
  if (!userFormRef.value) return
  const valid = await userFormRef.value.formRef?.validate()
  if (valid) {
    alert(&amp;#39;表单校验通过，准备提交&amp;#39;)
    userFormRef.value.resetForm() // 这里TS会自动补全resetForm，手滑打错reset会报错
  }
}
const resetUserForm = () =&amp;gt; {
  userFormRef.value?.resetForm()
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这样是不是舒服多了？TS会帮你检查有没有打错方法名，还会自动补全参数和返回值，开发效率和代码质量都能提高不少。&lt;/p&gt;
&lt;h2&gt;有没有不用ref的方案？provide/inject也能凑合用但不推荐&lt;/h2&gt;
&lt;p&gt;有些朋友可能会想,既然Vue3有provide/inject来跨组件传递数据和方法，那能不能用这个来父调子呢？其实理论上是可以的，但强烈不推荐这样做，为什么呢？
先举个简单的例子看看：父组件provide一个空的ref，子组件inject之后把自己的方法赋值给这个ref，然后父组件就可以调用了。&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- 用provide/inject的Parent.vue（不推荐） --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;h2&amp;gt;用户管理页面&amp;lt;/h2&amp;gt;
    &amp;lt;UserForm /&amp;gt;
    &amp;lt;div style=&amp;quot;margin-top: 20px;&amp;quot;&amp;gt;
      &amp;lt;el-button type=&amp;quot;primary&amp;quot; @click=&amp;quot;submitForm&amp;quot;&amp;gt;提交表单&amp;lt;/el-button&amp;gt;
      &amp;lt;el-button @click=&amp;quot;resetUserForm&amp;quot;&amp;gt;重置表单&amp;lt;/el-button&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, provide } from &amp;#39;vue&amp;#39;
import UserForm from &amp;#39;./UserForm.vue&amp;#39;
// provide一个空的ref
const userFormMethods = ref(null)
provide(&amp;#39;userFormMethods&amp;#39;, userFormMethods)
const submitForm = async () =&amp;gt; {
  if (!userFormMethods.value) return
  const valid = await userFormMethods.value.formRef?.validate()
  if (valid) {
    alert(&amp;#39;表单校验通过，准备提交&amp;#39;)
    userFormMethods.value.resetForm()
  }
}
const resetUserForm = () =&amp;gt; {
  userFormMethods.value?.resetForm()
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;!-- 用provide/inject的UserForm.vue（不推荐） --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;!-- 模板还是一样 --&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, reactive, inject } from &amp;#39;vue&amp;#39;
// inject刚才的ref
const userFormMethods = inject(&amp;#39;userFormMethods&amp;#39;)
const formRef = ref(null)
const form = reactive({ name: &amp;#39;&amp;#39;, email: &amp;#39;&amp;#39; })
const resetForm = () =&amp;gt; {
  formRef.value?.resetFields()
  form.name = &amp;#39;&amp;#39;
  form.email = &amp;#39;&amp;#39;
}
const previewFormData = () =&amp;gt; {
  console.log(&amp;#39;当前表单内容是&amp;#39;, JSON.parse(JSON.stringify(form)))
}
// 挂载完成之后把方法赋值给inject的ref
onMounted(() =&amp;gt; {
  userFormMethods.value = {
    resetForm,
    formRef
  }
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;看起来能跑,但为什么不推荐呢？主要有三个问题：
第一，耦合度太高了——provide/inject本来是用来跨多层组件传递数据的，用在父子组件之间有点大材小用，而且父组件需要知道子组件inject的key是什么，子组件也需要知道父组件provide的ref结构是什么，双方都依赖对方的内部实现，封装性被破坏了。
第二，时序问题——刚才的例子里我们加了&lt;code&gt;onMounted&lt;/code&gt;，因为如果子组件刚inject就赋值，父组件可能还没渲染完成（当然一般父子组件是父先挂载到一半子先挂载，但为了保险还是得加），如果忘了加，或者时序有变化，就会出现调用失败的情况。
第三，类型支持麻烦——用provide/inject加类型支持需要用&lt;code&gt;InjectionKey&lt;/code&gt;，比刚才的ref+接口的方式复杂多了。
除非是非常特殊的情况（比如父组件和子组件之间隔了一层第三方组件，不能直接传ref），否则还是老老实实的用ref+defineExpose的方案吧。&lt;/p&gt;
&lt;h2&gt;最后说几个常见的坑，帮你避避雷&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;模板里的ref和父组件里的变量名不一致&lt;/strong&gt;：这个是普通JS项目&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;写法里最常见的坑，刚才已经提醒过了，必须完全一致，包括大小写。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有加可选链或者非空判断&lt;/strong&gt;：组件挂载完成之前，&lt;code&gt;userFormRef.value&lt;/code&gt;是null，直接调用会报错，所以一定要加。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;暴露了太多私有内容&lt;/strong&gt;：Vue3默认不暴露是为了封装性，所以别什么方法和属性都往外塞，只暴露父组件真正需要的就行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript里用了InstanceType&lt;/strong&gt;：刚才也说了，&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;里默认&lt;code&gt;InstanceType&amp;lt;typeof UserForm&amp;gt;&lt;/code&gt;拿到的是空类型，因为默认不暴露，所以得用专门的接口。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;子组件是异步组件&lt;/strong&gt;：如果子组件是用&lt;code&gt;defineAsyncComponent&lt;/code&gt;或者动态导入的方式加载的，那&lt;code&gt;userFormRef.value&lt;/code&gt;会在组件加载完成之后才会有值，所以最好加一个&lt;code&gt;isAsyncComponentLoaded&lt;/code&gt;的状态变量，或者用&lt;code&gt;nextTick&lt;/code&gt;，或者在异步组件的&lt;code&gt;onMounted&lt;/code&gt;里通知父组件。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Vue3父组件调用子组件方法的核心就是“ref绑定+defineExpose暴露”，不管是普通JS还是TypeScript，不管是&lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;还是传统写法，原理都是一样的，只是细节有点区别，掌握了这个核心，再避开刚才说的几个坑，你就不会再在这个问题上踩雷啦。&lt;/p&gt;</description><pubDate>Thu, 17 Sep 2026 14:35:01 +0800</pubDate></item><item><title>Vue3官网有哪些核心板块能帮你快速入门？</title><link>https://www.codeqd.com/post/20260922114.html</link><description>&lt;p&gt;打开Vue3官网，你第一眼不会看到花里胡哨的装饰，整个界面走的是极简实用风，这和Vue本身“渐进式框架”的气质很搭——不强制你接受一套复杂的体系，你可以根据自己的需求从任意核心板块切入，但新手往往会在一堆入口前犯懵，不知道先点哪个效率最高，今天就把官网拆解成几个必看板块,帮你梳理出一条循序渐进的入门路径。&lt;/p&gt;
&lt;h2&gt;新手友好的“引导入口”：教程区&lt;/h2&gt;
&lt;p&gt;教程区绝对是Vue3官网的“新手村”，没有之一，它不是扔给你一堆API文档让你啃，而是通过一个虚拟的Todo List或者商品展示页面的开发，把Vue3的核心概念——响应式数据、组件化、指令——揉进具体的操作里，整个教程分“基础”和“深入”两个部分，新手只看“基础”就行，大概花2-3个小时就能跟着敲出一个完整的小项目,还能搞懂每个代码行背后的逻辑。&lt;/p&gt;
&lt;p&gt;深入教程部分适合那些有一定前端基础、或者已经用Vue3基础版做过小项目的人，这里会讲路由、状态管理、性能优化这些进阶内容，但同样保持了官方文档一贯的风格：先讲概念，再给可直接复制的示例，最后补充注意事项，特别值得一提的是，官方最近还更新了“TypeScript入门”和“组合式API实战进阶”两个小专题，TypeScript是现在前端开发的标配，组合式API又是Vue3最大的亮点之一,这两个专题能帮你把技术栈和Vue3的新特性结合得更紧密。&lt;/p&gt;
&lt;h2&gt;解决问题的“百科全书”：API参考区&lt;/h2&gt;
&lt;p&gt;API参考区可能是你以后用得最多的板块，不管是忘了某个指令的参数，还是想搞清楚某个组合式函数的返回值，来这里一搜就能找到最准确的答案，和旧版Vue2的文档相比，Vue3的API参考区分类更清晰，你可以根据“组合式API”“选项式API”“内置指令”“全局API”“组件”“工具函数”这些标签快速筛选。&lt;/p&gt;
&lt;p&gt;更贴心的是，官方给每个API都加了“使用示例”和“注意事项”，比如组合式API里的ref和reactive，新手一开始很容易搞混，官方文档不仅分别讲了它们的定义和用法，还专门加了一个对比表格，把它们的适用场景、注意事项列得明明白白，还有像watch和watchEffect这种功能类似但使用方式不同的API，文档里也有专门的章节对比,看一遍就能记住区别。&lt;/p&gt;
&lt;h2&gt;提升开发效率的“工具箱”：生态系统区&lt;/h2&gt;
&lt;p&gt;Vue3之所以能这么火，除了框架本身好用之外，强大的生态系统也是关键，生态系统区就把这些官方和社区优秀的工具、库、组件库整理在了一起,你不用再去GitHub上一个个搜。&lt;/p&gt;
&lt;p&gt;官方生态里的三个工具是必装的：Vite是构建工具，比Webpack快很多，开发体验特别好；Vue Router是路由管理工具，帮你实现单页应用的页面跳转；Pinia是状态管理工具，是官方推荐的替代Vuex的选择，比Vuex更轻量、更易用，社区生态里的组件库也很多，比如Element Plus、Ant Design Vue、Naive UI,你可以根据项目的设计风格和需求选择合适的。&lt;/p&gt;
&lt;p&gt;除了工具和组件库，生态系统区还有“官方示例”“社区资源”“教程和视频”三个入口，官方示例里有很多复杂的项目案例，比如仿GitHub的项目管理页面、在线游戏等，你可以参考这些案例的代码结构和开发思路；社区资源里有很多开发者分享的博客、插件、模板；教程和视频里有很多国内和国外的优质学习资源,适合不同学习习惯的人。&lt;/p&gt;
&lt;h2&gt;学习最佳实践的“避坑指南”：风格指南和最佳实践区&lt;/h2&gt;
&lt;p&gt;风格指南和最佳实践区虽然不是必看的，但如果你想写出规范、可维护的Vue3代码，这两个板块绝对不能错过，风格指南里规定了Vue3代码的编写规范，比如组件名怎么命名、响应式数据怎么定义、代码缩进用几个空格等，遵循这些规范能让你的代码更易读、更易维护,也能减少和团队成员的代码冲突。&lt;/p&gt;
&lt;p&gt;最佳实践区里讲了很多Vue3开发中的实用技巧和避坑方法，比如如何优化应用的性能、如何测试Vue3组件、如何部署Vue3应用等，这些都是官方和社区开发者多年积累的经验，能帮你少走很多弯路，比如性能优化部分，官方讲了如何使用v-once减少不必要的渲染、如何使用异步组件减少首屏加载时间、如何使用Vue DevTools排查性能问题,每一个技巧都有具体的操作步骤和效果对比。&lt;/p&gt;
&lt;p&gt;Vue3官网是一个非常优秀的学习资源，不管你是完全的新手还是有一定经验的前端开发者，都能在这里找到有用的东西，建议新手先看教程区，跟着敲出一个小项目；有问题了再去API参考区查；然后去生态系统区找合适的工具和组件库；最后再看风格指南和最佳实践区，规范自己的代码，学习最佳实践，只要你跟着这个路径走，很快就能掌握Vue3的核心用法,开发出自己的应用。&lt;/p&gt;</description><pubDate>Thu, 17 Sep 2026 08:01:14 +0800</pubDate></item><item><title>怎么通过Vue3官方GitHub快速上手进阶？还有哪些容易被忽略的宝藏资源？</title><link>https://www.codeqd.com/post/20260922113.html</link><description>&lt;p&gt;很多刚接触Vue3或者从Vue2过渡过来的开发者，都会下意识打开浏览器找教程，但其实最权威、更新最快的入门和进阶资料，全藏在Vue3的官方GitHub仓库里，不过很多人点进去一看，文件夹、issue、PR堆得密密麻麻，根本不知道从哪下手，今天就结合真实的踩坑经验，带大家梳理一遍Vue3官方GitHub的正确打开方式,顺便挖几个藏在角落里的实用工具和资料。&lt;/p&gt;
&lt;h2&gt;先摸透仓库的基本结构，找到入门的第一扇门&lt;/h2&gt;
&lt;p&gt;第一次进仓库的朋友，别直接盯着vue目录或者核心代码看，很容易劝退，先把仓库的README.md看完，虽然是全英文的，但用浏览器翻译插件大概翻一遍就能懂框架的核心定位——渐进式JavaScript框架，生态覆盖从单组件到大型SPA，再到SSR/SSG全链路。&lt;/p&gt;
&lt;p&gt;重点看仓库的顶层文件夹，只有分清这些文件夹的作用,才能精准定位自己需要的内容：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;vue目录&lt;/strong&gt;：这是核心库的源码，新手暂时不用碰，但如果想搞懂Vue3的Composition API底层、虚拟DOM Diff算法、响应式原理这些进阶内容,这里就是宝库。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;packages目录&lt;/strong&gt;：拆分成了很多小的npm包，vue/runtime-core（负责核心运行时逻辑，不依赖浏览器）、@vue/runtime-dom（把核心逻辑适配到浏览器DOM）、@vue/reactivity（单独的响应式系统，可以在任何JavaScript环境用）、@vue/compiler-sfc（编译单文件组件的工具），这些单独的包对高级开发者做定制化开发很有用，比如写个跨平台的UI组件库，就可以只引入@vue/runtime-core,然后自己写适配层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;docs目录&lt;/strong&gt;：别以为官方文档只在vuejs.org上，仓库里也有一份镜像版本！更重要的是，文档的所有源码也在这里，如果你觉得某段官方文档写得不够清楚，或者想提交翻译贡献，直接来这里找对应的Markdown文件就行，仓库里的文档更新比官网镜像快个一两天，遇到刚发布的小版本新功能，想第一时间看文档,来这里准没错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;test目录&lt;/strong&gt;：全是核心库的测试用例，新手虽然不需要写，但可以通过看测试用例快速了解某个API的正确用法——比如你对watchEffect和watch的区别有点模糊，直接搜watchEffect.spec.ts文件，里面有各种场景的测试,比看文字教程直观多了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;playground目录&lt;/strong&gt;：这个是重点！官方在仓库里内置了一个在线的Vue3代码编辑器，不用自己搭项目就能试代码，如果是在本地克隆了仓库，运行pnpm install &amp;amp;&amp;amp; pnpm dev就能启动，编辑器里还预设了很多Composition API、Teleport、Suspense等Vue3新特性的示例,改完代码就能实时看到效果。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;新手入门的宝藏路径：从示例到迷你项目&lt;/h2&gt;
&lt;p&gt;很多新手跟着文字教程学，看完就忘，动手写还是不会，没关系，官方GitHub早就准备好了动手练手的地方,不用再去第三方网站找乱七八糟的示例。&lt;/p&gt;
&lt;h3&gt;先啃仓库里的官方“小例子集合”&lt;/h3&gt;
&lt;p&gt;除了playground里的预设示例，仓库顶层还有一个examples文件夹（对，刚才忘说了顶层还有个examples），这里的示例更贴近真实项目，但代码量又很小，很适合新手一步步拆解学习，比如examples里的todo-mvc，就是用Vue3 Composition API + Pinia（哦对，Pinia也是官方团队做的，虽然不在vue仓库，但在同一个GitHub组织里）写的经典待办清单，涵盖了数据绑定、列表渲染、条件渲染、事件处理、状态管理这些Vue开发的核心知识点；还有examples里的composition-api，专门对比了Vue2 Options API和Vue3 Composition API的写法,对过渡开发者太友好了。&lt;/p&gt;
&lt;h3&gt;进阶迷你项目？可以看组织里的starter模板&lt;/h3&gt;
&lt;p&gt;刚才提到Vue官方有自己的GitHub组织，名字叫vuejs，除了核心的vue仓库，组织里还有很多官方维护的工具和模板，比如vitejs/vite虽然不是vuejs组织的，但官方推荐的Vue3快速启动模板，全是基于Vite的，而且在vuejs组织里有专门的仓库叫create-vue，这就是我们平时用npm create vue@latest命令启动的那个脚手架的源码！更重要的是，create-vue仓库的README.md里，详细列出了所有可以选择的模板配置，比如TypeScript、JSX、Pinia、Vue Router、ESLint、Prettier、Vitest、Cypress等等，新手如果不确定某个配置要不要加，可以先看README里的说明，或者直接用默认配置搭一个项目,慢慢探索。&lt;/p&gt;
&lt;p&gt;除了create-vue，组织里还有一个叫vue3-realworld-example-app的仓库，这个是RealWorld项目的Vue3实现版本！RealWorld项目是什么？简单来说就是一个真实的博客平台，前后端分离，涵盖了登录注册、文章发布、评论、点赞、关注用户这些真实项目的常见功能，而且代码质量很高，完全符合Vue3的最佳实践，新手把这个项目clone下来，先跑通，再跟着代码注释一行行看,比看十本入门书都有用。&lt;/p&gt;
&lt;h2&gt;进阶开发者必看：源码之外的进阶资料和工具&lt;/h2&gt;
&lt;p&gt;新手入门靠示例，进阶开发者就要深入原理，或者提高开发效率了，除了vue目录里的核心源码,vuejs组织里还有很多进阶的宝藏。&lt;/p&gt;
&lt;h3&gt;深入原理？看官方的设计文档和会议视频&lt;/h3&gt;
&lt;p&gt;在vue仓库的.github/rfcs目录里，全是Vue3的设计提案（RFC是Request For Comments的缩写，也就是请求社区评论的文档），比如Composition API、Teleport、Suspense、Script Setup这些Vue3的核心新特性，都是先在这里发布RFC，然后收集社区的意见，再修改完善，最后才正式发布的，RFC里不仅有新特性的用法，还有为什么要设计这个新特性、设计思路是什么、和其他方案的对比，甚至还有底层的实现思路，对想深入理解Vue3原理的开发者来说，这是比源码更重要的资料——因为源码只是“怎么做”，RFC是“为什么这么做”。&lt;/p&gt;
&lt;p&gt;除了RFC，官方还会把VueConf（Vue官方的技术大会）的视频上传到YouTube和Bilibili，同时在vuejs组织的events仓库里，也会上传大会的PPT和部分演讲稿，这些视频和PPT里，有Vue核心团队成员对Vue3新特性、新工具的深度解读，还有大型互联网公司用Vue3开发的实战经验分享，比如字节跳动用Vue3+Vite重构今日头条App、腾讯用Vue3开发企业级应用的案例,都是非常有价值的进阶资料。&lt;/p&gt;
&lt;h3&gt;提高开发效率？用官方维护的工具链&lt;/h3&gt;
&lt;p&gt;刚才提到的Pinia、Vue Router、Vite、create-vue都是官方团队维护的，但还有很多容易被忽略的工具,也在vuejs组织里。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;@vue/test-utils&lt;/strong&gt;：Vue3官方的单元测试工具库，比第三方的测试工具更贴合Vue3的API，写起来更简单,官方文档里也有详细的教程。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;@vue/devtools&lt;/strong&gt;：这个不用多说，Vue开发必备的浏览器调试工具，Vue3版本的devtools功能更强大，不仅可以调试组件的状态和 props，还可以调试Pinia的状态、Vue Router的路由,甚至可以查看虚拟DOM的结构和Diff算法的过程。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;volar&lt;/strong&gt;：Vue3官方推荐的VSCode插件，代替了之前的Vetur，支持Script Setup、TypeScript、JSX等Vue3的所有新特性，代码提示、语法高亮、错误检查都比Vetur更准确。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;vue-language-server&lt;/strong&gt;：volar的核心依赖，如果你用的不是VSCode，而是其他支持LSP（Language Server Protocol）的编辑器，比如WebStorm、Sublime Text,也可以安装这个插件来获得和volar类似的体验。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;vue-tsc&lt;/strong&gt;：Vue3官方的TypeScript类型检查工具，可以在命令行里检查单文件组件的类型错误，不用依赖编辑器的插件，非常适合CI/CD流程。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;遇到问题怎么办？别只看百度，去GitHub的issue和discussions里找答案&lt;/h2&gt;
&lt;p&gt;很多开发者遇到Vue3的问题，第一反应就是去百度或者Stack Overflow搜，但其实GitHub的issue和discussions里，往往有更准确、更及时的答案。&lt;/p&gt;
&lt;h3&gt;找已知问题？看仓库的issue&lt;/h3&gt;
&lt;p&gt;在vue仓库的issue页面，先点一下“Closed”标签，因为大部分常见问题已经被解决了，搜索的时候，可以用关键词组合，Composition API  ref reactive 区别”“Teleport  不生效”“Suspense  异步组件”，如果搜不到，可以再用英文关键词搜，Composition API ref vs reactive”“Teleport not working”“Suspense async component”，如果还是搜不到，再考虑提交新的issue，但提交issue之前，一定要先看issue模板，按照模板的要求填写问题描述、复现步骤、运行环境（比如Vue版本、浏览器版本、Node版本）、最小复现仓库链接,不然核心团队成员可能不会理你。&lt;/p&gt;
&lt;h3&gt;交流经验、提建议？看仓库的discussions&lt;/h3&gt;
&lt;p&gt;之前Vue仓库只有issue，主要用来提bug和功能请求，但2020年之后，GitHub推出了discussions功能，官方就把Vue仓库的非bug、非功能请求的内容，都移到了discussions里，discussions分为几个板块，Q&amp;amp;A”（专门用来提问，社区里的开发者和核心团队成员都会回答）、“Ideas”（专门用来提新特性的想法，比issue更自由）、“Show and tell”（专门用来分享自己用Vue3开发的项目）、“Announcements”（官方发布的重要公告），新手如果有什么简单的问题，或者想分享自己的学习心得，都可以去discussions里发帖,社区氛围非常好。&lt;/p&gt;
&lt;h3&gt;想参与Vue3的开发？贡献自己的力量&lt;/h3&gt;
&lt;p&gt;如果你对Vue3的源码很熟悉，或者想为开源社区做贡献，也可以参与Vue3的开发，具体的贡献流程，在vue仓库的CONTRIBUTING.md文件里有详细的说明,大概分为以下几步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先fork一份vue仓库到自己的GitHub账号下。&lt;/li&gt;
&lt;li&gt;把自己fork的仓库clone到本地。&lt;/li&gt;
&lt;li&gt;安装依赖（官方推荐用pnpm，因为pnpm的安装速度更快，而且依赖管理更严格）。&lt;/li&gt;
&lt;li&gt;创建一个新的分支，分支名要清晰，比如fix/watchEffect-bug、feat/add-new-composable。&lt;/li&gt;
&lt;li&gt;在新的分支上修改代码，然后运行pnpm test确保所有测试用例都通过。&lt;/li&gt;
&lt;li&gt;提交代码，提交信息要符合Conventional Commits规范，比如fix(reactivity): 修复watchEffect在SSR环境下不触发的bug。&lt;/li&gt;
&lt;li&gt;把自己的分支推送到自己fork的仓库里。&lt;/li&gt;
&lt;li&gt;在vue仓库的PR页面，提交一个新的PR，PR的标题和提交信息一样，内容要详细说明修改了什么、为什么这么修改、有没有测试用例覆盖。&lt;/li&gt;
&lt;li&gt;等待核心团队成员的审核，如果有修改意见，按照意见修改，直到审核通过,代码就会被合并到主分支里。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;参与Vue3的开发，不仅可以提高自己的技术水平，还可以认识很多志同道合的开发者，甚至可以获得Vue官方的贡献者徽章,是一件非常有意义的事情。&lt;/p&gt;
&lt;p&gt;Vue3官方GitHub仓库是一个巨大的宝藏，新手可以通过README.md、examples文件夹、create-vue模板快速入门，进阶开发者可以通过RFC、核心源码、VueConf视频深入原理，遇到问题可以通过issue和discussions找答案，甚至可以参与Vue3的开发，贡献自己的力量，希望今天的分享能帮到大家,让大家的Vue3学习之路更顺利。&lt;/p&gt;</description><pubDate>Wed, 16 Sep 2026 20:01:14 +0800</pubDate></item><item><title>Vue3里getCurrentInstance到底怎么用？踩过的坑能不能避？</title><link>https://www.codeqd.com/post/20260922112.html</link><description>&lt;p&gt;最近刷前端群,看到好多刚转Vue3的小伙伴在聊getCurrentInstance——有人说它是“开发神器”，能拿到Vue2里this的所有东西；有人刚用就踩雷，一会获取不到ctx一会又报错ctx只能在开发环境用；还有人纠结什么时候该用什么时候不该碰，今天就把我做项目踩过的坑、查过的文档（当然是官方的）和实际用到的场景串起来讲清楚，争取一篇就能把这个API搞明白。&lt;/p&gt;
&lt;h2&gt;先搞懂：getCurrentInstance到底是什么来头&lt;/h2&gt;
&lt;p&gt;先回到Vue3的核心变化——Composition API对吧？Vue2我们用Options API，不管是data、methods还是computed，所有逻辑都绑在组件实例this上，拿根节点DOM、访问全局属性、操作子组件都是顺理成章的事，但Composition API不一样，setup函数是在组件实例创建&lt;strong&gt;之前&lt;/strong&gt;执行的（准确说是beforeCreate钩子前，created钩子时其实实例已经挂了但官方还是推荐别在setup外依赖它的内部状态），这时候根本没有this可用，那怎么办呢？&lt;/p&gt;
&lt;p&gt;官方就给了getCurrentInstance这个API,作用就是在setup函数（或者它内部同步调用的组合式函数）里，获取当前正在执行的组件实例对象，这里画个重点：&lt;strong&gt;必须是同步调用&lt;/strong&gt;，异步回调里（比如setTimeout、Promise.then、async/await的await后面）直接调它拿不到东西，返回的是null，这个后面避坑第一条会详细说。&lt;/p&gt;
&lt;h2&gt;实际用起来：getCurrentInstance能帮我们做什么事？&lt;/h2&gt;
&lt;p&gt;别以为getCurrentInstance只是用来补Vue2 this的缺口的，它有几个专属的、非常实用的场景，我整理了三个高频的：&lt;/p&gt;
&lt;h3&gt;获取组件的内部属性和方法（不推荐直接用在生产环境的核心逻辑）&lt;/h3&gt;
&lt;p&gt;这里先说明：getCurrentInstance返回的实例对象，有两个核心部分——&lt;strong&gt;ctx（上下文）&lt;/strong&gt;和&lt;strong&gt;proxy（代理对象）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;ctx在开发环境里,几乎是Vue2 this的翻版，你可以拿到$data、$props、$attrs、$refs、$emit这些熟悉的东西，甚至能访问到全局挂载的$axios、$message（比如用element-plus全局引入的），但注意！&lt;strong&gt;ctx是仅供开发调试用的，生产环境会被压缩或者移除&lt;/strong&gt;，别不信邪写到生产环境，上线后直接报错找不到属性哭都来不及。&lt;/p&gt;
&lt;p&gt;那要在生产环境用Vue2 this的功能怎么办？用proxy！proxy才是组件实例的安全代理，不管开发还是生产环境都稳定存在，它上面暴露的属性和方法，和Vue3官方推荐的对外公开API完全一致——$props、$attrs、$slots、$emit、$refs这些都有，而且和Vue2里的用法几乎没区别，举个简单的例子：&lt;/p&gt;
&lt;pre class=&quot;brush:js;toolbar:false&quot;&gt;import { getCurrentInstance, ref, onMounted } from &amp;#39;vue&amp;#39;
export default {
  setup() {
    const { proxy } = getCurrentInstance()
    const inputRef = ref(null)
    // 模拟全局挂载的$message弹框
    const showSuccess = () =&amp;gt; {
      proxy.$message.success(&amp;#39;操作成功啦！&amp;#39;)
    }
    // onMounted同步调用没问题，这时候组件已经挂载完成，$refs也能拿到
    onMounted(() =&amp;gt; {
      inputRef.value.focus() // 这里用ref绑定更安全，但如果是通过$el或者全局注册的子组件用$refs也可以
      // 如果非要用proxy的$refs也行：proxy.$refs.inputRef.focus()，但前提是inputRef绑定在模板的ref上
    })
    return { inputRef, showSuccess }
  }
}&lt;/pre&gt;
&lt;h3&gt;访问组合式函数内部暴露的私有状态（仅限自己写的组合式函数或者完全可控的场景）&lt;/h3&gt;
&lt;p&gt;有时候我们写组合式函数,不想把所有状态和方法都暴露给组件，但又想在另一个组合式函数或者组件的特定逻辑里用到这些私有内容，这时候getCurrentInstance就派上用场了——通过它可以拿到组件实例的&lt;strong&gt;exposed&lt;/strong&gt;对象？不对不对，exposed是组件用defineExpose暴露给父组件的内容，私有状态在instance的&lt;strong&gt;setupState&lt;/strong&gt;里。&lt;/p&gt;
&lt;p&gt;举个场景,比如我写了一个useCounter组合式函数，用来管理计数器，有私有变量count、私有方法addPrivateCount，只想暴露给组件公共的count和add方法，但又想写一个useCounterLogger组合式函数，专门监听privateCount的变化打印日志：&lt;/p&gt;
&lt;pre class=&quot;brush:js;toolbar:false&quot;&gt;// useCounter.js
import { ref } from &amp;#39;vue&amp;#39;
export function useCounter() {
  const privateCount = ref(0)
  const publicCount = ref(0)
  const addPrivate = () =&amp;gt; privateCount.value++
  const addPublic = () =&amp;gt; publicCount.value++
  return { publicCount, addPublic }
}
// useCounterLogger.js
import { getCurrentInstance, watch } from &amp;#39;vue&amp;#39;
export function useCounterLogger() {
  const instance = getCurrentInstance()
  if (!instance) return // 防止异步调用或者非setup调用
  // 监听setupState里的privateCount
  watch(
    () =&amp;gt; instance.setupState.privateCount,
    (newVal) =&amp;gt; {
      console.log(`私有计数器变了：${newVal}`)
    }
  )
}
// 组件里用
import { useCounter, useCounterLogger } from &amp;#39;./composables&amp;#39;
export default {
  setup() {
    // 必须先调用useCounter，把privateCount挂到setupState上
    const { publicCount, addPublic } = useCounter()
    // 再调用useCounterLogger同步监听
    useCounterLogger()
    // 加个测试按钮触发addPublic的同时也触发addPrivate？哦不对，addPrivate没暴露，那只能通过组合式函数内部或者instance.setupState调用
    const testBoth = () =&amp;gt; {
      addPublic()
      const instance = getCurrentInstance()
      if (instance) instance.setupState.addPrivate()
    }
    return { publicCount, testBoth }
  }
}&lt;/pre&gt;
&lt;p&gt;这个场景虽然有点偏,但在写复杂的业务逻辑或者开源组合式库的时候，偶尔会用到，不过还是要强调：&lt;strong&gt;非必要别碰setupState&lt;/strong&gt;，因为它是Vue3的内部实现，没有对外公开API稳定，版本更新可能会改变结构，导致代码直接挂掉。&lt;/p&gt;
&lt;h3&gt;全局挂载工具函数的另一种方式？或者说组合式函数里访问全局属性更优雅？&lt;/h3&gt;
&lt;p&gt;哦对了,刚才用proxy访问全局挂载的$axios、$message是一种方式，但如果我们写组合式函数，每次都要先getCurrentInstance再拿proxy会不会有点麻烦？有没有更优雅的方式？其实Vue3官方还推荐用provide/inject来传递全局属性，但如果是已经全局挂载到app.config.globalProperties上的老项目工具，getCurrentInstance的proxy还是最快的过渡方式。&lt;/p&gt;
&lt;p&gt;不过这里可以提一个小技巧：我们可以自己封装一个useGlobalProperties组合式函数，把getCurrentInstance的逻辑包起来，这样每次用的时候直接导入调用就行：&lt;/p&gt;
&lt;pre class=&quot;brush:js;toolbar:false&quot;&gt;// useGlobalProperties.js
import { getCurrentInstance } from &amp;#39;vue&amp;#39;
export function useGlobalProperties() {
  const instance = getCurrentInstance()
  if (!instance) {
    throw new Error(&amp;#39;useGlobalProperties 只能在setup函数或其同步调用的组合式函数中使用&amp;#39;)
  }
  return instance.proxy
}
// 组件或者组合式函数里用
import { useGlobalProperties } from &amp;#39;./composables&amp;#39;
export default {
  setup() {
    const { $axios, $message } = useGlobalProperties()
    const fetchData = async () =&amp;gt; {
      try {
        const res = await $axios.get(&amp;#39;/api/data&amp;#39;)
        $message.success(&amp;#39;数据获取成功&amp;#39;)
        return res.data
      } catch (err) {
        $message.error(&amp;#39;数据获取失败&amp;#39;)
        console.error(err)
      }
    }
    fetchData() // 注意这里fetchData是async函数，await后面不能再调useGlobalProperties或者getCurrentInstance哦
    return {}
  }
}&lt;/pre&gt;
&lt;p&gt;这样是不是清爽多了？而且我们还加了错误提示，万一有人在异步回调里用，会直接抛出明确的错误，方便调试。&lt;/p&gt;
&lt;h2&gt;划重点：getCurrentInstance的避坑指南，这几个雷千万别踩！&lt;/h2&gt;
&lt;p&gt;刚才说了那么多好处,现在要泼点冷水——getCurrentInstance虽然好用，但坑也不少，我整理了三个最常见、最容易犯的：&lt;/p&gt;
&lt;h3&gt;雷区1：在异步回调里直接调用getCurrentInstance，返回null&lt;/h3&gt;
&lt;p&gt;刚才已经提过一次,但这个坑太常见了，必须单独拿出来说三遍：&lt;strong&gt;同步调用！同步调用！同步调用！&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;为什么异步回调里不行？因为getCurrentInstance是通过内部的“当前实例栈”来工作的——每次进入一个组件的setup函数或者同步调用的组合式函数，Vue3会把当前组件实例压入栈顶；执行完之后，再把它弹出栈，而异步回调（比如setTimeout、Promise.then、async/await的await后面）是在事件循环的下一个周期执行的，这时候当前实例栈早就空了，所以getCurrentInstance只能返回null。&lt;/p&gt;
&lt;p&gt;那如果非要在异步回调里用到proxy或者setupState怎么办？提前在同步代码里把它们存起来！&lt;/p&gt;
&lt;pre class=&quot;brush:js;toolbar:false&quot;&gt;import { getCurrentInstance, ref } from &amp;#39;vue&amp;#39;
export default {
  setup() {
    const { proxy } = getCurrentInstance() // 同步存起来
    const data = ref(null)
    const fetchData = async () =&amp;gt; {
      try {
        // await后面不能再调getCurrentInstance，但可以用提前存的proxy
        const res = await proxy.$axios.get(&amp;#39;/api/data&amp;#39;)
        data.value = res.data
        proxy.$message.success(&amp;#39;数据获取成功&amp;#39;)
      } catch (err) {
        proxy.$message.error(&amp;#39;数据获取失败&amp;#39;)
      }
    }
    fetchData()
    return { data }
  }
}&lt;/pre&gt;
&lt;p&gt;对,就这么简单——提前把需要的东西赋值给setup里的变量，异步回调里就能用了。&lt;/p&gt;
&lt;h3&gt;雷区2：在生产环境用ctx访问属性，上线后直接报错&lt;/h3&gt;
&lt;p&gt;刚才也说了,ctx是开发环境的调试工具，生产环境会被Tree Shaking掉或者压缩成乱码，所以千万千万不要用ctx，要用proxy！&lt;/p&gt;
&lt;p&gt;很多刚转Vue3的小伙伴可能会图省事,一开始在开发环境用ctx测试没问题，就直接上线了，结果用户一打开页面就白屏，控制台一堆“Cannot read property &#039;$xxx&#039; of undefined”的错误，排查半天还找不到原因，这就是踩了这个雷。&lt;/p&gt;
&lt;p&gt;怎么避免？养成习惯——只要用getCurrentInstance，就只解构proxy，连ctx看都别看！比如刚才的useGlobalProperties组合式函数，我们只返回了proxy，根本没碰ctx。&lt;/p&gt;
&lt;h3&gt;雷区3：在非setup函数或其同步调用的组合式函数里使用，比如Options API的methods、mounted里&lt;/h3&gt;
&lt;p&gt;这个雷区虽然不如前两个常见,但偶尔也会有人犯——比如有人觉得Vue2的this用习惯了，突然转Vue3写了一半Options API，想加个Composition API的小功能，就直接在mounted或者methods里调getCurrentInstance，结果要么返回null要么报错。&lt;/p&gt;
&lt;p&gt;为什么不行？因为Options API的生命周期钩子（除了beforeCreate和created，但created里其实也没必要用）都是在组件实例已经完全创建并挂载之后执行的，这时候虽然组件实例存在，但getCurrentInstance的“当前实例栈”机制已经不工作了（因为setup函数早就执行完了），所以返回null。&lt;/p&gt;
&lt;p&gt;那如果非要在Options API的生命周期钩子或者methods里用到类似proxy的东西怎么办？直接用this啊！Vue3的Options API完全兼容Vue2的this，this就是proxy的别名（或者说完全一致的对象），&lt;/p&gt;
&lt;pre class=&quot;brush:js;toolbar:false&quot;&gt;export default {
  data() {
    return {
      count: 0
    }
  },
  mounted() {
    // 直接用this访问全局属性或者$refs，别调getCurrentInstance
    this.$refs.inputRef.focus()
    this.$message.success(&amp;#39;组件挂载完成&amp;#39;)
  },
  methods: {
    add() {
      this.count++
      this.$axios.post(&amp;#39;/api/count&amp;#39;, { count: this.count })
    }
  }
}&lt;/pre&gt;
&lt;p&gt;对,Vue3的Options API还是原来的味道，不用强行混着Composition API用getCurrentInstance，反而容易出错。&lt;/p&gt;
&lt;h2&gt;深度思考：getCurrentInstance什么时候该用，什么时候不该碰？&lt;/h2&gt;
&lt;p&gt;刚才说了那么多场景和避坑,现在要总结一下——getCurrentInstance到底什么时候该用，什么时候不该碰？&lt;/p&gt;
&lt;h3&gt;该用的场景：&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;组合式函数里需要访问全局挂载的app.config.globalProperties属性（比如老项目的$axios、$message），且不想用provide/inject重构&lt;/strong&gt;：这时候getCurrentInstance的proxy是最快的过渡方式，封装成useGlobalProperties组合式函数更优雅。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自己写的开源组合式库或者复杂业务组合式函数，需要在内部同步访问另一个自己写的组合式函数暴露的私有状态&lt;/strong&gt;：这时候可以用setupState，但一定要加注释说明，而且要注意Vue3版本更新的影响，尽量用对外公开的API替代。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调试阶段需要快速查看组件的内部状态（比如setupState、props、attrs等）&lt;/strong&gt;：这时候可以用ctx，因为开发环境ctx的属性最全，最方便调试，但调试完一定要删掉。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;不该碰的场景：&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;能用Vue3官方对外公开的API替代的场景&lt;/strong&gt;：比如获取根节点DOM用ref绑定，访问父组件传递的props用defineProps，访问attrs用defineAttrs，触发事件用defineEmits，操作子组件用defineExpose+ref绑定，全局传递属性用provide/inject——这些都是官方推荐的、稳定的API，比getCurrentInstance安全多了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生产环境的核心逻辑&lt;/strong&gt;：不管是ctx还是setupState，都不是生产环境的稳定选择，千万千万不要碰。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;异步回调里&lt;/strong&gt;：除非提前在同步代码里把需要的东西存起来，否则别调getCurrentInstance。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Options API的生命周期钩子或methods里&lt;/strong&gt;：直接用this就行，没必要混着用。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;最后再补充一点：Vue3.3+有没有对getCurrentInstance的优化或者替代方案？&lt;/h2&gt;
&lt;p&gt;哦对了,Vue3.3+版本确实有一些小优化，&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;defineProps、defineEmits、defineAttrs、defineSlots这些宏，现在可以在组合式函数里直接用了&lt;/strong&gt;：不过需要注意，这些宏必须在setup函数的顶层或者组合式函数的顶层同步调用，不能在异步回调或者条件语句里用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;useAttrs、useSlots现在是官方对外公开的API了&lt;/strong&gt;：Vue3早期版本useAttrs和useSlots是内部API，需要通过getCurrentInstance的ctx或者proxy访问，现在可以直接导入使用，更安全了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;provide/inject现在支持类型推断了&lt;/strong&gt;：如果用TypeScript的话，provide/inject的体验更好了，完全可以替代全局挂载的app.config.globalProperties属性，毕竟类型安全才是Vue3的核心优势之一。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;那是不是Vue3.3+之后，getCurrentInstance就没用了？也不是，刚才说的几个该用的场景（比如老项目过渡、组合式库内部访问私有状态、调试）还是有用的，但确实用得越来越少了，毕竟官方对外公开的API越来越完善了。&lt;/p&gt;
&lt;p&gt;今天我们从getCurrentInstance的来历、实际用起来的三个高频场景、三个最常见的雷区、什么时候该用什么时候不该碰，以及Vue3.3+的优化几个方面，把这个API讲得差不多了。&lt;/p&gt;
&lt;p&gt;最后再给刚转Vue3的小伙伴一个建议：&lt;strong&gt;尽量先用官方对外公开的API（ref、reactive、defineProps、defineEmits、provide/inject等），getCurrentInstance能不用就不用，非用不可的话，一定要注意同步调用、只用proxy、别碰生产环境的核心逻辑&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果你还有其他关于Vue3的问题,欢迎在评论区留言，我们一起讨论！&lt;/p&gt;</description><pubDate>Wed, 16 Sep 2026 14:01:24 +0800</pubDate></item></channel></rss>