万字解析JavaScript中的深浅拷贝

602 阅读9分钟

本文基于lodashbaseClone函数,对JavaScript中不同类型的深浅拷贝进行解析。lodash的深浅拷贝基于JavaScript的结构化克隆算法实现,进行了部分改造。通过本文,您能够对JavaScript中的类型有更全面的认知。

开始之前,本文可能存在部分笔误或错误的地方,请在评论区指出,笔者会及时更正

深浅拷贝的概念

首先,深浅拷贝这个概念只和复合对象有关。

  • 浅拷贝构造一个新的复合对象,向其中插入对原始对象中的对象的引用。
  • 深拷贝构造一个新的复合对象,然后递归地将原始对象中的对象的副本插入其中。

通过浅拷贝生成的对象在更新内部对象的时候,会影响到原有的对象。

结构化克隆算法

被用在WorkespostMessage数据传递,通过IndexdDB保存数据,或其他API中。它通过递归拷贝传入的对象,并在内部维护了一个之前遍历属性引用的map,防止无限循环

结构化克隆算法不生效的对象

lodash baseClone相比较于结构化克隆算法

  • 支持Symbol的拷贝
  • 不支持错误对象Error的拷贝
  • 正则表达式保留了lastIndex
  • 原型链保留
  • 复制不可复制对象时,不会抛出异常

以下是一些方法的回忆(夹杂了一些笔者自己不熟悉的东西),不需要可以直接跳到 lodash中对JavaScript不同对象的拷贝处理

部分方法回忆

字符串操作

RegExp.prototype.exec

let re = /quick\s(brown).+?(jumps)/igd;
let result = re.exec('The Quick Brown Fox Jumps Over The Lazy Dog');
// ['Quick Brown Fox Jumps', 'Brown', 'jumps']
// 0 返回匹配到的字符串
// 1-n 返回捕获的子表达式
// index 返回捕获字符串在原字符串中的位置
// input 返回原字符串
// indices 包含匹配字符串的开始索引和结束索引的二维数组
// indices[0] [4, 25] 'Quick Brown Fox Jumps'
// indices[1] [10, 15] 'Brown'
// indices[2] [20, 25] 'Jumps'

groups 和 indices.groups属性演示

// indices.groups 捕获正则表达式的具名捕获组,如果没有具名捕获组,返回undefined。
// 对上面的例子稍作更改
let re = /quick\s(?<group1>brown).+?(?<group2>jumps)/igd;
let result = re.exec('The Quick Brown Fox Jumps Over The Lazy Dog');
// indices.groups.group1 [10, 15]
// indices.groups.group2 [20, 25]
// groups {group1: 'Brown', group2: 'Jumps'}

re属性如下:

属性描述
lastIndex实例属性,并非定义在原型连上。下次匹配开始时的位置索引。只有存在gy标识符时,该属性会被自动设置。lastIndex在匹配不同的字符串的时候不会被自动重置,比如在匹配完a字符串后再去匹配b字符串,此时b的起始位置是上一次匹配a字符串的lastIndex位置25
dotAlls标识符。.默认匹配除换行符以外的任意字符。加上s后,.可以匹配所有字符。配合u使用可以匹配所有的unicode字符false
hasIndicesd标识符。该标识符会生成indices属性,用于返回匹配的子字符串的开始和结束索引的值true
ignoreCasei标识符。忽略大小写false
globalg标识符。是否全局匹配true
multilinem标识符。是否跨行匹配。使用该标识符后,^$会从匹配字符串的开头和结尾变成匹配每行字符串的开头和结尾false
source当前正则表达式/quick\s(brown).+?(jumps)/igd
stickyy标识符。从lastIndex位置开始匹配,只匹配一次,并将lastIndex后移。未匹配到,则将lastIndex设置为0false
unicodeu标识符。用来将模式视为 Unicode 代码点序列进行匹配。false

while循环中使用regexp构造函数,直接申明正则表达式或不使用g标识符。都可能造成无限循环,因为lastIndex每次都被初始化为0。

// 
// dotAll 
// hasIndices

String.prototype.replace

如果pattern是一个字符串,那么只会替换第一个匹配到的字符串。 字符串作为第二个参数的情况

Pattern插入值
$$插入一个$
$&插入匹配的子字符串
$`插入匹配子字符串之前的字符串部分
$'插入匹配子字符串之后的字符串部分。
$n插入捕获到的第n个表达式,索引以1开始
$<Name>插入对应具名捕获组的值

e.g:

var str ="cabc"
var str1 = str.replace(/a/, '$$') // c$bc
var str2 = str.replace(/a/, '$&') // cabc
var str3 = str.replace(/a/, '$`') // ccbc
var str4 = str.replace(/a/, `$'`) // cbcbc
var str5 = str.replace(/(a)(b)/, '$1') // cac
var str6 = str.replace(/(?<test>b)(?<test2>c)/, '$<test>') // cab

函数作为第二个参数的情况 函数接受的参数如下:

自定义名称
match匹配到的字符串
p1,p2...第一个参数为正则表达式时,返回捕获的值
offset匹配到的字符串在字符串中的起始位置
string需要替换的字符串
groups返回具名捕获组及对应的名称

