通过 Lodash cloneDeep 函数的实现学习深拷贝

简介

深度克隆是程序设计中一个常见的需求,深度克隆意味着所有的嵌套对象和数组都会被复制,而不仅仅是顶层的引用。它能够帮助开发者复制一个独立的、与原对象内容相同的对象,保证原对象的修改不会影响到新对象。 cloneDeep函数是著名JavaScript库lodash的实现之一,它使用了复杂的内部逻辑和技巧,来应对各式各样的JavaScript数据类型和场景。

源码地址:github.com/lodash/loda...

一起让我们来看看源码实现和具体用法。

cloneDeep函数的源码:

首先看一下cloneDeep函数的源码实现:

typescript 复制代码
import baseClone from './.internal/baseClone.js';

/** Used to compose bitmasks for cloning. */
const CLONE_DEEP_FLAG = 1;
const CLONE_SYMBOLS_FLAG = 4;

/**
 * This method is like `clone` except that it recursively clones `value`.
 * Object inheritance is preserved.
 *
 * @since 1.0.0
 * @category Lang
 * @param {*} value The value to recursively clone.
 * @returns {*} Returns the deep cloned value.
 * @see clone
 * @example
 *
 * const objects = [{ 'a': 1 }, { 'b': 2 }]
 *
 * const deep = cloneDeep(objects)
 * console.log(deep[0] === objects[0])
 * // => false
 */
function cloneDeep(value) {
    return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG);
}

export default cloneDeep;

cloneDeep函数接受一个待克隆的对象value为输入,然后调用库中的baseClone函数,将 value 和一些标志位作为参数传入,进行具体的克隆工作。这里的CLONE_DEEP_FLAGCLONE_SYMBOLS_FLAG就是所谓的位掩码,它们用来告知baseClone函数该如何克隆对象。

CLONE_DEEP_FLAGCLONE_SYMBOLS_FLAG 是两个位掩码,它们决定了 baseClone 函数的行为。CLONE_DEEP_FLAG 表示应该进行深度克隆,CLONE_SYMBOLS_FLAG 表示应该克隆对象的符号属性。

baseClone 函数是 lodash 库的内部函数,它实现了实际的克隆逻辑。这个函数处理了各种复杂的情况,包括函数、日期、正则表达式、Map、Set、ArrayBuffer、DataView、TypedArray 等等。它还处理了循环引用的情况,避免了无限递归的问题。

总的来说,cloneDeep 函数提供了一种简单的方式来深度克隆 JavaScript 对象,无论这个对象有多复杂。

位掩码(bitmask)在cloneDeep函数中的作用

位掩码是计算机科学中一种常用的编程技巧。在cloneDeep函数中,CLONE_DEEP_FLAGCLONE_SYMBOLS_FLAG这两个位掩码被用来控制baseClone函数的克隆行为。

CLONE_DEEP_FLAG的值为1(001),表示执行深度克隆,即克隆对象的所有嵌套对象或数组,而不仅仅是复制引用。

CLONE_SYMBOLS_FLAG的值为4(100),表示克隆对象的Symbol属性。在JavaScript中,对象的属性键可以是字符串或者Symbol,而默认情况下,baseClone只会克隆字符串属性。如果你希望同时克隆Symbol属性,就需要设置CLONE_SYMBOLS_FLAG

这两个位掩码通过位运算符|("按位或")被组合成一个新的位掩码,然后被传入baseClone函数。CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG的结果是5101)。

CLONE_SYMBOLS_FLAG 的作用是告诉 baseClone 函数应该克隆对象的符号属性。在 JavaScript 中,对象的属性可以是字符串或符号。默认情况下,只有字符串属性会被克隆,如果你想要克隆符号属性,就需要设置 CLONE_SYMBOLS_FLAG

cloneDeep 函数通过 | 运算符将这两个位掩码合并成一个位掩码,然后将这个位掩码传递给 baseClone 函数。| 是一个位运算符,称为"按位或"(Bitwise OR)。对于每一个位,如果两个数字在该位上至少有一个是 1,那么结果在该位上也是 1,否则结果在该位上是 0。因此,CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG 的结果是 5,在二进制中表示为 101

