手写代码(2) 深浅拷贝,push,filter,flat,map,repeat,数组乱序输出,扁平化,求和,去重,字符串翻转

88 阅读11分钟

手写代码(0) 开始手写代码之前,需要知道什么

手写代码(1) call apply bind 数据类型判断 Object.create new 防抖节流 柯里化

浅拷贝和深拷贝

轻松拿下 JS 浅拷贝、深拷贝

数据类型

原始数据类型:Undefined、Null、Boolean、Number、String、BigInt、symbol

引用数据类型:对象,数组,函数

引用类型赋值会存在一个问题:令B = A,修改A上的值,B也会跟着改变:

image.png

如果两个对象指向同一个地址,用 == 运算符作比较会返回 true,指向不同地址会返回false。图中可以看出他们指向的是同一个地址

因为对于基本数据类型的赋值,栈内存会开辟一个新的内存,而对于引用数据类型赋值,其实赋的不是值,而是地址,这个地址指向原堆内存的值,只要堆内存的值一变,指向同一个地址的对象的值都会变

区别

浅拷贝和深拷贝都是创建一份数据的拷贝。

JS 分为原始类型和引用类型,对于原始类型的拷贝,并没有深浅拷贝的区别,我们讨论的深浅拷贝都只针对引用类型。

  • 浅拷贝和深拷贝都复制了值和地址,都是为了解决引用类型赋值后互相影响的问题。
  • 但是浅拷贝只进行一层复制,深层次的引用类型还是共享内存地址,原对象和拷贝对象还是会互相影响。
  • 深拷贝就是无限层级拷贝,深拷贝后的原对象不会和拷贝对象互相影响。

浅拷贝

Object.assign

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象

简单来说,就是Object.assign()是对象的静态方法,可以用来复制对象的可枚举属性到目标对象,利用这个特性可以实现对象属性的合并

Object.assign()接口可以接收多个参数,第一个参数是目标对象,后面的都是源对象,assign方法将多个原对象的属性和方法都复制到目标对象上面,如果在这个过程中出现同名的属性(方法),后合并的属性(方法)会覆盖之前的同名属性(方法)

let newObj = Object.assign({}, obj);

image.png

扩展运算符

let newArr = [...arr];

image.png

slice

let newArr = arr.slice(0);

image.png

concat

let newArr = [].concat(arr);

image.png

Array.from

let newArr = Array.from(arr);

image.png

深拷贝

上面提过浅拷贝只能进行一层复制,更深的对象还是会指向同一个地址,相互影响:

image.png

所以要使用深拷贝解决

JSON.stringify()

利用JSON.stringify 将js对象序列化(JSON字符串),再使用JSON.parse反序列化(还原)js对象

let newObj = JSON.parse(JSON.stringify(obj));

image.png

但是这个方法会忽略undefined,symbol和函数:

image.png

NaN、Infinity、-Infinity 会被序列化为null:

image.png

也不能解决循环引用的问题:

image.png

递归+处理各种类型

  • 支持对象、数组、日期、正则的拷贝。

  • 处理原始类型(原始类型直接返回,只有引用类型才有深拷贝这个概念)。

  • 处理 Symbol 作为键名的情况。

  • 处理函数(函数直接返回,拷贝函数没有意义,两个对象使用内存中同一个地址的函数,问题不大)。

  • 处理 DOM 元素(DOM 元素直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个)。

  • 额外开辟一个储存空间 WeakMap,解决循环引用递归爆栈问题(引入 WeakMap 的另一个意义,配合垃圾回收机制,防止内存泄漏)。