String.prototype.replaceAll

使用正则表达式时,必须加上g标识

String.prototype.match

接受一个正则表达式作为参数,不传参返回[""],非正则表达式会调用new RegExp进行转换。主要分两种情况,正则表达式包含g和不包含g

  • 包含g,返回所有匹配到的字符串的数组
  • 不包含g,仅返回第一个完整匹配及其相关的捕获组(类似于exec表格中的p1,p2..replace$1$2...)。同时包含额外属性groupsindexinput类似于exec方法
  • 匹配不到,返回null。和exec返回值一致。

String.prototype.matchAll

matchAll虽然看上去是match的全局匹配版本,但它的行为更像exec。就像generatorasync的关系。

exec需要我们手动去遍历获取到的匹配数组和相关参数(indexinput

const regexp = new RegExp('foo[a-z]*','g');
const str = 'table football, foosball';
let match;

while ((match = regexp.exec(str)) !== null) {
  console.log(`Found ${match[0]} start=${match.index} end=${regexp.lastIndex}.`);
  // expected output: "Found football start=6 end=14."
  // expected output: "Found foosball start=16 end=24."
}

matchAll可以直接这样实现:

const regexp = new RegExp('foo[a-z]*','g');
const str = 'table football, foosball';
const matches = str.matchAll(regexp);

for (const match of matches) {
  console.log(`Found ${match[0]} start=${match.index} end=${match.index + match[0].length}.`);
}
// expected output: "Found football start=6 end=14."
// expected output: "Found foosball start=16 end=24."

// matches iterator is exhausted after the for..of iteration
// Call matchAll again to create a new iterator
Array.from(str.matchAll(regexp), m => m[0]);
// Array [ "football", "foosball" ]

exec唯一的区别在于matchAll并不修改正则表达式实例的lastIndex属性。

同样和replaceAll的行为一样,传递一个不包含g的正则表达式会导致matchAll抛出异常

RegExp.prototype.test

调用该方法会改变正则表达式实例的lastIndex值,且直到返回false的时候,该实例的lastIndex才会被重置为0

String.prototype.search

接收一个正则表达式的实例作为参数,如果是非正则表达式的实例,那么会调用new RegExp(object)方法进行类型转换

返回值是目标字符串的起始位置,匹配不到则返回-1

属性遍历方法回忆

for..in

for...in 语句迭代对象的所有以字符串为键的可枚举属性(忽略以Symbol键的那些),包括继承的可枚举属性。

for...in 循环以任意顺序迭代对象的属性。原因。不适合遍历迭代索引顺序很重要的对象,如数组

Object.keys()

返回对象自身的可枚举属性。

以与普通循环相同的顺序进行迭代。

Object.getOwnPropertyNames()

获取对象自身的所有属性,包括不可枚举的(除了那些使用 Symbol 的属性)

数组中可枚举属性的顺序与 for...in 循环(或 Object.keys())对对象属性公开的顺序一致。根据 ES6,对象的整数键(可枚举和不可枚举)首先按升序添加到数组中,然后按插入顺序添加字符串键。

hasOwnProperty

判断一个对象上是否存在对应的属性。对于数组对象,可以判断是否存在对应的索引

in

如果指定的属性在指定的对象或其原型链中,则 in 运算符返回 true。

Object实例方法回忆

Object.prototype.propertyIsEnumerable

该方法判断一个对象中的是否可以被for...in枚举,不包括原型链上的属性

Object.getOwnPropertySymbols()

获取对象上的以Symbol为键的属性

Object.getOwnPropertyNames()

获取对象上的所有属性(包括不可枚举的属性),但是不包含以Symbol为键的属性

Object.assign()

拷贝一个(或多个)对象所有可枚举(propertyIsEnumerable)的自身属性(hasOwnProperty)到目标对象上。返回修改后的对象。

对于源对象使用[[GET]],对于目标对象使用[[SET]]。所以能够触发相应的getterssetters方法

Object.getOwnPropertyDescriptor()

返回一个对象上指定属性的配置项。JavaScript 中的属性由字符串值名称或符号和属性描述符组成。

对象中存在的属性描述符有两种主要形式:数据描述符和访问器描述符。数据描述符是具有值的属性,该值可能可写,也可能不可写。访问器描述符是由一对 getter-setter 函数描述的属性。描述符必须是这两种风格之一;它不能两者兼而有之。

上述内容翻译过来就是:如果描述符同时具有 [value or writable][get or set] 键,则会引发异常。

Object.defineProperty()

Object.defineProperty(obj, prop, descriptor)

默认情况下,使用 Object.defineProperty() 添加的值是不可变(writable:false)的且不可枚举(enumerable:false)的。

为了确保保留属性描述符的默认值,您可以预先冻结对象,明确指定所有选项,或使用 Object.create(null) 指向 null。

// using __proto__
var obj = {};
var descriptor = Object.create(null); // no inherited properties
descriptor.value = 'static';

// not enumerable, not configurable, not writable as defaults
Object.defineProperty(obj, 'key', descriptor);

使用.赋值和使用 Object.defineProperty() 之间通常存在差异。

var o = {};

o.a = 1;
// is equivalent to:
Object.defineProperty(o, 'a', {
  value: 1,
  writable: true,
  configurable: true,
  enumerable: true
});

// On the other hand,
Object.defineProperty(o, 'a', { value: 1 });
// is equivalent to:
Object.defineProperty(o, 'a', {
  value: 1,
  writable: false,
  configurable: false,
  enumerable: false
});

new 操作符回忆

对于如下例子:

function Foo(bar1, bar2) {
      this.bar1 = bar1;
      this.bar2 = bar2;
    }
var myFoo = new Foo('Bar 1', 2021);

new Foonew Foo()是相等的。即,如果未指定参数列表,则不带参数调用 Foo。

TypedArray回忆

一个类型化数组(TypedArray)对象描述了一个底层的二进制数据缓冲区(binary data buffer)的一个类数组视图(view)。

ArrayBuffer回忆

ArrayBuffer 对象用于表示通用的、固定长度的原始二进制数据缓冲区

你不能直接操作 ArrayBuffer 的内容。但是,你可以创建一个以特定格式表示缓冲区的类型化数组(typed array objects)对象或 DataView 对象,并使用它来读取和写入缓冲区的内容。

ArrayBuffer() 构造函数创建一个给定长度(以字节为单位)的新 ArrayBuffer。您还可以从现有数据中获取一个Buffer对象,例如,从 Base64 字符串或 from a local file中获取。

Base64

Base64 是一组二进制到文本编码方案,通过将二进制数据转换为基数 64 表示,以 ASCII 字符串格式表示二进制数据

  • btoa:用于把二进制数据编码为ASCII码编码的数据
  • atob: 用于把ASCII码编码的数据解码为二进制数据

问题

  • 每个 Base64 数字正好代表 6 位数据(127)。因此,输入字符串/二进制文件的三个 8 位字节(3×8 位 = 24 位)可以由四个 6 位 Base64 数字(4×6 = 24 位)表示。。编码后体积增大(133%)。如果编码数据较小,则增加可能更大。

  • Unicode问题。DOMStrings 是 16 位编码的字符串,解决方案如下:

Data URLs

data:[<mediatype>][;base64],<data> 如果data中包含特殊字符( characters defined in RFC 3986 as reserved characters)[datatracker.ietf.org/doc/html/rf…] ,空格,换行符或自他非打印字符,这些字符必须被Percent-encoding

FileReader

FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。

  • FileReader.prototype.readAsDataURL常用于前端预览
  • FileReader.prototype.readAsBinaryString常用于文件上传

File System Access API

此 API 允许与用户本地设备或用户可访问的网络文件系统上的文件进行交互。

Blob

  • Blob.prototype.arrayBuffer()返回一个Promise对象 resolver arrayBuffer对象
  • Blob.prototype.slice()返回一个新的 Blob 对象,其中包含调用它的 Blob 的指定字节范围内的数据。类似于数组的slice方法
  • Blob.prototype.stream()返回可用于读取 Blob 内容的 ReadableStream。
  • Blob.prototype.text()返回一个使用 USVString resolvePromise对象,该 USVString 包含被解释为 UTF-8 文本的 Blob 的全部内容。
Using files from web applications

列举了上传的一些例子

FileList

该类型的对象由 HTML <input> 元素的 files 属性返回;

DataTransfer

该对象可从所有拖动事件的 dataTransfer 属性中获得。

File

File继承自Blob。主要的属性包含:

  • File.prototype.lastModified最近修改时间
  • File.prototype.type``MIME类型
  • File.prototype.size大小
  • File.prototype.name文件名称

lodash中对JavaScript不同对象的拷贝处理

基本数据类型

通过一个isObject函数来判断该类型是否需要特殊处理,isObject函数源码如下:

function isObject(value) {
  const type = typeof value
  // 例如:普通的对象,函数,数组,正则表达式对象,String构造函数生成的对象,Number构造函数生成的对象等等
  // 关于Object类型的定义 https://262.ecma-international.org/7.0/#sec-object-type
  return value != null && (type === 'object' || type === 'function')
}

对于null undefined string number boolean这几个类型直接返回。对于new调用基本数据类型的构造函数创建的对象(除去nullundefined),则通过对象的constructor属性(该属性是该实例构造函数的引用),创建新的基础数据类型对象,如下initCloneByTag函数(该函数的其他部分会在后续列出):

function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    // 布尔转数字
    case boolTag:
      return new Ctor(+object)
    ...
    case numberTag:
    case stringTag:
      return new Ctor(object)
  }
}