baseClone函数中,会使用位运算符&("按位与")来检查每一位。例如,bitmask & CLONE_DEEP_FLAG的结果表示是否需要深度克隆,bitmask & CLONE_SYMBOLS_FLAG的结果表示是否需要克隆Symbol属性。如果结果不为 0,那么说明对应的位掩码是设置的,应该执行相应的操作。

关于 Symbol 类型的介绍以及在前端工程中的应用,可以看我另一篇博客:juejin.cn/post/729720...

baseClone

我们从上边的源码中可以看到,cloneDeep 函数的运行逻辑是基于./.internal/baseClone.js的,在 lodash 这个项目中,internal 文件夹下的函数主要是 lodash 库内部使用的一些工具函数。这些函数在库的主要部分中被广泛使用,但并不直接暴露给库的最终用户。

例如,lodashinternal 文件夹中可能包含一些用于类型检查、错误处理、数据格式转换等任务的函数。这些函数帮助 lodash 的主要部分保持整洁和可读,同时也使得代码更易于维护和测试。

总的来说,internal 文件夹下的代码是为了支持库或应用的主要功能而存在的,但它们自身并不构成库或应用的公共接口。

baseClone 是一个用于深度克隆 JavaScript 对象的函数。它可以处理各种数据类型,包括数组、对象、函数、Buffer、TypedArray、Map、Set、RegExp、Symbol 等。

基本工作原理

  1. 参数
typescript 复制代码
/**
 * The base implementation of `clone` and `cloneDeep` which tracks
 * traversed objects.
 *
 * @private
 * @param {*} value The value to clone.
 * @param {number} bitmask The bitmask flags.
 *  1 - Deep clone
 *  2 - Flatten inherited properties
 *  4 - Clone symbols
 * @param {Function} [customizer] The function to customize cloning.
 * @param {string} [key] The key of `value`.
 * @param {Object} [object] The parent object of `value`.
 * @param {Object} [stack] Tracks traversed objects and their clone counterparts.
 * @returns {*} Returns the cloned value.
 */
 
function baseClone(value, bitmask, customizer, key, object, stack) {
// Other fields are omitted for brevity
}

baseClone 函数接收六个参数:value(要克隆的值)、bitmask(位掩码标志)、customizer(自定义克隆函数)、keyvalue 的键)、objectvalue 的父对象)和 stack(用于跟踪已遍历的对象和它们的克隆副本)。

  1. 位掩码标志
typescript 复制代码
const CLONE_DEEP_FLAG = 1
const CLONE_FLAT_FLAG = 2
const CLONE_SYMBOLS_FLAG = 4

const isDeep = bitmask & CLONE_DEEP_FLAG
const isFlat = bitmask & CLONE_FLAT_FLAG
const isFull = bitmask & CLONE_SYMBOLS_FLAG

bitmask 参数是一个位掩码,用于指定克隆的行为。例如,CLONE_DEEP_FLAG 表示进行深度克隆,CLONE_FLAT_FLAG 表示扁平化继承的属性,CLONE_SYMBOLS_FLAG 表示克隆 Symbol 属性。

  1. 自定义克隆函数 如果提供了customizer函数,baseClone会用它来克隆value。如果customizer返回了undefinedbaseClone会继续执行默认的克隆逻辑。

  2. 克隆逻辑 baseClone 首先检查 value 是否是对象。如果不是,直接返回 value。然后,根据 value 的类型(数组、Buffer、对象、函数等),使用相应的函数(如 initCloneArraycloneBufferinitCloneObject 等)来初始化克隆。如果需要进行深度克隆,baseClone 会递归地克隆 value 的属性。

  3. 循环引用检查 baseClone 使用 stack 对象来跟踪已遍历的对象和它们的克隆副本。如果检测到循环引用(即一个对象直接或间接引用了自身),baseClone 会返回该对象的克隆副本,而不是无限递归。

  4. 处理特殊类型 :对于 Map、Set 和 TypedArray,baseClone 有特殊的处理逻辑。例如,对于 Map,baseClone 会遍历其所有键值对,并对每个值进行克隆;对于 Set,baseClone 会遍历其所有元素,并对每个元素进行克隆;对于 TypedArray,baseClone 会直接返回其克隆副本。

  5. 属性复制 :最后,baseClone 会复制 value 的所有属性(包括 Symbol 属性和继承的属性)到克隆副本。如果需要进行深度克隆,baseClone 会递归地克隆每个属性的值。