function deepClone (target, hash = new WeakMap()) { // 额外开辟一个存储空间WeakMap来存储当前对象
  if (target === null) return target // 如果是 null 就不进行拷贝操作
  if (target instanceof Date) return new Date(target) // 处理日期
  if (target instanceof RegExp) return new RegExp(target) // 处理正则
  if (target instanceof HTMLElement) return target // 处理 DOM元素

  if (typeof target !== 'object') return target // 处理原始类型和函数 不需要深拷贝,直接返回

  // 是引用类型的话就要进行深拷贝
  if (hash.get(target)) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
  const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
  hash.set(target, cloneTarget) // 如果存储空间中没有就存进 hash 里

  Reflect.ownKeys(target).forEach(key => { // 引入 Reflect.ownKeys,处理 Symbol 作为键名的情况
    cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
  })
  return cloneTarget // 返回克隆的对象
}

image.png

1664340483143.png

const cloneTarget = new target.constructor():省去判断原对象类型,直接创建与原对象相同类型的实例对象

Reflect.ownKeys(target):Reflect.ownKeys 方法返回一个由目标对象自身的属性键组成的数组。它的返回值等同于Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target)),为了处理Symbol

hash = new WeakMap():解决循环引用问题,额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系

WeakMap

垃圾回收:js引擎在值"可达"和可能被使用时会将其保存在内存中。

在日常工作中,对于不再使用的对象,通常我们会希望它们会被垃圾回收器回收。这时可以使用 null 来覆盖对应对象的引用

但是,当对象、数组这类数据结构在内存中时,它们的子元素,如对象的属性、数组的元素都被认为是可达的

举个例子:如果把一个对象放入数组中,只要这个数组存在,这个对象也就存在,即使用null覆盖也无法被垃圾回收机制回收

image.png

同样,如果我们使用对象作为常规 Map 的键,那么当 Map 存在时,该对象也将存在。它会占用内存,并且不会被垃圾回收机制回收

image.png

WeakMap和Map不同点在于

  1. WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名
  2. WeakMap的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用

image.png

数组乱序输出

遍历取出数组中第i个元素,随机产生一个范围在[i, arr.length - 1]的索引值,将第一个元素和这个索引对应的元素交换

伪乱序:数组的sort()方法

let arr = [1,2,3,4,5];
arr.sort(() => {
    return Math.random() - 0.5;
});

为什么这么写

sort()方法如果指定了一个函数,会按照这个函数的返回值对数组进行排序:

  • 返回值大于0,被比较的相邻两个数a,b,b会排到a前面
  • 返回值等于0,a,b位置不变
  • 返回值小于0,a会排到b前面

image.png

按照这个特性,如果每次返回值的正负都是随机的,那么经过sort()后数组的顺序也是随机的,就实现了打乱数组

用Math.random()生成[0, 1)之间的随机数,再减去0.5,就得到[-0.5, 0.5)之间的随机数,从而使返回值正负随机

这是完全乱序吗

换句话说,每个数字被换到每个位置的几率相等吗?试一下:

image.png

行代表位置,列代表每个数字,可以看到元素出现在每个位置的概率并不趋于相等,停留在原位置的几率更大

这是为啥呢

因为v8处理sort()方法使用了插入排序和快排(7.0版本前),7.0版本后采用混合排序算法TimSort,数据量小的子数组中使用插入排序,再使用归并排序将有序的子数组合并排序,时间复杂度为O(nlogn)。大多数排序算法时间复杂度介于O(n)到O(n2)之间,而真正的乱序是数组中每两个元素都要比较,有50%的交换位置概率,元素之间的总共比较次数等于n(n - 1) ,所以这并不是完全随机的结果

真乱序:Fisher–Yates shuffle 洗牌算法

从后往前:

function shuffle(arr) {
    for(let i = arr.length - 1; i > 0; i--) {
        let randomIndex = Math.floor(Math.random() * (i + 1));
        [arr[randomIndex], arr[i]] = [arr[i], arr[randomIndex]];
    }
    return arr;
}

image.png

从前往后:

function shuffle(arr) {
    for(let i = 0; i < arr.length - 1; i++) {
        let randomIndex = Math.floor(Math.random() * (arr.length - i)) + i;
        [arr[randomIndex], arr[i]] = [arr[i], arr[randomIndex]];
    }
    return arr;
}