数组

数组在对象拷贝中,是主要处理的对象之一(另外一个是对象类型)。判断流程如下:

  • 判断对象是否是一个数组

    const isArr = Array.isArray(value)
    
  • 生成数组实例,即根据继承关系创建一个新的实例(可能是基于Array实现的其他类型的数组)。同时处理特殊数组,如上述回忆中的execmatchAll返回的数组会包含额外的indexinput属性

    initCloneArray函数创建新实例和特殊处理:

    function initCloneArray(array) {
      const { length } = array
      const result = new array.constructor(length)
    
      // Add properties assigned by `RegExp#exec`.
      // matchAll 方法也返回同类型的数组
      // 特殊处理正则exec返回的结果数组。exec返回一个数组,用匹配到的字符串作为数组的第一项,input属性保存匹配字符串
      // index 保存当前匹配字符串的位置 同时更新正则表达式的lastIndex属性 作为下一次匹配的起始位置
      // exec没有匹配到则返回null,同时设置正则对象的lastIndex为0
      // 更多说明: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec
      if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
        result.index = array.index
        result.input = array.input
      }
      return result
    }
    

    baseClone函数保存初始化的数组实例对象:

    ...
    // result 为baseClone的返回值
    result = initCloneArray(value)
    
  • 浅拷贝,直接遍历老数组并将老数组的项写入到新数组中

    copyArray函数:

    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
    }
    

    这里的copyArray为什么不直接使用slice呢?因为通过遍历可以返回一个密集数组,即empty item会被undefined填充。通过这一操作,可以使数组的forEach map等方法正常对数组进行操作

  • 深拷贝,forEach老数组的每一项并递归调用baseClone对每一项数据进行处理

    arrayEach函数,是lodash专门为数组实现的forEach版本,代码如下:

    // iteratee 为forEach 的 callback
    // 此处并未实现forEach的第二个参数`thisArg`的功能
    // forEach的callback不像`map reduce`,它的回调函数始终返回undefined
    function arrayEach(array, iteratee) {
      let index = -1
      const length = array.length
    
      while (++index < length) {
        if (iteratee(array[index], index, array) === false) {
          break
        }
      }
      return array
    }
    

    调用arrayEach(等同于Array.prototype.forEach)和baseClone递归的对数组项进行操作(此处代码为了清晰起见对部分源码进行了修改):

    // value 指代 当前的数组对象
    arrayEach(value, (subValue, key) => {
      // result 生成的新数组实例
      // key 数组的索引
      // subValue 数组索引处的值
      // bitmask 用于判断递归类型,见下方
      // customizer 自定义的递归函数
      // stack 用于记录已处理过的value的引用,避免对象内部循环引用的情况
      assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    

    上面的assignValue函数基于baseAssignValue函数,根据传入的对象,键,值,对该对象进行赋值,有点类似于Object.assign函数(只合并目标对象本身的可枚举属性)。这里直接调用assignValue的原因是valuebaseClone函数处理过了,所以能够直接赋值,实现深拷贝功能。baseAssignValue特殊处理了__proto__属性并重置了Object.defineProperty的默认值,代码如下:

    function baseAssignValue(object, key, value) {
      // 特殊处理__proto__属性
      if (key == '__proto__') {
        Object.defineProperty(object, key, {
          'configurable': true,
          'enumerable': true,
          'value': value,
          'writable': true
        })
      } else {
        object[key] = value
      }
    }
    

    assignValue函数在baseAssignValue函数的基础上,baseAssignValue接受三个参数object key valueobject需要写入属性的对象,key需要写入的键,value key对应的值。额外处理了以下条件:

    • object是否包含key,如果有,则判断object[key]是否value
    • 上述条件存在额外情况,因为+0 === -0,因此还要对0进行特殊处理
    • 如果value未传且源对象不包含key,则写入undefined,否则保留原始值 代码如下:
    const hasOwnProperty = Object.prototype.hasOwnProperty
    function assignValue(object, key, value) {
      const objValue = object[key]
    
      // object不包含当前的key 并且 object[key]与传入的value相等
      // 即当前key在object的原型链上 或 不包含当前的key
      if (!(hasOwnProperty.call(object, key) && eq(objValue, value))) {
        // value 不为+0或-0时,直接把key定义在当前对象上
        if (value !== 0 || (1 / value) === (1 / objValue)) {
          baseAssignValue(object, key, value)
        }
        // value 未传或全等于undefined && key 不在 object 对象或其原型链上
      } else if (value === undefined && !(key in object)) {
        baseAssignValue(object, key, value)
      }
    }
    

    eq函数,用于处理两个对象是否全等,并兼容了NaN === NaN的情况(Object.is(NaN, NaN) // true),代码如下:

    function eq(value, other) {
      // 特殊处理NaN NaN
      return value === other || (value !== value && other !== other)
    }
    

普通对象

  • 根据对象的原型初始化该对象的一个新的实例。该函数为initCloneObject函数,通过对象的原型创建一个新的实例。依赖一个isPrototype函数,用来校验传入的object是否是一个原型对象,即实例的constructor和原型链prototype.constructor两个属性的引用是否一致。initCloneObject函数的源码如下:

    function initCloneObject(object) {
      // object是一个实例并且不是一个原型对象
      return (typeof object.constructor === 'function' && !isPrototype(object))
        // 基于object的实例创建一个原型对象
        ? Object.create(Object.getPrototypeOf(object))
        : {}
    }
    

    object.constructor === 'function'用于兼容Object.create(null)的情况。isPrototype的源码如下:

    const objectProto = Object.prototype
    function isPrototype(value) {
      const Ctor = value && value.constructor
      const proto = (typeof Ctor === 'function' && Ctor.prototype) || objectProto
    
      return value === proto
    }
    
  • 浅拷贝

    • 平铺拷贝。思路是,先通过for...in获取对象和对象原型链上的可枚举属性,再通过Object.getOwnPropertySymbols并从原型链向上依次查找并通过Object.prototype.propertyIsEnumerable过滤不可枚举的Symbol对象。源码如下:

      copySymbolsIn(value, copyObject(value, keysIn(value), result))
      

      keysIn函数用于对象和对象原型链上的可枚举属性。源码如下:

      function keysIn(object) {
        const result = []
        for (const key in object) {
          result.push(key)
        }
        return result
      }
      
      

      copyObject函数用于把value对象上的属性copyresult上(该函数实际上可以通过一个customizer参数自定义该函数的实现,但由于此处不需要,就省略掉了)。该函数引用了baseAssignValue函数和assignValue函数(数组深拷贝中介绍过,这里不再赘述。注意baseAssignValue的赋值方式,是直接取源对象的值直接赋值):

      function copyObject(source, props, object) {
        // 是否创建新的空对象
        const isNew = !object
        object || (object = {})
      
        for (const key of props) {
          // 是否传递了自定义复制函数customizer
          let newValue = source[key]
          // object未传
          if (isNew) {
            baseAssignValue(object, key, newValue)
          } else {
            assignValue(object, key, newValue)
          }
        }
        return object
      }
      

      处理完普通的可枚举属性,现在来处理Symbol类型的属性。copySymbolsIn函数,本质上在内部又调用了一次copyObject函数,只不过此处获取对象键的方式由keyIn变为了getSymbolsIn。源码如下:

      function copySymbolsIn(source, object) {
        // getSymbolsIn 获取原型链上可枚举的symbol属性
        return copyObject(source, getSymbolsIn(source), object)
      }
      
      

      getSymbolsIn通过原型链向上查找可枚举的Symbol属性,并调用getSymbols来过滤不可枚举的Symbol属性。源码如下:

      function getSymbolsIn(object) {
        const result = []
        while (object) {
          result.push(...getSymbols(object))
          // Object(object) 兼容es5版本
          object = Object.getPrototypeOf(Object(object))
        }
        return result
      }
      
      

      getSymbols源码如下:

      /** Built-in value references. */
      // 该方法判断一个对象中的是否可以被for...in枚举,除去原型链上的属性
      // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/propertyIsEnumerable
      const propertyIsEnumerable = Object.prototype.propertyIsEnumerable
      
      /* Built-in method references for those with the same name as other `lodash` methods. */
      const nativeGetSymbols = Object.getOwnPropertySymbols
      
      /**
       * Creates an array of the own enumerable symbols of `object`.
       *
       * @private
       * @param {Object} object The object to query.
       * @returns {Array} Returns the array of symbols.
       */
      function getSymbols(object) {
        if (object == null) {
          return []
        }
        // Object(object) 兼容es5版本
        object = Object(object)
        // 返回可枚举的symbol属性
        return nativeGetSymbols(object).filter((symbol) => propertyIsEnumerable.call(object, symbol))
      }
      
    • 非平铺拷贝。相对于平铺拷贝,非平铺拷贝就简单多了,因为不需要获取原型链上的属性,可以直接使用Object.assign生成一个新对象。再把对应的Symbol属性复制过来。源码如下:

    copySymbols(value, Object.assign(result, value))
    

    copySymbols函数与copySymbolsIn函数相比,在内部直接调用了getSymbols函数而非getSymbolsIn函数。源码如下:

    function copySymbols(source, object) {
      return copyObject(source, getSymbols(source), object)
    }
    
  • 深拷贝

    下面代码对源代码进行部分了修改

    • 平铺拷贝。同非平铺拷贝相比,仅仅是获取键的方式不同。调用getAllKeysIn函数,源码如下:
    const props = getAllKeysIn(value)
    
    • 非平铺拷贝。调用getAllKeys函数,源码如下:
    const props = getAllKeys(value)
    

    之后就是对获取到的props数组调用forEacharrayEach)方法,并在内部递归调用baseClone函数和assignValue函数。源码如下:

    arrayEach(props, (subValue, key) => {
      // props e.g ['name', 'age']
      // key = 'name'
      // subvalue = value['name']
      key = subValue
      subValue = value[key]
      // Recursively populate clone (susceptible to call stack limits).
      // result[key] = baseClone(subValue, bitmask, customizer, key, value, stack)
      assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    
    

Symbol

通过getTag(内部调用typeof返回[object Symbol])函数获取到对象的类型。然后传递给initCloneByTag函数,生成一个Symbol实例。initCloneByTag函数源码如下:

const symbolTag = '[object Symbol]'
function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case symbolTag:
      return cloneSymbol(object)
  }
}

cloneSymbol函数,内部调用了Symbol.prototype.valueOf方法,传递给Object()作为参数生成一个新的对象。源码如下:

const symbolValueOf = Symbol.prototype.valueOf
function cloneSymbol(symbol) {
  return Object(symbolValueOf.call(symbol))
}

valueOf用户获取一个对象的原始值,一般不需要我们手动调用这个方法。当遇到需要原始值的对象时,JavaScript 会自动调用它

此处为什么不直接将symbol直接传递给Object呢?因为Symbol.prototype.valueOf方法获取到的值是一个个Primitive valuePrimitive不是一个对象并且也不包含任何方法,这意味着生成的新对象并不会影响到老对象。

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。你不能直接操作 ArrayBuffer 的内容,而是要通过类型数组对象或 DataView 对象来操作,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容。

每一个字节可以存储8位二进制数字0x00-0xff

类似于Symbol,通过getTag函数获取对应的类型,然后传递给initCloneByTag函数,调用对应的cloneArrayBuffer函数。源码如下:

const arrayBufferTag = '[object ArrayBuffer]'

function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case arrayBufferTag:
      return cloneArrayBuffer(object)
    ...
  }
}