这就是 baseClone 函数的基本工作原理。下边我将详细讲解下上边提到的几个关键能力的实现原理以及应用场景。

customizer

baseClone接受的参数中的customizer 是一个可选的自定义函数,它允许你控制如何克隆对象。当你需要对某些特定类型或值进行特殊处理时,可以使用 customizer

例如,假设你有一个包含日期对象的数组,你想在克隆时将所有的日期转换为它们的时间戳。你可以提供一个 customizer 函数来实现这个需求:

typescript 复制代码
const array = [new Date()];
const customizer = (value) => {
  if (value instanceof Date) {
    return value.getTime();
  }
};

const clonedArray = baseClone(array, 1, customizer);
// clonedArray 现在包含日期的时间戳,而不是日期对象

在这个例子中,customizer 函数检查每个值是否是日期对象。如果是,它返回该日期的时间戳,这将被用作克隆的值。如果不是,它返回 undefined,这将导致 baseClone 使用默认的克隆逻辑。

这只是 customizer 可以做的事情的一个例子。你可以根据你的具体需求来自定义它。

getTag

getTag 函数用于获取 JavaScript 对象的 toStringTagtoStringTag 是一个内置的 Symbol 值,用于表示对象的默认字符串描述。当调用对象的 toString 方法时,toStringTag 会被用于生成返回的字符串。

typescript 复制代码
const toString = Object.prototype.toString

/**
 * Gets the `toStringTag` of `value`.
 *
 * @private
 * @param {*} value The value to query.
 * @returns {string} Returns the `toStringTag`.
 */
function getTag(value) {
  if (value == null) {
    return value === undefined ? '[object Undefined]' : '[object Null]'
  }
  return toString.call(value)
}

export default getTag

getTag 函数中,toString.call(value) 会返回一个形如 "[object Type]" 的字符串,其中 "Type" 是 value 的类型。例如,对于数组,它会返回 "[object Array]";对于函数,它会返回 "[object Function]",等等。

getTag 函数在 baseClone 中被用于确定如何克隆 value。例如,如果 getTag(value) 返回 "[object Array]"baseClone 会使用 initCloneArray 函数来克隆 value;如果 getTag(value) 返回 "[object Object]"baseClone 会使用 initCloneObject 函数来克隆 value,等等。

此外,getTag 还处理了 nullundefined 的特殊情况,因为对它们调用 toString 方法会抛出错误。对于 nullundefinedgetTag 直接返回 "[object Null]""[object Undefined]"

这里我们复习下call、bind和apply的作用和区别。

callapplybind 都是 JavaScript 中的函数方法,它们都可以改变函数的 this 上下文。但是,它们的使用方式和目的有所不同:

  1. callcall 方法接受一个对象和一系列参数,并立即执行函数,将第一个参数作为函数的 this 上下文,其余参数作为函数的参数。例如:func.call(obj, arg1, arg2)

  2. applyapply 方法的作用与 call 相同,但它接受一个对象和一个数组(或类数组对象),并将数组的元素作为函数的参数。例如:func.apply(obj, [arg1, arg2])。这在你不知道要传递多少参数,或参数已经在一个数组中时非常有用。

  3. bindbind 方法创建一个新的函数,这个函数在被调用时将第一个参数作为 this 上下文,其余参数作为新函数的参数。不同于 callapplybind 不会立即执行函数,而是返回一个新的函数。例如:const newFunc = func.bind(obj, arg1, arg2)

总结一下,callapply 用于立即调用函数并改变 this 上下文,而 bind 用于创建一个新的函数并预设 this 上下文和部分参数。

创建克隆副本

下边这部分的逻辑,目的是根据 value 的类型和克隆的需求(深度克隆还是浅克隆,克隆 Symbol 属性还是继承的属性等)来创建一个合适的克隆副本。