image.png

为啥这么写

从前往后的思路:遍历到i,生成[i, arr.length - 1]的随机数作为下标randomIndex,交换arr[i]和arr[randomIndex],遍历到arr[arr.length - 1]时,因为它只能和自己交换,没啥必要,所以for循环条件是i < arr.length - 1

从后往前的思路:遍历i,生成[0, i]的随机数作为下标randomIndex,交换arr[i]和arr[randomIndex],遍历到arr[0]时,因为它只能和自己交换,没啥必要,所以for循环条件是i > 0

用Math.random()生成概率相等的随机整数

对于纯数字类型的一般数字,比如123.21,parseInt转换方式是取整数部分123,相当于Math.floor()

  • 随机生成[x, y]的整数: parseInt(Math.random() * (y - x + 1) + x);
  • 随机生成(x, y)的整数: parseInt(Math.random() * (y - x - 1) + (x + 1))
  • 随机生成[x, y)的整数: parseInt(Math.random() * (y - x) + x)
  • 随机生成(x, y]的整数: parseInt(Math.random() * (y - x) + x) + 1

数组元素求和

reduce()

reduce()方法接收一个函数作为累加器,数组的每个值(从左到右)开始缩减,最终计算为一个值

array.reduce(function(total, currentValue, currentIndex, arr), initialValue)

image.png

注意:

  1. reduce()对于空数组不会执行回调函数,所以一般提供初始值通常更安全
  2. 当提供了初始值initialValue,第一次执行回调函数时total值为initialValue,currentValue值为数组第一项,否则为total值为数组第一项,currentValue值为数组第二项

不含嵌套数组

let arr = [1,2,3,4,5,6,7,8,9,10];
let sum = arr.reduce((total, i) => return total + i,0);

image.png

含有嵌套数组

let arr = [1,2,3,[[4,5],6],7,8,9];
let sum = arr.toString().split(',').reduce((total, i) => total += Number(i), 0);

把数组用toString()转为字符串再使用split变成字符串数组,再对数组中每个值转成数字后累加

image.png

递归

function add(arr) {
    if(arr.length === 1) return arr[0];
    return arr[0] + add(arr.slice(1));
}

image.png

slice 和 splice 的区别

  • slice截取元素,slice(start,end),不改变原数组,返回新数组
  • splice插入、删除或替换数组的元素,splice(start,deleteCount,item1,item2...),会改变原数组,deleteCount表示要截取的个数,如果为0表示不删除元素,从start位置开始添加后面元素item1,item2...到原始数组

数组扁平化

递归

思路就是通过循环递归的方式,遍历每一项,如果某一项是数组,可能这项里面还嵌套了数组,递归这一项,最后会返回一个被合并的数组,它与result再次合并返回一个数组

function flatten(arr) {
    let result = [];
    for(let cur of arr) {
        if(Array.isArray(cur)) {
            result = result.concat(flatten(cur));
        } else {
            result.push(cur);
        }
    }
    return result;
}

image.png

concat()用于合并两个或多个数组,不会更改现有数组,而是返回一个新数组,字符串同理

reduce()迭代

function flatten(arr) {
    return arr.reduce(function(prev, next) {
        return prev.concat(Array.isArray(next) ? flatten(next) : next)
    },[])
}

image.png

扩展运算符

arr中遇到数组就用扩展运算符展开一层再和"[]"合并成数组

function flatten(arr) {
    while(arr.some(item => Array.isArray(item))) {
        arr = [].concat(...arr);
    }
    return arr;
}

image.png

扩展运算符...每次只能展开最外层的数组

some()方法用于检测数组中的元素是否满足条件,如果有一个表达式满足条件,表达式返回true,剩余元素不>会再执行检测。如果没有满足条件的元素,返回false

注意:

  1. some()不会对空数组进行检测
  2. some()不会改变原始数组

