web

前端的缓存策略

前端资源也需要配置缓存策略,以提高网站的访问速度。由于网站的流量增大,我们需要合适的缓存策略才能让 CDN 发挥最大效果保证网站的访问速度。

# Cache-Control

Cache-Control 用来设置资源的缓存策略,常见的的值有:no-store 、no-cache、public、private、max-age、immutable。

# no-store

不缓存资源,每次都需要重新请求。

# no-cache

需要先验证资源是否过期,如果没有过期,可以使用缓存。

# public

资源可以被所有的用户缓存,包括 CDN。

# private

资源只能被用户缓存,不能被 CDN 缓存。

# max-age

设置资源的最大缓存时间,单位是秒。

# immutable

资源不会改变,可以永久缓存。

# 前端网站的缓存策略

现在的前端网站一般都是 SPA 或者 PWA,index.html 是一个入口文件,其他的资源都是通过 index.html 加载的。所以我们可以设置 index.html 的缓存策略为 no-store,保证每次都是最新的资源。

其他的都是静态资源,并且文件名包含 hash,所以我们可以设置这些资源的缓存策略为 immutable,保证这些资源不会改变,可以永久缓存。

Cache-Control: no-store

Cache-Control: public, max-age=604800, immutable
1
2
3

# 总结

index.html 文件是网站入口,这个文件不能缓存,每次都需要重新请求,否则不能及时更新网站。不过这个文件一般也不大,所以不会影响网站的访问速度。其他的静态资源文件名包含 hash,所以这些资源不会改变,可以永久缓存,这样可以提高网站的访问速度。

# 参考

MDN Cache-Control (opens new window)

Prevent unnecessary network requests with the HTTP Cache (opens new window)

无限滚动加载列表

滚动加载是分页显示列表常用的一个技术,具体的实现方式基本上后端都会在返回数据上给一个 cursor 字段,前端在请求下一页的时候会把这个字段带上,后端根据这个字段来返回下一页的数据。这种方式的好处是可以在前端缓存数据,减少请求次数,但是缺点也很明显,就是无法跳页,只能一页一页的往下翻。为此,我们要实现一个滚动加载组件,当这个组件在可视范围内的时候,会去加载下一页的数据。

# 核心技术点

如果检测组件进入可视范围,我们可以使用 IntersectionObserver API,来监听元素进入可视范围。我们使用 React 实现一个组件,当这个组件进入可视范围的时候,会触发一个回调函数,这个回调函数会去加载下一页的数据。