由于不能直接操作ArrayBuffer,我们需要一个媒介,这个媒介就是Uint8Array这个TypedArray数组。因为计算机是以字节为基本单位的,因此8位的Uint8Array就足以处理我们需要的数据。cloneArrayBuffer的源代码如下:

function cloneArrayBuffer(arrayBuffer) {
  // 根据原有arrayBuffer的内存地址长度
  // 生成一块新的内存地址
  const result = new arrayBuffer.constructor(arrayBuffer.byteLength)
  // 因为无法直接操纵arrayBuffer,我们需要一个typedarray 数组来帮我们实现内存的复写
  // 因此借助一个Uint8Array typedArray 数组, new Uint8Array(result)
  // 调用set方法,set接收一个array或typedArray,因此我们需要把原始的arraybuffer也初始化为一个Uint8Array数组
  // 为什么使用Uint8Array呢?
  // 计算机的基本单位是字节,每个字节是8位2进制,所以Uint8Array就足够了
  new Uint8Array(result).set(new Uint8Array(arrayBuffer))
  return result
}

DataView

DataView 视图是一个可以从 二进制ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序问题。

还是通过initCloneByTag函数初始化。源码如下:

const dataViewTag = '[object DataView]'

function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case dataViewTag:
      return cloneDataView(object, isDeep)
    ...
  }
}

