如何优雅地实现深拷贝

前言

对于深拷贝,大家肯定都不陌生。虽然可能深拷贝实际应用场景不多,但是在面试或者刷题过程中,大家免不了需要亲自动手去实现一个深拷贝功能。接下来,以个人的思路带大家实现一遍深拷贝功能,希望能帮助到大家。

思路

深拷贝实现并不难,这里可能需要注意的点,就是如何解决循环引用了。私以为,实现深拷贝只需以下三个核心步骤:

  1. 创建出对应数据类型的空白数据。即,若须拷贝的是对象类型数据,则创建一个空白对象,以此类推。
  2. 拷贝目标数据。即把目标数据拷贝到步骤1中创建的空白数据里。
  3. 返回拷贝的数据。

综上,可以得出以下伪代码框架:

typescript 复制代码
export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  
  // 1. 创建对应类型的空白数据
  
  // 2. 复制
  
  // 3. 返回
  
}

接下来,我们来逐个步骤实现,循环引用处理最后再补充实现。

实现

准备工作

js数据类型的获取

js里获取数据的类型有很多种方式,这里利用 Object.prototype.toString.call 方式实现。

首先,我们在文件中定义一个对象,用于列出常见的可能需要深拷贝的数据类型:

js 复制代码
/**
 * 常量定义
 */

// js 的数据类型
export const JS_DATA_TYPE = {
  OBJECT: "[object Object]",
  ARRAY: "[object Array]",
  FUNCTION: "[object Function]",
  MAP: "[object Map]",
  SET: "[object Set]",
  DATE: "[object Date]",
  REGEXP: "[object RegExp]",
};

这里,我们简单列出了接下来拷贝过程中会遇到的数据类型,其中,对象和数组就不用说了,高频出现的数据类型。为了全面一些,这里还用到了 Function,Map, Set, Date, RegExp 这些数据类型。

接下来,我们把获取数据类型等工具函数,在另一个文件内实现并导出:

typescript 复制代码
/**
 * 工具函数
 */

/**
 * 获取给定数据的类型
 * @param data 目标数据
 * @returns 类型字符串
 */
export function getType(data: any): string {
  return Object.prototype.toString.call(data);
}

/**
 * 判断 item 是否为 target 的实例
 * @param item 目标数据
 * @param target 作为对比的目标数据
 * @returns Boolean
 */
export function isInstanceOf(item: any, target: any): boolean{
  return item instanceof target;
}

getType 工具函数,用于获取数据的类型,返回的结果就是上面说过的 JS_DATA_TYPE 对象里列出的字符串。

isInstanceOf 工具函数,就是把 instanceof 封装了一下,后续判断拷贝的数据是否为普通类型数据时用到。

创建对应数据类型的空白数据

创建对应数据类型的空白数据,我们通常会利用 Object.createObject.getPrototypeOf 这两个API实现。例如:创建一个空白的对象:

可能有人会说,干嘛这么麻烦,直接 emptyObj = {} 不就完了。说的没错,但这里主要考虑到,不仅仅拷贝目标数据,还需要把目标数据原型上的数据也拷贝过来。例如,我在上图里的 obj1 的原型里定义了一个通用方法 globalFn,这时候,直接 emptyObj = {} 的话,那这个通用方法,emptyObj 上就没有了。而利用上述的两个api来创建一个空白数据,是可以做到的:

但这里,我们也不利用 Object.createObject.getPrototypeOf 这两个API实现创建空白数据。因为这样的话,在创建空白Map, Set,Date 等数据时,会有问题:

你会发现,这样创建出来的空白数据,使用对应的api会报错,所以这条路行不通。

那么,还有哪种方式既可以拷贝原型上的共享数据,又不会出现这样的错误呢?如果你有更好的方法,欢迎评论区里提出来。

本文采用的是,利用目标数据的构造函数:

那么,我们把利用目标数据的构造函数创建空白数据的逻辑封装到函数去:

