技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

原生 JS 灵魂之问(中),检验自己是否真的熟悉 JavaScript?

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/87-原生JS灵魂之问(中),检验自己是否真的熟悉JavaScript?
本文目录18 个章节
  1. 第七篇:arguments 为什么不是数组?如何转化成数组?
  2. 第八篇:forEach 中 return 有效果吗?如何中断循环?
  3. 第九篇:JS 判断数组中是否包含某个值
  4. 第十篇:数组扁平化 flat
  5. 第十一篇:JS 数组的高阶函数——基础篇
  6. 第十二篇:能不能实现数组 map 方法?
  7. 第十三篇:能不能实现数组 reduce 方法?
  8. 第十四篇:能不能实现数组 push、pop 方法?
  9. 第十五篇:能不能实现数组 filter 方法?
  10. 第十六篇:能不能实现数组 splice 方法?
  11. 第十七篇:能不能实现数组 sort 方法?
  12. 第十八篇:能不能模拟实现一个 new 的效果?
  13. 第十九篇:能不能模拟实现一个 bind?
  14. 第二十篇:能不能实现一个 call/apply 函数?
  15. 第二十一篇:谈谈你对 JS 中 this 的理解
  16. 第二十二篇:JS 中浅拷贝的手段有哪些?
  17. 第二十三篇:能不能写一个完整的深拷贝?
  18. 小结

原生 JS 灵魂之问(中),检验自己是否真的熟悉 JavaScript?

Category(分类): JavaScript Status: 已整理

本文保留原文从数组、高阶函数、手写数组方法到 newbindcall/applythis、浅拷贝和深拷贝的练习路线。手写实现用于学习规范思路,不应覆盖原生方法,也不应宣称等同引擎实现。

真实业务中不一定需要手写 splice 或深拷贝,但边界问题能帮助我们理解 JavaScript 的对象模型、属性描述符、稀疏数组和异常传播。涉及性能的结论应以目标引擎和实际基准测试为准。

第七篇:arguments 为什么不是数组?如何转化成数组?

arguments 是一个类数组对象:有从 0 开始的索引和 length,但它不是 Array,不能直接调用 mapfilter 等数组方法。浏览器中的 HTMLCollectionNodeList 也可能是类数组或可迭代对象。

function sum(a, b) {
  console.log(Array.isArray(arguments)) // false
  const args = Array.prototype.slice.call(arguments)
  return args.reduce((total, value) => total + value, 0)
}

console.log(sum(1, 2)) // 3

1. Array.prototype.slice.call

这是历史上常见的 ES5 写法:

function toArray(value) {
  return Array.prototype.slice.call(value)
}

它依赖类数组的 length 和索引属性,某些宿主对象或旧浏览器可能存在兼容性差异。

2. Array.from

现代代码通常优先使用 Array.from

function sum(...args) {
  return Array.from(args).reduce((total, value) => total + value, 0)
}

console.log(Array.from(new Set([1, 2, 2]))) // [1, 2]

Array.from 可以处理可迭代对象和具有 length 的类数组对象。

3. 展开语法

展开语法要求对象可迭代:

function collect(...args) {
  return [...args]
}

console.log(collect(1, 2, 3)) // [1, 2, 3]

一个只有索引和 length、但没有 Symbol.iterator 的类数组对象不能直接使用 [...value],此时使用 Array.from(value)

4. concat.apply

旧代码还会这样写:

function concatArguments() {
  return Array.prototype.concat.apply([], arguments)
}

console.log(concatArguments(1, 2, 3)) // [1, 2, 3]

这种写法可读性和边界行为都不如 Array.from,新代码不必优先使用。

第八篇:forEachreturn 有效果吗?如何中断循环?

forEach 会调用每个符合条件的元素的回调,但回调的返回值不会成为 forEach 的返回值,也不能用 break 终止外层遍历:

const nums = [1, 2, 3]
const result = nums.forEach((item) => {
  if (item === 2) return
  console.log(item)
})

console.log(result) // undefined