cloneDataView传入了深浅拷贝的标识符isDeep,也就是说深浅拷贝的方式不同。

  • 深拷贝 因为DataView是用来操作一块二进制缓冲区。所以对于深拷贝来讲,实际上就是把原来内存中的数据存储到一个新的地址即可。即使用原有的arrayBuffer生成一个新的arrayBuffer。其实上面的cloneArrayBuffer已经实现了这个功能。源码如下:

    const buffer = cloneArrayBuffer(dataView.buffer)
    
  • 浅拷贝 对于浅拷贝,则直接引用原有的内存地址即可。源码如下:

    const buffer =  dataView.buffer
    

    综上,cloneDataView的最终代码:

function cloneDataView(dataView, isDeep) {
  const buffer = isDeep ? cloneArrayBuffer(dataView.buffer) : dataView.buffer
  return new dataView.constructor(buffer, dataView.byteOffset, dataView.byteLength)
}

其中dataView.byteOffsetdataView.byteLength是为了确保和原有dataView对象的可操作范围保持一致。

TypedArray

一个类型化数组(TypedArray)对象描述了一个底层的二进制数据缓冲区(binary data buffer)的一个类数组视图(view)。事实上,没有名为 TypedArray 的全局属性,也没有一个名为 TypedArray 的构造函数。相反,有许多不同的全局属性,它们的值是特定元素类型的类型化数组构造函数,