typescript 复制代码
/**
 * 获取对应类型的空白数据
 * @param data 被克隆的数据
 * @returns 对应类型的空白数据
 */
function getRelativeEmptyData(data: any){
  const Ctor = data.constructor;
  return new Ctor();
}

deepClone 里调用该方法,第一步就完成了:

typescript 复制代码
export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  // 2. 复制
  
  // 3. 返回
  
}

拷贝目标数据

拷贝目标数据,说起来就是,被拷贝的数据是什么类型的,我们就拷贝一份一模一样的相应类型的数据。我们可能会这样写:

typescript 复制代码
export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  
  // 2. 复制
  const dataType = getType(data);
  if(dataType === JS_DATA_TYPE.OBJECT){
    // 对象数据拷贝逻辑
  }else if(dataType === JS_DATA_TYPE.ARRAY){
    // 数组数据拷贝逻辑
  }else if(dataType === JS_DATA_TYPE.MAP){
    // 映射数据拷贝逻辑
  }else if(dataType === JS_DATA_TYPE.SET){
    // 集合数据拷贝逻辑
  }else if(dataType === JS_DATA_TYPE.FUNCTION){
    // 函数拷贝逻辑
  }else if(dataType === JS_DATA_TYPE.DATE){
    // 日期数据拷贝逻辑
  }else if(dataType === JS_DATA_TYPE.REGEXP){
    // 正则拷贝逻辑
  }
  
  // 3. 返回
  
}

或者这样写:

typescript 复制代码
export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  
  // 2. 复制
  const dataType = getType(data);
  switch(dataType){
    case JS_DATA_TYPE.OBJECT: {
      // 对象数据拷贝逻辑
      break
    }
    case JS_DATA_TYPE.ARRAY: {
      // 数组数据拷贝逻辑
      break
    }
    case JS_DATA_TYPE.MAP: {
      // 映射数据拷贝逻辑
      break;
    }
    case JS_DATA_TYPE.SET: {
      // 集合数据拷贝逻辑
      break;
    }
    case JS_DATA_TYPE.FUNCTION: {
      // 函数拷贝逻辑
      break;
    }
    case JS_DATA_TYPE.DATE: {
      // 日期数据拷贝逻辑
      break;
    }
    case JS_DATA_TYPE.REGEXP: {
      // 正则拷贝逻辑
      break;
    }
  }
  
  // 3. 返回
  
}

无论是在函数内使用 if-else 或者 switch-case 来实现逻辑,虽然都是完全可以实现拷贝的目的,但这样有什么不足之处?相信大家都能说出来,无非就是:

  1. 违反SOLID五大原则里的第二条,开放封闭原则。在这里就是函数应该是可拓展的,但是是不可修改的。因为你想,如果后续需要新增拷贝DOM元素的逻辑,是不是得在最后再加一个 else if,这样是不是就是修改了函数。
  2. 当有越来越多类型的数据需要拷贝时,这个拷贝逻辑的代码量会越来越多,维护起来不方便,阅读起来也不方便。

能说出代码的不足之处固然好,如果能解决问题,能用一种更优雅的方式来解决就更好了。这里我们把所有的拷贝数据的逻辑,都归纳到一个对象上:

typescript 复制代码
// 拷贝函数处理器
const COPIER_HANDLER = {
  [JS_DATA_TYPE.OBJECT]: objectCopier,
  [JS_DATA_TYPE.ARRAY]: arrayCopier,
  [JS_DATA_TYPE.MAP]: mapCopier,
  [JS_DATA_TYPE.SET]: setCopier,
  [JS_DATA_TYPE.FUNCTION]: functionCopier,
  [JS_DATA_TYPE.DATE]: dateCopier,
  [JS_DATA_TYPE.REGEXP]: regExpCopier,
};

使用的时候这样使用:

