网上有很多组件库的实践,本人小白,在实现这个组件的过程中,也有参考很多大佬的实现方案。本篇技术文档用于记录在开发组件库的一整个过程,包括技术选型,目录的结构规范,每个组件的需求分析、如何实现组件以及在实现过程中遇到的问题。在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组件
👏 需求分析
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>
👏 需求分析
👏 具体实现
🔸menu
🔸Submenu
🔸menuItem
👏 实现过程中技术难点
🔸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缺少变化
解决方案 😉:
✔ 用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类,在里面设置一些属性,如下图所示:
<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样式如下所示,使用时会发现进入时是有动画效果的,但是离开时没有动画效果
样式过程
“进入”的状态的动画的过程
“离开”的状态的动画的过程
这个过程可以看出:display:none提前发生,所以导致离开时的动画没有展示
添加unmountOnExit属性,此属性控制:在组件离开DOM后是否从DOM中卸载组件
▪ 这样就无需menu-open类,因为原先一开始是没有这个组件的,所以display原本就是none,此组件一旦加载,便开始执行动态效果。组件一旦卸载,那么也就没有这个组件,再次执行display:none
5️⃣ Upload组件
👏 初始版本
🔸初始需求
🔸初始版本上传功能实现
🔸初始版本下方列表展示功能实现
// 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
}
⭕ 实现上传
上传切片涉及一个问题,网络请求并发控制 ,为什么要做并发控制呢?
// 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组件--初始版本下方列表展示功能实现
仓库地址
参考资料
1、慕课网 张轩老师课程 # TS+ React18高仿AntD从零到一打造组件库 coding.imooc.com/class/428.h…
2、字节跳动面试官,我也实现了大文件上传和断点续传 - 掘金 (juejin.cn)