如果需要提前结束:

  • some:回调返回真值时停止并返回 true
  • every:回调返回假值时停止并返回 false
  • find/findIndex:找到结果时停止。
  • 普通 forfor...of:可以使用 breakcontinue
  • 抛出异常可以强行中断,但通常不是正常控制流方案。
const nums = [1, 2, 3, 4]

const found = nums.some((item) => {
  console.log(item)
  return item === 2
})

console.log(found) // true

另外,forEach 不会等待异步回调:

async function wrong() {
  [1, 2, 3].forEach(async (item) => {
    await save(item)
  })
  console.log('这里不会等待 save')
}

需要串行等待时使用 for...of

async function serial(items) {
  for (const item of items) {
    await save(item)
  }
}

需要并行等待时使用 Promise.all

async function parallel(items) {
  return Promise.all(items.map((item) => save(item)))
}

第九篇:JS 判断数组中是否包含某个值

1. indexOf

const arr = [1, 2, 3, 4]
console.log(arr.indexOf(3)) // 2
console.log(arr.indexOf(5)) // -1

indexOf 使用严格相等风格的比较,因此找不到 NaN

console.log([NaN].indexOf(NaN)) // -1

2. includes

console.log([1, 2, 3].includes(3)) // true
console.log([NaN].includes(NaN)) // true
console.log([1, 2, 3].includes(2, 1)) // true

includes 使用 SameValueZero 比较,适合表达“是否包含”。

3. find

find 返回第一个满足条件的元素,而不是索引:

const result = [1, 2, 3, 4].find((item) => item > 3)
console.log(result) // 4

4. findIndex

原文中的索引查找方法名称有拼写错误,正确名称是 findIndex

const index = [1, 2, 3, 4].findIndex((item) => item > 3)
console.log(index) // 3

如果只需要判断,可以优先使用 includessome,不要为了布尔结果保存不必要的索引。

第十篇:数组扁平化 flat

需求是把多层数组展开:

const nested = [1, [2, [3, [4, 5]]], 6]
console.log(nested.flat(Infinity)) // [1, 2, 3, 4, 5, 6]

1. 原生 flat

flat(depth) 默认只展开一层,Infinity 表示尽可能展开所有嵌套数组。它会创建新数组,不会递归修改原数组。

2. 递归实现

function flatten(value, depth = Infinity) {
  const result = []

  for (const item of value) {
    if (Array.isArray(item) && depth > 0) {
      result.push(...flatten(item, depth - 1))
    } else {
      result.push(item)
    }
  }

  return result
}

console.log(flatten([1, [2, [3]]], 1)) // [1, 2, [3]]

3. reduce 实现

function flattenWithReduce(value) {
  return value.reduce((result, item) => {
    return result.concat(Array.isArray(item) ? flattenWithReduce(item) : item)
  }, [])
}

4. while + concat

function flattenWithConcat(value) {
  let result = value.slice()
  while (result.some(Array.isArray)) {
    result = [].concat(...result)
  }
  return result
}

不建议的字符串方案

原文的 JSON.stringify + replace + split 只对非常受限的数字数组勉强有效,会破坏字符串中的逗号、null、对象、undefined、稀疏项和特殊值。它不是通用数组扁平化实现,新代码应使用 flat 或明确的递归实现。上面的递归和循环实现也主要面向普通、无环数组;要完整复刻原生 flat,还要考虑稀疏槽位、Symbol.isConcatSpreadable、数组 species 和代理。它们适合说明递归/迭代思路,不是 polyfill。

第十一篇:JS 数组的高阶函数——基础篇

接收函数作为参数,或返回函数的函数,通常称为高阶函数。数组的 mapfilterreducesort 等方法都接受回调,但它们的返回值和是否修改原数组不同。

1. map

map 创建新数组,回调参数依次是当前值、索引和原数组;它会跳过稀疏数组中的空槽,但结果数组保留对应空槽:

const nums = [1, 2, 3]
const obj = { value: 5 }

const newNums = nums.map(function (item, index, array) {
  return item + index + array[index] + this.value
}, obj)

