仿antd开发企业级UI组件库--技术文档

769 阅读8分钟

  网上有很多组件库的实践,本人小白,在实现这个组件的过程中,也有参考很多大佬的实现方案。本篇技术文档用于记录在开发组件库的一整个过程,包括技术选型,目录的结构规范,每个组件的需求分析、如何实现组件以及在实现过程中遇到的问题。在upload组件中,考虑大文件上传的问题,针对这个问题,做了处理。

✨ 技术选型

👏 工程化

 i. React

 ii. typescript:www.tslang.cn/docs/home.h… js的超集

 iii. scss

   官网:www.sass.hk/

   快速入手:阮一峰SASS用法指南 www.ruanyifeng.com/blog/2012/0…

👏 单元测试

 i. jest(www.jestjs.cn/) JavaScript 测试框架

👏 自动生成文档

 i. torybook(storybook.js.org/)

📏 代码结构规范

    my-app/
       REAFME.md
       node_modules/
       package.json
       tsconfig.json
       src/
          components/
             Button/
                button.tsx(主体文件)
                button.test.tsx(测试文件)
                style.scss(与组件相关样式文件)
             styles/
                _variables.scss(各种变量以及可配置设置)
                _mixins.scss(全局mixins)
                _functions.scss(全局functions)
             index.tsx

🎈 组件需求分析

1️⃣ Button组件

  👏 需求分析

image.png

2️⃣ Menu组件

  👏 Menu 组件结构分析

    <Menu defaultIndex={'2'} onselect={(index)=>{alert(index)}} defaultOpenmenu={['2']}>
      <MenuItem>cool link</MenuItem>
      <MenuItem  disabled>cool link 2</MenuItem>
      <SubMenu title="dropdown">
      <MenuItem >dropdown 1</MenuItem>
      <MenuItem  >dropdown2</MenuItem>
      </SubMenu>
      <MenuItem >cool link 3</MenuItem>
    </Menu>

  👏 需求分析

image.png   👏 具体实现

    🔸menu

image.png

    🔸Submenu

image.png

    🔸menuItem

image.png

  👏 实现过程中技术难点

    🔸is-active触发时机

     ◾ 横向 : 鼠标移动上去,就触发

     ◾ 竖向 : 鼠标点击触发

    🔸自动生成item项

    🔸控制menu的子组件是subMenu/MenuItem,控制subMenu的子组件是MenuItem

    🔸竖着的时候,submenu的里面具体项可以展示出来

3️⃣ Menu组件

4️⃣ Icon组件

  👏 icon的发展历程

    🔸雪碧图(css sprite)

    🔸Font Icon

    🔸SVG

     ◾ 优势 : ① 完全可控;② svg即取即用,font icon要下载全部字体文件 ③ font icon有很多奇怪的bug

  👏 具体实现--插件font Awesome

    🔸为什么仍需要二次封装?

    希望能将主题颜色添加进去(把成熟的第三方组件拿来添加自己的功能,从而完成适应自己系统的改造)

  👏 实现过程中技术难点

css动画实现:css动画实现方式 - 掘金 (juejin.cn)

    🔸箭头添加动画效果

     ◾css-transition可以解决

  .submenu-item {
    position: relative;
    .submenu-title {
      display: flex;
      align-items: center;
    }
    .arrow-icon {
      // css transition提供了一种在更改css属性时,控制动画速度的方法,让属性变化持续一段时间,而不是立马生效
      // transition属性不能继承,需要精确添加在需要动画效果的元素上
      // "ease-in-out" 是CSS中的一种过渡动画函数,用于在CSS属性变化时平滑地过渡到新状态。
      // 它的特点是在动画开始和结束时速度较慢,在中间时速度较快,这样可以让动画效果更加自然。
      transition: transform 0.25s ease-in-out;
      margin-left: 3px;
    }
    &:hover {
      .arrow-icon {
        transform: rotate(180deg);
      }
    }
  }
  // vertical时,覆盖掉上面的旋转动画,使其不旋转
  .is-vertical {
    .arrow-icon {
     // transform: rotate(0deg) !important;
      transform: rotate(0deg) ;
    }
  }
  // 既有vertical 又有opened 那么图标旋转
  .is-vertical.is-opened {
    .arrow-icon {
     transform: rotate(180deg) ;
    }
  }

   ◾下拉菜单subMenu添加动画效果

