R.BLOG
R.BLOG
  • 首页
  • 文章
  • 作品
  • 关于

开源发布 🚀 | 解决 Vue Hero 动画的最后一块拼图:完美支持 v-show!

背景 在前段时间我实现了一个Vue指令,用于实现元素的跨页面动画效果: 【Hero动画】用一个指令实现Vue跨路由/组件动画(https://blog.rjie.top/articles/a4f97d1b-3b97-41eb-b176-9514ca1f3712)) 但有个遗憾一直没解决:不支持v-show指令。 最近终于有时间了,决定攻克这个技术难题,让 Hero 动画更加完整! 为什么v-show这么棘手🤔 v-if / 路由切换 在v-if和路由切换的情况下,使用指令的mounted和beforeUnmount钩子非常方便,只需要在挂载时注册Hero元素,在卸载前执行过渡动画即可。 // 这种很简单:挂载时注册,卸载时执行动画 const heroAnimationDirective: Directive = { mounted(el, { value }) { el.dataset.heroId = value.heroId; }, beforeUnmount(el, { value }) { heroAnimation(el, value); } }; v-show 触发的变化 v-show通过display属性控制显示/隐藏,没有卸载过程,只能通过beforeUpdate和updated钩子来监听元素的变化。 核心难点:如何区分是v-show触发的显示变化,还是其他响应式数据的变化? 解决方案思路 所以我们只能手动判断是否是v-show触发的变化,只有在display属性变化时,才执行过渡动画。 大致实现步骤: 在mounted钩子中,将相同heroId的元素注册到一个集合中,标记为v-show组合。 在updated钩子中,判断display状态,从而判断是否是v-show触发的变化。 11.png 实现 注册Hero元素 我们先定义一个Map,用于存储heroId和对应的v-show元素集合。 并且实现注册和注销函数。 // 元素映射表 用于v-show 元素对的匹配 const heroMap = new Map>(); /** 注册Hero元素 @param el Hero元素 @param heroId Hero ID */ function registerHero(el: HTMLElement, heroId: string) { if (!heroMap.has(heroId)) { heroMap.set(heroId, new Set()); } heroMap.get(heroId)?.add(el); } /** 注销Hero元素 @param el Hero元素 @param heroId Hero ID */ function unregisterHero(el: HTMLElement, heroId: string) { const set = heroMap.get(heroId); if (set) { set.delete(el); if (set.size === 0) heroMap.delete(heroId); } } 除此之外,我们还需要在元素都挂载好之后,来验证每个heroId是否有且只有2个v-show元素。 /** 验证Hero元素对是否匹配 @param heroId Hero ID */ function validatePair(heroId: string) { const set = heroMap.get(heroId); if (set) { if (set.size === 2) { set.forEach(el => { const display = getComputedStyle(el).display; (el as any).__isVShowPair = true; (el as any).__wasHidden = display === 'none'; // 记录原始display属性 display !== 'none' && ((el as any).__originDisplay = display); }); } else if (set?.size (el as any).__isVIfPair = true); heroMap.delete(heroId); } else { console.error(Hero ID "${heroId}" 有 ${set.size} 个元素,预期 2 个); } } } 再在指令处调用方法: mounted钩子中注册并验证元素对. updated钩子中判断是否是v-show触发的变化,从而执行过渡动画。 beforeUnmount钩子中注销元素对。 const heroAnimationDirective: Directive = { mounted(el, { value }) { const heroId = value.heroId; el.dataset.heroId = heroId; registerHero(el, heroId); queueMicrotask(() => validatePair(heroId)); }, updated(el, { value }) { if (!(el as any).__isVShowPair) return const wasHidden = (el as any).__wasHidden; const display = getComputedStyle(el).display; // 初始display为隐藏的元素触发 避免触发两次 if (!wasHidden) { heroAnimation(el, value); } // 重新记录隐藏状态 (el as any).__wasHidden = display === 'none'; (display !== 'none' && !(el as any).__originDisplay) && ((el as any).__originDisplay = display); }, beforeUnmount(el, { value }) { // v-if/路由切换元素触发动画 if ((el as any).__isVIfPair) { heroAnimation(el, value); } unregisterHero(el, value.heroId); } }; 改造动画 因为我们是在updated钩子中执行的动画,这时起始元素的display属性已经被改变为none,我们需要先恢复原始值然后再执行动画。 /** 执行元素的动画过渡 @param source 起始元素 @param props 动画属性 */ async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) { const { heroId, duration = '1s', timingFunction = 'ease', delay = '0s', position = 'fixed', zIndex = 9999, container = document.body } = props; // 容器 const containerEl: HTMLElement = isRef(container) ? container.value ?? document.body : typeof container === 'string' ? document.querySelector(container) ?? document.body : container; const containerRect = getRect(containerEl); // v-show 标识 const isVShowPair = (source as any).__isVShowPair; // v-show情况下,需要先显示元素,才能获取到正确的位置信息 if (isVShowPair) { source.style.setProperty('display', (source as any).__originDisplay || 'block'); await nextTick(); } const rect = getRect(source); const clone = source.cloneNode(true) as HTMLElement; copyStyles(source, clone); // v-show 恢复隐藏 isVShowPair && source.style.setProperty('display', 'none'); await nextTick(); let target: HTMLElement | null = null; if (isVShowPair) { // 从映射表中获取目标元素 const set = heroMap.get(heroId); set && set.forEach(item => item !== source && (target = item)); } else { target = document.querySelector( data-hero-id="${heroId}"]:not([data-clone]):not([style*="display: none"]) ) as HTMLElement; } if (!target) return; ...先前的动画逻辑 } 简单来个页面测试一下 触发 import { ref } from 'vue-demi'; import type { HeroAnimationProps } from 'vue-hero-cross'; const flag = ref(false) const boxRef = ref() const animationProps: HeroAnimationProps = { heroId: 'box', duration: '2s', position: 'absolute', container: '.container' } .container { position: relative; width: 500px; height: 500px; border: 1px solid #000; border-radius: 12px; overflow: hidden; } .box1 { position: absolute; top: -50px; left: -50px; width: 200px; height: 200px; background-color: red; border-radius: 12px; } .box2 { position: absolute; bottom: -50px; right: -50px; width: 300px; height: 300px; background-color: blue; border-radius: 50%; transform: rotate(45deg); } 看看效果: 12.gif 完美触发过渡😀 细节优化 快速切换优化 想到一个场景,如果快速点击按钮/切换路由,会出现什么效果。 13.gif 可以看到连点两下按钮后,虽然只有一个动画再执行,但是目标元素已经变化到了最初的蓝色BOX,但是动画的路径却没有变化,这明显是不符合预期的。 预期效果应该是如果目标元素已经变化了,那么动画的路径也应该变化到新的目标位置。 实现步骤: 当触发动画时,先判断是否存在正在进行的动画。 如果存在,需要先中断当前动画,然后创建一个新的动画元素。 新的动画元素需要复制当前动画元素的所有样式。 新元素的位置需要设置为当前动画元素的位置。 最后,新元素作为起始元素,开始新的动画。 我们先定义一个映射表,用于存储当前正在进行的动画元素。 // 正在进行的动画元素映射表 const animatingMap = new Map(); 然后再实现中断当前动画的逻辑。 async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) { const { heroId, duration = '1s', timingFunction = 'ease', delay = '0s', position = 'fixed', zIndex = 9999, container = document.body } = props; // 中断动画标识 let isInterruptedAnimation = false; // 容器 const containerEl: HTMLElement = isRef(container) ? container.value ?? document.body : typeof container === 'string' ? document.querySelector(container) ?? document.body : container; const containerRect = getRect(containerEl); // 存在正在进行的动画,需要中断 if (animatingMap.has(heroId)) { // 当前动画元素 const animatingEl = animatingMap.get(heroId) as HTMLElement; const animatingElStyle = window.getComputedStyle(animatingEl); // 克隆当前动画元素,用于新的动画 const newSource = animatingEl.cloneNode(true) as HTMLElement; copyStyles(animatingEl, newSource); // copyStyles 函数排除了 left、top 样式,手动计算并设置当前动画元素的位置 newSource.style.left = animatingElStyle.left; newSource.style.top = animatingElStyle.top; containerEl.appendChild(newSource); // 移除旧的动画元素 containerEl.removeChild(animatingEl); source = newSource; isInterruptedAnimation = true; } ... copyStyles(source, clone); // v-show 恢复隐藏 isVShowPair && source.style.setProperty('display', 'none'); // 这时候的source是我们手动添加的 现在需要手动移除 isInterruptedAnimation && containerEl.removeChild(source); await nextTick(); ... containerEl.appendChild(clone); // 添加动画元素到映射表 animatingMap.set(heroId, clone); requestAnimationFrame(() => { ... clone.addEventListener('transitionend', () => { ... // 动画结束后删除 animatingMap.delete(heroId); }, { once: true }); }) } 再看看现在的效果: 14.gif 这下可以实现移动到新的目标位置了😀。 动画时间优化 但这也带来了一个问题,就是动画时间。 现在中断动画后,当前动画元素过渡到新的目标位置还是需要2秒,但这不符合预期。 我们预想一个场景: 假设一个A到B的动画,过渡动画时间是2000ms。 在前进的途中,动画播放了750ms,用户再次点击了按钮,那当前动画元素应该回到A位置,而过渡时间就是已播放的750ms。 在折返的途中,动画播放了500ms,用户再次点击了按钮,那当前动画元素应该回到B位置,而过渡时间就是总播放时长2000ms减去A到B已过渡的250ms得到的1750ms。 16.png 根据这个逻辑,我们需要多记录几个信息: 动画当前被重播的次数,以此来判断是前进还是折返。 已前进的时长,以此来计算继续前进和折返的过渡时间。 动画开始时间,用于计算已播放时长。 我们修改animatingMap的类型,添加这些属性。 再添加一个方法,用于转换duration为毫秒数。 // 正在进行的动画元素映射表 interface AnimatingInfo { el: HTMLElement; count: number; elapsed: number; startTime: number; } const animatingMap = new Map(); /** 解析动画时长 @param d 时长字符串或数字 @returns 时长(毫秒) */ function parseDuration(d: string | number): number { if (typeof d === 'number') return d const match = String(d).match(/^([\d.]+)\s*(s|ms)?$/) if (!match) return 1000 const [, n, unit] = match return unit === 's' ? parseFloat(n) * 1000 : parseInt(n, 10) } 我们再改造heroAnimation函数,来实现动画时间优化。 async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) { const { heroId, duration = '1s', timingFunction = 'ease', delay = '0s', position = 'fixed', zIndex = 9999, container = document.body } = props; // 解析时长 let durationMs = parseDuration(duration); ... const animatingInfo = animatingMap.get(heroId); // 存在正在进行的动画,需要中断 if (animatingInfo) { const timeElapsed = performance.now() - animatingInfo.startTime; // 前进 还是 折返 const isForward = animatingInfo.count % 2 === 0; animatingInfo.elapsed = isForward ? (animatingInfo.elapsed || 0) - timeElapsed : animatingInfo.elapsed + timeElapsed; durationMs = isForward ? durationMs - animatingInfo.elapsed : animatingInfo.elapsed; // 当前动画元素 const animatingEl = animatingInfo.el; const animatingElStyle = window.getComputedStyle(animatingEl); ... } ... containerEl.appendChild(clone); // 更新动画元素 const animationData = animatingInfo || { el: clone, count: 1, elapsed: 0, startTime: performance.now(), } if (animatingInfo) { animatingInfo.el = clone; animatingInfo.count++; animatingInfo.startTime = performance.now(); } animatingMap.set(heroId, animationData); requestAnimationFrame(() => { // 改用转换后的时间 clone.style.transition = all ${durationMs}ms ${timingFunction} ${delay}; ... }); } 这时我们再看看效果: 15.gif 这下动画时间就符合预期了🎉。 源码 和 使用 GitHub仓库 该指令的源码已经上传到github,如果对你有帮助,请点点star⭐: [GitHub vue-hero-cross npm包安装 同时,也发布到了npm,你可以通过npm install vue-hero-cross安装来直接使用: npm vue-hero-cross 🤝 参与贡献 如果你对这个项目感兴趣,欢迎: 提交 Issue 报告问题或建议。 提交 PR 添加新功能或修复 Bug。 在项目中实际使用并反馈体验。 分享给更多开发者
2026-01-19
34
动效
Vue.js

CSS终于支持渐变色的过渡了🎉

背景 在做项目时,总会遇到UI给出渐变色的卡片或者按钮,但在做高亮的时候,由于没有过渡,显得尤为生硬。 过去的解决方案 在过去,我们如果要实现渐变色的过渡,通常会使用如下几种方法: 添加遮罩层,通过改变遮罩层的透明度做出淡入淡出的效果,实现过渡。 通过background-size/position使得渐变色移动,实现渐变色移动的效果。 通过filter: hue-rotate滤镜实现色相旋转,实现过渡。 但这几种方式都有各自的局限性: 遮罩层的方式看似平滑,但不是真正的过渡,差点意思。 background-size/position的方式需要计算好background-size和background-position,否则会出现渐变不完整的情况。并且只是实现了渐变的移动,而不是过渡。 filter: hue-rotate也需要计算好旋转角度,实现复杂度高,过渡的也不自然。 See the Pen 基础渐变过渡 by Roki-7 (@Roki-7) on CodePen. @property新规则 @property规则可以定义一个自定义属性,并且可以指定该属性的语法、是否继承、初始值等。 @property --color { syntax: ''; inherits: false; initial-value: #000000; } 我们只需要把这个自定义属性--color应用到linear-gradient中,在特定的时候改变它的值,非常轻松就可以实现渐变色的过渡了。 See the Pen @property渐变过渡 by Roki-7 (@Roki-7) on CodePen. 我们再看看@property规则中这些属性的含义。 Syntax语法描述符 Syntax用于描述自定义属性的数据类型,必填项,常见值包括: `` 数字(如0,1,2.5) `` 百分比(如0%,50%,100%) `` 长度单位(如px,em,rem) `` 颜色值 `` 角度值(如deg,rad) `` 时间值(如s,ms) `` 图片 `` 任意类型 Inherits继承描述符 Inherits用于描述自定义属性是否从父元素继承值,必填项: true 从父元素继承值 false 不继承,每个元素独立 Initial-value初始值描述符 Initial-value用于描述自定义属性的初始值,在Syntax为通用时为可选。 兼容性 @property目前仍是实验性规则,但主流浏览器较新版本都已支持。 b70bdd9815d54aa3a3c4b4d08a7aba9c.png 总结与展望 @property规则的出现,标志着CSS在动态样式控制方面迈出了重要一步。它不仅解决了渐变色过渡的技术难题,更为未来的CSS动画和交互设计开辟了新的可能性。 随着浏览器支持的不断完善,我们可以期待: 更丰富的动画效果 更简洁的代码实现 更好的性能表现
2026-01-13
27
CSS

【Hero动画】用一个指令实现Vue跨路由/组件动画

背景 在上半年接触flutter时,遇到了一个比较有意思的组件Hero,它可以实现页面之间的元素动画过渡,比如点击一个图片,跳转到详情页,详情页的图片会有一个动画过渡到详情页的图片。 效果可见这篇文章:Flutter 中的 Hero 动画 我一直想尝试在Vue中实现这样的效果,但是在Vue中并没有这样的组件,在社区上也很少看到有人去实现这样的效果。 直到前段时间看到了这个视频:【Anthony Fu】起飞!跨路由 Vue 组件动画实验,这个视频给了我极大的启发,让我决定动手用自己的方式实现。 实现思路 视频中的思路 Anthony Fu的思路是在两个不同的页面中使用代理组件进行占位,实际元素渲染在根节点,然后通过代理组件将状态共享给目标组件来实现过渡。 我的思路 受到视频的启发,但我想做一种更轻量级的实现:在不影响原有组件结构,只需添加简单属性可实现过渡。 我的思路是: 组件销毁时,复制源DOM到根节点 视图更新后,先隐藏目标DOM 将目标DOM样式赋给克隆DOM,添加过渡动画 动画结束后移除克隆DOM,显示目标DOM 0e2b06071d3c49c1a9d6b48e624117a2.png 实现 自定义指令 为避免使用全局组件,所以我决定使用自定义指令来实现。 我们需要在指令中接收一个参数,参数为需要过渡组件的 id。以及动画的配置项。 export interface HeroAnimationProps {  heroId: string;  duration?: ${number}s | ${number}ms;  timingFunction?: 'ease' | 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | string;  delay?: ${number}s | ${number}ms; } 接下来编写指令: 首先,我们需要在DOM上绑定一个属性,属性为需要过渡组件的id。 const heroAnimationDirective: Directive = {  mounted(el: HTMLElement, { value }: { value: HeroAnimationProps }) {    el.dataset.heroId = value.heroId; } }; 可能会遇到这些情况来触发动画: 路由切换 组件内元素销毁v-if 路由切换和v-if都属于组件销毁的情况,在指令的beforeUnmount中触发动画即可。 ⚠️v-show的情况暂时没想好怎么处理,v-show切换时只能通过beforeUpdate和updated来触发动画,但是在这两个钩子中,并不好判断是v-show的值变化还是别的响应式值变化。希望有大佬能够提供思路。 const heroAnimationDirective: Directive = {  mounted(el, { value }) {    el.dataset.heroId = value.heroId; },  beforeUnmount(el, { value }) {      heroAnimation(el, value); } }; 动画实现 现在我们再根据最开始的思路来实现动画。 async function heroAnimation(el: HTMLElement, props: HeroAnimationProps) {  const {    duration = '1s',    timingFunction = 'ease',    delay = '0s' } = props;  const containerEl = document.body;  // 视图更新前获取源DOM位置 并 克隆源DOM  const rect = el.getBoundingClientRect();  const clone = el.cloneNode(true) as HTMLElement;  await nextTick();  const heroId = el.dataset.heroId;  const newEl = document.querySelector(    data-hero-id="${heroId}"]:not([data-clone]):not([style*="display: none"]) ) as HTMLElement;  if (!newEl) return;  // 视图更新后获取目标DOM位置  const newRect = newEl.getBoundingClientRect();  // 先赋值源DOM的位置和大小  clone.style.position = 'fixed';  clone.style.zIndex = '9999';  clone.style.left = ${rect.left}px;  clone.style.top = ${rect.top}px;  clone.style.width = ${rect.width}px;  clone.style.height = ${rect.height}px;  clone.dataset.clone = 'true';  // 隐藏目标DOM  newEl.style.visibility = 'hidden';  containerEl.appendChild(clone);  // 在下一帧中赋值目标DOM的位置和大小 达到过渡效果  requestAnimationFrame(() => {    clone.style.visibility = 'visible';    clone.style.transition = all ${duration} ${timingFunction} ${delay};    copyStyles(newEl, clone);    clone.style.left = ${newRect.left}px;    clone.style.top = ${newRect.top}px;    clone.style.width = ${newRect.width}px;    clone.style.height = ${newRect.height}px;    // 动画结束后移除克隆DOM 显示目标DOM    clone.addEventListener('transitionend', () => {      newEl.style.visibility = 'visible';      containerEl.removeChild(clone);   }, { once: true }); }); }; 我们再来实现一下copyStyles方法,我们需要排除一下我们已经显式定义的属性和可能会影响过渡的属性。 function copyStyles(source: HTMLElement, target: HTMLElement) {  const computedStyle = window.getComputedStyle(source);  const props = Array.from(computedStyle);  const excludes = [    'transition',    'visibility',    'position',    'z-index',    'left',    'top',    'right',    'bottom',    'width',    'height',    'inset' ];  for (const prop of props) {    if (excludes.some(item => prop.includes(item))) continue;    target.style.setProperty(prop, computedStyle.getPropertyValue(prop)); } }; 测试页面 我们简单写两个页面来测试一下效果。 App.vue:                    home                    detail                 页面1:         .box1 {  position: absolute;  top: 500px;  left: 300px;  width: 100px;  height: 100px;  background-color: red; } 页面2:         .box2 {  position: absolute;  top: 50px;  left: 20px;  width: 200px;  height: 200px;  background-color: blue;  border-radius: 20px; } hero_gif1.gif 目前效果看起来还不错🤔,但是如果过渡元素的样式中有transform属性,那么过渡的过程就会出现问题。 问题分析与解决 我们在页面1的box1元素上添加transform属性: .box1 { ...保留其他样式 transform: rotate(45deg); } 再次看下效果: hero_gif2.gif 可以看到在动画结束后,元素抖动了一下,然后才回到了原来的位置。 我们先注释掉containerEl.removeChild(clone),发现克隆出来的DOM在动画结束后的宽高和目标元素对不上。 动画结束后克隆DOM的宽高:width: 141.412px; height: 141.412px; 实际目标元素的宽高是:width: 100px; height: 100px; hero3.png getBoundingClientRect计算的是元素经过所有CSS变换(包括transform)后的最终渲染边界框。这意味着: 旋转(rotate)会影响元素的边界框尺寸 缩放(scale)会改变元素的实际显示大小 平移(translate)会调整元素的位置坐标 这些变换都会反映在返回的DOMRect对象中。 解决方案:既然问题出在transform属性上,那么我们就在getBoundingClientRect之前暂时去除transform属性,之后再赋值回来。 function getRect(el: HTMLElement) { // 保存原始transform和transition属性 const originalTransform = el.style.transform; const originalTransition = el.style.transition; el.style.transform = 'none'; el.style.transition = 'none'; const rect = el.getBoundingClientRect(); el.style.transform = originalTransform; // 在下一帧中赋值transition属性 避免恢复transform时发生过渡动画 拿到的rect信息还是会存在问题 requestAnimationFrame(() => { el.style.transition = originalTransition; }); return rect; }; async function heroAnimation(el: HTMLElement, props: HeroAnimationProps) { ... const rect = getRect(el); ... const newRect = getRect(newEl); ... } 再来看下效果: hero_gif3.gif 这下就没有抖动了😀。 一些优化 当前用于过渡的组件是添加到根节点下的,z-index是定死的,但实际使用中还会有些情况: 动画可能是需要在某个容器执行 动画元素可能会被别的元素遮盖来完成一些特殊的效果 那我们需要在props中再添加如下属性: export interface HeroAnimationProps { ... position?: 'absolute' | 'fixed'; zIndex?: number; container?: string | Ref } 现在就可以指定过渡元素所在的容器了,我们再在heroAnimation方法进行如下改造: 指定容器 动态z-index 在指定容器的情况下,坐标要改为相对坐标 async function heroAnimation(el: HTMLElement, props: HeroAnimationProps) { const { duration = '1s', timingFunction = 'ease', delay = '0s', position = 'fixed', zIndex = 9999, container = document.body } = props; const containerEl: HTMLElement = isRef(container) ? container.value ?? document.body : typeof container === 'string' ? document.querySelector(container) ?? document.body : container; ... const containerRect = getRect(containerEl); const pos = getPosition(rect, containerRect, position); clone.style.left = ${pos.left}px; clone.style.top = ${pos.top}px; ... requestAnimationFrame(() => { ... const newPos = getPosition(newRect, containerRect, position); clone.style.left = ${newPos.left}px; clone.style.top = ${newPos.top}px; ... }); }; 由于当前动画元素的定位可能是相对定位,所以我们得计算出元素的相对坐标,只需要通过元素坐标 - 容器坐标就可以计算出当前元素的相对坐标: function getPosition (rect: DOMRect, containerRect: DOMRect, position: HeroAnimationProps['position']) { return { left: position === 'absolute' ? rect.left - containerRect.left : rect.left, top: position === 'absolute' ? rect.top - containerRect.top : rect.top, }; }; 我们再改造一下App.vue,在router-view外再包一层容器,并在两个页面中指定动画容器,再看看效果: App.vue ... .container { position: relative; margin-inline: 20px; width: 500px; height: 500px; border: solid 2px #000; border-radius: 20px; overflow: hidden; } 页面1: .box1 { position: absolute; bottom: -50px; right: -50px; width: 100px; height: 100px; background-color: red; transform: rotate(45deg); } 页面2: .box2 { position: absolute; top: -100px; left: -100px; width: 200px; height: 200px; background-color: blue; border-radius: 20px; } hero_gif4.gif 效果还是不错的😁。 在线预览 [跨路由动画 Hero动画 | R.BLOG 总结 目前实现 实现跨路由的动画效果,支持容器定位(position)和层级(z-index)控制 解决transform属性导致的边界框计算问题 支持指定动画元素的容器 一些限制/不足 v-show 隐藏控制 目前还不支持v-show隐藏来触发动画 图片内容过渡 源/目标图片不一致时会出现闪烁(建议使用同源图片)
2025-09-04
111
动效
Vue.js

👨面试官:后端一次性给你一千万条数据,你该如何优化渲染?

问题背景 在去年的一场面试中,面试官向我提了一个问题: 面试官:后端一次性给你一千万条数据,渲染到页面上发生卡顿,你该怎么优化? 我:我会问候后端(bushi) 实际上我的回答是:如果没办法改变后端的情况下,我会避免给这种大数据量赋予响应式,然后手动 分页渲染。 面试官:不对哈,用Object.freeze来优化。 我:??? 这段时间突然想起这个问题,决定试一下实际遇到这种情况到底该怎么优化。 测试环境搭建 前端实现(Vue3) 我是 {{ user.name }} import { ref } from 'vue' const tableData = ref([]) const getData = async () => { const res = await fetch('/api/mock') const data = await res.json() tableData.value = data } getData() .user-info { height: 30px; } 后端实现(NestJS) getMockData() { function generateMockData(amount) { const data: any = [] for (let i = 0; i { const res = await fetch('/api/mock') const data = await res.json() userList.value = data.map((item: any) => Object.freeze(item)) } 测试效果: ⏳渲染时间:仍需要 30s 左右 ✅优点:能够避免后续数据变更的响应式消耗 ❌缺点:无法解决初始渲染性能瓶颈 方案二:分块渲染(requestAnimationFrame) 通过分批渲染避免 主线程阻塞 : import { ref } from 'vue' const userList = ref([]) const CHUNK_SIZE = 1000 const getData = async () => { const res = await fetch('/api/mock') const data = await res.json() function* chunkGenerator() { let index = 0 while(index { const chunk = generator.next() if (!chunk.done) { userList.value.push(...chunk.value) requestAnimationFrame(processChunk) } } requestAnimationFrame(processChunk) } getData() 测试效果: ⏳首屏时间:** 我是 {{ user.name }} import { ref, computed, onMounted } from 'vue' const userList = ref([]) const getData = async () => { const res = await fetch('/api/mock') const data = await res.json() userList.value = data } getData() const viewportRef = ref() const ITEM_HEIGHT = 30 const visibleCount = ref(0) const startIndex = ref(0) const offset = ref(0) // 计算总高度 const totalHeight = computed(() => userList.value.length * ITEM_HEIGHT) // 计算可见数据 const visibleData = computed(() => { return userList.value.slice( startIndex.value, Math.min(startIndex.value + visibleCount.value, userList.value.length) ) }) // 初始化可视区域数量 onMounted(() => { visibleCount.value = Math.ceil((viewportRef.value?.clientHeight || 0) / ITEM_HEIGHT) + 2 }) // 滚动处理 const handleScroll = () => { if (!viewportRef.value) return const scrollTop = viewportRef.value.scrollTop startIndex.value = Math.floor(scrollTop / ITEM_HEIGHT) offset.value = scrollTop - (scrollTop % ITEM_HEIGHT) } .viewport { height: 100vh; /* 根据实际需求调整高度 */ overflow-y: auto; position: relative; } .scroll-holder { position: absolute; left: 0; right: 0; top: 0; } .visible-area { position: absolute; left: 0; right: 0; } .user-info { height: 30px; } 测试效果: ⏳首屏时间:< 1s ✅优点:只渲染 可视区域 内的DOM,减少不必要的消耗 ❌缺点:实现相对麻烦,实际情况可能需要动态计算元素高度 方案对比总结 | 方案 | 首屏时间 | 内存占用 | 滚动性能 | 实现复杂度 | | ------------- | -------- | -------- | -------- | ---------- | | 原始渲染 | 30s+ | 高 | 差 | 简单 | | Object.freeze | 30s+ | 高 | 差 | 简单 | | 分块渲染 | <1s | 持续增长 | 逐渐变差 | 中等 | | 虚拟列表 | <1s | 低 | 流畅 | 较高 | 彩蛋:为什么我只测了100万条数据? 当我试图测试 一千万 条数据时: 第一次报错: FATAL ERROR: JS堆内存不足 🤔 第二次报错(调高内存上限后): RangeError: 字符串长度超标 💥响应体过大了,超出了V8引擎的字符串长度限制🤣,如果要返回只能使用SSE了,但这就违背了问题的“一次性返回”。(Java的JVM引擎响应限制比较大,应该是可以返回的) 总结 响应式优化 ≠ 渲染优化: Object.freeze 只能解决响应式开销,不能解决渲染瓶颈。 分块渲染算是折中方案: 也并不适合大量的数据渲染,性能开销依旧很大。 虚拟列表是最佳实践: 能够应对大数据量的渲染,且不影响性能。 实际情况: 还是应该避免后端一次性返回大量的数据。测试用例中,本地返回百万条数据(还是简单的json结构)接口都需要响应 1.7s~3s 。如果后端只能返回全量数据,那只能考虑 虚拟列表 解决方案。
2025-07-22
217
Vue.js
面试
闽ICP备2025106461号
|
Copyright © 2025 R.BLOG All Rights Reserved