console.log(newNums) // [7, 10, 13]

2. reduce

reduce 的回调参数依次是累积值、当前值、索引和原数组。没有初始值时,数组第一个存在的元素成为初始累积值;空数组没有初始值会抛出 TypeError

const total = [1, 2, 3].reduce((sum, item) => sum + item, 0)
console.log(total) // 6

3. filter

filter 返回一个新数组,只保留回调结果为真值的元素:

const oddNums = [1, 2, 3].filter((item) => item % 2 === 1)
console.log(oddNums) // [1, 3]

4. sort

不传比较器时,数组元素会先转为字符串,按 UTF-16 code unit 顺序排序:

console.log([10, 2, 1].sort()) // [1, 10, 2]
console.log([10, 2, 1].sort((a, b) => a - b)) // [1, 2, 10]

从 ECMAScript 2019 起,标准要求 Array.prototype.sort 稳定:比较结果相等的元素保持原有相对顺序。但排序算法和性能仍由引擎决定,不能把某个旧版 V8 的实现细节当作规范。

第十二篇:能不能实现数组 map 方法?

下面的实现展示 map 的主要步骤:把 this 转为对象、读取固定的长度、跳过不存在的索引、传入三个回调参数。它没有完整实现 species、代理异常和所有属性描述符语义,因此只作为教学版本。

Array.prototype.map 规范步骤历史图

function mapLike(arrayLike, callback, thisArg) {
  if (arrayLike == null) {
    throw new TypeError('map called on null or undefined')
  }
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function')
  }

  const object = Object(arrayLike)
  const numberLength = Number(object.length)
  const length = Number.isFinite(numberLength) && numberLength > 0
    ? Math.min(Math.floor(numberLength), Number.MAX_SAFE_INTEGER)
    : 0
  const result = new Array(length)

  for (let index = 0; index < length; index++) {
    if (index in object) {
      result[index] = callback.call(
        thisArg,
        object[index],
        index,
        object,
      )
    }
  }

  return result
}

console.log(mapLike([1, 2, 3], (value) => value * 2)) // [2, 4, 6]

原文使用 length >>> 0,这会把长度强制成 32 位无符号整数,不等同于现代规范的 LengthOfArrayLike,也会错误处理超过 2^32 - 1 的类数组长度。原文代码中的 callbackfn/KValue 大小写不一致、newArray 等抓取错误也已修正。

第十三篇:能不能实现数组 reduce 方法?

实现 reduce 时最容易出错的是:不能用 initialValue === undefined 判断调用者是否传了初始值,因为调用者可以显式传入 undefined。应使用参数个数判断。

Array.prototype.reduce 规范步骤历史图

function reduceLike(arrayLike, callback, initialValue) {
  if (arrayLike == null) {
    throw new TypeError('reduce called on null or undefined')
  }
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function')
  }

  const object = Object(arrayLike)
  const numberLength = Number(object.length)
  const length = Number.isFinite(numberLength) && numberLength > 0
    ? Math.min(Math.floor(numberLength), Number.MAX_SAFE_INTEGER)
    : 0
  let index = 0
  let accumulator

  if (arguments.length >= 3) {
    accumulator = initialValue
  } else {
    while (index < length && !(index in object)) index++
    if (index >= length) {
      throw new TypeError('Reduce of empty array with no initial value')
    }
    accumulator = object[index++]
  }

  for (; index < length; index++) {
    if (index in object) {
      accumulator = callback(
        accumulator,
        object[index],
        index,
        object,
      )
    }
  }

  return accumulator
}

console.log(reduceLike([1, 2, 3], (sum, value) => sum + value, 0)) // 6

in 会检查原型链,这是数组内置方法处理稀疏数组时需要考虑的语义之一。上面的实现仍然没有实现 ArraySpeciesCreate 等完整规范细节。

第十四篇:能不能实现数组 pushpop 方法?

pushpop 会修改数组本身,并返回新长度或移除的元素。它们也是泛型方法,可以对合适的类数组对象调用,但真正的 Array length 有自己的范围和属性约束。