问题 🤔: 原先设想粉色的代码,可以通过css来直接设置透明度的动画;但是由于出现了黄色的代码,当display从none转换为block时,动画效果会完全失效,因为display不是一个可以支持animation的属性的标准,所以transation根本不起作用;同时,display:block和opacity:1是同时生效的,自然opacity缺少变化

  1. img

解决方案 😉

✔ 用css-animation解决

```scss
.viking-submenu{
   display:none;
   list-style:none;
   padding-left:0;
   white-space:nowrap;
}
.viking-submenu.menu-opened{
    display:block;
    animation: fadeIn 0.3s ease-in ;
}
@keyframes fadeIn {
    from {
      opacity: 0;
      display: block;
    }
    to {
      opacity: 1;
    }
  }
```

✔ 可以不在.menu-opened上面加.viking-submenu上面加display:none,将其转换成.zoomm-in-top-exit-done的属性

✔(不推荐)组件:React Transition Group组件从无到有,再从有到无过程中添加多个描述组件生命周期的class名称,由时间按顺序排列。其实本身没有实现动画效果,而是使用很多class来记录组件进入和离开的阶段. 给需要动画的这个组件外面包裹CSSTransition类,在里面设置一些属性,如下图所示:

image.png

 <CSSTransition
    // 指示组件是否处于“进入”状态 
    // 当in属性为true时,CSSTransition组件会自动添加一个enter类名,用于触发“进入”状态的CSS动画效果。
    // 此时,我们可以在CSS中定义enter类名的样式,实现进入动画效果。
    // 当in属性为false时,CSSTransition组件会自动添加一个exit类名,用于触发“离开”状态的CSS动画效果。
    // 此时,我们可以在CSS中定义exit类名的样式,实现离开动画效果。
     in={menuOpen} 
     timeout={300} //设置CSS动画的持续时间 它是一个对象,包含了两个属性:enter和exit,分别表示“进入”和“离开”动画的持续时间。
     classNames='zoom-in-top'
     appear // 组件初始渲染时是否会执行过渡效果
      // 当unmountOnExit属性为true时,组件在离开DOM后会被卸载,这意味着组件的状态和事件处理程序都会被清除。这对于需要在组件离开DOM后释放资源或避免内存泄漏的情况非常有用。
     // 当unmountOnExit属性为false时,组件在离开DOM后不会被卸载,而是保留在DOM中。这对于需要在组件离开DOM后保留状态或避免重新挂载组件的情况非常有用。
     unmountOnExit
        >
</CSSTransition>

对应的css样式如下所示,使用时会发现进入时是有动画效果的,但是离开时没有动画效果 样式过程

“进入”的状态的动画的过程

image.png “离开”的状态的动画的过程

这个过程可以看出:display:none提前发生,所以导致离开时的动画没有展示

image.png 添加unmountOnExit属性,此属性控制:在组件离开DOM后是否从DOM中卸载组件

▪   这样就无需menu-open类,因为原先一开始是没有这个组件的,所以display原本就是none,此组件一旦加载,便开始执行动态效果。组件一旦卸载,那么也就没有这个组件,再次执行display:none

5️⃣ Upload组件

  👏 初始版本

    🔸初始需求

image.png

    🔸初始版本上传功能实现

image.png

    🔸初始版本下方列表展示功能实现

