【20230513】近期面试遇到的一些场景题

707 阅读10分钟

在面试中,面试官有时会举一些场景题来考察面试者,又或是让面试者说说自己项目中的难点。对于前端而言,项目中普通的业务开发似乎很难说出什么难点和亮点。笔者在2个多月的面试历程中深受这个问题的困扰,越发觉得自身对场景的积累不足。既然是不足的点,那就要努力去将其补全,以提升自己的竞争力。

场景一:异步请求的竞态问题

举个例子,比如我的表格数据由redux进行管理,在网络请求返回数据后会触发对store的更新,表格通过消费store的新数据实现视图更新。不过如果我两次同样请求返回的时间不一致,就会导致前一个请求的数据把最新的请求数据覆盖的问题。

解决问题的出发点大致有以下几个方向:

  • 限制请求发出的次数(事实上,对于表格加载数据而言采用这种方式会非常影响用户体验)

  • 以最新发出的请求返回的数据为准,之前发出的请求不算数

一般来说,我们解决竞态问题都会从第二点出发。由于笔者之前公司项目采用的是dvajs进行状态管理和异步请求的,下面给出的代码片段也会使用dva

dvareduxredux-saga的上层封装,而redux-saga对于竞态问题有相应的解决方案:takeLatest。这里对redux-saga处理异步函数的几种方式做下介绍:

  • 使用 takeredux-saga会暂停并等待指定类型的action,然后执行相应的逻辑。可以用于处理单个异步操作。
  • 使用 takeEveryredux-saga会立即启动一个新的任务来处理每个匹配到的action,不会阻塞并发出多个请求。适用于需要处理多个并发请求的情况。
  • 使用 takeLatestredux-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,就会触发请求取消的逻辑。

场景二:大文件上传实现

大文件上传也是面试中常考的一类场景题。一般我们会采用文件分片去解决这个问题。有以下几个要点:

下面是不包含并发逻辑的切片上传代码:

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 提供了这种场景的解决方案。

比如我们要存储一个大数组,需要进行以下步骤:

  1. 打开或创建IndexedDB数据库。

  2. 创建一个对象存储空间(Object Store),用于存储数组。

  3. 将大数组拆分为较小的块,以便逐个存储。

  4. 使用事务(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数据库中数据应该要清掉)