Array.prototype.push 历史规范图

Array.prototype.pop 历史规范图

function pushLike(arrayLike, ...items) {
  const object = Object(arrayLike)
  const length = Number(object.length) || 0
  const max = Number.MAX_SAFE_INTEGER

  if (length + items.length > max) {
    throw new TypeError('Invalid array length')
  }

  for (let index = 0; index < items.length; index++) {
    object[length + index] = items[index]
  }
  object.length = length + items.length
  return object.length
}

function popLike(arrayLike) {
  const object = Object(arrayLike)
  const length = Number(object.length) || 0
  if (length === 0) {
    object.length = 0
    return undefined
  }

  const index = length - 1
  const value = object[index]
  delete object[index]
  object.length = index
  return value
}

这两个示例没有处理不可写 length、不可配置属性、代理和所有异常回滚语义;生产代码直接使用 Array.prototype.push/pop

第十五篇:能不能实现数组 filter 方法?

function filterLike(arrayLike, callback, thisArg) {
  if (arrayLike == null) {
    throw new TypeError('filter called on null or undefined')
  }
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function')
  }

  const object = Object(arrayLike)
  const numberLength = Number(object.length)
  const length = Number.isFinite(numberLength) && numberLength > 0
    ? Math.min(Math.floor(numberLength), Number.MAX_SAFE_INTEGER)
    : 0
  const result = []

  for (let index = 0; index < length; index++) {
    if (index in object) {
      const value = object[index]
      if (callback.call(thisArg, value, index, object)) {
        result.push(value)
      }
    }
  }

  return result
}

过滤结果会重新从索引 0 开始排列,不会保留原数组中的空槽位置。和 map 一样,回调中修改数组会影响后续可见值,但遍历长度是开始时读取的长度;具体代理和属性异常属于规范边界。

Array.prototype.filter 历史规范图

第十六篇:能不能实现数组 splice 方法?

splice(start, deleteCount, ...items) 会在原数组上删除和插入元素,并返回被删除元素组成的新数组:

const array = [1, 2, 3, 4]
const removed = array.splice(1, 2, 'a', 'b', 'c')

console.log(removed) // [2, 3]
console.log(array) // [1, 'a', 'b', 'c', 4]

参数规则包括:负索引从末尾计算;start 超出范围会被夹到边界;省略 deleteCount 与显式传入 0 不同;删除数量会被限制在剩余长度内。

Array.prototype.splice 规范步骤历史图

受限的教学实现

下面的版本假定 startdeleteCount 已经是有限整数,只针对密集普通数组,用来展示移动尾部元素的思路,不处理稀疏数组、代理、species、不可扩展对象、访问器或精确的异常回滚:

function spliceDense(array, start, deleteCount, ...items) {
  const length = array.length
  const actualStart = start < 0
    ? Math.max(length + start, 0)
    : Math.min(start, length)
  const actualDeleteCount = Math.min(
    Math.max(deleteCount, 0),
    length - actualStart,
  )
  const removed = array.slice(
    actualStart,
    actualStart + actualDeleteCount,
  )
  const tail = array.slice(actualStart + actualDeleteCount)

  array.length = actualStart
  array.push(...items, ...tail)
  return removed
}

const array = [1, 2, 3, 4]
console.log(spliceDense(array, 1, 2, 'a')) // [2, 3]
console.log(array) // [1, 'a', 4]

原文声称手写实现“通过 MDN 所有测试用例”并不可靠:完整 splice 需要处理大量边界,包括 deleteCount 省略、长度上限、稀疏槽位、Proxy 访问顺序、冻结/密封对象和属性定义失败。不要把上述示例当作 polyfill。

密封和冻结对象

  • 密封对象不能新增或删除自有属性,但可修改仍可写的值。
  • 冻结对象不能新增、删除或修改数据属性值。
  • splice 可能需要移动和删除属性,因此在密封/冻结数组上可能抛出异常或只完成部分前置操作,不能用一个简单的 isSealed 条件覆盖所有情况。

splice 移动尾部元素示意图

