React组件开发实战-头像组件

935 阅读7分钟

需求背景

产品: 你看这个页面, 我要在这里, 这里, 还有这里都要展示头像. 这里头像鼠标已过去要显示下用户名称, 这边头像是一组的, 多个头像要层叠显示.
开发: 还有呢.
产品: 头像还没有加载完成, 或者加载失败了, 或者没有头像, 就用用户名称替代. 文字头像的规则是这样的 ...
开发: 还有?
产品: 嗯, 可以的话, 给我加个超椭圆风格的头像?
开发: 额, 我试试.
开发: 嘿哥们, 你看这个头像的数据你要怎么给我, 原来的数据里面没有这个字段.
服务端: 这, 不好办呀, ^(&^*&)%&&(**&)^)%&, 所以我能不能开个接口给你, 你通过用户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组件开发分享吧.