写点什么

「趣学前端」骨架屏,分享一波前端 UI 组件开发的经验

作者:叶一一
  • 2022 年 9 月 05 日
    北京
  • 本文字数:10884 字

    阅读完需:约 36 分钟

「趣学前端」骨架屏,分享一波前端UI组件开发的经验

前言

在过去的两个月中,我们的移动端项目重构 UI 组件,表单组件、Layout 布局、弹框提示、导航、Card 卡片等基础组件已经完成并应用到日常功能开发中。


一期的开发中,我们把基础的常用的组件基本都完成了。二期计划将一些不太常用但是能提升交互体验组件纳入开发计划,比如骨架屏,比如步骤条等。组件开发系列第一篇,让我们一起来实现一个骨架屏组件的开发吧。


注:移动端项目使用的 react 框架,组件开发使用的 hooks 函数式组件。

知其然

何为骨架屏

Skeleton Screen(骨架屏)就是在页面数据尚未加载前先给用户展示出页面的大致结构,直到请求数据返回后再渲染页面,补充进需要显示的数据内容。


如何页面数据没有加载完毕,会展示空白,骨架屏的主要作用的替代白屏,展示页面的大致结构,直到页面数据完全返回。

何时使用

1)首次加载数据时,可能出现白屏,可以使用骨架屏替代白屏展示;


2)数据量较大的列表中,每次数据返回前,可以使用骨架屏做临时展示;


3)某些授权中间页,一般授权中间页并没有内容,所以会出现短暂的空白,可以使用骨架屏替代空白页展示;


以上几种情况会造成页面空白,使用骨架屏代替展示,可以提升用户体验,视觉上是可以看到页面有内容的,且内容铺满了屏幕。

知其所以然

图解组件


上图是我完成的骨架屏的实际效果。因为骨架屏是替代页面实际内容暂时的占位,所以一个常见骨架屏的结构和常见的列表结构很像,包括头像、标题、段落三个部分。想实现一个基本的骨架屏,也基本涵盖了这三个部分,而这三块内容的风格可以是直角也可以是圆角,为了增加用户体验,可以增加动画效果,其中头像是非必须的,可以不展示,标题和段落是必须的,但是展示长度可以变化。既然骨架屏是暂时的,那么一旦数据加载完成,实际内容需要渲染出来,骨架屏需要被隐藏,因此骨架屏展示可以有控制开关。


上面这段话,包含了基础的骨架屏开发的全部关键点,也帮我们屡清楚了开发是思路。 组件内容三部分:头像、标题、段落,组件传参 props:头像对象、标题对象、段落对象、展示开关、动画效果、展示风格。

粮草先行

兵法有云:“兵马未动粮草先行”,组价开发也需要有序进行。前面,我们图解了骨架屏组件,清楚了它的结构和样式,不同样式和不同结构相互组合,能排列出不同的展示结构,而控制这些结构的关键在于组件的 props 传参,所以我们想开发一个组件,第一步要先定一下它的 props 传参,这也就是我们说的"粮草"。


PropTypes


在项目中使用自定义组件时,需要对组件的 props 进行类型检测。而 React 提供了专门的库,可以校验组件的 props 类型,也可以做一些特定的限制。关于 PropTypes 的详细介绍,可以看我另一篇文章【知识点】PropTypes提供的验证器,这里不做详细介绍。下面列出骨架屏组件的 props 类型校验,并做一一解读。


Skeleton.propTypes = {  avatar: PropTypes.oneOfType([PropTypes.bool, PropTypes.Object]), // 是否显示头像占位图  title: PropTypes.bool, // 是否显示标题占位图  paragraph: PropTypes.oneOfType([PropTypes.bool, PropTypes.Object]), // 是否显示段落占位图  show: PropTypes.bool, // 是否显示骨架屏,传 false 时会展示子组件内容  active: PropTypes.bool, // 是否开启动画  round: PropTypes.bool, // 是否将标题和段落显示为圆角风格};
Skeleton.defaultProps = { avatar: false, title: true, paragraph: true, show: true, active: false, round: false,};
复制代码


avatar


是否显示头像占位图。


校验类型是布尔值和对象。


1)布尔值:控制图像模块是否展示,默认为 false-不展示,可选 true-展示。

