如何实现独立于被包裹组件的SkeletonLoader/WireframeLoader?
组件骨架屏实现方案(基于限制条件)
问题背景
我们有如下UserCard组件:
function UserCard(props) { const { avatarUrl, name, loading } = props return ( <div className='UserCard'> <Avatar image={avatarUrl} /> <Name name={name} /> </div> ) }
需要为其实现线框加载效果,且满足以下限制:
- 不能为
Avatar、Name组件单独设计骨架副本(避免组件尺寸/形状修改后骨架不同步) - 无法修改
Avatar、Name组件内部结构或CSS - 可以从外部包裹组件,并控制它们的间距
- 需适配组件的透明区域、圆角,仅在可见部分显示骨架动画
理想的Loader使用方式如下:
function UserCard(props) { const { avatarUrl, name, loading } = props return ( <div className='UserCard'> <Loader loading={loading}> <Avatar image={avatarUrl} /> </Loader> <Loader loading={loading}> <Name name={loading ? '占位文本控制骨架尺寸' : name} /> </Loader> </div> ) }
一、单个子组件的Loader实现方法
方法1:CSS混合模式实现适配型骨架动画
核心思路是利用混合模式,让渐变动画仅在组件的不透明区域显示,同时用底色填充组件占位空间,自动适配圆角和透明区域。
import styled, { keyframes } from 'styled-components'; // 骨架闪烁动画 const shimmer = keyframes` 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } `; const LoaderOverlay = styled.div` position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent); animation: ${shimmer} 1.5s infinite; mix-blend-mode: overlay; pointer-events: none; `; const LoaderWrapper = styled.div` position: relative; display: inline-block; background-color: #e0e0e0; opacity: ${props => props.loading ? 1 : 0}; `; function Loader({ loading, children }) { if (!loading) return <>{children}</>; return ( <LoaderWrapper loading={loading}> {children} <LoaderOverlay /> </LoaderWrapper> ); }
该方案无需提取组件形状,依赖浏览器原生混合模式特性,适配性强,代码简洁。
方法2:CSS遮罩实现精准骨架动画
通过提取组件的形状生成SVG遮罩,让骨架动画仅在组件可见区域显示,适合复杂形状的组件。
import styled, { keyframes } from 'styled-components'; import { useRef, useEffect, useState } from 'react'; const shimmer = keyframes` 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } `; const LoaderWrapper = styled.div` position: relative; display: inline-block; &::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0) 100%); animation: ${shimmer} 1.5s infinite; mask: url(${props => props.maskUrl}); mask-mode: alpha; pointer-events: none; opacity: ${props => props.loading ? 1 : 0}; } `; function Loader({ loading, children }) { if (!loading) return <>{children}</>; const childRef = useRef(null); const [maskUrl, setMaskUrl] = useState(''); useEffect(() => { if (!childRef.current) return; // 提取组件尺寸和圆角,生成简易SVG遮罩 const { offsetWidth, offsetHeight } = childRef.current; const borderRadius = getComputedStyle(childRef.current).borderRadius; const svgMask = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${offsetWidth} ${offsetHeight}"><rect x="0" y="0" width="${offsetWidth}" height="${offsetHeight}" rx="${borderRadius}" fill="white"/></svg>`; setMaskUrl(svgMask); }, [loading]); return ( <LoaderWrapper loading={loading} maskUrl={maskUrl}> {React.cloneElement(children, { ref: childRef })} </LoaderWrapper> ); }
如果组件是复杂不规则形状,可以用dom-to-svg等库提取更精准的DOM路径生成遮罩。
二、外部包裹UserCard为子div生成骨架的处理方式
若只能从外部包裹整个UserCard,且已知其为包含多个div子元素的容器,可通过以下方式实现:
实现思路
- 包裹原组件,加载状态下隐藏原组件
- 获取原组件子div的位置、尺寸、样式信息
- 生成匹配的骨架块并添加动画
代码实现
import styled, { keyframes } from 'styled-components'; import { useRef, useEffect, useState } from 'react'; const shimmer = keyframes` 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } `; const SkeletonBlock = styled.div` position: absolute; top: ${props => props.top}px; left: ${props => props.left}px; width: ${props => props.width}px; height: ${props => props.height}px; border-radius: ${props => props.borderRadius}; background-color: #e0e0e0; overflow: hidden; &::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0) 100%); animation: ${shimmer} 1.5s infinite; } `; const SkeletonWrapper = styled.div` position: relative; width: ${props => props.width}px; height: ${props => props.height}px; `; function UserCardSkeleton({ loading, children }) { if (!loading) return <>{children}</>; const cardRef = useRef(null); const [skeletonBlocks, setSkeletonBlocks] = useState([]); useEffect(() => { if (!cardRef.current) return; const card = cardRef.current; const childDivs = Array.from(card.querySelectorAll('div')); const blocks = childDivs.map(child => { const childRect = child.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); return { top: childRect.top - cardRect.top, left: childRect.left - cardRect.left, width: childRect.width, height: childRect.height, borderRadius: getComputedStyle(child).borderRadius }; }); setSkeletonBlocks(blocks); }, [loading]); return ( <SkeletonWrapper width={cardRef.current?.offsetWidth || 0} height={cardRef.current?.offsetHeight || 0}> <div ref={cardRef} style={{ opacity: 0 }}>{children}</div> {skeletonBlocks.map((block, index) => ( <SkeletonBlock key={index} {...block} /> ))} </SkeletonWrapper> ); } // 使用示例 function App() { const [loading, setLoading] = useState(true); return ( <UserCardSkeleton loading={loading}> <UserCard avatarUrl="..." name="..." /> </UserCardSkeleton> ); }
该方案完全从外部实现,无需修改原组件代码,骨架尺寸和样式会随原组件子元素自动同步。
内容的提问来源于stack exchange,提问作者grabantot
相关产品推荐
相关产品推荐