// UploadFile组件
// 上传进度、上传是否完成、上传是否失败等状态UploadFile接口
export type UploadFileStatus = 'ready' | 'uploading' | 'success' | 'error';
export interface UploadFile {
    uid: string;
    size: number;
    name: string;
    status?: UploadFileStatus;
    percent?: number;
    raw?: File;
    response?: any;
    error?: any;
}
// UploadList
interface UploadListProps {
  fileList: UploadFile[];
  onRemove: (filr: UploadFile) => void;
}
export const UploadList: FC<UploadListProps> = (props) => {
  const { fileList, onRemove } = props;
return (
    <ul className='viking-upload-list'>
      {fileList.map(item => {
        return (
          <li className='viking-upload-list-item' key={item.uid}>
            <span className={`file-name file-name-${item.status}`}>
              <Icon icon="file-alt" theme="secondary" />
              {item.name}
            </span>
            <span className='file-status'>
              {(item.status === 'uploading' || item.status === 'ready') && <Icon icon="spinner" spin theme="primary" />}
              {item.status === 'success' && <Icon icon="check-circle" theme="success" />}
              {item.status === 'error' && <Icon icon="times-circle" theme="danger" />}
            </span>
            <span className="file-actions">
              <Icon icon="times" onClick={() => { onRemove(item)}}/>
            </span>
            {item.status==='uploading' && 
            <Progress
               percent={item.percent || 0}
            />}
          </li>
        )
      })}
    </ul>
  )
  }
  // Progress组件
  const Progress:FC<progressProps> =(props)=>{
  const {
    percent,
    strokeheight,
    showText,
    styles,
    theme
  }=props;
  return (
    <div className='viking-progress-bar' style={styles}>
      <div className='viking-progress-bar-outer' style={{height:`${strokeheight}px`}}>
        <div className={`viking-progress-bar-inner color-${theme}`} style={{width:`${percent}%`}}>
          {showText && <span className='inner-text'>{`${percent}%`}</span>}
        </div>
      </div>
    </div>
  )
}

  👏 进阶版本

    🔸进阶需求 :需要支持大文件上传

    🔸具体实现

    ⭕ 文件切片