克隆数组

baseClone 函数中,对数组的克隆主要通过 initCloneArraycopyArray 两个函数来实现。

typescript 复制代码
let result
const isArr = Array.isArray(value)
if (isArr) {
    result = initCloneArray(value)
    if (!isDeep) {
      return copyArray(value, result)
    }
} 

如果 isDeep(深度克隆标志)为 false,它会使用 initCloneArray 创建新数组,然后使用 copyArray 复制原始数组的元素。这样,新数组的元素将是原始数组元素的浅克隆(即,如果元素是对象,新数组和原始数组将共享同一个对象)。

如果 isDeeptruebaseClone 会在后面的代码中递归地克隆原始数组的每个元素,从而实现深度克隆。

  1. initCloneArray :这个函数首先获取原始数组的长度,然后使用原始数组的构造函数创建一个新的、长度相同的数组。这样做的好处是,如果原始数组是一个特殊的数组类型(如 Uint8Array),新数组也会是相同的类型。然后,如果原始数组是由 RegExp#exec 生成的,并且其第一个元素是字符串,initCloneArray 会复制 indexinput 属性到新数组。这是因为 RegExp#exec 会在返回的数组上添加这些额外的属性。
typescript 复制代码
function initCloneArray(array) {
  const { length } = array
  const result = new array.constructor(length)

  // Add properties assigned by `RegExp#exec`.
  if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
    result.index = array.index
    result.input = array.input
  }
  return result
}

这里顺便提一下,当你通过 const arr = [] 定义一个数组时,你创建的是一个普通的 JavaScript 数组,它不属于 RegExp#exec 生成的数组,也不是 Uint8Array

  • RegExp#execRegExp#exec 是一个方法,用于在字符串中执行正则表达式匹配。当匹配成功时,它返回一个数组,其中包含匹配的结果和任何捕获的组。此外,返回的数组还有两个额外的属性:index(匹配的位置)和 input(原始字符串)。例如:

    typescript 复制代码
    const regex = /(hello)\s(world)/;
    const result = regex.exec('hello world');
    console.log(result);  // ["hello world", "hello", "world", index: 0, input: "hello world", groups: undefined]

    在这个例子中,result 是由 RegExp#exec 生成的数组,它包含匹配的结果和额外的属性。

  • Uint8ArrayUint8Array 是一个类型化数组,用于表示 8 位无符号整数的数组。它通常用于处理二进制数据,如在网络通信或文件操作中。例如:

    typescript 复制代码
    const buffer = new ArrayBuffer(8);
    const uint8 = new Uint8Array(buffer);
    console.log(uint8);  // Uint8Array(8) [0, 0, 0, 0, 0, 0, 0, 0]

    在这个例子中,uint8 是一个 Uint8Array,它包含 8 个 8 位无符号整数。

所以,const arr = [] 创建的是一个普通的 JavaScript 数组,它既不是由 RegExp#exec 生成的,也不是 Uint8Array

  1. copyArray:这个函数将原始数组的所有元素复制到新数组。它首先检查是否已经有一个新数组,如果没有,它会创建一个新的、长度与原始数组相同的数组。然后,它遍历原始数组的所有元素,并将它们复制到新数组的相应位置。
typescript 复制代码
/**
 * Copies the values of `source` to `array`.
 *
 * @private
 * @param {Array} source The array to copy values from.
 * @param {Array} [array=[]] The array to copy values to.
 * @returns {Array} Returns `array`.
 */
function copyArray(source, array) {
  let index = -1
  const length = source.length

  array || (array = new Array(length))
  while (++index < length) {
    array[index] = source[index]
  }
  return array
}

export default copyArray

除了数组以外

typescript 复制代码
const objectTag = '[object Object]'
const argsTag = '[object Arguments]'

const isFunc = typeof value === 'function'

if (isArr) {
  // Other fields are omitted for brevity
}else {
    const isFunc = typeof value === 'function'

    if (isBuffer(value)) {
      return cloneBuffer(value, isDeep)
    }
    if (tag === objectTag || tag === argsTag || (isFunc && !object)) {
      result = (isFlat || isFunc) ? {} : initCloneObject(value)
      if (!isDeep) {
        return isFlat
          ? copySymbolsIn(value, copyObject(value, keysIn(value), result))
          : copySymbols(value, Object.assign(result, value))
      }
    } else {
      if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
      }
      result = initCloneByTag(value, tag, isDeep)
    }
  }