splice 插入元素示意图

第十七篇:能不能实现数组 sort 方法?

sort 会原地排序并返回同一个数组。默认比较会把元素转换为字符串;自定义比较器应返回负数、正数或 0:

const array = [10, 2, 1]
array.sort((a, b) => a - b)
console.log(array) // [1, 2, 10]

当前规范要求稳定排序,但没有规定必须使用哪一种排序算法。V8 的旧版本曾使用快速排序等策略,现代 V8 采用过 TimSort 等实现细节;不同引擎和版本会变化。原文基于 Node v10 的基准和 V8 源码得出的“自己实现的排序性能一样”不能保留为结论。

旧版排序算法性能截图

一个用于学习的插入排序

function insertionSort(array, compare = defaultCompare) {
  for (let index = 1; index < array.length; index++) {
    const value = array[index]
    let position = index - 1

    while (position >= 0 && compare(array[position], value) > 0) {
      array[position + 1] = array[position]
      position--
    }

    array[position + 1] = value
  }

  return array
}

function defaultCompare(left, right) {
  const a = String(left)
  const b = String(right)
  return a < b ? -1 : a > b ? 1 : 0
}

console.log(insertionSort([10, 2, 1])) // [1, 10, 2]
console.log(insertionSort([10, 2, 1], (a, b) => a - b)) // [1, 2, 10]

插入排序适合小数据或接近有序的数据;它不是现代引擎 sort 的通用替代品。

原文中关于“n <= 10 使用插入排序、n > 1000 取样选择哨兵”的描述属于旧版 V8 的实现观察,不能作为当前 JavaScript 或 V8 的规范保证。

旧版排序测试截图一

旧版排序测试截图二

旧版排序测试截图三

旧版排序测试截图四

第十八篇:能不能模拟实现一个 new 的效果?

new Constructor(...args) 的教学模型包括:创建对象、把实例的原型设置为 Constructor.prototype、以新对象为 this 执行构造器、处理构造器返回值。若构造器显式返回对象或函数,返回该对象;返回原始值则忽略。

function newLike(Constructor, ...args) {
  if (typeof Constructor !== 'function') {
    throw new TypeError('Constructor must be callable')
  }

  const prototype = Constructor.prototype
  const object = Object.create(
    prototype !== null && (typeof prototype === 'object' || typeof prototype === 'function')
      ? prototype
      : Object.prototype,
  )
  const result = Reflect.apply(Constructor, object, args)

  if (result !== null && (typeof result === 'object' || typeof result === 'function')) {
    return result
  }
  return object
}

这个实现不能完整模拟原生 newnew.target、代理构造器、内置构造器内部槽和所有异常行为。需要保留 new.target 语义时,优先使用 Reflect.construct

function constructLike(Constructor, args, NewTarget = Constructor) {
  return Reflect.construct(Constructor, args, NewTarget)
}

第十九篇:能不能模拟实现一个 bind

原生 bind 至少需要处理:普通调用时的 this、预置参数、绑定函数再次被 new 调用,以及原函数原型链的关系。下面是一个教学实现:

function bindLike(fn, thisArg, ...boundArgs) {
  if (typeof fn !== 'function') {
    throw new TypeError('bind target must be callable')
  }

  function bound(...callArgs) {
    const args = [...boundArgs, ...callArgs]

    if (new.target) {
      return Reflect.construct(fn, args, new.target)
    }

    return Reflect.apply(fn, thisArg, args)
  }

  if (fn.prototype) {
    bound.prototype = Object.create(fn.prototype, {
      constructor: {
        configurable: true,
        value: bound,
        writable: true,
      },
    })
  }

  return bound
}

仍然不能把它称为完整 polyfill:原生 bound function 的 namelength、构造器细节、代理和跨 realm 行为都有更多规范要求。生产代码直接使用 Function.prototype.bind

function Person(name) {
  this.name = name
}

const BoundPerson = bindLike(Person, { ignored: true }, 'Ada')
const person = new BoundPerson()
console.log(person.name) // Ada
console.log(person instanceof Person) // true