typescript 复制代码
// 拷贝函数处理器
const COPIER_HANDLER = {
  [JS_DATA_TYPE.OBJECT]: objectCopier,
  [JS_DATA_TYPE.ARRAY]: arrayCopier,
  [JS_DATA_TYPE.MAP]: mapCopier,
  [JS_DATA_TYPE.SET]: setCopier,
  [JS_DATA_TYPE.FUNCTION]: functionCopier,
  [JS_DATA_TYPE.DATE]: dateCopier,
  [JS_DATA_TYPE.REGEXP]: regExpCopier,
};

export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  
  // 2. 复制
  const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
  clone = copierHandler(clone, data);
  
  // 3. 返回
  
}

这样,我们把拷贝数据的逻辑都收归到了 COPIER_HANDLER 对象上,后续即需要使新增拷贝数据逻辑,也不需要修改函数,只需在这个对象里新增一项即可。而且这样函数里的逻辑更简洁,阅读起来更容易些。

拷贝数据的函数,它们的实现思路都是一样的:遍历目标数据,逐个拷贝到新的空白数据里。为了各拷贝函数逻辑和结构的一致,我们统一约定:

  1. 函数都有两个参数,第一个是拷贝数据,即空白数据,第二个是被拷贝数据,即目标数据;
  2. 函数都需要把拷贝数据返回。

像这样:

typescript 复制代码
/**
 * 拷贝数据
 * @param clone 拷贝数据
 * @param data 被拷贝的数据
 * @returns 对应类型的空白数据
 */
function xxxCopier(clone, data){
  // 具体的拷贝逻辑
  return clone;
}

接下来,我们逐一实现:

对象拷贝逻辑

对象数据的拷贝,我们需要留意的一点,就是数据的嵌套,例如,对象里的嵌套对象等情况。所以,遍历的时候,每个属性值都需要递归调用深拷贝函数来复制数据:

typescript 复制代码
/**
 * 对象拷贝
 * @param clone 拷贝对象
 * @param data 被拷贝的对象
 * @returns 拷贝对象
 */
function objectCopier(clone: Record<any, any>, data: Record<any, any>): Record<any, any>{
  Object.keys(data).forEach((key: string) => (clone[key] = deepClone(data[key])));
  return clone;
}

数组拷贝逻辑

数组的拷贝和对象拷贝一样,需要考虑到数据嵌套的情况,对应的处理方式也一致,遍历时每个属性值都递归调用深拷贝函数进行处理:

typescript 复制代码
/**
 * 数组拷贝
 * @param clone 拷贝数组
 * @param data 被拷贝的数组
 * @returns 拷贝数组
 */
function arrayCopier(clone: any[], data: any[]): any[]{
  data.forEach((item) => clone.push(deepClone(item)));
  return clone;
}

Map拷贝逻辑

映射数据的拷贝,因为属性对应的值可能是对象,数组等复杂数据类型,所以需要调用深拷贝函数进行处理:

typescript 复制代码
/**
 * 映射数据拷贝
 * @param clone 拷贝映射数据
 * @param data 被拷贝的映射数据
 * @returns 拷贝映射数据
 */
function mapCopier(clone: Map<any, any>, data: Map<any, any>): Map<any, any>{
  data.forEach((val, key) => clone.set(key, deepClone(val)));
  return clone;
}

Set拷贝逻辑

集合数据的拷贝,因为集合的每个元素,也可能是对象,数组等复杂数据类型,所以同样需要调用深拷贝函数进行处理:

typescript 复制代码
/**
 * 集合数据拷贝
 * @param clone 拷贝集合数据
 * @param data 被拷贝的集合数据
 * @returns 拷贝集合数据
 */
function setCopier(clone: Set<any>, data: Set<any>): Set<any>{
  data.forEach((val) => clone.add(deepClone(val)));
  return clone;
}

函数拷贝逻辑

函数的拷贝,因为被拷贝的函数,可能绑定了上下文,所以我们直接用 function 套一层即可:

