需求背景
产品: 你看这个页面, 我要在这里, 这里, 还有这里都要展示头像. 这里头像鼠标已过去要显示下用户名称, 这边头像是一组的, 多个头像要层叠显示.
开发: 还有呢.
产品: 头像还没有加载完成, 或者加载失败了, 或者没有头像, 就用用户名称替代. 文字头像的规则是这样的 ...
开发: 还有?
产品: 嗯, 可以的话, 给我加个超椭圆风格的头像?
开发: 额, 我试试.
开发: 嘿哥们, 你看这个头像的数据你要怎么给我, 原来的数据里面没有这个字段.
服务端: 这, 不好办呀, ^(&^*&)%&&(**&)^)%&, 所以我能不能开个接口给你, 你通过用户ID来获取头像数据
开发: 额, 行吧. 我把需求缕缕
(半个小时后)
- 支持方形/圆形/超椭圆形风格
- 分组展示
- 支持头像图片加载平滑过渡
- 方便自定大小, 设置背景色, 及自定义样式
- 支持简单的多层级文本高亮
- 支持文字头像
- 可通过 src 配置头像图片路径
- 可通过 userId 获取头像图片路径
- 可批量请求头像数据(如果有一百个头像, 不应该用100个接口去请求头像图片数据, 要兼顾异步加载的数据)
- 可设置并发数(防止频繁的异步数据, 导致卡顿现象)
- 可配置头像获取的路径
- 头像数据要缓存, 避免同一个头像被请求多次
- 头像数据要做过期时间, 防止用户更换头像, 但数据一直更新不了
产品: (点赞)
设计API
<EmployeeAvatar src="imgSrc" alt="userName" />
<EmployeeAvatar alt="userName" bgColor="#ff5555" />
<EmployeeAvatar alt="userName" style={{ background: '#ff5555' }} />
<EmployeeAvatar userId="xxx" shape="superEllipse" />
<EmployeeAvatar userId="xxx" size={50} />
<EmployeeAvatar userId="xxx" size={24} concurrent={10} />
这个是我期望的样子, 撸起袖子开始干
实现
思路:
实现的代码以 Antd 的 Avatar 组件为基础.
虽然功能多, 但是部分功能 Avatar 组件已经实现了. 比如分组/气泡提示/圆形/方形/文字图标 等功能.
主要麻烦在于头像的数据获取. 我们可以设置对象用来缓存头像数据, 在设置个队列, 用来收集要获取的头像数据请求. 定个时钟, 类似防抖, 300ms间隔, 如果没有后续请求或者其他终止条件就发起请求, 获取数据后写入缓存并返回给组件.
大致思路有了. 来实现下.
先来解决下超椭圆的问题, 试了很多方案, 目前以下这个方案能用, 但是不完美, 不能设置边框颜色
.superEllipse {
mask: url('data:image/svg+xml;utf8,<svg preserveAspectRatio="none" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><path d="M 0, 100 C 0, 23 23, 0 100, 0 S 200, 23 200, 100 177, 200 100, 200 0, 177 0, 100" fill="white"></path></svg>');
mask-size: 100% 100%;
-webkit-mask-image: url('data:image/svg+xml;utf8,<svg preserveAspectRatio="none" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><path d="M 0, 100 C 0, 23 23, 0 100, 0 S 200, 23 200, 100 177, 200 100, 200 0, 177 0, 100" fill="white"></path></svg>');
border-radius: 0 !important;
}
再来实现下大致逻辑
import { FC, useEffect, useMemo, useState } from 'react';
import cn from 'classnames';
import type { AvatarProps } from 'antd';
import { Avatar as AvatarAnt } from 'antd';
import { formatAvatarUsername } from './utils';
import styles from './index.module.less';
/**
* 用户名称字符串处理
*
* 默认用户头像规则:英文取前2英文字母,汉字小于等于2全部展示,大于等于2取后2汉字显示,需取统一底色,中英文的话取后两位展示;
* @param {string} username
* @param {number | null} [avatarSize]
* @returns {string | null}
*/
const formatAvatarUsername = (username, avatarSize) => {
// 中文正则
const pattern = new RegExp('[\u4E00-\u9FA5]+');
// 英文正则
const pattern2 = new RegExp('[A-Za-z]+');
// 数字正则
const pattern3 = new RegExp('[0-9]+');
// 判断特殊字符@
const pattern4 = new RegExp('[@]?');
// 如果宽度小于20px,则只取一个,要不然文字都看不清楚,目前判断取最后一个字
const isSmallSize = avatarSize ? avatarSize <= 20 : false;
// 中文或者中文+英文组合
if (username && pattern.test(username)) {
return username.substr(isSmallSize ? -1 : -2, 2);
}
// 纯英文或者纯数字
if ((username && pattern2.test(username)) || (username && pattern3.test(username))) {
return `${username.substr(0, 1)} ${username.substr(1, 1)}`;
}
// 所有人时头像显示为@
if (username && pattern4.test(username)) {
return username;
}
// 都没
return null;
};
interface Props extends Omit<AvatarProps, 'src' | 'shape'> {
/** 头像图片路径 */
src?: string;
/** 通过用户 ID 来获取头像图片 */
userId?: string;
/** 用户名称, 没有图片的时候使用文字头像兜底 */
alt: string;
/** 形状 */
shape?: 'circle' | 'square' | 'superEllipse';
/** 头像背景色, 在图片为空显示文字的时候有用 */
bgColor?: string;
/** 并发数. 默认会合并同一时间获取头像数据的接口, false 时不使用合并请求 */
concurrent?: number | boolean;
/** 获取头像数据的方法 */
fetchUrl?: string;
}
const EmployeeAvatar: FC<Props> = ({
src,
userId,
alt = 'Unknown', // 兜底
size,
className,
style,
shape = 'superEllipse',
bgColor = '#5590f6',
concurrent = false,
fetchUrl,
...otherProps
}) => {
const [avatarSrc, setAvatarSrc] = useState(src);
// alt的值, 按照一定的规则把名称简化, 防止超出图标范围
const altInitials = useMemo(() => {
return (alt && formatAvatarUsername(alt, size)) || '';
}, [alt, size]);
// TODO 处理 userId 获取头像的逻辑
return (
<AvatarAnt
size={size}
shape={shape === 'superEllipse' ? undefined : shape}
src={avatarSrc}
className={cn(shape === 'superEllipse' ? styles.superEllipse : null, className)}
style={{ backgroundColor: bgColor, ...style }}
{...otherProps}
>
{altInitials}
</AvatarAnt>
);
};
EmployeeAvatar.displayName = 'Avatar';
export default EmployeeAvatar;
图片是要异步加载的, 如果图片还在请求中, 应该先用文字头像替代. 会更丝滑一些.
/**
* 异步加载图片
* @param imgUrl
*/
export const fetchImg = (imgUrl: string): Promise<string> => {
// 返回一个 Promise, 这样就可以在图片加载完后再显示
return new Promise((resolve, reject) => {
if (!imgUrl) {
reject();
}
const ImgObj = new Image();
ImgObj.src = imgUrl;
ImgObj.onload = function () {
resolve(imgUrl);
};
ImgObj.onerror = function (err) {
reject(err);
};
});
};
// 通过 src 来设置头像
useEffect(() => {
if (src) {
fetchImg(src)
.then((imgSrc) => {
setAvatarSrc(imgSrc);
})
.catch(() => {
setAvatarSrc(undefined);
});
}
}, [src]);
再来实现下通过userId获取头像的逻辑
// 设置缓存的时间
const ONE_DAY_MILLISECOND = 1000 * 3600 * 24;
// 需要一个全局时钟, 来控制收集code的最长空闲时间
let timer;
// 头像缓存数据映射表 avatarCache = {'xxx' : {code: 'xxx', src: 'xxx', expiresTime: 1649572747616 }};
const avatarStorageData = localStorage.getItem('avatarCache'); // 从缓存中获取初始数据
const avatarCache = avatarStorageData ? JSON.parse(avatarStorageData) || {};
// 用户 id 队列
let userIdsQueue: string[] = [];
// requestAvatar 的请求队列
let resolveQueue: Array<{ code: string; resolve: Function }> = [];
export interface RequestParam {
code: string;
concurrent?: number | boolean;
fetchUrl: string;
}
const requestAvatar = ({ code, concurrent = true, fetchUrl }: RequestParam) => {
return new Promise((resolve) => {
// 把当前的 Code 放入队列
userIdsQueue.push(code);
resolveQueue.push({
code,
resolve,
});
const handleRequest = () => {
// 使用闭包的方式把当前队列的值缓存下来, 不影响下一轮的数据请求
const userIds = [...userIdsQueue];
const resolveList = [...resolveQueue];
userIdsQueue = []; // 清空队列
resolveQueue = []; // 清空队列
// 发起请求
request
.post(fetchUrl, { // 接口的参数这里做了假设, 实际情况按自己的项目来
userIds: Array.from(new Set(userIds)), // 去重
})
.then((res) => {
if (res.code === 10000) {
// 把结果写入映射表
const { data } = res;
Object.keys(data).forEach((userId) => {
avatarCache[userId] = {
code: userId,
src: data[userId],
expiresTime: new Date().getTime() + ONE_DAY_MILLISECOND, // 缓存 1 天
};
});
// 把映射表写入缓存
localStorage.set('avatarCache', JSON.stringify(avatarCache));
}
// 把当前队列中的异步请求的结果返回
resolveList.forEach((item) => item.resolve());
})
.catch(() => {
// 即使请求失败了, 也要释放掉所有请求
resolveList.forEach((item) => item.resolve());
});
};
// 判断并发数量
if (typeof concurrent === 'number' && userIdsQueue.length >= concurrent) {
// 有并发且超出限制的情况下, 立即执行
clearTimeout(timer);
handleRequest();
} else {
// 否则等待300ms自动发起
// 清空时钟 clearTimeout
clearTimeout(timer);
timer = setTimeout(handleRequest, 300);
}
});
};
export const fetchAvatar = async ({ code, concurrent, fetchUrl }: RequestParam) => {
// 判断是否存在缓存数据
const cache = avatarCache[code];
// 判断过期时间
if (cache?.expiresTime > new Date().getTime()) {
// 命中缓存直接返回
return cache.src;
}
// 没有命中就请求
await requestAvatar({ code, concurrent, fetchUrl });
return avatarCache[code]?.src;
};
组件部分做相应的修改
// 通过 userId 来获取头像
useEffect(() => {
let isSubScribed = true;
if (userId) {
fetchAvatar({ code: userId, concurrent, fetchUrl }).then((res) => {
if (!isSubScribed) {
return;
}
if (res) {
fetchImg(res).then((imgSrc) => {
if (!isSubScribed) {
return;
}
setAvatarSrc(imgSrc);
});
}
});
}
return () => {
isSubScribed = false;
};
}, [userId, concurrent, fetchUrl]);
这个isSubScribed是干什么用的? 因为我们组件获取数据是个异步, 会存在一种情况, 就是数据还在请求中, 这个时候我们把组件给销毁了, 但是这个请求并没有被一起销毁. 请求结束后, 回调还是会被执行, 但这时候组件已经不在了, 控制台就会报一个警告. 如果回调里面还做了些有副作用的函数, 影响就更大了.
所以任何组件涉及到异步的, 都要注意下有没有副作用, 要及时清除.
最后还有个问题, 就是组件涉及到了业务相关的代码, 就是那个接口, 我可不希望我使用组件的时候是这样:
<EmployeeAvatar userId="123" alt="userName" fetchUrl="xxxx" />
所以开发时候会做个高阶组件包裹这些业务部分的代码
// BusinessesUserAvatar
import React from 'react';
import UserAvatar from '@/components/UserAvatar';
const fetchAvatarUrl = 'xxxx';
const defaultBgColor = '#f55'
const BusinessesUserAvatar = (props) => {
return <UserAvatar fetchUrl={fetchAvatarUrl} bgColor={defaultBgColor} {...props} />
}
export default BusinessesUserAvatar;
总结
我遇到的场景你可能不会遇到, 但是一些思路还是可以参考下的:
- 组件频繁异步请求获取数据的优化方法
- 超椭圆
- 基础组件和业务组件的整合方式
这些技巧熟悉起来, 在以后的组件开发中经常会用到.
期待后续的React组件开发分享吧.