这些类数组分别是:

const float32Tag = '[object Float32Array]'
const float64Tag = '[object Float64Array]'
const int8Tag = '[object Int8Array]'
const int16Tag = '[object Int16Array]'
const int32Tag = '[object Int32Array]'
const uint8Tag = '[object Uint8Array]'
const uint8ClampedTag = '[object Uint8ClampedArray]'
const uint16Tag = '[object Uint16Array]'
const uint32Tag = '[object Uint32Array]'

lodash的初始化方式,initCloneByTag函数初始化。源码如下:

function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case float32Tag: case float64Tag:
    case int8Tag: case int16Tag: case int32Tag:
    case uint8Tag: case uint8ClampedTag: case uint16Tag: case uint32Tag:
      return cloneTypedArray(object, isDeep)
    ...
  }
}

cloneTypedArray传入了深浅拷贝的标识符isDeep,也就是说深浅拷贝的方式不同。由于typedArrayDataView一样都是用来操作内存的,所以深浅拷贝的方式自然也是一样的。源码如下:

function cloneTypedArray(typedArray, isDeep) {
  const buffer = isDeep ? cloneArrayBuffer(typedArray.buffer) : typedArray.buffer
  return new typedArray.constructor(buffer, typedArray.byteOffset, typedArray.length)
}

baseClone函数中,对于TypedArray类型的对象,直接返回:

if (isTypedArray(value)) {
    return result
  }

isTypedArray函数,判断传入的值是否是一个TypedArray的实例。对于浏览器环境,可以使用正则表达式匹配toStringTag的方式判断,对于Node环境,可以直接调用Util模块下的types.isTypedArray方法判断。源码如下:

// 浏览器环境判断
const reTypedTag = /^\[object (?:Float(?:32|64)|(?:Int|Uint)(?:8|16|32)|Uint8Clamped)Array\]$/
// https://nodejs.org/api/util.html#util_util_types
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
// node
const nodeIsTypedArray = nodeTypes && nodeTypes.isTypedArray