2)对象:控制图像展示风格,包括:active-是否有动画效果,默认 false-没有动画效果;shape-头像风格,默认 circle-圆形,可选 square-矩形;size-头像大小,默认 default,可选 large-较大, small-较小,number-具体值。


title


是否显示标题占位图。


校验类型是布尔值。


布尔值:控制图像模块是否展示,默认为 true-展示,可选 false-不展示。


paragraph


是否显示段落占位图


校验类型是布尔值和对象。


1)布尔值:控制段落模块是否展示,默认为 true-展示,可选 false-不展示。

2)对象:控制段落展示风格,包括:rows-设置段落的行数;width-设置段落的宽度,若为数组时则为对应段落每行宽度,反之则是最后一行的宽度。


show


骨架屏是否展示的开关。


校验类型是布尔值。


布尔值:控制骨架屏是否展示,默认为 true-展示,可选 false-不展示。


active


骨架屏是否开启动画效果。


校验类型是布尔值。


布尔值:控制骨架屏风格是否开启动画效果,默认为 false-不开启动画效果,可选 true-开启动画效果。


round


骨架屏风格是否是圆角。


校验类型是布尔值。


布尔值:控制骨架屏风格是否是圆角,默认为 false-不是圆角,可选 true-是圆角。

整顿兵马

props 传参已设置好,接下来就可以进行下一步对“兵马”的整顿了。


正如我们前面所讲的,基础的骨架屏分为三个部分:头像、标题、段落,且实际内容加载完成之后可以选择隐藏骨架屏。所以“兵马”的布阵应该是这样的:


/** @name class前缀 */const prefixCls = 'fly-skeleton';
/** @name 根元素class */const rootCls = classnames(prefixCls, className, { [`${prefixCls}--round`]: round, [`${prefixCls}--active`]: active,});
/** @name 内容class */const contentCls = classnames(`${prefixCls}--content`);
return ( <> {show ? ( <div className={rootCls}> {avatarContent()} <div className={contentCls}> {titleContent()} {paragraphContent()} </div> </div> ) : ( children )} </>);
复制代码


当参数 show 设置为 true 时,展示骨架屏内容,反之展示骨架屏组件内容包裹的子组件的内容即页面真实内容。其中头像、标题、段落又分别提炼成了方法,这样的处理,让组件的结构很清楚,易于维护。下面,我们来看这三块内容到底是怎么实现的。


头像


头像模块的功能实现如下:


/** @name 是否展示头像 */const hasAvatar = !!avatar;
/** @name 尺寸枚举 */const sizgClsObj = { large: 'lg', small: 'sm', default: null,};
/** * 获取对象属性值 * @param {void} 无 * @return {render} 展示内容 */const getTypeOfObject = prop => { if (prop && typeof prop === 'object') { return prop; } return {};};
/** * 头像展示 * @param {void} 无 * @return {render} 展示内容 */const avatarContent = () => { if (hasAvatar) { /** @name 头像对象数据 */ let avatarObj = { active: false, shape: 'circle', size: 'default', ...getTypeOfObject(avatar), };
/** @name 头像父容器class */ const headCls = classnames(`${prefixCls}__header`);
/** @name 头像class */ const avatarCls = `${prefixCls}__avatar`;
/** @name 尺寸class */ const sizeCls = sizgClsObj[avatarObj.size] && classnames(`${avatarCls}--${sizgClsObj[avatarObj.size]}`);
/** @name 形状class */ const shapeCls = classnames(`${avatarCls}--${avatarObj.shape}`);
/** @name 尺寸内联样式 */ const sizeStyle = typeof avatarObj.size === 'number' ? { width: avatarObj.size, height: avatarObj.size, lineHeight: `${avatarObj.size}px`, } : {};
return ( <div className={headCls}> <span className={classnames(avatarCls, sizeCls, shapeCls, className)} style={{ ...sizeStyle }} /> </div> ); }};
复制代码


在上面的代码中,主要做了以下功能:


1、展示控制

通过 avatar 参数控制展示与否,如果骨架屏组件上的 props 没有 avatar 参数,则不展示头像模块,反之则展示;

