前端面试必刷手写题系列 [3]

597 阅读9分钟

这个系列也没啥花头,就是来整平时面试的一些手写函数,考这些简单实现的好处是能看出基本编码水平,且占用时间不长,更全面地看出你的代码实力如何。一般不会出有很多边界条件的问题,那样面试时间不够用,考察不全面。

平时被考到的 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,我看到就通过,加了之后我会尽我所能帮你,但是注意提问方式,建议先看这篇文章:提问的智慧

参考