const isTypedArray = nodeIsTypedArray
  // node环境调用util.types上的isTypedArray方法
  ? (value) => nodeIsTypedArray(value)
  // 对象 | 函数 
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray#typedarray_objects
  : (value) => isObjectLike(value) && reTypedTag.test(getTag(value))

那么lodash是如何判断node环境并返回nodeTypesrequire('util').types)的呢?

检测Node环境的globalThis是否指向global,用代码来说就是global.Object === Object。源代码如下:

const freeGlobal = typeof global === 'object' && global !== null && global.Object === Object && global

检测上下文的moduleexports以及module.exports。源码如下:

/** 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

关于module的处理主要是为了区分esm见此处

最后返回我们需要的工具函数:

const nodeTypes = ((() => {
  try {
    /* Detect public `util.types` helpers for Node.js v10+. */
    /* Node.js deprecation code: DEP0103. */
    const typesHelper = freeModule && freeModule.require && freeModule.require('util').types
    return typesHelper
      ? typesHelper
      /* Legacy process.binding('util') for Node.js earlier than v10. */
      : freeProcess && freeProcess.binding && freeProcess.binding('util')
  } catch (e) {}
})())

关于上述的Node.js deprecation code: DEP0103.见此处

Buffer

BufferNode中的类型,lodash的处理方式也很简单:

if (isBuffer(value)) {
  return cloneBuffer(value, isDeep)
}

cloneBuffer这个函数,有一个bug,就是深浅拷贝搞反了

const Buffer = moduleExports ? root.Buffer : undefined, allocUnsafe = Buffer ? Buffer.allocUnsafe : undefined

function cloneBuffer(buffer, isDeep) {
  // 深度拷贝
  // 原先为isDeep,这里我改掉了
  if (!isDeep) {
    // 返回一个新的 Buffer ,它引用与原始内存相同的内存
    return buffer.slice()
  }
  const length = buffer.length
  // 生成一块新的内存(可能包含脏数据)
  const result = allocUnsafe ? allocUnsafe(length) : new buffer.constructor(length)

  // 将原始数据copy到新内存中
  buffer.copy(result)
  return result
}

对于深拷贝,本质上是使用Buffer.allocUnsafe生成一块新的内存,并将老内存的数据copy过去。

root函数返回当前上下文的this对象,源码如下:

const freeGlobal = typeof global === 'object' && global !== null && global.Object === Object && global

/** Detect free variable `globalThis` */
const freeGlobalThis = typeof globalThis === 'object' && globalThis !== null && globalThis.Object == Object && globalThis

/** Detect free variable `self`. */
// https://developer.mozilla.org/en-US/docs/Web/API/Window/self
const freeSelf = typeof self === 'object' && self !== null && self.Object === Object && self

/** Used as a reference to the global object. */
const root = freeGlobalThis || freeGlobal || freeSelf || Function('return this')()

优先级globalThis > global > self > Function('return this')()

Map Set

这两个类型的处理方式,先根据toStringTag初始化一个对应的MapSet实例。源码如下:

const setTag = '[object Set]'
const mapTag = '[object Map]'

function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case mapTag:
      return new Ctor
    ...
    case setTag:
      return new Ctor
    ...
  }
}

注意这里由于不需要初始化参数,是直接使用new Ctor的方式来创建实例的,这种方式和直接调用new Ctor()是一样的。

lodash对于MapSet的值默认使用深拷贝:

// map类型
  if (tag == mapTag) {
    value.forEach((subValue, key) => {
      result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    return result
  }

  // set类型
  if (tag == setTag) {
    value.forEach((subValue) => {
      result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
    })
    return result
  }

Map和Set的更多参考

Date

类似于基础数据类型,直接调用构造函数并显示隐式调用Date.prototype.valueOf方法(+object)。initCloneByTag代码如下:

const dateTag = '[object Date]'
function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case dateTag:
      return new Ctor(+object)
    ...
  }
}

RegExp

initCloneByTag代码如下:

const regexpTag = '[object RegExp]'
function initCloneByTag(object, tag, isDeep) {
  // 获取对象构造函数的引用
  const Ctor = object.constructor
  switch (tag) {
    ...
    case regexpTag:
      return cloneRegExp(object)
    ...
  }
}

对于cloneRegExp方法。有一个和结构化克隆算法不同的点,那就是该方法把原正则表达式实例的lastIndex的值也copy了。同时,需要获取到原有正则表达式的flags。源码如下:

const reFlags = /\w*$/
function cloneRegExp(regexp) {
  // reFlags.exec(regexp)返回匹配到的flags数组,猜测RegExp内部会隐式调用toString方法,将flags和正则字符串进行组合
  const result = new regexp.constructor(regexp.source, reFlags.exec(regexp))
  // 由于上述操作lastIndex被重置为0,所以此处将lastIndex重新赋值
  result.lastIndex = regexp.lastIndex
  return result
}

默认不支持拷贝的对象

  • function
  • [object WeakMap]
  • [object Error] 这个对象在结构化克隆算法中是支持的

baseClone源码借鉴

通过位运算区分深浅拷贝