2、头像大小

通过 avatar 参数中的 size 属性控制头像大小。

size 值默认是 default,可以设置固定变量值如:large-较大,small-较小,也可是设置具体数值,当设置具体数值的时候,会使用内联样式进行覆盖,将头像的宽度和高度的值设置为当前数值,单位均为像素。

3、头像风格

通过 avatar 参数中的 shape 属性控制头像风格。

shape 的值默认是 circle,也就是圆角风格,头像展示样式为圆形,如果 shape 的值设置为 square,那么展示效果会为矩形。


标题


标题模块的功能实现如下:


/** @name 是否展示标题 */const hasTitle = !!title;
/** * 获取对象属性值 * @param {void} 无 * @return {render} 展示内容 */const getTypeOfObject = prop => { if (prop && typeof prop === 'object') { return prop; } return {};};
/** * 标题展示 * @param {void} 无 * @return {render} 展示内容 */const titleContent = () => { if (hasTitle) { /** @name 标题class */ const titleCls = classnames(`${prefixCls}__title`);
/** @name 标题style */ const titleStyle = { width: !hasAvatar ? '35%' : '50%', ...getTypeOfObject(title), };
return <h3 className={titleCls} style={titleStyle} />; }};
复制代码


标题模块的实现相较于头像会简单一些:


1、展示控制

通过 title 参数控制展示与否,默认展示标题,如果想隐藏标题,可以设置 title 的值为 false;

2、标题宽度

通过 title 参数中的 width 属性控制标题宽度。

如果 width 变量设置了具体值,标题宽度取设置的值。不设置值的情况下,如果有头像则宽度的值默认为 50%,如果没有头像则宽度的值默认为 35%,当有头像的情况下,标题父容器的宽度会变小,所以对应值设置要大于没有头像的值。


段落


段落模块的功能实现如下:


/** @name 是否展示段落 */const hasParagraph = !!paragraph;
/** * 获取对象属性值 * @param {void} 无 * @return {render} 展示内容 */const getTypeOfObject = prop => { if (prop && typeof prop === 'object') { return prop; } return {};};
/** * 段落-获取段落宽度 * @param {number} index 段落的索引 * @param {object} obj 段落的数据对象 * @return {number} 计算之后的宽度 */const getParagraphWidth = (index, obj) => { const { width, rows = 2 } = obj; // =>true: 如果width的值是数组时,设置每行对应宽度 if (Array.isArray(width)) { return width[index]; } // =>true: 如果width的值不是数组时,设置为最后一行的宽度 if (rows - 1 === index) { return width; } return undefined;};
/** * 段落展示 * @param {void} 无 * @return {render} 展示内容 */const paragraphContent = () => { if (hasParagraph) { /** @name 段落class */ const paragraphCls = classnames(`${prefixCls}__paragraph`); let paragraphObj = {};
// =>true: 有标题但是没有头像,默认3行,其他是2行 if (!hasAvatar && hasTitle) { paragraphObj.rows = 3; } else { paragraphObj.rows = 2; }
// => true: 没有标题或者没有头像,最后一个段落的宽度是61% if (!hasAvatar || !hasTitle) { paragraphObj.width = '61%'; }
// =>true: paragraph传参有值时,对默认值进行覆盖 paragraphObj = { ...paragraphObj, ...getTypeOfObject(paragraph), };
/** @name 段落的行数组 */ const rowList = [...Array(paragraphObj.rows)].map((_, index) => <li key={index} style={{ width: getParagraphWidth(index, paragraphObj) }} />);
return <ul className={paragraphCls}>{rowList}</ul>; }};
复制代码


段落的实现相对复杂一些,其中主要处理是针对段落行数的处理:


1、展示控制

通过 paragraph 参数控制展示与否,默认展示标题,如果想隐藏标题,可以设置 title 的值为 false;

2、段落宽度

通过 paragraph 参数中的 width 属性控制段落宽度。如果 width 的值设置为数组,每行的宽度对应为数组中的值,如 width 值是具体数值或者字符串类型的值,那么段落最后一行的值会被设置。

3、段落的行数

通过 paragraph 参数中的 rows 属性控制段落的行数。如果 rows 属性有值,那么段落行数为 rows 的值,如果 rows 属性没有设置值,那么没有头像有标题的情况下,段落行数设置为 3 行,反之设置为 2 行。

优秀兵法

到此,”粮草兵马“皆已备齐,骨架屏组件圆满完成。接下来可以尝试在页面中使用它了,让骨架屏成为我们在日常开发中提升用户体验的优秀”兵法“。

组件使用

完整 API

Skeleton


SkeletonParagraphProps


SkeletonTitleProps


SkeletonAvatarProps


完整代码

组件


skeleton.less


@textColorGrey: rgba(201, 201, 201, 0.2);@textColorGrey2: rgba(186, 186, 186, 0.3);
.fly-skeleton { display: table; width: 100%;
&--active { .fly-skeleton__avatar, .fly-skeleton__title, .fly-skeleton__paragraph > li { background: linear-gradient(90deg, @textColorGrey 25%, @textColorGrey2 37%, @textColorGrey 63%); background-size: 400% 100%; animation: fly-skeleton__loading 1.2s ease infinite; } } &--round { .fly-skeleton__title, .fly-skeleton__paragraph > li { border-radius: 100px; } } &--content { display: table-cell; width: 100%; vertical-align: top; .fly-skeleton__title { margin-top: 8px; } } &__avatar { flex-shrink: 0; width: 32px; height: 32px; margin-right: 15px; background-color: @textColorGrey; display: inline-block; &--circle { border-radius: 50%; } &--sm { width: 24px; height: 24px; line-height: 24px; } &--lg { width: 40px; height: 40px; line-height: 40px; } }
&__title { height: 16px; background-color: @textColorGrey; & + .fly-skeleton__paragraph { margin-top: 20px; } } &__paragraph { li { width: 100%; height: 16px; list-style: none; background: @textColorGrey; border-radius: 4px; & + li { margin-top: 12px; } } }}
@keyframes fly-skeleton__loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; }}
复制代码


