ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3 JSX函数组件更新机制:重新执行不等于重新渲染

Vue3 JSX函数组件更新机制:重新执行不等于重新渲染 先直接回答标题的问题是的Vue3 的 JSX 函数组件在父组件每次更新时都会重新调用执行一次。但这背后有几个关键点必须说清楚——函数组件重新执行不代表它的 DOM 一定会被重建也不代表所有子节点都会被 diff 一遍。这个问题不搞明白你写出的组件可能频繁重渲染性能隐患也排查不清楚。很多人一开始接触 Vue3 的 JSX 函数组件会把它当成 React 的函数组件来理解觉得“组件只要执行里面所有东西都得重新来一遍”。这个理解放在 Vue 的响应式模型下对了一半但容易把自己吓到。先别急着优化先把 Vue3 的组件更新链路拆开看才能搞懂“重新运行”到底意味着什么。1. 正确理解函数组件的“重新运行”1.1 一次更新到底经过了什么Vue3 的组件更新不是“组件函数重新执行一遍再手动操 DOM 更新所有节点”而是走了一条链路父组件更新 → 触发父组件 render或模板重新渲染 → 生成新的虚拟 DOM 树 → 框架调用 patch打补丁阶段 → 深度对比新旧子树 → 找出真正变化的节点 → 只更新真实 DOM 上变化的部分。用户能感知到的“更新”其实是 patch 阶段的结果。所以评判一个组件性能好不好不能只盯着“函数调用了多少次”要看 patch 阶段耗费了多少时间以及真正变化的节点有多少。JSX 函数组件在这种链路里的位置类似于一个“生成虚拟 DOM 片段的工厂函数”。它被调用一次生成一棵 vnode 子树框架把这棵子树挂到组件树的对应位置。下一次更新时它再次被调用生成一棵新的 vnode 子树然后由 diff 算法去判断新旧两棵树之间的差异。即使函数组件源码里没有任何响应式依赖只要父组件更新它仍然会被调用。1.2 “重新运行”和“重新渲染 DOM”是两码事这是理解整个问题的核心分水岭。两件事要分开看待重新运行函数体重新执行确实会发生。因为父组件的 render 阶段需要拿到最新状态的子组件 vnode不执行函数体新的 vnode 从哪来重新渲染 DOM真实 DOM 节点的增删查改取决于 diff 之后发现的变化。如果新旧 vnode 对比下来没什么结构性变化patch 阶段很轻真实 DOM 只做必要的修改比如更新 textContent、修改 class、更新属性。举个例子假设你有一个纯展示性的 JSX 函数组件Title它只渲染一个h1import { defineComponent } from vue const Title () { console.log(Title component executed) return h1Hello World/h1 }父组件里放了个按钮点击后 count 加 1并把 count 传给另一个子组件。此时父组件重新渲染Title组件函数也会被执行控制台也会打印日志。但真实 DOM 的 h1 不会重建框架只会复用之前的元素然后 patch 阶段发现它没任何变化直接跳过。这个机制非常关键——很多人一看到“函数重新执行了”就担心性能但实际开销往往比想象中小得多。相比之下真正昂贵的是 patch 阶段对复杂大树的深度遍历、以及组件实例的 mount/unmount 过程。1.3 为什么面试官爱问这个问题我理解这个问题的“题眼”不在于函数是否会重新执行而在于你如何理解 Vue3 组件更新的最小粒度。Vue 和 React 最大的不同在于在 React 中state 变化后默认情况下整个组件树都会重新 render只能靠 memo、useMemo、useCallback 等手段手动优化。在 Vue3 中由于有响应式系统组件级依赖追踪默认情况下“组件自身状态变化”只会触发该组件重新 render。但如果父组件重新渲染了子组件的函数式组件仍然会重新执行因为父组件要重新生成子组件的 vnode此时不像 React 那样默认跳过子组件。Vue3 为此提供了memo来帮你跳过这些不必要的执行。所以这个问题的完整答案可以概括成三句话会执行而且父组件每次更新函数组件都会被调用因为它需要生成新的 vnode。执行代价通常远小于 patch 代价真实 DOM 不会盲目重建。如果函数组件内部逻辑较重比如复杂计算、大型数据遍历或者处于高频更新场景就应该手动用memo做优化避免不必要的执行开销。2. 更新机制深入render 阶段与 patch 阶段是在干什么2.1 render 阶段的生命周期要彻底搞清楚函数组件的执行时机得先看 Vue3 的 render 阶段是怎么运转的。组件实例上挂着一个update方法。首次挂载和后续更新都会调用这个 update内部依次做两件事执行 render渲染函数 / 模板编译生成的函数获取新的 vnode。调用patch(prevNode, nextNode, container)等逻辑来处理挂载或更新。一个函数组件本质上就是一个 render 函数。你写的const MyComp () divhello/div它和setup()里返回 render 函数的组件在“每次生成返回值”这件事上没有区别都会在每次更新时执行。Vue3 内部针对这些构造函数或对象组件有不同的挂载方式但对外表现一致函数体每次调用都会产出新的 vnode 树。2.2 patch 阶段的按需精修patch 阶段的关键在于 Diff 逻辑。Vue3 在 diff 时已经做了一些“静态优化”与“动态标记”用于加快节点比较尤其是针对模板编译场景静态节点没有绑定任何动态绑定不会参与重新创建。只带动态 class、style、props 的节点会被打上 patchFlagdiff 时只对比对应部分不用完全对比整个 vnode。对于动态文本、动态属性等Vue3 用快速路径直接更新。不过在 JSX 场景下Babel/插件一般帮你生成createVNode调用但不太会做像模板那样极致的静态节点标记。如果完全没有依赖响应式数据纯 JSX 也会生成 vnode但并不会“自动记录”哪些节点是静态的。真正能跳过整个 vnode 生成过程的手段还得靠 memo。把 render 阶段理解成“查问题前先拍一张 X 光片”patch 阶段是“根据片子做手术”。拍片可能会频繁但手术都是精准的。函数组件每次执行等于每次拍片你担心的是拍片本身会不会累死人。在现实工程里拍片很便宜真正贵的是手术。2.3 Block Tree 对 JSX 函数组件的影响Vue3 在模板编译中引入了一个重要的优化叫 Block Tree。它的核心思路是用带有动态节点收集能力的“区块节点”来记录整棵树中可能变化的节点从而跳过全树遍历。模板编译器会把 vnode 的动态子节点挂到一个数组中patch 时只需遍历这个数组对比真正变化的节点。但模板编译优化有个前提它是在编译时注入标记的。JSX 如果通过插件编译插件的实现深度会决定你能否获得这些优化。如果你直接用vitejs/plugin-vue-jsx编译Vue 官方插件的做法是把 JSX 转成createElementBlock/createVNode调用但不会精确地帮你标记所有静态节点。换句话说JSX 场景下你能获得的基础优化没有模板那么多更需要自己在业务层考虑 memo 的使用。这其实就是 Vue 官方文档中“不要过度优化”的一个辩证点模板帮你做了很多事JSX 更灵活但灵活也要付出代价。2.4 虚拟 DOM 的“一次创建多次复用”不是绝对原则还有一个容易误解的点有的人以为 diff 算法一定会尽可能“复用”旧的 vnode 对象。但准确地说diff 复用的是“真实 DOM 节点”不是 vnode 对象本身。每次函数组件重新执行都会生成全新的 vnode 树vnode 对象是完全新的。只有在 patch 过程中框架会判断新旧 vnode 是否可复用如果可复用就更新属性而不是重新挂载。所以函数组件的每次执行必然会重新创建 vnode 对象。你以为的“两次生成一样的东西能不能缓存”在框架层面做不到只能靠 memo 把“整个组件的 vnode 生成过程”跳过。3. 不同场景下的更新表现说了一百遍不如看一次3.1 父组件更新但子组件未依赖任何父级状态这种情况最常见。父组件里有一个计数器const Parent () { const [count, setCount] useState(0) return ( div button onClick{() setCount(count 1)} count is {count} /button Child / /div ) }Child是一个函数组件内部没有 props也没有响应式状态。在 React 里父组件 setState 后Child 默认会重新执行。在 Vue 里也一样吗在 Vue3 的 JSX 里如果Child是定义在组件函数内部的普通子组件并且Parent使用 setup 返回 render 函数答案是会重新执行。因为父组件更新了要生成新的 vnode它只能调用Child()来获取新 vnode。哪怕 Child 的执行结果和上次完全相同也会重新生成一次 vnode 树。但如果把 Child 定义在 module 级别比如单独写在另一个文件里性能表现确实会好很多其实不对——它在每次父组件生成 render 的时候依然会被作为子组件 vnode 的构造函数执行。唯一的区别是你想要使用 memo 的话操作起来会更方便。此时你想要性能优化有两种方案用memo包裹 Childimport { memo } from vue const ChildMemo memo(Child)当 memo 的 props 没有变化时父组件更新时会直接复用旧的 vnode跳过 Child 函数体的执行。把 Child 的 render 结果提取成静态内容const staticVNode Child /这个方式在很多场景下不可行因为 vnode 是共享的并不推荐滥用但足以说明思路如果 vnode 完全可以静态化你都不需要执行子组件函数。3.2 子组件自身的响应式状态变化这个场景更接近 VUE 的核心模型。函数组件内部如果用了ref和reactive当这些内部状态变化时会触发组件自身的 updateimport { ref } from vue const Counter () { const count ref(0) return ( div p{count.value}/p button onClick{() count.value}add/button /div ) }当 count 变化时Vue3 的响应式系统会通知这个组件实例更新组件函数会被重新调用重新生成 vnode再 patch 到真实 DOM 上。这里有个非常关键的细节不同于父组件更新触发子组件函数执行组件自身的响应式更新是可以“按需触发”的。组件实例在 render 阶段建立了一个“render effect”它的依赖是组件内所有读取的响应式数据。只有这些数据变化时组件才会重新更新。这也带来一个很实际的调优原则如果函数组件内部依赖很多响应式数据那就应该考虑拆分。把变化频率不同的数据拆到不同组件里各自形成独立的更新单元避免一个数据变化导致整个大组件重新执行。3.3 props 变化时子组件的更新分析props 变化对函数组件来说同样意味着重新执行。此时你关心的是props 变化后子组件是否必须重新生成 vnode答案是必须。因为 props 变化了子组件的输出可能变化必须执行函数体来生成新的 vnode。然后用 patch 对比新旧 vnode。但如果 props 里的数据只是引用变化、内部内容并没实质变化框架无法自动知晓这一点。比如 props 传入一个对象父组件每次 render 都会重新生成一个对象内容相同引用不同那么子组件函数每次都会重新执行。memo 对此无能为力它只做浅层比较shallow compare只要 props 引用变化它就会判定为需要更新。要正确处理这种场景唯一可靠的办法是保证传入的 props 引用稳定。所以当你看到某个 JSX 函数组件布局没什么变化却被反复执行时排查方向往往是父组件给它传了什么不稳定引用。3.4 插槽与具名插槽的隐蔽更新扩大这一节是实操中最容易被忽视的。如果父组件通过插槽传了一段 JSX 给子组件子组件更新时这段插槽内容会不会跟着重新渲染在 Vue3 中插槽内容会被编译成返回 vnode 的函数。当父组件更新子组件的插槽 props 会更新。如果子组件重新执行并渲染插槽等于执行了父组件环境里定义的插槽函数。很多情况下这会导致多一层的重新生成 vnode但还不一定会造成 DOM 级更新。真正容易出问题的模式是“把一个函数组件整体丢进插槽”Layout {{ default: () ExpensiveComponent / }} /Layout父组件每次更新时由于重新生成插槽 vnodeExpensiveComponent会重新执行。此时你无法在子组件这一层用 memo 去控制只能在插槽内容层面做优化或者提取为稳定的渲染函数。插槽本身是组件设计里非常灵活的能力但灵活的代价就是“更新边界”更复杂。建议在性能敏感场景下优先避免高成本组件被放到插槽里。4. 性能优化让函数组件只在“该更新”的时候更新4.1 memo 是 Vue3 官方提供的最直接方案Vue3 的memo函数作用类似 React.memo用于包裹函数组件让它在 props 没有变化时跳过执行并复用上一次的 vnodeimport { memo } from vue const Child memo((props) { console.log(Child render) return div{props.count}/div })使用方式非常简洁。包裹之后每次父组件 rendermemo 组件会比较 props 是否变化。默认使用浅比较逐个对比新旧 props 的值基本类型对比值对象对比引用。如果你觉得浅比较不够精确可以传入自定义比较函数const Child memo( (props) div{props.items.length}/div, (prevProps, nextProps) prevProps.items nextProps.items )只有当自定义比较函数返回 true 时才会复用 vnode。这种方式适合对复杂 props 做深度比较的场景但注意比较函数本身不要写得太重否则性能优化就失去了意义。4.2 props 稳定性记住这条铁律对于 memo 提升性能来说最核心的不是 memo 本身而是 props 的稳定性。props 不稳定memo 就是个摆设。比如父组件里给子组件传了一个箭头函数const Child memo((props) { return button onClick{props.onClick}click/button }) const Parent () { const [count, setCount] useState(0) return ( div Child onClick{() console.log(count)} / button onClick{() setCount(count 1)}update/button /div ) }这里onClick每次 render 都是一个新函数memo 比较时发现 props 引用变了于是判定需要更新。函数组件照样重新执行memo 白写了。常见解法用useCallback包裹回调函数const handleClick useCallback(() { console.log(count) }, [count])这样只有 count 变化时函数引用才会变。或者把函数定义为模块级别常量const handleClick () console.log(fixed)如果回调不依赖组件内状态直接提到外面天然稳定。对象类型 props 同理尽量使用useMemo或把静态对象提到模块级。4.3 大规模列表与动态组件里怎么用才合理列表渲染是优化价值最大的场景之一。假设你有一个包含几百条数据的列表每条数据渲染一个函数组件且每条数据有独立的展开/折叠状态const ListItem memo(({ item, onToggle }) { return ( div onClick{() onToggle(item.id)} {item.name} /div ) }) const List () { const [items, setItems] useState(initialItems) const handleToggle (id) { setItems((prev) prev.map((item) item.id id ? { ...item, expanded: !item.expanded } : item )) } return ( div {items.map((item) ( ListItem key{item.id} item{item} onToggle{handleToggle} / ))} /div ) }这里要注意两点handleToggle如果在组件内定义且没有 useCallback每次 List 更新时它都是新函数会导致所有 ListItem 的 memo 判断失效。items更新时只有展开状态变化的那一项会真正变了但其他项的引用并没有变。只要 handleToggle 引用稳定memo 就能正确发挥作用只有被点击的那一项会重新执行。这种优化模式才是 memo 在列表里真正意义所在避免“一个数据变化全表重渲染”的无谓开销。但前提是所有 props 的引用都足够稳定。4.4 函数组件内部的重逻辑拆分与惰性计算函数组件每次执行时函数体内的一切逻辑都会重来一遍。如果有人习惯在函数组件里写const ExpensiveList ({ list }) { const sortedList deepSort(list) return ( div {sortedList.map((item) Item key{item.id} data{item} /)} /div ) }deepSort 如果是个非常耗时的操作每次组件执行都会白白计算一遍。即使你已经用 memo 包裹了组件props 变化时仍会重新执行你拦不住重计算。正确处理方式是把这类计算放到父组件中用useMemo或computed来缓存const sortedList computed(() deepSort(props.list))但 JSX 函数组件里不直接使用 computed因为它不是有状态组件。一个可行方案是改成 defineComponent 写法const ExpensiveList defineComponent({ name: ExpensiveList, setup(props) { const sortedList computed(() deepSort(props.list)) return () ( div {sortedList.value.map((item) Item key{item.id} data{item} /)} /div ) } })这样 deepSort 只有在list变化时才会重新计算。即使父组件导致组件函数重新执行computed 缓存也能拦下大部分重复计算。4.5 什么情况下没必要优化避免误用 memo需要泼一盆冷水不是所有函数组件都要套 memo。memo 本身也有代价需要存储上一次的 props 用于比较还需要在每次更新时执行一次浅比较。对渲染开销极低的组件比如只渲染一个固定字符串套 memo 的收益微乎其微甚至可能因为比较逻辑导致额外的性能损耗。要根据实际场景来权衡组件内部使用了大量响应式数据、计算复杂度高 → 优先拆分再考虑 memo。组件处于高频更新区域动画、键盘输入、实时数据 → 值得 memo。纯静态组件压根没有 props 变化 → 直接提到模块级并复用 vnode 可能更合适。组件树较浅、层级简单、依赖响应式数据少 → 先分析再决定是否要 memo。真正的性能优化不是“能加的都加”是在 profiling 之后精准地找到真正消耗大的点然后针对性化解。5. 常见问题速查表与调试技巧5.1 问题速查表我整理了一张经常在工作中遇到的现象对应表方便排查现象可能原因解决方案函数组件每条日志都打印但 DOM 没变化父组件每次渲染都会重新生成子组件 vnode这是正常机制如果成本高用 memo 包裹memo 包裹后子组件仍然渲染props 引用不稳定函数、对象或自定义比较函数判断有误检查 props 是否来自 useCallback / 模块级常量修改父组件的某个 state导致所有子组件全部渲染子组件没有 memo或 memo 比较失效为高频子组件添加 memo并修正 props 引用函数组件内使用大量响应式数据改一个数据整个组件重新执行依赖粒度太粗拆分组件将不同依赖分成独立更新单元插槽内的子组件频繁渲染父组件每次渲染会重新生成插槽 vnode提取插槽函数定义或使用稳定的 vnode函数组件内部重计算memo 拦不住props 变化时组件函数必然执行重计算放入 computed / 父组件 useMemo 缓存动态组件/defineAsyncComponent 反复挂载卸载key 变化导致组件被重新创建避免 key 改变或使用 KeepAlive这个表基本覆盖了我在项目中遇到的大多数场景。出现问题时还是那句话先测量再优化别凭感觉。5.2 为什么要分清楚“函数执行”和“patch 更新”结合速查表再强调一次这个思维模型日志打印 ≠ 性能问题。有时候组件函数执行了成百上千次但因为它特别轻整体耗时可以忽略。一个问题组件可能一周后才会暴露出来。排查性能问题时不建议用“打印 console.log 看到每次渲染就慌了”作为起点。正确路径是先看用户可感知的卡顿是否真的存在再用浏览器 Performance 工具定位耗时函数最后借助 Vue DevTools 的组件树耗时面板确认瓶颈。往往结果让你大跌眼镜——真正耗时的大头居然是某段不被注意的深度遍历或者一次跨组件的 reactive 副作用。所以把“函数组件是否每次更新都重新运行”理解成“更新机制的一部分”而不是“性能罪魁祸首”会靠谱得多。5.3 调试函数组件更新的一些硬核技巧给大家分享一下调试这类问题的排查技巧在函数组件里加计数器区分执行调用与更新真实 patchlet renderCount 0 const Child (props) { renderCount console.log(function called:, renderCount) return div{props.name}/div }这个计数不是幂等的但可以帮你快速确认函数体执行频率。借助 Vue DevTools 的 “Timeline” 或 “Performance” 面板查看组件 render 耗时和 update 触发来源。用onBeforeUpdate钩子观察组件更新前的事件注意函数组件里需要通过 defineComponent 定义const Child defineComponent({ setup(props) { onBeforeUpdate(() { console.log(about to update) }) return () div{props.name}/div } })使用markRaw和readonly标记不需要响应式的数据从源头减少响应式通知的范围。掌握这些排查手段之后下次看到“函数组件被执行了很多次”就不会乱投医了。6. 真实项目里的三个典型问题复盘6.1 图表组件反复重绘百思不得其解之前负责的一个后台管理系统里用户切换 tab 时图表页会明显卡顿。排查后发现图表被封装成一个函数组件父组件是一个大容器内部有不少响应式变量。容器里只要有一点小小的状态变化图表组件的函数体就会重新执行图表初始化逻辑跑一遍。初始化逻辑里还带着 setOption相当于更是重绘一次。最后的解法把图表组件内部状态抽离避免容器状态影响它。用 memo 包裹图表组件确保只有图表相关的 props 变化时才执行。给图表实例加缓存实例存到组件实例外部避免被重复创建。这个案例让我更坚定一个观点组件边界该拆就要拆别把所有状态堆在一个大组件里否则无论你如何备忘录都很难控制更新边界。6.2 表格组件输入框失焦bug无限渲染的元凶另一个项目里表格中有一列是输入框。用户每输入一个字符整个表格全部重新渲染输入框跟着失焦体验极其糟糕。后来定位到问题输入框的 value 和 onChange 都依赖了父组件的某一对象属性每次输入修改值触发父组件更新从而重新渲染所有行。这里本质上和数据流设计有关——几乎每一行的 props 引用都在变化memo 拦都拦不住。当时的解决思路是每行输入状态拆到行组件内部维护只在提交blur时通知父组件。这样父组件的状态不因输入动作频繁变化整个表格的渲染频率大大降低。这个案例的核心教训是函数组件会不会重新运行有时并不是“是否 memo”的问题而是“状态放哪”的问题。状态放错了层级谁都没办法帮你把渲染频率降下来。6.3 依赖不稳定的 useCallbackmemo 白包了还有一次代码 review 中发现一个同事用 memo 包裹了一个组件但 props 里有一个setFilter它每次 render 都重新创建。问起来才知道他把函数直接写在了 JSX 的属性里Filter onFilter{(v) setFilter(v)} /这样的写法下memo 永远不会生效因为每次父组件 render函数引用都变了。最后统一改用useCallback并且把 filter 数据本身提升到 store 层。改动很小但组件执行次数从“每次更新都执行”降到了“只在实际数据变化时执行”。很多人以为“memo 包裹了就好了”实际上如果 props 里混入了原生类型以外的对象/函数引用还要保证引用稳定否则白包。这个点值得反复强调。写在最后的一点经验回到标题的问题Vue3 的 JSX 函数组件每次更新都会重新运行吗我的答案是会而且这很正常。编程这么久我的体会是与其纠结“函数体执行次数”不如把注意力放在“patch 阶段的工作量”和“真的昂贵的部分”上。函数组件本身很轻但如果你在其内部做了重计算、生成大数组、深度拷贝这类操作那“重新运行”就成了实实在在的性能开销。所以你接下来要做的事其实很简单先检查函数组件里有没有大计算量的逻辑再检查 props 引用是否稳定最后才是决定要不要用 memo。用对工具胜过盲目加缓存。如果刚开始用 Vue3 JSX我建议你先在非核心场景多测试几次打几个 console.log 感受一下触发时机。等你熟悉了这套更新链路以后再做优化就轻车熟路了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进