typescript 复制代码
/**
 * 函数拷贝
 * @param clone 拷贝函数
 * @param data 被拷贝的函数
 * @returns 拷贝函数
 */
function functionCopier(clone: Func, data: Func): Func{
  clone = function(this: any, ...args: any){
    return data.call(this, ...args);
  }
  return clone;
}

日期数据拷贝逻辑

日期数据的拷贝,我们直接把被拷贝的数据当作参数传递给 new Date() 即可:

typescript 复制代码
/**
 * 日期数据拷贝
 * @param clone 拷贝日期数据
 * @param data 被拷贝的日期数据
 * @returns 拷贝日期数据
 */
function dateCopier(clone: Date | number, data: Date | number): Date {
  clone = new Date(data);
  return clone;
}

正则拷贝逻辑

我们可能平时比较少用到正则,或者比较少使用构造函数的方式创建正则表达式。所以我们先复习下,使用构造函数的方式创建正则表达式,以及创建的正则,里面是长什么样的。

首先,我们来看下,使用构造函数的方式创建正则,使用方式是怎样的:

我们可以看到,使用构造函数的方式是 new RegExp(pattern[, flags]) 我们需要提供必填的 pattern 参数,和可选的 flags 参数,像这样使用:

复习了如何使用构造函数的方式创建正则后,我们来看一下,创建出来的正则,里面包含什么我们需要用到的信息:

很明显,根据 new RegExp(pattern[, flags]) 的方式创建正则,我们需要用到里面的 flagssource 字段。所以,我们这样来拷贝正则:

typescript 复制代码
/**
 * 正则拷贝
 * @param clone 拷贝正则
 * @param data 被拷贝的正则
 * @returns 拷贝正则
 */
function regExpCopier(clone: RegExp, data: RegExp): RegExp{
  clone = new RegExp(data.source, data.flags);
  return clone;
}

至此,我们所有的拷贝数据的函数都实现了。

返回拷贝数据

这个不用多说,直接返回拷贝数据即可:

typescript 复制代码
/**
 * 深拷贝
 * @param data 被拷贝的数据
 * @returns 拷贝数据
 */
export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  
  // 2. 复制
  const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
  clone = copierHandler(clone, data);
  
  // 3. 返回
  return clone;
}

循环引用

虽然我们利用递归调用深拷贝函数的方式,可以解决对象嵌套对象等情况,但是如果对象嵌套的对象存在循环引用的情况下,递归调用深拷贝函数,是会爆栈的:

那么,如何解决呢?不难,我们利用一个 WeakMap 即可解决。思路是:创建一个全局的 WeakMap 数据结构,不妨名为 globalMap,用于存放存在循环引用的数据,每次深拷贝数据前,都先判断该数据是否以存在于globalMap中,存在的话直接返回从globalMap获取的数据即可。然后在每次创建完空白数据后,都以被拷贝数据为key,以拷贝数据为value,存放到globalMap去。

typescript 复制代码
const globalMap = new WeakMap();

/**
 * 深拷贝
 * @param data 被拷贝的数据
 * @returns 拷贝数据
 */
export function deepClone(data: any){
  // 普通类型数据则直接返回
  
  // 循环引用处理
  if(globalMap.has(data)) return globalMap.get(data);
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  globalMap.set(data, clone);
  
  // 2. 复制
  const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
  clone = copierHandler(clone, data);
  
  // 3. 返回
  return clone;
}

细节补充:普通数据类型的处理

此时深拷贝的基本逻辑已经实现,最后把处理普通类型数据的逻辑补充下即可,即,判断到当前需拷贝的数据属于普通类型,直接返回即可。

typescript 复制代码
/**
 * 深拷贝
 * @param data 被拷贝的数据
 * @returns 拷贝数据
 */
