在面试中,面试官有时会举一些场景题来考察面试者,又或是让面试者说说自己项目中的难点。对于前端而言,项目中普通的业务开发似乎很难说出什么难点和亮点。笔者在2个多月的面试历程中深受这个问题的困扰,越发觉得自身对场景的积累不足。既然是不足的点,那就要努力去将其补全,以提升自己的竞争力。
场景一:异步请求的竞态问题
举个例子,比如我的表格数据由redux进行管理,在网络请求返回数据后会触发对store的更新,表格通过消费store的新数据实现视图更新。不过如果我两次同样请求返回的时间不一致,就会导致前一个请求的数据把最新的请求数据覆盖的问题。
解决问题的出发点大致有以下几个方向:
-
限制请求发出的次数(事实上,对于表格加载数据而言采用这种方式会非常影响用户体验)
-
以最新发出的请求返回的数据为准,之前发出的请求不算数
一般来说,我们解决竞态问题都会从第二点出发。由于笔者之前公司项目采用的是dvajs进行状态管理和异步请求的,下面给出的代码片段也会使用dva。
dva是redux和redux-saga的上层封装,而redux-saga对于竞态问题有相应的解决方案:takeLatest。这里对redux-saga处理异步函数的几种方式做下介绍:
- 使用
take时redux-saga会暂停并等待指定类型的action,然后执行相应的逻辑。可以用于处理单个异步操作。 - 使用
takeEvery时redux-saga会立即启动一个新的任务来处理每个匹配到的action,不会阻塞并发出多个请求。适用于需要处理多个并发请求的情况。 - 使用
takeLatest时redux-saga只会处理最新的匹配到的action,之前的任务会被取消。适用于确保只处理最新请求的场景。
可以看到采用takeLatest是符合我们要求的,通过dva/API.md at master · dvajs/dva (github.com)我们也可以了解到其在dva中的用法。下面是大致的代码实现:
export default {
namespace: 'yourModel',
state: {},
effects: {
fetchData: [function* (action, { call, put }) {
const data = yield call(fetchService, action.payload);
yield put({ type: 'saveData', payload: data });
}, { type: 'takeLatest' }],
},
subscriptions: {
setup({ dispatch, history }) {
history.listen(({ pathname }) => {
if (pathname === '/some/path') {
dispatch({ type: 'yourModel/fetchData' });
}
});
},
},
};
这样可以保证写入store的数据为最新发出请求返回的数据。
当然,如果我们希望对前面发出的请求进行取消,也是可行的。下面是示例代码:
export default {
namespace: 'yourModel',
state: {},
effects: {
fetchData: [
function* (action, { call, put, cancelled }) {
const controller = new AbortController();
const signal = controller.signal;
try {
const response = yield call(fetch, '/some/url', { signal });
if (response.ok) {
const data = yield response.json();
yield put({ type: 'saveData', payload: data });
} else {
throw new Error('Network response was not ok');
}
} catch (error) {
if (error.name === 'AbortError') {
// Fetch 请求被取消
} else {
// 其他错误
}
} finally {
if (yield cancelled()) {
// 如果saga被取消,取消fetch请求
controller.abort();
}
}
},
{ type: 'takeLatest' }
],
},
subscriptions: {
setup({ dispatch, history }) {
history.listen(({ pathname }) => {
if (pathname === '/some/path') {
dispatch({ type: 'yourModel/fetchData' });
}
});
},
},
};
上面的代码以使用fetch发请求为例,当action被取消时,会抛异常,此时会走finally的代码逻辑。如果是redux-saga取消引起的异常,cancelled返回值为true,就会触发请求取消的逻辑。
场景二:大文件上传实现
大文件上传也是面试中常考的一类场景题。一般我们会采用文件分片去解决这个问题。有以下几个要点:
-
切片的实现:
File对象继承了Blob的slice方法(Blob.slice - Web API 接口参考 | MDN (mozilla.org)),可以使用该api进行文件切片 -
切片大小的控制
-
切片上传的并发控制
下面是不包含并发逻辑的切片上传代码:
const file = event.target.files[0];
const chunkSize = 1024 * 1024; // 1MB
const chunksCount = Math.ceil(file.size / chunkSize);
for (let i = 0; i < chunksCount; i++) {
const start = i * chunkSize;
const end = Mth.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
await uploadChunk(chunk, i, chunksCount);
}
async function uploadChunk(chunk, index, total) {
const formData = new FormData();
formData.append('file', chunk);
formData.append('index', index);
formData.append('total', total);
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
}
这种串行的分片上传效率是比较低的,所以我们的代码设计要是支持并发的。不过说起并发就又涉及到并发控制的问题,因此我们要设计一个类以支持并发控制。代码如下:
class PromiseQueue {
constructor(concurrency) {
this.concurrency = concurrency;
this.tasks = [];
this.running = 0;
}
addTask(task) {
this.tasks.push(task);
this.run();
}
run() {
while (this.running < this.concurrency && this.tasks.length) {
this.running++;
this.tasks.shift()()
.then(() => {
this.running--;
this.run();
})
.catch((error) => {
console.error('An error occurred:', error);
});
}
}
}
然后前面的代码可以改为:
const uploadQueue = new PromiseQueue(3); // 最大并发数量为 3
const file = event.target.files[0];
const chunkSize = 1024 * 1024; // 1MB
const chunksCount = Math.ceil(file.size / chunkSize);
for (let i = 0; i < chunksCount; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
uploadQueue.addTask(() => uploadChunk(chunk, i, chunksCount, file));
}
async function uploadChunk(chunk, index, total, file) {
const formData = new FormData();
formData.append('file', chunk);
formData.append('index', index);
formData.append('total', total);
formData.append('filename', file.name);
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
}
总而言之,一般涉及到大文件上传的场景问题,我们可以从以下几个角度作答:
-
如何分片
-
如何进行并发控制
场景三:海量数据渲染
对于中后台项目来说,海量数据渲染优化的场景会存在于以下几个方面:
-
大数据列表渲染
-
特殊应用(如地图应用撒点)
要针对以上场景做优化,我们可以有如下的思考角度:
-
用户角度:用户希望更快的看到数据,而一次性渲染大量数据会造成卡顿
-
性能角度:尽可能渲染少的
dom节点,从而缓解浏览器侧压力
当然,业界比较常用的优化思路有如下几种:
-
分页
-
分时间片渲染
-
虚拟列表、虚拟表格
下面将着重对下面两种的原理做讲解(基于React)。
分时间片渲染
分时间片渲染需要考虑以下两个方面的内容:
-
数据分片
-
在合适的时候进行增量渲染
第一点自不用说,关键是第二点。我们使用requestIdleCallback进行增量渲染:
window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
海量数据列表分时间片渲染的示例代码如下(优化思路参考外星人老师的React小册):
import React, { useState, useEffect, useRef } from "react";
export const TimeSlice = ({ dataSource }: {
dataSource: string[]
}) => {
const loadDataFlag = useRef<boolean>(false);
const [renderCache, setRenderCache] = useState<React.ReactNode[]>([]);
const renderCacheRef = useRef<typeof renderCache>([]);
const renderData = (dataSource: any[]) => {
let i = 0;
function loadMore() {
if (i < dataSource.length) {
const nextItems = dataSource.slice(i, i + 20);
setRenderCache(
renderCacheRef.current.concat(
nextItems.map((item) => {
return <div key={item}>{item} </div>;
})
)
);
i += 20;
requestIdleCallback(() => loadMore());
}
}
loadDataFlag.current = true;
loadMore();
};
useEffect(() => {
renderCacheRef.current = renderCache;
}, [renderCache]);
useEffect(() => {
if (loadDataFlag.current) return;
loadDataFlag.current = false;
renderData(dataSource);
}, [dataSource]);
return <>{renderCache}</>;
};
上面的代码中有几个值得注意的点:
-
为了避免重复渲染,使用
renderCache来对Element进行缓存 -
使用
useRef配合useEffect,可以保证从renderCacheRef拿到的是最新的renderCache的值
虚拟列表
所谓虚拟列表,就是只渲染可视区域的元素,与此同时,通过一些 CSS 技巧使得滚动条的行为看起来好像所有的元素都被渲染出来了(即每次触发滚动都会对视图进行更新,最终的效果和普通的列表几乎没啥差别)。
实现虚拟列表的话,有以下几个要留意的问题:
-
实际列表高度为数据数量 * 子容器高度,需要用一个
div撑开,以模拟滚动条的效果;可视区域的子容器则以绝对定位的方式呈现,随着滚动条滚动动态计算位置。 -
可视区域的子容器个数计算:父容器高度 / 子容器高度。
-
实际渲染的第一个子容器的索引:Math.floor(scrollTop / 子容器高度),从这里开始加上可视区域的子容器数量和缓冲区数量(为了滚动衔接自然,需要设置缓冲区),就是实际列表渲染的
dom数量。 -
对于每个可视区域的元素而言,距离顶部的高度是子容器的
n倍,其中n是起始子容器的索引与当前子容器的索引之和。
下面是一个简易的实现,麻雀虽小,五脏俱全:
import { useState, useEffect } from "react";
const VirtualScroll = ({ listHeight = 0, itemHeight = 0, data = [] }) => {
const itemCount = data.length;
const totalHeight = itemCount * itemHeight;
const bufferCount = 5; // 可以根据需要调整缓冲区大小
const [visibleData, setVisibleData] = useState([]);
const [startIndex, setStartIndex] = useState(0);
const handleScroll = (event: any) => {
const { scrollTop } = event.target;
setStartIndex(Math.floor(scrollTop / itemHeight));
};
useEffect(() => {
// 预留缓冲区,提高滚动性能
const endIndex = startIndex + Math.ceil(listHeight / itemHeight) + bufferCount;
setVisibleData(data.slice(startIndex, endIndex));
}, [startIndex, data, itemHeight, listHeight]);
return (
<div
style={{
height: `${listHeight}px`,
overflow: 'auto',
position: 'relative',
width: '100%'
}}
onScroll={handleScroll}
>
<div style={{
height: `${totalHeight}px`,
position: 'relative',
width: '100%'
}}>
{visibleData.map((item, index) => (
<div
key={index}
style={{
height: `${itemHeight}px`,
position: 'absolute',
top: `${(startIndex + index) * itemHeight}px`,
width: '100%'
}}
>
{item}
</div>
))}
</div>
</div>
);
};
export default VirtualScroll;
场景四:前端海量数据持久化存储
之前面试的时候被问过一个问题,假设网页应用有个接口获取的数据量非常大,不过这个数据基本不怎么会变动,前端要怎么处理。我回答了使用storage API,不过面试官似乎不满意,又说如果数据量大到storage塞不下怎么办,我就无言以对了。
尽管这种场景一般是不会出现在实际的开发中的,不过对于该场景而言也并非无解可循。我们可以使用IndexedDB来解决本地大数据存储的问题。下面是MDN中关于IndexedDB的介绍:
IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。而 IndexedDB 提供了这种场景的解决方案。
比如我们要存储一个大数组,需要进行以下步骤:
-
打开或创建
IndexedDB数据库。 -
创建一个对象存储空间(
Object Store),用于存储数组。 -
将大数组拆分为较小的块,以便逐个存储。
-
使用事务(
Transaction)来写入每个数组块到对象存储空间中。
示例代码如下:
// 打开或创建 IndexedDB 数据库
const request = indexedDB.open('myDatabase', 1);
let db, objectStore;
request.onupgradeneeded = function(event) {
db = event.target.result;
// 创建对象存储空间
objectStore = db.createObjectStore('myArray', { autoIncrement: true });
};
request.onsuccess = function(event) {
db = event.target.result;
// 拆分大数组为块,这里以每个块大小为1000为例
const array = [/* your large array here */];
const blockSize = 1000;
let offset = 0;
// 开启事务来写入数组块
const transaction = db.transaction(['myArray'], 'readwrite');
objectStore = transaction.objectStore('myArray');
function writeNextBlock() {
if (offset >= array.length) {
// 全部块已写入完成
console.log('大数组存储完成');
return;
}
// 获取当前块的数据
const block = array.slice(offset, offset + blockSize);
offset += blockSize;
// 将块写入对象存储空间
const request = objectStore.add(block);
request.onsuccess = function() {
// 写入成功后继续写入下一块
writeNextBlock();
};
request.onerror = function(event) {
// 写入出错
console.error('写入块时出错', event.target.error);
};
}
// 开始写入第一块
writeNextBlock();
};
request.onerror = function(event) {
// 打开/创建数据库出错
console.error('打开/创建数据库时出错', event.target.error);
};
然后是读取数据库的操作,代码如下:
// 打开或创建 IndexedDB 数据库
const request = indexedDB.open('myDatabase', 1);
let db, objectStore;
request.onsuccess = function(event) {
db = event.target.result;
// 开启事务读取对象存储空间
const transaction = db.transaction(['myArray'], 'readonly');
objectStore = transaction.objectStore('myArray');
const dataArray = [];
objectStore.openCursor().onsuccess = function(event) {
const cursor = event.target.result;
if (cursor) {
// 读取每个数组块
const block = cursor.value;
dataArray.push(...block);
cursor.continue();
} else {
// 全部数组块已读取完成
console.log('读取的数组数据:', dataArray);
}
};
};
request.onerror = function(event) {
// 打开/创建数据库出错
console.error('打开/创建数据库时出错', event.target.error);
};
对于前面提到的场景,结合IndexedDB,我们可以这样设计:
-
前端初次向后端请求数据,数据之外应该包含一个数据摘要(与数据内容唯一相关,如果数据内容改变则会改变),然后把数据写入
IndexDB -
前端二次向后端请求数据,会调用接口校验数据新鲜度(将数据摘要作为参数)
-
如果数据不需要更新,则直接从数据库拿
-
如果数据需要更新,则走初次请求数据的流程(原有的
IndexDB数据库中数据应该要清掉)