buffer(详细讲下)

typescript 复制代码
if (isBuffer(value)) { return cloneBuffer(value, isDeep) }

如果 value 是一个 Buffer(在 Node.js 中用于处理二进制数据),它会使用 cloneBuffer 函数来克隆 valuecloneBuffer 函数会创建一个新的 Buffer,并复制 value 的所有字节到新的 Buffer。

typescript 复制代码
import root from './.internal/root.js';

/* Built-in method references for those with the same name as other `lodash` methods. */
const nativeIsBuffer = root?.Buffer?.isBuffer;

/**
 * Checks if `value` is a buffer.
 *
 * @since 4.3.0
 * @category Lang
 * @param {*} value The value to check.
 * @returns {boolean} Returns `true` if `value` is a buffer, else `false`.
 * @example
 *
 * isBuffer(Buffer.alloc(2))
 * // => true
 *
 * isBuffer(new Uint8Array(2))
 * // => false
 */
const isBuffer = typeof nativeIsBuffer === 'function' ? nativeIsBuffer : () => false;

export default isBuffer;
typescript 复制代码
import root from './root.js'

/** Detect free variable `exports`. */
const freeExports = typeof exports === 'object' && exports !== null && !exports.nodeType && exports

/** Detect free variable `module`. */
const freeModule = freeExports && typeof module === 'object' && module !== null && !module.nodeType && module

/** Detect the popular CommonJS extension `module.exports`. */
const moduleExports = freeModule && freeModule.exports === freeExports

/** Built-in value references. */
const Buffer = moduleExports ? root.Buffer : undefined, allocUnsafe = Buffer ? Buffer.allocUnsafe : undefined

/**
 * Creates a clone of `buffer`.
 *
 * @private
 * @param {Buffer} buffer The buffer to clone.
 * @param {boolean} [isDeep] Specify a deep clone.
 * @returns {Buffer} Returns the cloned buffer.
 */
function cloneBuffer(buffer, isDeep) {
  if (isDeep) {
    return buffer.slice()
  }
  const length = buffer.length
  const result = allocUnsafe ? allocUnsafe(length) : buffer.constructor.alloc(length)

  buffer.copy(result)
  return result
}

export default cloneBuffer

Function and Plain Object

如果 value 是一个普通对象(tag === objectTag)、函数参数对象(tag === argsTag)或函数(isFunc === true),那么它会创建一个新的空对象(如果 isFlat === trueisFunc === true)或使用 initCloneObject 函数来初始化一个新的对象(如果 isFlat === false 并且 isFunc === false)。

typescript 复制代码
/**
* isFlat:这个变量用于指示是否应该复制 `value` 的继承属性。如果 `isFlat` 为 `true`,
* `baseClone` 函数会复制 `value` 的所有自身属性和继承的属性(包括 Symbol 属性)。
* 如果 `isFlat` 为 `false`,`baseClone` 函数只会复制 `value` 的自身属性。
* 
* isFunc:这个变量用于检查 `value` 是否是一个函数。
* 如果 `value` 是一个函数,`baseClone` 函数会创建一个新的空对象,
* 而不是使用 `initCloneObject` 函数来初始化新对象。
*/

if (tag === objectTag || tag === argsTag || (isFunc && !object)) {
      result = (isFlat || isFunc) ? {} : initCloneObject(value)
      if (!isDeep) {
        return isFlat
          ? copySymbolsIn(value, copyObject(value, keysIn(value), result))
          : copySymbols(value, Object.assign(result, value))
      }
    }

然后,如果不需要深度克隆(isDeep === false),它会复制 value 的所有属性(包括 Symbol 属性和继承的属性)到新对象。这是通过 copySymbolsIncopyObjectObject.assign 函数实现的。

关于initCloneObject的详解可以参考我的博客 lodash 源码之 initCloneObject

  1. copyObject(递归研究,单起)