export function deepClone(data: any){
  // 普通类型数据则直接返回
  if(!isInstanceOf(data, Object)) return data;
  
  // 循环引用处理
  if(globalMap.has(data)) return globalMap.get(data);
  
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  globalMap.set(data, clone);
  
  // 2. 复制
  const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
  clone = copierHandler(clone, data);
  
  // 3. 返回
  return clone;
}

至此,深拷贝函数实现完成。我们看到,按照三个步骤的思路来实现深拷贝,核心函数也就十几二十行代码。看着就比较简洁易懂。

深拷贝函数文件全貌

我们来看下,整个深拷贝函数的全貌:

typescript 复制代码
/**
 * 深拷贝
 */

import { JS_DATA_TYPE } from '../utils/const/index.js';
import { getType, isInstanceOf } from '../utils/tools/index.js';

const globalMap = new WeakMap();

// 拷贝函数处理器
const COPIER_HANDLER = {
  [JS_DATA_TYPE.OBJECT]: objectCopier,
  [JS_DATA_TYPE.ARRAY]: arrayCopier,
  [JS_DATA_TYPE.MAP]: mapCopier,
  [JS_DATA_TYPE.SET]: setCopier,
  [JS_DATA_TYPE.FUNCTION]: functionCopier,
  [JS_DATA_TYPE.DATE]: dateCopier,
  [JS_DATA_TYPE.REGEXP]: regExpCopier,
};

/**
 * 深拷贝
 * @param data 被拷贝的数据
 * @returns 拷贝数据
 */
export function deepClone(data: any){
  // 普通类型数据则直接返回
  if(!isInstanceOf(data, Object)) return data;
  // 循环引用处理
  if(globalMap.has(data)) return globalMap.get(data);
  // 1. 创建对应类型的空白数据
  let clone = getRelativeEmptyData(data);
  globalMap.set(data, clone);
  // 2. 复制
  const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
  clone = copierHandler(clone, data);
  // 3. 返回
  return clone;
}

/**
 * 获取对应类型的空白数据
 * @param data 被克隆的数据
 * @returns 对应类型的空白数据
 */
function getRelativeEmptyData(data: any){
  const Ctor = data.constructor;
  return new Ctor();
}

/**
 * 对象拷贝
 * @param clone 拷贝对象
 * @param data 被拷贝的对象
 * @returns 拷贝对象
 */
function objectCopier(clone: Record<any, any>, data: Record<any, any>): Record<any, any>{
  Object.keys(data).forEach((key: string) => (clone[key] = deepClone(data[key])));
  return clone;
}

/**
 * 数组拷贝
 * @param clone 拷贝数组
 * @param data 被拷贝的数组
 * @returns 拷贝数组
 */
function arrayCopier(clone: any[], data: any[]): any[]{
  data.forEach((item) => clone.push(deepClone(item)));
  return clone;
}

/**
 * 映射数据拷贝
 * @param clone 拷贝映射数据
 * @param data 被拷贝的映射数据
 * @returns 拷贝映射数据
 */
function mapCopier(clone: Map<any, any>, data: Map<any, any>): Map<any, any>{
  data.forEach((val, key) => clone.set(key, deepClone(val)));
  return clone;
}

/**
 * 集合数据拷贝
 * @param clone 拷贝集合数据
 * @param data 被拷贝的集合数据
 * @returns 拷贝集合数据
 */
function setCopier(clone: Set<any>, data: Set<any>): Set<any>{
  data.forEach((val) => clone.add(deepClone(val)));
  return clone;
}

/**
 * 函数拷贝
 * @param clone 拷贝函数
 * @param data 被拷贝的函数
 * @returns 拷贝函数
 */
function functionCopier(clone: Func, data: Func): Func{
  clone = function(this: any, ...args: any){
    return data.call(this, ...args);
  }
  return clone;
}

/**
 * 日期数据拷贝
 * @param clone 拷贝日期数据
 * @param data 被拷贝的日期数据
 * @returns 拷贝日期数据
 */