Skeleton.jsx


/** * @description Skeleton 骨架屏 * @author 叶一一 */
import React from 'react';import PropTypes from 'prop-types';import classnames from 'classnames';import './skeleton.less';
const Skeleton = ({ ...props }) => { const { avatar, title, paragraph, round, active, show, children, className } = props;
/** @name class前缀 */ const prefixCls = 'fly-skeleton';
/** @name 是否展示头像 */ const hasAvatar = !!avatar;
/** @name 是否展示标题 */ const hasTitle = !!title;
/** @name 是否展示段落 */ const hasParagraph = !!paragraph;
/** @name 尺寸枚举 */ const sizgClsObj = { large: 'lg', small: 'sm', default: null, };
/** * 获取对象属性值 * @param {void} 无 * @return {render} 展示内容 */ const getTypeOfObject = prop => { if (prop && typeof prop === 'object') { return prop; } return {}; };
/** * 头像展示 * @param {void} 无 * @return {render} 展示内容 */ const avatarContent = () => { if (hasAvatar) { /** @name 头像对象数据 */ let avatarObj = { active: false, shape: 'circle', size: 'default', ...getTypeOfObject(avatar), };
/** @name 头像父容器class */ const headCls = classnames(`${prefixCls}__header`);
/** @name 头像class */ const avatarCls = `${prefixCls}__avatar`;
/** @name 尺寸class */ const sizeCls = sizgClsObj[avatarObj.size] && classnames(`${avatarCls}--${sizgClsObj[avatarObj.size]}`);
/** @name 形状class */ const shapeCls = classnames(`${avatarCls}--${avatarObj.shape}`);
/** @name 尺寸内联样式 */ const sizeStyle = typeof avatarObj.size === 'number' ? { width: avatarObj.size, height: avatarObj.size, lineHeight: `${avatarObj.size}px`, } : {};
return ( <div className={headCls}> <span className={classnames(avatarCls, sizeCls, shapeCls, className)} style={{ ...sizeStyle }} /> </div> ); } };
/** * 标题展示 * @param {void} 无 * @return {render} 展示内容 */ const titleContent = () => { if (hasTitle) { /** @name 标题class */ const titleCls = classnames(`${prefixCls}__title`);
/** @name 标题style */ const titleStyle = { width: !hasAvatar ? '38%' : '50%', ...getTypeOfObject(title), };
return <h3 className={titleCls} style={titleStyle} />; } };
/** * 段落-获取段落宽度 * @param {number} index 段落的索引 * @param {object} obj 段落的数据对象 * @return {number} 计算之后的宽度 */ const getParagraphWidth = (index, obj) => { const { width, rows = 2 } = obj; // =>true: 如果width的值是数组时,设置每行对应宽度 if (Array.isArray(width)) { return width[index]; } // =>true: 如果width的值不是数组时,设置为最后一行的宽度 if (rows - 1 === index) { return width; } return undefined; };
/** * 段落展示 * @param {void} 无 * @return {render} 展示内容 */ const paragraphContent = () => { if (hasParagraph) { /** @name 段落class */ const paragraphCls = classnames(`${prefixCls}__paragraph`); let paragraphObj = {};
// =>true: 有标题但是没有头像,默认3行,其他是2行 if (!hasAvatar && hasTitle) { paragraphObj.rows = 3; } else { paragraphObj.rows = 2; }
// => true: 没有标题或者没有头像,最后一个段落的宽度是61% if (!hasAvatar || !hasTitle) { paragraphObj.width = '61%'; }
// =>true: paragraph传参有值时,对默认值进行覆盖 paragraphObj = { ...paragraphObj, ...getTypeOfObject(paragraph), };
/** @name 段落的行数组 */ const rowList = [...Array(paragraphObj.rows)].map((_, index) => <li key={index} style={{ width: getParagraphWidth(index, paragraphObj) }} />);
return <ul className={paragraphCls}>{rowList}</ul>; } };
/** @name 根元素class */ const rootCls = classnames(prefixCls, className, { [`${prefixCls}--round`]: round, [`${prefixCls}--active`]: active, });
/** @name 内容class */ const contentCls = classnames(`${prefixCls}--content`);
/** * 骨架屏展示 * @param {void} 无 * @return {render} 展示内容 */ const skeletonContent = () => { if (show) { return ( <div className={rootCls}> {avatarContent()} <div className={contentCls}> {titleContent()} {paragraphContent()} </div> </div> ); } return children; };
return <>{skeletonContent()}</>;};
Skeleton.propTypes = { paragraph: PropTypes.oneOfType([PropTypes.bool, PropTypes.Object]), // 是否显示段落占位图 title: PropTypes.bool, // 是否显示标题占位图 avatar: PropTypes.oneOfType([PropTypes.bool, PropTypes.Object]), // 是否显示头像占位图 show: PropTypes.bool, // 是否显示骨架屏,传 false 时会展示子组件内容 active: PropTypes.bool, // 是否开启动画 round: PropTypes.bool, // 是否将标题和段落显示为圆角风格};
Skeleton.defaultProps = { paragraph: true, title: true, avatar: false, show: true, active: false, round: false,};
export default Skeleton;
复制代码


页面引用


style.less


.fly-skeleton {  &__content {    padding: 15px;    background: #fff;    min-height: 100vh;    padding-bottom: 50px;  }  &__children {    display: flex;    align-items: flex-start;    line-height: 20px;    font-size: 14px;    margin-top: 10px;    &--avatar {      margin-right: 15px;      width: 30px;      height: 30px;      border-radius: 50%;      overflow: hidden;      img {        display: block;        width: 100%;        height: 100%;      }    }    &--title {      margin-top: 8px;    }    &--desc {      margin-top: 20px;    }  }  &__button {    text-align: center;    width: 100px;    padding: 0 10px;    font-size: 14px;    line-height: 24px;    border-radius: 5px;    color: #fff;    background-color: #45b7f5;  }}
复制代码


skeleton-test.jsx


/** * @description Skeleton 骨架屏 使用展示 * @author 叶一一 */import React, { useEffect, useState } from 'react';import { Skeleton, Switch } from 'fly';import './style.less';
const SkeletonTest = () => { const title = 'Skeleton 骨架屏'; // 标题 const [skeletonShow, setSkeletonShow] = useState(true);
useEffect(() => { // 设置标题 document.title = title; }, []);
// 子组件内容展示开关控制方法 const onChangeSwitch = () => { setSkeletonShow(!skeletonShow); };
return ( <div className='fly-skeleton__content'> <div className='text-xxxl mb20'>{title}</div> <div className='mb10'>基础用法</div> <Skeleton /> <div className='mb10 mt10'>显示头像</div> <div className='text-sm text-darkgray mt5 mb10'>设置avatar属性,可以展示头像。</div> <Skeleton avatar /> <div className='mb10 mt10'>动画效果</div> <div className='text-sm text-darkgray mt5 mb10'>设置active属性,可以有动画效果。</div> <Skeleton avatar active /> <div className='mb10 mt10'>展示子组件内容</div> <div className='text-sm text-darkgray mt5 mb10'>设置show属性为false时,可以展示Skeleton的子组件。</div> {/* <Switch value={!skeletonShow} onChange={onChangeSwitch} /> */} <div onClick={onChangeSwitch} className='fly-skeleton__button'> 控制展示 </div> <Skeleton avatar active show={skeletonShow} className='mt10'> <div className='fly-skeleton__children'> <div className='fly-skeleton__children--avatar'> <img src='https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/c6c1a335a3b48adc43e011dd21bfdc60~300x300.image' /> </div> <div className='fly-skeleton__children--content'> <div className='fly-skeleton__children--title'>这是标题</div> <div className='fly-skeleton__children--desc'> 这是段落111111111111111111111 <br /> 这是段落222222222 </div> </div> </div> </Skeleton> <div className='mb10 mt10'>头像样式</div> <div className='text-sm text-darkgray mt5 mb10'>设置avatar属性中size属性的值,可以调整头像大小,设置shape的值可以调整头像形状。</div> <Skeleton avatar={{ size: 50, shape: 'square' }} /> <div className='mb10 mt10'>设置标题的宽度</div> <div className='text-sm text-darkgray mt5 mb10'>设置title属性中width属性的值,可以调整标题的宽度,值可以是百分比、具体像素值、具体数值,比如:70%、150px、150。</div> <Skeleton title={{ width: 150 }} /> <div className='mb10 mt10'>标题和段落圆角</div> <div className='text-sm text-darkgray mt5 mb10'>设置round属性,可以调整段落和标题为圆角。</div> <Skeleton avatar round /> <div className='mb10 mt10'>多行段落</div> <div className='text-sm text-darkgray mt5 mb10'>设置paragraph属性中rows属性的值为具体数值,可以调整段落的行数。</div> <Skeleton avatar paragraph={{ rows: 4 }} /> <div className='mb10 mt10'>段落宽度可设置</div> <div className='text-sm text-darkgray mt5 mb10'>设置paragraph属性中width属性的值为数组值,可以调整每行段落的宽度。</div> <Skeleton avatar paragraph={{ rows: 4, width: ['90%', '90%', '90%', '60%'] }} /> </div> );};export default SkeletonTest;
复制代码

总结

整个流程下来,我们就实现了一个基础的骨架屏组件,对骨架屏也有了系统的了解,如果自己尝试去实现也会有思路怎么做。


但是,这只是实现了基础功能,并不完善,比如不支持头像、按钮等元素的单独使用。我在完成之后,去 antd 的官网看了一下它的源码,发现 antd 的功能做的更加完善,不愧是大厂的项目。从 antd 处获取的灵感,也让我完善了一下我的代码。所以,我们可以在日常的空闲时间,看一些大厂的源码,他们的功能更加强大、考虑问题更全面、实现思路也更优秀。


开头难吗?有时候挺难的,但是有了这个困难且良好的开头,后面的事就变得简单且顺利了,组件开发也会自然而然,水到渠成。所以,诸君加油。


再次感谢所有的开源项目,可以让像我一样的学习者获取技术上的进步。

发布于: 刚刚阅读数: 3
用户头像

叶一一

关注

苍生涂涂,天下缭燎,诸子百家,唯我纵横。 2022.09.01 加入

非职业传道受业解惑前端程序媛,华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。

评论

发布
暂无评论
「趣学前端」骨架屏,分享一波前端UI组件开发的经验_JavaScript_叶一一_InfoQ写作社区