箭头函数没有自己的 this,也不能作为构造器;给箭头函数调用 bind 只能预置参数,不能改变其词法 this

第二十篇:能不能实现一个 call/apply 函数?

直接实现原生 call/apply 的核心其实就是 Reflect.apply

function callLike(fn, thisArg, ...args) {
  return Reflect.apply(fn, thisArg, args)
}

function applyLike(fn, thisArg, args) {
  const list = args == null ? [] : Array.from(args)
  return Reflect.apply(fn, thisArg, list)
}

如果为了理解早期 ES5 技术而临时把函数挂到对象上,需要注意 context || window 是错误的:它会错误处理 0false、空字符串和 null,也无法精确模拟严格函数的 this 语义。可以写出一个有限的教学版本:

function callByTemporaryProperty(fn, thisArg, ...args) {
  const receiver = thisArg == null ? globalThis : Object(thisArg)
  const key = Symbol('temporary-call')
  Object.defineProperty(receiver, key, {
    configurable: true,
    value: fn,
  })

  try {
    return receiver[key](...args)
  } finally {
    delete receiver[key]
  }
}

不要修改 Function.prototype.callFunction.prototype.apply 来做练习;这会破坏运行时和其他代码。

第二十一篇:谈谈你对 JS 中 this 的理解

判断普通函数的 this,可以按下面顺序检查:

  1. 是否通过 new 调用?
  2. 是否使用 callapplybind 显式指定?
  3. 是否是 obj.method() 这种成员调用?
  4. 是否是独立调用?区分严格模式、ES module、class 和 classic script。
  5. 是否是箭头函数?如果是,查定义位置的外层 this
  6. DOM 事件中还要区分 event.targetevent.currentTarget
const object = {
  value: 1,
  method() {
    return this.value
  },
}

const method = object.method
console.log(object.method()) // 1
// method():严格模式下 this 为 undefined

new > call/apply/bind > 对象方法 > 默认绑定”是普通函数的速记,不覆盖箭头函数、super、代理和不同宿主的回调规则。

第二十二篇:JS 中浅拷贝的手段有哪些?

直接赋值不会复制对象:

const arr = [1, 2, 3]
const same = arr
same[0] = 100
console.log(arr) // [100, 2, 3]

浅拷贝只复制一层属性值;嵌套对象仍共享引用:

const arr = [1, 2, { value: 4 }]
const copy = arr.slice()
copy[0] = 100
copy[2].value = 1000

console.log(arr[0]) // 1
console.log(arr[2].value) // 1000

1. 手动实现有限的浅拷贝

function shallowClone(value) {
  if (value === null || typeof value !== 'object') return value

  const clone = Array.isArray(value) ? [] : {}
  for (const key of Reflect.ownKeys(value)) {
    if (Object.hasOwn(value, key)) {
      clone[key] = value[key]
    }
  }
  return clone
}

这个版本只复制可直接赋值的自有键,不保留原型、属性描述符和访问器语义。若需要保留原型和描述符,应使用 Object.getOwnPropertyDescriptors 配合 Object.create

2. Object.assign

const object = { name: 'sy', age: 18 }
const copy = Object.assign({}, object, { name: 'sss' })
console.log(copy) // { name: 'sss', age: 18 }

Object.assign 只复制源对象的自有可枚举字符串和 Symbol 键;读取源 getter,写入目标时可能触发目标 setter。它是浅复制。

3. 数组 concatslice 和展开

const array = [1, 2, 3]
const a = array.concat()
const b = array.slice()
const c = [...array]

console.log(a, b, c)

对象展开和 Object.assign 也都是浅复制:

const source = { nested: { value: 1 } }
const copy = { ...source }
copy.nested.value = 2
console.log(source.nested.value) // 2

第二十三篇:能不能写一个完整的深拷贝?

“完整深拷贝”不是一个没有边界的通用概念。函数闭包、DOM 节点、WeakMap、带私有字段的实例、宿主对象和外部资源都不能简单复制。

1. JSON.parse(JSON.stringify(value)) 的问题