function dateCopier(clone: Date | number, data: Date | number): Date {
  clone = new Date(data);
  return clone;
}

/**
 * 正则拷贝
 * @param clone 拷贝正则
 * @param data 被拷贝的正则
 * @returns 拷贝正则
 */
function regExpCopier(clone: RegExp, data: RegExp): RegExp{
  clone = new RegExp(data.source, data.flags);
  return clone;
}

实际上,为了文件内的总代码数少一些,我们还可以把各拷贝函数收归到一个文件内,这样这个文件看着就更清爽干净些。

验证

测试数据

为了验证我们写的深拷贝函数是否靠谱,我们来验证下。

首先,我们创建一个 .html 文件,引入 index.js 文件:


接着,`index.js`文件内创建这么一个测试数据:

typescript 复制代码
import { deepClone } from './deepClone/index.js';

const testData = {
  // String
  field_1: "",
  field_2: "test test",
  // Number
  field_3: 1,
  field_4: -1,
  field_5: 0,
  // Boolean
  field_6: false,
  field_7: true,
  // null
  field_8: null,
  // undefined
  field_9: undefined,
  // Symbol
  field_10: Symbol(""),
  field_11: Symbol("1111"),
  // Object
  field_12: {a: 1, b: 2},
  field_13: {a: 1, b: 2, c: {c1: 3, c2: 4, c3: {}}, d: [1, 2, "", true, false]},
  // Array
  field_14: [1, 2, "", true, false],
  field_15: [[1, 2], {a: 1, b: 2}],
  // Map
  field_16: new Map([
    ["a", 1],
    ["b", 2],
    ["c", 5],
  ]),
  // Set
  field_17: new Set([1, 2, 3, "", true, false, ["a", "b", "c"], {a: 1, b: 2}]),
  // Function
  field_18: function(x: number, y: number): number{
    return x + y;
  },
  field_19: (txt: any) => console.log("[field_19] ===> ", txt),
  // Date
  field_20: new Date(),
  field_21: Date.now(),
  // RegExp
  field_22: /^a.*b$/ig,
  field_23: new RegExp("^b.*c$", "ig"),
};

// 循环引用
testData.field_13.c.c3 = testData.field_13.c;

// 验证
const cloneData = deepClone(testData)
console.log({testData, cloneData, result: cloneData === testData});
(window as unknown as any).cloneData = cloneData;
(window as unknown as any).testData = testData;

拷贝测试数据,输出被拷贝数据以及拷贝数据,以及它们是否相等的结果。为了进一步验证,我们把两个数据都绑定到 window 对象上:


我们来看下打印结果:

我们看到,resultfalse,说明两个数据没有指向同一块地址,深拷贝是成功的。没有运行出错,说明循环引用处理逻辑也是正常的:

Map,Set, Date, 拷贝的函数也能调用:

通过以上验证,说明我们写的深拷贝函数还是靠谱的。

总结

深拷贝函数,实现思路总共分为三个步骤:

  1. 创建对应类型的空白数据。
  2. 拷贝数据
  3. 返回拷贝数据

需要留意的点,就是循环引用。

根据这三个步骤,再把一个重点处理好。我们就能实现一个靠谱的深拷贝函数。

当然了,这个深拷贝函数还是存在不足之处的。例如,我们都知道,递归调用的不足之处,其中之一就是递归深度过深的情况下,存在爆栈的可能性。例如:当数据嵌套太深了,本文的深拷贝函数还是会爆栈的。

代码地址

相关推荐
并不会32 分钟前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
悦涵仙子35 分钟前
CSS中的变量应用——:root,Sass变量,JavaScript中使用Sass变量
javascript·css·sass
衣乌安、35 分钟前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜36 分钟前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师38 分钟前
CSS的三个重点
前端·css
耶啵奶膘2 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^4 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie4 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic5 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿5 小时前
webWorker基本用法
前端·javascript·vue.js