利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片`

// 文件切片
//预先定义好单个切片大小,将文件切分为一个个切片
  const createFileChunk = (file: File, size: number = SIZE) => {
    const fileChunkList = [];
    let cur = 0;
    while (cur < file.size) {
      fileChunkList.push({ file: file.slice(cur, cur + size) });
      cur += size;
    }
    return fileChunkList;
  }

    ⭕ 计算hash值

可以利用spark-md5,它可以根据文件内容计算出文件的 hash 值

如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash

由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5

    // hash.js
    /* eslint-disable no-restricted-globals */
    const hashWorker = () => {
      // 导入脚本
    // import script for encrypted computing
    self.importScripts("http://localhost:3001/spark-md5.min.js");
    
    // 生成文件 hash
    // create file hash
    // 接收主线程的消息
    self.onmessage = e => {
      console.log('Received message from main thread:', e.data);
      const { fileChunkList } = e.data;
      // ArrayBuffer 提供了一种方式来存储和操作原始的二进制数据
      const spark = new self.SparkMD5.ArrayBuffer();
      // percentage 计算进度 count 已处理的文件块数量
      let percentage = 0;
      let count = 0;
      const loadNext = index => {
        const reader = new FileReader();
        reader.readAsArrayBuffer(fileChunkList[index].file);
        // 当 FileReader 对象调用 readAsArrayBuffer() 方法并成功读取文件内容时,
        // 会触发 reader.onload 事件,并将文件内容存储在 e.target.result 中
        reader.onload = e => {
          count++;
          // 其文件内容追加到 SparkMD5 实例中的哈希计算
          spark.append(e.target.result);
          if (count === fileChunkList.length) {
            self.postMessage({
              percentage: 100,
              hash: spark.end() //spark.end() 返回的是所有文件块追加后计算得到的最终哈希值
            });
            self.close();
          } else {
            percentage += 100 / fileChunkList.length;
            self.postMessage({
              percentage
            });
            loadNext(count);
          }
        };
      };
      loadNext(0);
    };
    }
    export default hashWorker
    // worker-build.js
      export default class WorkerBuilder extends Worker {
      constructor(worker) {
        const code = worker.toString();
        const blob = new Blob([`(${code})()`]);
        return new Worker(URL.createObjectURL(blob));
      }
    }
  // 2、计算文件hash值
  import WorkerBuilder from "./worker-build";
  import hashWorker from "./hash";
  const calculateHash = (fileChunkList: { file: Blob }[]) => {
    return new Promise(resolve => {
      const worker = new WorkerBuilder(hashWorker)
      const updateContainer = (prevContainer: { file: Blob | null; hash: string; worker: Worker | null }) => {
        return { ...prevContainer, worker: worker };
      };
      setcontainer(updateContainer);
      // 向Worker发送消息
      worker.postMessage({ fileChunkList });
      // 接收Worker的消息
      worker.onmessage = e => {
        const { percentage, hash } = e.data;
        let hashPercentage = percentage;
        sethashPercentage(hashPercentage);
        if (hash) {
          resolve(hash);
        }
      };
    });
  }

    ⭕ 实现文件秒传

所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功

文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可

  const { shouldUpload, uploadedList } = await verifyUpload(file, hash);
        if (!shouldUpload) {
          return;
        }
        let fililist = fileChunkList.map(({ file }, index) => ({
          uid: hash as string,
          name: (file as File).name,
          index,
          hash: hash + "-" + index,
          chunk: file as File,
          size: file.size,
          percentage: uploadedList.includes(index) ? 100 : 0
        }));
    // 文件秒传 -- 原理:利用了服务器只要存在该资源,用户再次上传就会提示上传成功
    const verifyUpload = async (file: any, fileHash: any) => {
        let filename = file.name;
        const { data } = await request(
          {
            url: "http://localhost:3000/verify",
            headers: {
              "content-type": "application/json"
            },
            data: JSON.stringify({
              filename,
              fileHash
            })
          });
        return data
      }

    ⭕ 实现上传

上传切片涉及一个问题,网络请求并发控制 ,为什么要做并发控制呢?

请见:为什么浏览器要限制并发连接数呢? - 掘金 (juejin.cn)

// 4、上传切片,同时过滤已上传的切片
// upload chunks and filter uploaded chunks
const uploadChunks = async (uploadedList: string[] = [], fililist: UploadFile[]) => {
 setIscontroller((prev)=>{return new AbortController();})
 const requestList: any = fililist
   .filter(({ hash }) => !uploadedList.includes(hash))
   .map(({ uid, name, chunk, hash, index }) => {
     const formData = new FormData();
     formData.append("chunk", chunk as File);
     formData.append("hash", hash);
     formData.append("filename", name);
     formData.append("fileHash", uid);
     return { formData, index };
   })
   .map(({ formData, index }) =>
     request({
       url: "http://localhost:3000",
       data: formData,
       signal:controller?controller.signal:undefined,
       requestList: requestList_all
     })
   );
  // 全量并发
  // await Promise.all(requestList);
  // 控制并发,并实现暂停效果  
 const ret =  await sendRequest(requestList,4)
 // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时合并切片
 // merge chunks when the number of chunks uploaded before and
 // the number of chunks uploaded this time
 // are equal to the number of all chunks
 setlen({ updatelen: uploadedList.length, requstlen: requestList.length, filllen: fililist.length })
 if (uploadedList.length + requestList.length === fililist.length) {
   await mergeRequest();
  }
}
// 控制并发,并实现暂停效果 
 const sendRequest = (forms:AxiosRequestConfig[],max = 10)=>{
 return new Promise(resolve => {
   const len = forms.length;
   let idx = 0;
   let counter = 0;
   const start = async ()=> {
     while (idx < len && max > 0) {
       if(ispause==true) break;
       max--; // 占用通道
       console.log(idx, "start");
       const form = forms[idx].form;
       const index = forms[idx].index;
       idx++;
       request({
         url: "http://localhost:3000",
         data: form,
         signal:controller?controller.signal:undefined,
         requestList: requestList_all
       }).then(() => {
         max++; // 释放通道
         counter++;
         if (counter === len) {
           resolve();
         } else {
           start();
         }
       });
     }
   }
 });
}

    ⭕ 断点续传

1.  断点:新建一个暂停按钮,当点击按钮时,之前上传时,控制并发数,没有上传的就不用再上传了

1.  续传:

    由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果
    
    而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
    
    服务端已存在该文件,不需要再次上传
    
    服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端

6️⃣ progress组件

详情见Upload组件--初始版本下方列表展示功能实现

仓库地址

github.com/qiaoruiqi/m…

参考资料

1、慕课网 张轩老师课程 # TS+ React18高仿AntD从零到一打造组件库 coding.imooc.com/class/428.h…

2、字节跳动面试官,我也实现了大文件上传和断点续传 - 掘金 (juejin.cn)

3、字节跳动面试官:请你实现一个大文件上传和断点续传 - 掘金 (juejin.cn)

4、前端大文件上传,断点续传(附源码) - 掘金 (juejin.cn)