split 和 toString

先转成字符串再转成字符串数组,最后将数组中的字符转成数字再返回

function flatten(arr) {
    return arr.toString().split(",").map(Number);
}

image.png

数组转字符串

  • arr.join():省略参数默认用逗号作为分隔符
  • toString():把每个元素转换为字符串,以逗号连接并显示
  • toLocalString:把数组转换成本地约定的字符串

image.png

字符串转数组

  • str.spllit():把字符串分割成字符串数组,参数是分割符
  • 扩展运算符:[...str]

image.png

flat

ES6的方法:flat([depth])

depth代表传递数组的展开深度,不填默认展开深度为1。层数不确定可以传进Infinity,代表展开所有层

arr.flat(Infinity);

1664292315937.png

数组去重

Set()

es6提供了一种新的数据结构Set,类似于数组,但Set里面的值不重复,也就是说值唯一,new Set()中的值会去重

Array.from(new Set(arr));

1664292648037.png

先用Set()去重,再用Array.form()将Set转换为数组

image.png

image.png

实现数组flat方法

flat() 方法会按照一个可指定的深度递归遍历数组,并将所有元素与遍历到的子数组中的元素合并为一个新数组返回,也就是数组扁平化

function _flat(arr, depth) {
    if(!Array.isArray(arr) || depth <= 0) {
        return arr;
    }
    return arr.reduce((prev, cur) => {
        if(Array.isArray(cur)) {
            return prev.concat(_flat(cur, depth - 1));
        } else {
            return prev.concat(cur);
        }
    }, []);
}

image.png

实现数组map方法

map() 方法创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果

Array.prototype._map = function(fn) {
    if(typeof fn !== "function") {
        throw Error('参数必须是一个函数');
    }
    const res = [];
    for(let i = 0, len = this.length; i < len; i++) {
        res.push(fn(this[i]));
    }
    return res;
}

image.png

实现数组push方法

push() 方法可向数组的末尾添加一个或多个元素,并返回新的长度

Array.prototype._push = function() {
    for(let i = 0; i < arguments.length; i++) {
        this[this.length] = arguments[i];
    }
    return this.length;
}

image.png

实现数组filter方法

filter() 方法创建一个新的数组,新数组中的元素是通过检查指定数组中符合条件的所有元素。filter() 用于对数组进行过滤,不会对空数组进行检测,不会改变原始数组

array.filter(function(currentValue,index,arr), thisValue)

image.png

这里只实现了接受函数参数:

Array.prototype._fliter = function(fn) {
    if(typeof fn !== "function") {
        throw Error('参数必须是一个函数');
    }
    const res = [];
    for(let i = 0, len = this.length; i < len; i++) {
        fn(this[i]) && res.push(this[i]);
    }
    return res;
}

image.png

实现字符串repeat方法

方法一

function repeat(s, n) {
    return (new Array(n + 1)).join(s);
}

image.png

上面的做法是:长度为n + 1的数组有n个间隔,将s作为连接符插入空数组的n个间隔:

image.png

方法二

使用递归:

function repeat(s, n) {
    return n > 0 ? s.concat(repeat(s, --n)) : "";
}

1664333501801.png

字符串翻转

String.prototype._reverse = function(a) {
    return a.split("").reverse().join("");
}

image.png

因为是在字符串的原型链添加的方法,所以要实例化对象后再去调用定义的方法,否则找不到该方法

交换a,b的值,不用临时变量

a = a + b;//a === a + b
b = a - b;//b === a + b - b
a = a - b;//a === a + b - a

日期格式化函数

const dateFormat = (dateInput, format)=>{
    var day = dateInput.getDate() 
    var month = dateInput.getMonth() + 1  
    var year = dateInput.getFullYear()   
    format = format.replace(/yyyy/, year)
    format = format.replace(/MM/,month)
    format = format.replace(/dd/,day)
    return format
}

image.png