baseClone函数通过一个bitmask参数,进行位运算来判断开启哪些拷贝功能,如下:

// 0001
const CLONE_DEEP_FLAG = 1
// 0010
const CLONE_FLAT_FLAG = 2
// 0100
const CLONE_SYMBOLS_FLAG = 4

// 深度克隆 0001
const isDeep = bitmask & CLONE_DEEP_FLAG
// 平铺克隆 0010
const isFlat = bitmask & CLONE_FLAT_FLAG
// 包含Symbols  0100
const isFull = bitmask & CLONE_SYMBOLS_FLAG

这样做的好处,通过传递相应的类型,能够开启相应的拷贝模式,类似于开关功能:

CLONE_DEEP_FLAGCLONE_FLAT_FLAGCLONE_SYMBOLS_FLAG

通过一个|操作,将上述参数传递给bitmask,就能开启全部的三个功能。同时位运算的性能是最高的,也避免了在一个函数中写条件语句或switch语句的问题,在业务中会有大量类似的场景,因此我们可以参考bitmask的实现来优化我们的代码。

对象重复引用处理

Stack函数。依赖于ListCacheMapCache两个类,默认初始化为ListCache。在ListCachesize达到200的限制的时候,自动转换为MapCacheMapCache内部初始化了3个Map对象,包括HashMap(由Object.create(null)创建的对象存储数据)和原生Map

对于ListCacheMapCache的实现方式都很简单,类似如下模式:

class cache {
  constructor(entries) {
    // 存储数据结构
    this.__data__ = ...
    // 当前存储数据的个数
    this.size
    // 根据entries初始化
    ...
  }
  // 获取对应key的值
  get(key) {
    ...
  }
  // 设置对应key的value
  set(key, value) {
    ...
  }
  // 是否包含对应的key
  has(key) {
    ...
  }
  // 清空
  clear() {
    this.__data__ = ...
    this.size = 0
    ...
  }
  // 删除某一项
  delete(key) {
    ...
  }
}

那么ListCache是如何转换为MapCache的,一定是在调用set方法的时候,对当前的size进行校验,大于200时将已保存的数据传入MapCache的构造函数并替换ListCacheMapCache。源码如下:

set(key, value) {
    // 这是stack的this.__data__,该this.__data__引用ListCache或MapCache的实例
    let data = this.__data__
    if (data instanceof ListCache) {
      // 这里的paris获取ListCache实例的__data__,数据保存在这里
      const pairs = data.__data__
      // 超过数组最大长度限制,使用MapCache替换listCache
      if (pairs.length < LARGE_ARRAY_SIZE - 1) {
        pairs.push([key, value])
        this.size = ++data.size
        return this
      }
      // 替换当前的ListCache为MapCache,并将以保存pairs传入MapCache初始化
      data = this.__data__ = new MapCache(pairs)
    }
    data.set(key, value)
    this.size = data.size
    return this
  }

ListCache相比于MapCache(HashMap)存储数据的方式有如下差异:

  • ListCache
this.__data__ = [[key, value], ...]
  • HashMap
this.__data__ = {key: value}

此处内容不一定正确,笔者参照Java中的List和Map进行解释

List相比较于Map,存储数据是有序的,因此在删除元素时,能够快速定位元素的位置。而Map需要先通过get获取到元素才能进行删除操作。

看一下ListCache中是如何删除数组的最后一个元素的,这也是一个性能优化点:

delete(key) {
    const data = this.__data__
    // 获取当前key的索引
    const index = assocIndexOf(data, key)

    if (index < 0) {
      return false
    }
    const lastIndex = data.length - 1
    // 最后一项直接pop
    if (index == lastIndex) {
      data.pop()
    } else {
    // 否则删除对应的项
      data.splice(index, 1)
    }
    --this.size
    return true
  }

HashMap相比较于Map,通过直接通过属性键的方式取代了get方法,因此性能高于直接使用Map。那么MapCache是如何判断何时使用HashMap存储数据何时使用原生Map存储数据的呢。 MapCache在初始化this.__data__的时候,初始化为如下形式:

this.__data__ = {
                  'hash': new Hash,
                  'map': new Map,
                  'string': new Hash
                }

在实例调用set方法的时候,会调用getMapData这个函数根据key的类型选择存储在哪个map当中

function getMapData({ __data__ }, key) {
  const data = __data__
  // 检查key是否为__proto__或者null
  return isKeyable(key)
  // data[string] data[hash]
    ? data[typeof key === 'string' ? 'string' : 'hash']
    // key不是常规属性,则使用原生map
    : data.map
}

isKeyable判断如下:

function isKeyable(value) {
  const type = typeof value
  return (type === 'string' || type === 'number' || type === 'symbol' || type === 'boolean')
    ? (value !== '__proto__')
    // hashMap允许key为null
    : (value === null)
}

最后我们来看一下MapCacheset函数的原貌:

set(key, value) {
    const data = getMapData(this, key)
    const size = data.size

    data.set(key, value)
    this.size += data.size == size ? 0 : 1
    return this
  }

参考文档

lodash baseClone 源码

MDN

以上就是本文的全部内容,如果对你有帮助,请点个赞,让更多的人看到它~~~