const ScrollLoader: React.FC<IScrollLoaderProps> = ({
  inView,
  updateCompleted
}) => {
  const loaderRef = useRef(null);
  const observerRef = useRef<IntersectionObserver | null>(null);
  const cursorRef = useRef('');
  const [completed, setCompleted] = useState(false);

  const fetchMore = useCallback(() => {
    inView?.(cursorRef.current).then(res => {
      if (res.nextCursor) {
        cursorRef.current = res.nextCursor;
        if (observerRef.current && loaderRef.current) {
          // trigger the observer again
          observerRef.current.unobserve(loaderRef.current);
          observerRef.current.observe(loaderRef.current);
        }
      } else {
        setCompleted(true);
        updateCompleted?.(true);
      }
    });
  }, [inView, updateCompleted]);

  const callbackFunction: IntersectionObserverCallback = useCallback(
    entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting && !completed) {
          fetchMore();
        }
      });
    },
    [completed, fetchMore]
  );

  useEffect(() => {
    const observerOptions = {
      root: null,
      rootMargin: '0px',
      threshold: 0.5
    };
    observerRef.current = new IntersectionObserver(
      callbackFunction,
      observerOptions
    );
    if (loaderRef.current) {
      observerRef.current.observe(loaderRef.current);
    }

    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
        observerRef.current = null;
      }
    };
  }, [callbackFunction]);

  if (completed) {
    return null;
  } else {
    return (
      <div className={styles.loader} ref={loaderRef}>
        <Loading />
      </div>
    );
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

通过 inView 属性来传入回调函数,当组件进入可视范围的时候,会调用这个函数,然后去加载下一页的数据。这里我们使用了一个 cursor 来标记当前的页码,当请求下一页的时候,会把这个 cursor 传给后端,后端会根据这个 cursor 来返回下一页的数据。当后端返回的数据中没有 cursor 的时候,说明已经到了最后一页,这时候我们不再渲染这个组件。

# 边界情况处理

当用户的屏幕非常长的时候,我们需要加载很多页的数据才能填满屏幕。但是 IntersectionObserver 在组件进入可视范围的时候只会触发一次回调,所以我们需要手动去重新触发。当组件加载完数据的时候,我们会先 unobserve,然后再重新 observe 这个组件,这样就可以触发回调函数了。

# 总结

滚动加载是一个很常见的需求,但是针对边界情况的处理很多人却忽略掉了。针对比较长的屏幕,需要我们做特殊处理才能满足需求。

# 参考

Intersection Observer API (opens new window)

拖拽排序列表

拖拽排序是一个很常见的功能,在浏览器不支持原生拖拽功能的时代,人们只能使用鼠标的点击事件来模拟拖拽效果,手动控制拖拽元素的位置来进行移动操作。随着原生拖拽 API 的支持,在实现这个功能的时候就可以更简单一点了。

# 相关事件

首先,你要想拖拽某个元素的话必须设置属性 draggable="true" 才可以进行拖拽操作。然后进行拖拽操作主要有以下事件需要处理。

  1. onDragStart 当拖拽开始的时候触发
  2. onDragOver 当拖拽到某个元素内部的时候触发
  3. onDragEnter 当拖拽进入某个元素的时候触发
  4. onDragEnd 当鼠标松开,拖拽结束的时候触发
  5. onDrop 当拖拽到某个元素并松开鼠标的时候触发

由于我们使用拖拽进行排序,而不是拖拽元素放到别的元素里面,所以我们并不需要进行 drop 处理,当 dragOver 某个元素的时候也不需要额外的处理。所以这两个事件的代码最简单,我们只需要移除默认的事件响应。

const dragOverHandler = useCallback((event: React.DragEvent<HTMLElement>) => {
  event.preventDefault();
  event.dataTransfer.dropEffect = 'move';
  return false;
}, []);
const dropHandler = useCallback((event: React.DragEvent<HTMLElement>) => {
  event.stopPropagation();
  event.preventDefault();
  return false;
}, []);
1
2
3
4
5
6
7
8
9
10

dropEffect 有四种,默认的是 copy 我们需要改成 move 才符合需求。接下来再来看看其他三个事件:

const dragStartHandler = useCallback(
  (event: React.DragEvent<HTMLElement>, index: number) => {
    event.dataTransfer.effectAllowed = 'move';
    // set dataTransfer enable mobile drag
    event.dataTransfer.setData('text/plain', index.toString());
    dragItem.current = index;
    copyData.current = deepClone(sortedData);
    recordRect();
    onDragStart?.();
  },
  [sortedData, recordRect, onDragStart]
);
const dragEnterHandler = useCallback(
  (index: number) => {
    if (dragItem.current !== index && dragOverItem.current !== index) {
      dragOverItem.current = index;
      const newData = deepClone(copyData.current);
      const dragData = newData[dragItem.current];
      newData.splice(dragItem.current, 1);
      newData.splice(dragOverItem.current, 0, dragData);
      setSortedData(newData);
      onDragEnter?.();
    }
  },
  [onDragEnter]
);
const dragEndHandler = useCallback(() => {
  updateData?.(sortedData);
  onDragEnd?.();
}, [sortedData, updateData, onDragEnd]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  1. dragStart 的时候记录下当前的 index,并复制一份数组数据。
  2. dragEnter 的时候记录下拖拽进入的元素 index,然后重新排序数组会渲染一个新的列表
  3. dragEnd 的时候同步数据到上层,展示排序后的结果

完整的代码请看文章最后的链接。

# FLIP 动画

当进行拖拽排序的时候,我们想要加上元素移动的动画,可以更明显的感受到顺序的变化。 我们使用 FLIP 技术来完成这个效果。

  1. First: 记录当前元素的位置
  2. Last: 记录更新后元素的位置
  3. Invert: 计算元素在 X 方向和 Y 方向的偏移,然后使用 transform 进行移动
  4. Play: 播放 transform 动画
useEffect(() => {
  if (draggable && containerRef?.current) {
    Array.from(containerRef.current.querySelectorAll('[data-id]')).forEach(
      async node => {
        const dom = node as HTMLElement;
        const key = dom.dataset.id as string;
        const prevRect = prevRects.current[key];
        if (key) {
          const rect = dom.getBoundingClientRect();
          if (prevRect) {
            const dy = prevRect.y - rect.y;
            const dx = prevRect.x - rect.x;
            dom.style.pointerEvents = 'none';
            dom.animate(
              [
                {
                  transform: `translate(${dx}px, ${dy}px)`
                },
                { transform: 'translate(0, 0)' }
              ],
              {
                duration: TIMEOUT,
                easing: 'linear'
              }
            );
            await Promise.allSettled(
              node.getAnimations().map(animation => animation.finished)
            );
            dom.style.pointerEvents = '';
          }
          prevRects.current[key] = rect;
        }
      }
    );
  }
}, [draggable, sortedData, containerRef]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

上面是示例代码,注意这里的 FLIP 动画加了一些特殊处理,我们使用 pointerEvents 禁用动画过程中的事件响应,因为动画过程中触发任何 drag 事件会导致 index 顺序被改动,然后无限进行排序操作。 这不是我们期望的结果,然后在动画结束的时候再取消这个 CSS 属性。

# 总结

使用原生的拖拽 API 来实现排序功能更简单和易用,但目前手机浏览器还不支持这个功能。排序中的过渡动画是一个难点,特别是动画过程中移除事件的响应。如果可以使用 JS 的方式来临时禁用所有事件的响应,应该可以处理的更优雅。

# 参考

HTML Drag and Drop API (opens new window)

simple-drag-drop-sort-list (opens new window)

FLIP Your Animations (opens new window)

Using the HTML5 Drag and Drop API (opens new window)