这个系列也没啥花头,就是来整平时面试的一些手写函数,考这些简单实现的好处是能看出基本编码水平,且占用时间不长,更全面地看出你的代码实力如何。一般不会出有很多边界条件的问题,那样面试时间不够用,考察不全面。
平时被考到的 api 如果不知道或不清楚,直接问面试官就行, api 怎么用这些 Google 下谁都能马上了解的知识也看不出水平。关键是在实现过程,和你的编码状态、习惯、思路清晰程度等。
注意是简单实现,不是完整实现,重要的是概念清晰和实现思路清晰,建议
先写伪代码
,再实现具体功能。
在写这两个手写之前我们得先认清,什么是 浅拷贝 和 深拷贝,他们的区别是什么。
这又需要先从更基础的 基本类型和引用类型区别了解到
JS中的变量类型分为基本类型,和引用类型,对象值存储的是引用地址,所以和基本类型值不可变
的特性不同,对象值是可变的
。并且他们存放位置不同,基本类型值 => 栈内存
,引用类型 => 同时在栈内存和堆内存
引用类型的存储需要内存的栈内存和堆内存共同完成,栈区内存保存变量标识符
和指向堆内存中该对象的指针
,也可以说是该对象在堆内存的地址。
其实深拷贝和浅拷贝都是针对的引用类型
产生差异。
- 浅拷贝: 创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的
值
,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象
。
浅 copy 引用类型直接 指向同一地址 相互影响
obj [String] [Number] [Object] [Array]
| | | | |
copy | | 堆区地址1 堆区地址2
| | | | |
cloneObj [String] [Number] [Object] [Array]
(栈区直接存值)
- 深拷贝: 将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个
新的内存地址存放新对象
, 所以新老对象互不影响
。
深 copy 引用类型 堆区开辟新地址 互不影响
obj [String] [Number] [Object] [Array]
| | | | |
copy | | 堆区地址1 堆区地址2
| | |
cloneObj [String] [Number] [Object] [Array]
(栈区直接存值) | |
堆区地址3 堆区地址4
好了,清晰准确地了解了概念,你才能开始动手实现。
6. 浅拷贝 (shallowClone)
简单手写实现
实现
浅 copy 非常简单,其实就是
const a = {
b: 'b1',
c: {
c1: 100
},
}
function shallowClone(source) {
const target = {};
for (const i in source) {
if (source.hasOwnProperty(i)) {
target[i] = source[i];
}
}
return target;
}
const sCopyA = shallowClone(a);
sCopyA.b = 'b2'
sCopyA.c.c1 = 'change';
console.log(a) // { b: 'b1', c: { c1: 'change' } }
console.log(sCopyA) // { b: 'b2', c: { c1: 'change' } }
我们可以看到,c
这个属性在两个对象中,指向同一个内存地址,相互影响。
7. 深拷贝 (deepClone)
简单手写实现
深拷贝简单来说就是 浅拷贝 + 递归
const a = {
b: 'b1',
c: {
c1: 100
},
}
function deepClone(source) {
var target = {};
for(var i in source) {
if (source.hasOwnProperty(i)) {
if (typeof source[i] === 'object') {
// 如果还是对象(引用类型)就继续递归
target[i] = deepClone(source[i]);
} else {
target[i] = source[i];
}
}
}
return target;
}
const dCopyA = deepClone(a);
dCopyA.b = 'b2'
dCopyA.c.c1 = 'change';
console.log(a) // { b: 'b1', c: { c1: 100 } }
console.log(dCopyA) // { b: 'b2', c: { c1: 'change' } }
再附加个 reduce 写法
var a = {
a1: 1,
a2: {
b1: 1,
b2: {
c1: 1
}
}
}
let isObject = (target) => {
return Object.prototype.toString.call(target) === '[object Object]'
}
let deepCloneReduce = (source) => {
// Object.keys 不包含原型链上的属性
const keys = Object.keys(source)
return keys.reduce((acc, cur) => {
const value = source[cur]
if (isObject(value)) {
return {
...acc,
[cur]: deepCloneReduce(value)
}
} else {
return {
...acc,
[cur]: value
}
}
}, {})
}
let b = deepCloneReduce(a)
b.a1 = 8
b.a2.b1 = 'change1'
b.a2.b2.c1 = 'change2'
console.log(a) // { a1: 1, a2: { b1: 1, b2: { c1: 1 } } }
console.log(b) // { a1: 8, a2: { b1: 'change1', b2: { c1: 'change2' } } }
深入探讨
但是,真的就这么简单吗,如果我们在往专家的路上走,就需要考虑更多,在面试官向你发问前就告诉他,这种做法是有不少问题的,展示你对问题的思考深度。
这是知乎对深拷贝实现的一个探讨 有兴趣可以了解下
我这边列举一些先
- 没有对参数做检验, 如果不是对象的话直接返回(算是特判吧)
typeof
判断是否对象的逻辑不够严谨- 缺少对
Function, Array, Set, Map, WeakSet, WeakMap
等的兼容 - 循环引用
- 引用丢失
- 递归过深栈溢出
- 大规模(宽度、深度)数据的性能问题
- ...
前几个小问题我们简单带过
判断是否对象 、兼容数组
var a = {
a1: 1,
a2: {
b1: 1,
b2: {
c1: 1
}
},
a3: [1, 2, 3]
}
let isObject = (target) => {
return Object.prototype.toString.call(target) === '[object Object]'
}
function deepClone(source) {
// 参数校验
if (!isObject(source)) {
return source;
}
// 判断数组类型初始化
let target = Array.isArray(source) ? [] : {};
for(let key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (isObject(source[key])) {
target[key] = deepClone(source[key]); // 注意这里
} else {
target[key] = source[key];
}
}
}
return target;
}
let b = deepClone(a)
console.log(a) // { a1: 1, a2: { b1: 1, b2: { c1: 1 } }, a3: [ 1, 2, 3 ] }
console.log(b) // { a1: 1, a2: { b1: 1, b2: { c1: 1 } }, a3: [ 1, 2, 3 ] }
下面我们挑几个重点来讨论
栈过深溢出
先讨论下如何避免栈过深溢出的问题,也就是我们俗称爆栈。这主要是由于递归写法
引起的,那么如果换用迭代方式
,就没这个问题了。
其实就是 二叉树前序遍历的迭代实现的变体,不是二叉
而已,利用一个 stack
来完成遍历,其实递归问题都可以用 栈 + 迭代
来实现。
var a = {
a1: 1,
a2: {
b1: 1,
b2: {
c1: 1
}
}
}
// 这种判断对象方式会更准确
let isObject = (target) => {
return Object.prototype.toString.call(target) === '[object Object]'
}
// 迭代法 deepClone,其实就是树的遍历
let deepCloneIteration = (source) => {
// 做参数校验
if (!isObject(source)) {
return source
}
let root = {}
const stack = []
// 初始一个节点 root,先把 children设置为 source
// 之后初始化判断是根的话,就直接赋值到该元素下
stack.push({
parent: root,
keyName: 'root',
children: source
})
while (stack.length > 0) {
// 推出栈顶元素
let curNode = stack.pop()
// 分别列出当前节点的 父节点、keyName、孩子节点
let parent = curNode.parent
let keyName = curNode.keyName
let children = curNode.children
// 初始化赋值目标
let target = {};
if (keyName === 'root') {
// keyName为root则直接拷贝到父元素下
target = parent
} else {
// 否则拷贝到keyName对应的children
target = parent[keyName] = {};
}
// 下面遍历它的子节点
for(let k in children) {
if (children.hasOwnProperty(k)) {
// 如果孩子是对象,则入栈进入下一次循环,否则就直接赋值
if (isObject(children[k])) {
stack.push({
parent: target,
keyName: k,
children: children[k]
})
} else {
target[k] = children[k]
}
}
}
}
return root
}
let b = deepCloneIteration(a)
b.a1 = 8
b.a2.b1 = 'change1'
b.a2.b2.c1 = 'change2'
console.log(a) // { a1: 1, a2: { b1: 1, b2: { c1: 1 } } }
console.log(b) // { a1: 8, a2: { b1: 'change1', b2: { c1: 'change2' } } }
用迭代完成的 deepClone,就没有递归过深栈溢出的问题。
循环引用问题
下面是循环引用问题, 先简单解释下循环引用
let a = {}
a.a = a
console.log(deepClone(a)) // RangeError: Maximum call stack size exceeded
这就是循环引用,简单来说就是对象的属性间接或直接的引用了自身的情况,只要引用成环
就会永远复制不完。
再稍微解释下引用丢失含义
let ref = {r: 1};
let a = {a1: ref, a2: ref};
let c = JSON.parse(JSON.stringify(a));
console.log(a.a1 === a.a2) // true
console.log(c.a1 === c.a2) // false
// 复制后,当 ref 改变时,a1,a2 引用不指向一处,所以不会随之变化
a.a1.r = 100
c.a1.r = 100
console.log(a) // { a1: { r: 100 }, a2: { r: 100 } }
console.log(c) // { a1: { r: 100 }, a2: { r: 1 } }
注意不是说这个引用丢失就是错的,而是要看具体需求,有时需要这个引用保持,有时就是不需要保持引用,大多事情不是二元问题,而是多元问题,世界不是非黑即白的,而是不断变化的。
另外 JSON.parse(JSON.stringify());
这个方法平时确定数据格式情况下还挺好用,利用工具快速解决问题,还能做循环检测。但是我们要知道,它的局限在哪,这种方法的clone 不会 clone 对象内部的函数指针,其中函数是不会被复制的。
解决方案其实就是循环检测,可以设置一个数组或者哈希表存储已拷贝过的对象,当检测到当前对象已存在
,取出该值并返回即可。
let isObject = (target) => {
return Object.prototype.toString.call(target) === '[object Object]'
}
// 用 Array 、 Map 、 WeakMap 都 ok
function deepClone(source, hash = new WeakMap()) {
if (!isObject(source)) {
return source;
}
// 当发现已经存在该对象,直接返回
if (hash.has(source)) {
return hash.get(source);
}
var target = Array.isArray(source) ? [] : {};
// 否则保存进这个 hashmap 中
hash.set(source, target);
for(var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (isObject(source[key])) {
// 递归传入 hash 表
target[key] = deepClone(source[key], hash);
} else {
target[key] = source[key];
}
}
}
return target;
}
// 循环引用解决
let a = {}
a.a = a
let b = deepClone(a)
console.log(a) // { a: [Circular] }
console.log(b) // { a: [Circular] }
// 引用丢失解决
let ref = {r: 1};
let d = {a1: ref, a2: ref};
let e = deepClone(d)
console.log(e.a1 === e.a2) // true
如果选用 WeakMap
建议了解下 强/弱引用的区别。
MDN WeakMap 相比之下, WeakMap 持有的是每个键对象的 “弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。 原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。
其他方式: circular-json-es6 是个解决循环引用的库
另外项目中也可直接使用 lodash 的 cloneDeep,省时省力。
这个系列不适合写太多,太细,不写了,有可能之后在 [核心概念] 讲。
题外话:写这篇时莫名想起之前的一个卫星故障分析系统的项目,突然怀念和小伙伴一起熬夜开发,解决复杂数据问题的各种情形,很开心遇见你们 ^-^,我们未来见
另外向大家着重推荐下另一个系列的文章,非常深入浅出,对前端进阶的同学非常有作用,墙裂推荐!!!核心概念和算法拆解系列 记得点赞哈
今天就到这儿,想跟我一起刷题的小伙伴可以加我微信哦 点击此处交个朋友
Or 搜索我的微信号infinity_9368
,可以聊天说地
加我暗号 "天王盖地虎" 下一句的英文
,验证消息请发给我
presious tower shock the rever monster
,我看到就通过,加了之后我会尽我所能帮你,但是注意提问方式,建议先看这篇文章:提问的智慧