typescript 复制代码
import assignValue from './assignValue.js'
import baseAssignValue from './baseAssignValue.js'

/**
 * Copies properties of `source` to `object`.
 *
 * @private
 * @param {Object} source The object to copy properties from.
 * @param {Array} props The property identifiers to copy.
 * @param {Object} [object={}] The object to copy properties to.
 * @param {Function} [customizer] The function to customize copied values.
 * @returns {Object} Returns `object`.
 */
function copyObject(source, props, object, customizer) {
  const isNew = !object
  object || (object = {})

  for (const key of props) {
    let newValue = customizer
      ? customizer(object[key], source[key], key, object, source)
      : undefined

    if (newValue === undefined) {
      newValue = source[key]
    }
    if (isNew) {
      baseAssignValue(object, key, newValue)
    } else {
      assignValue(object, key, newValue)
    }
  }
  return object
}

export default copyObject

这段代码定义了一个名为 copyObject 的函数,它用于将 source 对象的属性复制到 object 对象。

函数接受四个参数:

  1. source:这是需要被复制属性的源对象。
  2. props :这是一个数组,包含了需要从 source 复制到 object 的属性标识符。
  3. object :这是需要复制属性的目标对象。如果没有提供,函数会创建一个新的空对象 {}
  4. customizer :这是一个可选的函数,用于自定义复制的值。如果提供了 customizer,那么在复制每个属性时,都会调用 customizer 函数,并将复制的值设置为 customizer 函数的返回值。

函数的主体是一个 for...of 循环,它遍历 props 数组中的每个属性标识符 key

对于每个 key,函数首先检查是否提供了 customizer 函数。如果提供了 customizer,那么函数会调用 customizer,并将复制的值设置为 customizer 的返回值。如果没有提供 customizer,或者 customizer 返回 undefined,那么函数会直接复制 source[key] 的值。

然后,函数会根据 object 是否是新创建的来决定如何复制属性。如果 object 是新创建的,函数会使用 baseAssignValue 来复制属性。否则,函数会使用 assignValue 来复制属性。

baseAssignValueassignValue 函数都用于将一个值赋给对象的属性,但它们可能会有一些不同的行为。具体的行为取决于这两个函数的实现。

最后,函数返回 object,这是复制了属性的目标对象。

总的来说,copyObject 函数的目的是复制 source 对象的一些或所有属性到 object 对象。复制的属性和值可以通过 props 数组和 customizer 函数来自定义。

  1. copySymbolsIn克隆继承属性(包括Symbol在内)
typescript 复制代码
import copyObject from './copyObject.js'
import getSymbolsIn from './getSymbolsIn.js'

/**
 * Copies own and inherited symbols of `source` to `object`.
 *
 * @private
 * @param {Object} source The object to copy symbols from.
 * @param {Object} [object={}] The object to copy symbols to.
 * @returns {Object} Returns `object`.
 */
function copySymbolsIn(source, object) {
  return copyObject(source, getSymbolsIn(source), object)
}

export default copySymbolsIn

Other Types

对于其他类型的 value,如果它是一个函数或不可克隆的类型(cloneableTags[tag]false),baseClone 会返回 value 本身(如果 objecttrue)或一个空对象(如果 objectfalse)。否则,它会使用 initCloneByTag 函数来初始化克隆。initCloneByTag 函数根据 value 的类型(由 tag 指定)来创建相应类型的克隆副本。

这段代码的目的是根据 value 的类型和克隆的需求(深度克隆还是浅克隆,克隆 Symbol 属性还是继承的属性等)来创建一个合适的克隆副本。

相关推荐
LuciferHuang41 分钟前
震惊!三万star开源项目竟有致命Bug?
前端·javascript·debug
GISer_Jing42 分钟前
前端实习总结——案例与大纲
前端·javascript
天天进步20151 小时前
前端工程化:Webpack从入门到精通
前端·webpack·node.js
姑苏洛言2 小时前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手2 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言2 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
你的人类朋友3 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手4 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3
一只小灿灿4 小时前
前端计算机视觉:使用 OpenCV.js 在浏览器中实现图像处理
前端·opencv·计算机视觉