JS中关于深拷贝和浅拷贝

JS 有基本数据类型和复合数据类型,基本数据类型包括 null,undefined,boolean,number,string,symbol。针对这些数据类型都是值拷贝。复合数据类型包括 object,以及衍生的内置数据类型,Array, Map, Set 等等。默认对复合数据类型的赋值操作都是引用拷贝,就是浅拷贝。

JSON.stringify 实现深拷贝 ​

一种常用的方式是使用 JSON 接口先转换成字符串再解析成对象。

js
const myDeepCopy = JSON.parse(JSON.stringify(myOriginal));

虽然 V8 引擎优化了 JSON 的解析速度,但是这种方式还是有一些限制条件的,如果数据中存在 Map, Date 等 JS 内置数据结构就没法正常复制。

手动实现 deepClone ​

ts
export const isObject = (obj: any): boolean => {
  return obj !== null && typeof obj === 'object';
};

export const objectToString = Object.prototype.toString;
export const toTypeString = (value: unknown): string =>
  objectToString.call(value);

export const isMap = (val: unknown): val is Map<any, any> =>
  toTypeString(val) === '[object Map]';
export const isSet = (val: unknown): val is Set<any> =>
  toTypeString(val) === '[object Set]';
export const isDate = (val: unknown): val is Date =>
  toTypeString(val) === '[object Date]';
export const isRegExp = (val: unknown): val is RegExp =>
  toTypeString(val) === '[object RegExp]';

export function deepClone<T>(val: T): T {
  if (!isObject(val)) {
    return val;
  }
  if (Array.isArray(val)) {
    return val.map(deepClone) as typeof val;
  }
  if (isDate(val)) {
    return new Date(val) as typeof val;
  }
  if (isMap(val)) {
    return new Map(val) as typeof val;
  }
  if (isSet(val)) {
    return new Set(val) as typeof val;
  }
  if (isRegExp(val)) {
    return new RegExp(val.source, val.flags) as typeof val;
  }
  return Object.keys(val).reduce((acc, key) => {
    acc[key as keyof T] = deepClone(val[key as keyof T]);
    return acc;
  }, {} as T);
}

手动实现 deepClone 可以处理 Date, Map, Set, RegExp 这些数据结构,但这也不是完美的解决方案,例如二进制的数据结构 ArrayBuffer 就没在处理范围,但对于绝大多数场景也足够用了。

使用 structuredClone ​

js
const myDeepCopy = structuredClone(myOriginal);

最新的浏览器标准添加了 structuredClone 函数,可以实现常见数据结构的复制,但是有些数据结构也是不能复制的,例如 Function。不过 Function 也没有复制的必要。 由于这个函数最近才添加,实际使用中可能还需要搭配 polyfill。

总结 ​

实际开发工作中大家可以根据自己的需要选择合适的深拷贝解决方案。

参考 ​

structuredClone

Deep-copying in JavaScript using structuredClone

cloneDeep

如何实现虚拟滚动条

最近在做UI组件,发现下拉组件经常会有很多选项导致渲染的DOM过多,在低性能的设备上交互变的很卡。例如最常见的需求就是下拉选择所在国家或者地区,全世界有两百多个国家或者地区。 如果把所有国家或者地区都渲染出来必然会导致DOM过多,在低性能设备上滚动起来都会很卡。这时候就需要使用虚拟滚动条来提高渲染性能。

虚拟滚动条原理 ​

虚拟滚动的原理是显示的容器高度固定,我们只渲染用户看到的选项。但是选项容器的高度还是设置成所有选项的高度,这样滚动条效果保持一样,不过实际渲染的DOM数量却大大减少。

虚拟滚动条的数学计算 ​

目前虚拟滚动的主要限制是需要知道显示容器的高度,渲染的每个选项的高度以及渲染的选项的数量。我们可以通过用户的滚动距离来计算需要显示的内容以及需要偏移的距离。

细节请参考下面的代码:

tsx
const VirtualScroll: React.FC<IVirtualScrollProps> = ({
  itemCount, // 选项的数量
  height, // 显示容器的高度
  childHeight, // 选项的高度
  itemData // 选项元素
}) => {
  const wrapperRef = useRef<HTMLDivElement>(null);
  // 得高容器滚动了多少距离
  const scrollTop = useScrollAware(wrapperRef);
  // 计算渲染的选项的总高度
  const totalHeight = useMemo(
    () => childHeight * itemCount,
    [childHeight, itemCount]
  );
  // 根据滚动距离,计算要渲染的选项的起始位置
  const startIndex = useMemo(
    () => Math.floor(scrollTop / childHeight),
    [scrollTop, childHeight]
  );
  // 根据容器的高度计算要渲染的选项的数量
  const visibleItemCount = useMemo(
    () => Math.ceil(height / childHeight),
    [height, childHeight]
  );
  // 根据渲染的选项的起始位置,计算选项的偏移量
  const offsetY = useMemo(
    () => startIndex * childHeight,
    [childHeight, startIndex]
  );
  // 计算实际显示的选项元素
  const visibleItemData = useMemo(() => {
    return itemData.slice(startIndex, startIndex + visibleItemCount);
  }, [itemData, startIndex, visibleItemCount]);

  return (
    <div
      style={{
        height,
        overflow: 'auto'
      }}
      ref={wrapperRef}
    >
      <div
        style={{
          position: 'relative',
          overflow: 'hidden',
          willChange: 'transform',
          height: totalHeight
        }}
      >
        <div
          style={{
            willChange: 'transform',
            transform: `translate3d(0, ${offsetY}px, 0)`
          }}
        >
          {visibleItemData}
        </div>
      </div>
    </div>
  );
};

export default memo(VirtualScroll);

完整的实现请参考 Simple UI - Select

总结 ​

在知道虚拟滚动条的原理之后,发现并不难实现,重点要理解几个数值的计算,才能明白为什么这样能达到预期的效果。

参考 ​

Build your Own Virtual Scroll

Mac M1系统配置C++环境

最近在学习计算机图形学,需要配置C++环境和opencv来编译作业代码,于是我就开始折腾下M1如何编译C++代码了。

安装依赖 ​

首先你需要安装Mac下的包管理器 homebrew,官网有详细的安装教程,我就不赘述了。

接下来安装C++相关的依赖:

bash
brew install gcc cmake

然后是图形学相关的依赖:

bash
brew install eigen opencv

配置头文件路径 ​

This script installs Homebrew to its preferred prefix (/usr/local for macOS Intel, /opt/homebrew for Apple Silicon and /home/linuxbrew/.linuxbrew for Linux) so that you don’t need sudo when you brew install.

homebrew 官网有描述M1的安装路径更改到 /opt/homebrew 了。所以我们需要指定头文件路径,这里我们指定到 /opt/homebrew/include。

bash
export CPATH=/opt/homebrew/include
export LIBRARY_PATH=/opt/homebrew/lib

把上述代码添加到 .bashrc 文件,如果你使用的是 zsh 请添加到 .zshrc 文件。

检测头文件路径 ​

bash
gcc -x c++ -v -E /dev/null

如果输出结果有 /opt/homebrew/include 就说明配置成功了。

总结 ​

配置环境看似简单,但找到解决方案需要花不少时间,这里主要问题是M1下安装路径不同,所以我们需要指定头文件路径。

参考 ​

Installing a C/C++ library with homebrew on M1 Macs

在 Mac OS 下配置 GAMES101 开发环境

View default include path of C headers in Mac OS X by gcc -v?