const source = {
  date: new Date(),
  value: undefined,
  nan: NaN,
  nested: { value: 1 },
}

const copy = JSON.parse(JSON.stringify(source))

JSON 方法会丢失或改变 undefined、函数、Symbol、NaNInfinity、Date、Map、Set、循环引用和某些属性信息;它不适合被宣传成通用深拷贝。

2. 现代首选:structuredClone

对可结构化克隆的数据,优先考虑标准 API:

const source = {
  date: new Date(),
  map: new Map([['key', { value: 1 }]]),
}
source.self = source

const copy = structuredClone(source)
console.log(copy !== source) // true
console.log(copy.self === copy) // true

structuredClone 能处理循环引用、Map、Set、Date、RegExp、TypedArray 等一组标准类型,但不能克隆函数、WeakMap、DOM 节点等所有值;具体可克隆边界应查看目标运行时文档。

3. 教学版:支持常见对象和循环引用

下面的实现支持原始值、数组、普通对象、Date、RegExp、Map、Set、循环引用、Symbol 键和属性描述符。它仍然不是通用 clone:函数按原引用返回,TypedArray、Error 内部槽、DOM、WeakMap 和带私有字段的实例需要额外策略。

function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== 'object') {
    return value
  }

  if (seen.has(value)) {
    return seen.get(value)
  }

  if (value instanceof Date) {
    return new Date(value.getTime())
  }

  if (value instanceof RegExp) {
    return new RegExp(value.source, value.flags)
  }

  if (value instanceof Map) {
    const result = new Map()
    seen.set(value, result)
    for (const [key, item] of value) {
      result.set(deepClone(key, seen), deepClone(item, seen))
    }
    return result
  }

  if (value instanceof Set) {
    const result = new Set()
    seen.set(value, result)
    for (const item of value) {
      result.add(deepClone(item, seen))
    }
    return result
  }

  const result = Array.isArray(value)
    ? []
    : Object.create(Object.getPrototypeOf(value))
  seen.set(value, result)

  for (const key of Reflect.ownKeys(value)) {
    const descriptor = Object.getOwnPropertyDescriptor(value, key)
    if ('value' in descriptor) {
      descriptor.value = deepClone(descriptor.value, seen)
    }
    Object.defineProperty(result, key, descriptor)
  }

  return result
}

const source = { value: 1 }
source.self = source
const copy = deepClone(source)

console.log(copy !== source) // true
console.log(copy.self === copy) // true

原文中把函数转成字符串,再用正则提取参数和函数体并 new Function,会丢失闭包、作用域、私有状态、名称和原型,还存在代码注入风险,因此删除这种“复制函数”的实现。函数通常应保持原引用,或由业务明确设计可序列化的行为描述。

4. 为什么需要 WeakMap

WeakMap 在这里主要用于记录“原对象 → 克隆对象”的映射,解决循环引用和重复引用关系:

const shared = { value: 1 }
const source = { first: shared, second: shared }
const copy = deepClone(source)

console.log(copy.first === copy.second) // true

WeakMap 的弱键特性还避免了“记录表本身长期强引用源对象”的额外保留,但在 deepClone 调用结束后普通局部 Map 通常也会随调用结束而可回收。不能把使用 Map 绝对描述成必然内存泄漏,关键是 Map 是否被长期保存。

小结

  • 数组方法常常是泛型的,但完整规范包含长度、稀疏槽、属性描述符、代理和异常边界。
  • sort 的算法由引擎决定,当前标准要求稳定但不规定 TimSort 或快速排序。
  • 手写 newbindcall/apply 适合学习,不能替代原生方法。
  • 浅拷贝只复制一层;深拷贝要先明确数据类型和业务边界。
  • 可结构化数据优先使用 structuredClone;函数、DOM 和资源对象需要单独设计复制策略。

配图说明:图示按原文顺序从 article.rivers.pub 的历史文章图片地址下载到本地 images/87-image-*,图片并非规范或引擎官方图,公开发布前仍需核实原始授权。

参考资料:

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS