前端知识体系(4)-js手写函数篇

8,117 阅读14分钟

1.模拟new

1-1.使用场景

当需要创建多个对象的时候,如果循环会「重复创建」很多变量,占用内存。 如果用new生成,那么里面重复的属性是在「原型」上,就不用占用内存。

1-2.意义

节省代码,属于语法糖,可以拥有使用构造函数里面的所有「属性和方法」,并且还可以「拓展」。

1-3.实现步骤

  • 传入参数为:构造函数和函数参数
  • 创建1个空对象
  • 使空对象的__proto__指向构造函数的原型(prototype)
  • 将this绑定到空对象上,执行构造函数 判断构造函数的返回值,如果是对象,直接返回这个值。如果不是,就返回开始创建的对象

1-4.代码实现

// 新建构造函数--用来new的函数
// 构造函数是一种特殊的方法:用来在创建对象时为对象成员变量赋初始值
function Dog(name){
  // 这里的this指向window,所以下面才需要重新将this指向自身,这样才能将属性挂载成功
  this.name = name
  this.say = function() {
    console.log("my name is" + this.name);
  }
}

// 手写new函数
function _new(fn,...arg){
  const obj = {};
  // 将构造函数的原型挂载到新对象上
  Object.setPrototypeOf(obj, fn.prototype)
  // 将this指向新对象,执行构造函数
  let result = fn.apply(obj, arg);
  return result instanceof Object ? result : obj
}
// 验证
var dog = _new(Dog, "caigou")
dog.say();// my name is caigou

2.模拟instanceof

2-1.使用场景

测试一个对象是否为一个类的实例,常用于判断一个引用类型的类型。 即对象隐士原型上是否与构造函数显示原型匹配

2-2.用法

如 :

console.log(1 instanceof Number);                    // false
console.log(true instanceof Boolean);                // false 
console.log('str' instanceof String);                // false 
console.log(null instanceof Object);                 // false 

console.log([] instanceof Array);                    // true
console.log(function(){} instanceof Function);       // true
console.log({} instanceof Object);                   // true

2-3.优缺点

优点」:能够区分Array、Object和Function,适合用于判断自定义的类实例对象 「缺点」:Number,Boolean,String基本数据类型不能判断

2-4.实现步骤

  • 传入参数为左侧的实例L,和右侧的构造函数R
  • 处理边界,如果要检测对象为基本类型则返回false
  • 分别取传入参数的原型
  • 判断左侧的原型是否取到了null,如果是null返回false;如果两侧原型相等,返回true,否则继续取左侧原型的原型。

2-5.代码实现

// 手写instanceof函数
function instance_of(L, R){
  // 验证如果为基本数据类型,就直接返回false,因为instanceof只能判断引用类型
  const baseType = ['string', 'number','boolean','undefined','symbol']
  if(baseType.includes(typeof(L)) || L === null) { return false }

  let Lp = L.__proto__;
  let Rp = R.prototype//函数才有prototype属性
  while(true){
    if(Lp === null){//找到最顶层还是没找到,说明不匹配
      return false
    }
    if(Lp === Rp){
      return true
    }
    Lp = Lp.__proto__
  }
}
// 测试
var a = instance_of([],Array)
console.log(a) //true

3.模拟call

3-1.使用场景

改变this的指向。 一般来说,es5中this总会指向最后调用它的方法; es6箭头函数中this指向箭头函数最近一层的非箭头函数。 所以我们看到,this经常让人感到困惑,于是js实现了一个新的函数来帮我们避免这种困惑。 使用call,apply,bind(这3个大体相同),this会指向传入的对象。 当对象不存在时:

严格模式下:this指向undefined 非严格模式下:this指向window

3-2.实现步骤

  • 传入参数为:「this指向的对象」,和「调用函数」要传入的参数
  • 处理边界:
  • 对象不存在,this指向window;
  • 调用的不是函数,抛出错误
  • 将「调用函数」挂载到「this指向的对象」的fn属性上。
  • 执行「this指向的对象」上的fn函数,并传入参数,删除fn属性,返回结果。

3-3.代码实现

// 手写call函数
// call是在函数原型上的,所以我们这里也要挂载到原型上
Function.prototype.call2 = function(obj, ...ary) {
  obj = obj || window; //obj为空则指向window
  obj.fn = this; //this指被调用的函数,将函数挂载到当前对象的fn属性上
  if (typeof this != "function") {
    //边界处理
    throw new TypeError("Erorr");
  }
  /**此时this指向的对象的结构
    obj:{
      fn:bar(){}
    }
   */ 
  //this指向最后一个调用函数(fn)的对象(obj),所以指向obj
  var result = obj.fn(...ary);//this指向最后一个调用函数的对象,所以指向obj
  // 执行完以后删除,因为对象原本没有这个fn属性
  delete obj.fn;
  return result;
};

// 测试
var value = 2;
var obj1 = {
  value:1
}
function bar(name, age){
  var myObj = {
    name:name,
    age:age,
    value:this.value
  }
  console.log(this.value,myObj)
}
bar.call2(null) //打印 2 {name: undefined, age: undefined, value: 2}
bar.call2(obj1,"zi","shu")// 打印 1 {name: "zi", age: "shu", value: 1}

4.模拟深拷贝

4-1.使用场景

当我们需要使用对象并修改对象值时,又不想改变原对象的情况下,如:

vue子组件接收父组件传递过来的props值时,并且子组件需要对这个值进行修改,直接改变props的值会报错。这时,我们就可以在data深拷贝一份props值,然后在data上对应的值进行修改。

4-2.各个方式的比较

JSON.parse(JSON.stringify(copyObj))优点」 使用简单 「缺点

  • 如果对象里的是函数,正则,date,undefnied无法被拷贝下来
  • 无法拷贝copyObj对象原型链上的属性和方法
  • 当数据的层次很深,会栈溢出 手写拷贝优点」 可以考虑到各种情况 「缺点」 实现较为复杂

4-3.赋值、浅拷贝与深拷贝的区别

4-4.实现步骤

  • 如果传入的对象不存在,就返回null;如果是特殊对象,就new一个特殊对象。
  • 创建一个对象objClone,来保存克隆的对象。
  • 然后遍历对象,如果是基础数据,就直接放入objClone
  • 如果是对象,就递归。

4-5.代码实现

// 手写深拷贝函数
function deepClone(obj){
  if(obj == null){
    return null
  }
  if(obj instanceof RegExp){
    return new RegExp(obj)
  }
  if(obj instanceof Date){
    return new Date(obj)
  }
  var objClone = Array.isArray(obj) ? [] : {}
  for(let key in obj){
    if(obj.hasOwnProperty(key)){
    //如果还是对象,就递归
      if(obj[key] && typeof obj[key] === "object"){
        objClone[key] = deepClone(obj[key])
      }else{
        objClone[key] = obj[key]
      }
    }
  }
  return objClone
}
// 测试
var dog = {
  name: {
    chineseName:"狗"
  }
}
var newDog = deepClone(dog)
newDog.name.chineseName = "新狗"
console.log("newDog",newDog)//{ name: { chineseName:"新狗"}}
console.log("dog",dog)//{ name: { chineseName:"狗"}}

5.手写防抖函数

5-1.使用场景

我们平常开发的过程中,有很多场景会「频繁触发事件」,比如说搜索框实时发请求,onmousemove,resize,onscroll等等。 为了性能,有些时候,我们并不能或者不想频繁触发事件,函数防抖是在事件被触发n秒后再执行回调,如果在「n秒内又被触发」,则「重新计时」。

5-2.实现步骤

  • 传入参数为执行函数fn,延迟时间wait。
  • 定义一个定时器变量n,初始值为null。
  • 返回一个函数,当n不为null的时候,意味着已经有了定时器,要清除它。 否则重新计时。 5-3.代码实现
// 手写防抖函数
function debounce(fn, wait){
  var timer = null;
  return function() {
    // 有定时器了,在规定时间内再触发就要清除前面的定时,重新计时
    if(timer !== null){
      clearTimeout(timer)
    }
    // 重新计时
    timer = setTimeout(fn, (wait));
  }
}
// 测试
function handle(){
  console.log(Math.random())
}
// 窗口大小改变,触发防抖,执行handle
window.addEventListener("resize",debounce(handle,1000))

怎么记住防抖和节流呢?」

防抖即抖音(「抖延」):防抖会延迟执行

节流即「一流」:节流时间内,只执行一次

6.手写节流函数

6-1.使用场景

当事件触发时,保证「一定时间段」内只「调用一次」函数。例如页面滚动的时候,每隔一段时间发一次请求

6-2.实现步骤

  • 传入参数为执行函数fn,等待时间wait。
  • 保存初始时间now。
  • 返回一个函数,如果超过等待时间,执行函数,将now更新为当前时间。

6-3.代码实现

// 手写节流函数
function throttle(fn, wait,...args){
  var pre = Date.now();
  return function() {
    // 函数可能会有入参
    var context = this
    var now = Date.now()
    if(now - pre >= wait){
      // 将执行函数的this指向当前作用域
      fn.apply(context,args)
      pre = Date.now()
    }
  }
}
// 测试
var name = "夏"
function handle(val){
  console.log(val+this.name)
}
// 滚动鼠标,触发防抖,执行handle
window.addEventListener("scroll", throttle(handle,1000,"龙"))

7.手写数组去重

7-1.new Set 方法

let arr = [1,2,2,2,33,33,4]
console.log([...new Set(arr)])//[1,2,33,4]

7-2. 手写去重函数(遍历获取唯一值到新数组)

let arr = [1,2,2,2,33,33,4]
let arrNew = []
arr.forEach((item,index)=>{
  if(arrNew.indexOf(item)===-1){
  //或者 if(!arrNew.includes(item)){
    arrNew.push(item)
  }
})
console.log(arrNew)//[1,2,33,4]

7-3. 利用map的键不能重复

// 利用map的键不能重复,去掉某个属性相同的项
function uniqBy(arr,key){
  return [...new Map(arr.map(item=>[item[key],item])).values()]
}
const singers = [
  {id:1,name:"lo"},
  {id:1,name:"ming"},
  {id:2,name:"li"}
]
console.log(uniqBy(singers,"id"))// [{id:1,name:"ming"},{id:2,name:"li"}]

8.数组合并

8-1.concat

let arr = ["a","b"]
let arr1 = ["c","d"]
console.log(arr.concat(arr1))//["a","b","c","d"]

8-2.es6展示符

let arr2 = [...arr,...arr1]
console.log(arr2)//["a","b","c","d"]

9.数组展开

9-1. flat

let arr = [1,2,[3,4],[5,6,[7,8,9]]]
console.log(arr.flat(Infinity))//[1, 2, 3, 4, 5, 6, 7, 8, 9]

9-2. join, split

console.log(arr.toString().split(",").map(Number))//[1, 2, 3, 4, 5, 6, 7, 8, 9] 
console.log(arr.join().split(",").map(Number))//[1, 2, 3, 4, 5, 6, 7, 8, 9] 

10.判断是否为数组

let arr = [1,2]

10-1. instanceof

console.log(arr instanceof Array)

10-2. constructor

console.log(arr.constructor === Array)

10-3. constructor

console.log(arr.constructor === Array)

10-4. 判断对象是否有数组的push等方法

console.log(!!arr.push && !!arr.concat)

10-5. constructor

console.log(Array.isArray(arr))

11.排序

(1)冒泡排序

概念

从第一个元素开始,把当前元素和下一个索引元素进行比较。如果当前元素大,那么就交换位置,重复操作直到比较到最后一个元素

步骤

  • 先遍历一共有多少个数要跟其它数比较
  • 再遍历每个数要跟其它数比较多少次
  • 如果前一个数小于后一个数,则交换位置 手写代码
function bubbleSort(arr) {
    var len = arr.length;
    //多少个数要跟其它数比较
    for (var i = 0; i < len; i++) {
    //每个数要跟其它数比较多少次
        for (var j = 0; j < len - 1 - i; j++) {
            if (arr[j] > arr[j+1]) {        //相邻元素两两对比
                //元素交换
               [arr[j+1],arr[j]] = [arr[j],arr[j+1]];       
            }
        }
    }
    return arr;
}
//测试
let arr = [1,44,6,77,3,7,99,12]
console.log(bubbleSort(arr))// [1, 3, 6, 7, 12, 44, 77, 99]

(2)快速排序

概念

在数据集之中,找一个基准点,建立两个数组,分别存储左边和右边的数组,利用递归进行下次比较。

步骤

  • 先做边界处理
  • 定义左右两侧的数组变量
  • 取数组中间位置,通过这个位置找到中间值,同时在原数组上删除它
  • 遍历数组,判断当前项的大小,放到对应的一边
  • 遍历完后,对左右两侧数组进行递归,再拼接中间数。 手写代码
  function quickSort(arr) {
      if (!Array.isArray(arr)) return;
      if (arr.length <= 1) return arr;
      var left = [], right = [];
      // 以中间位置为下标
      var num = Math.floor(arr.length / 2);
      // arr.splice(num,1)取下标为num的这个数(会改变原数组),结果是数组的形式,所以加[0]
      var numValue = arr.splice(num, 1)[0];
      for (var i = 0; i < arr.length; i++) {
        if (arr[i] > numValue) {
          right.push(arr[i]);
        } else {
          left.push(arr[i]);
        }
      }
      return [...quickSort(left), numValue, ...quickSort(right)]
    }
//测试
let arr = [1,44,6,77,3,7,99,12]
console.log(quickSort(arr))// [1, 3, 6, 7, 12, 44, 77, 99]

12.继承

(1)原型链继承

简介

「核心:」

将父类的「实例」作为子类的「原型」

「优点:」

实现简单,容易理解

「缺点:」

  • 包含引用类型值的原型属性会被所有实例共享,这会导致对一个实例的修改「会影响」另一个实例。
  • 要想为子类新增属性和方法,必须在new Cat()这样的语句后执行,不能放在构造器中 如:在创建 Child 的实例时,不能向Cat传参。如果要加,只能在new Cat()以后加 由于这两个问题的存在,实践中很少单独使用原型链。

手写代码

//父类
function Cat () {
    this.name = '橘猫';
}
Cat.prototype.getName = function () {
    console.log(this.name);
}
//子类
function Child () {}
// 将父类的实例作为子类的原型
Child.prototype = new Cat();
//测试
var child1 = new Child();//我们要取出里面的getName,需要通过对象的属性的方式取出来(obj.fn),所以要new出一个对象
console.log(child1.getName()) // 橘猫

解惑

可能有人不解为什么要定义一个Child 函数:

因为继承是子类继承父类,我们的父类是Cat构造函数。那么还缺一个子类, 所以要创建一个子类,注意:我们说的父类,子类都是指构造函数 已经有了Child 构造函数,为什么还要new Child():

实际上这一步是为了测试子类上是不是有了父类的方法。Child本身只是一个构造函数,我们需要拿到它的实例,查看它的实例上是不是有这个方法,来判断Child是不是完成了继承。

(2)借用构造函数继承

简介

「核心:」

用.call()和.apply()将「父类构造函数」引入子类函数,等于是「复制」父类的实例属性给子类,「没用到原型」。

「优点:」

  • 只继承了父类构造函数的属性,没有继承父类原型的属性
  • 解决了原型链继承的2个缺点
  • 可以继承多个构造函数属性(call多个)。
  • 在子实例中可向父实例传参

缺点:」

  • 只能继承父类构造函数的属性。
  • 无法实现构造函数的复用。(每次用都要重新调用)
  • 每个新实例都有父类构造函数的副本,臃肿。
  • 因此这种技术很少单独使用。

手写代码

//父类
function SuperType(name){
      this.name=name;
}
//子类
function Child(name,age){
      //将SuperType中的this指向了Child,从而将父类构造函数SuperType引入子类函数Child
      //Child继承了SuperType,同时还传递了参数
      SuperType.call(this,name);
      //实例属性
      this.age=age;
}
//测试
var Child1=new Child("mary",22);
console.log(Child1.name);  //mary
console.log(Child1.age);  //22
console.log(Child1 instanceof SuperType);  //false

解惑

优点1怎么理解:

由于继承使用的是「call」,相当于在原来的SuperType函数里面,把「this」换成了「Child」,所以Child也有了name属性。注意:「我们全程没有用到原型」。 优点2怎么理解:

由于「没有用到原型」,所以属性不会被所有实例共享;可以在构造器中新增属性和传参:如SuperType.call(this,name),向父类传了「name」值(属性值为「动态」的)。而「原型继承」中,父类只能「写死」:this.name = '橘猫'。另外,Child中this.age=age也在构造器中直接添加了「属性」。

(3) 组合继承

简介

「核心:」

原型链继承和构造继承双剑合璧,结合了两种模式的优点,「传参」和「复用」

思路是:

通过借用「构造函数」来实现对「实例属性的继承」 使用「原型链」实现对「原型方法的继承」 这样,既通过在原型上定义方法实现了函数的复用,又能够保证每个实例都有它自己的属性。 是 JavaScript 中最常用的继承模式。

「优点:」

  • 可以继承父类原型上的属性,可以传参,可复用。
  • 每个新实例引入的构造函数属性是私有的。

「缺点:」

调用了两次(call一次,new一次)父类构造函数(耗内存),子类的构造函数会代替原型上的那个父类构造函数。

手写代码

//父类
function SuperType(name){
      this.name=name;
      this.colors=["red", "blue", "green"];
}
SuperType.prototype.sayName=function(){
      console.log(this.name);
};
//子类
function Child(name, age){
      //继承【属性】    构造继承
      SuperType.call(this,name);
      this.age=age;
}
//继承【方法】     原型继承
Child.prototype=new SuperType();
Child.prototype.constructor=Child;
//测试
Child.prototype.sayAge=function(){
      console.log(this.age);
};
var Child1=new Child("mary", 22);
Child1.colors.push("black");
console.log(Child1.colors);   //red,blue,green,black
Child1.sayName();  //mary
Child1.sayAge();  //22

(4) 原型式继承

简介

「核心:」

  • 就是 ES5 Object.create 的模拟实现,将「传入的对象」作为「创建的对象」的原型。
  • 类似于复制一个对象,用函数来包装。

「优点:」

参考原型链继承

「缺点:」

参考原型链继承

手写代码

function createObj(o) {
    function F(){}
    F.prototype = o;
    return new F();
}
//测试
var person = {
    name: 'kevin',
    friends: ['daisy', 'kelly']
}

var person1 = createObj(person);
var person2 = createObj(person);

person1.name = 'person1';
console.log(person2.name); // kevin

person1.firends.push('taylor');
console.log(person2.friends); // ["daisy", "kelly", "taylor"]

注意:修改person1.name的值,person2.name的值并未发生改变,并不是因为person1和person2有独立的 name 值,而是因为person1.name = 'person1',给person1添加了 name 值,并非修改了原型上的 name 值。

(5) 寄生式继承

简介

「核心:」

  • 就是给原型式继承外面套了个壳子。
  • 创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。

「优点:」

没有创建自定义类型,因为只是套了个壳子返回对象(这个),这个函数顺理成章就成了创建的新对象。

「缺点:」

  • 跟借用构造函数模式一样,每次创建对象都会创建一遍方法。
  • 没用到原型,无法复用。 手写代码
//在原型式的外面套了个函数
function createObj (o) {
    //这一步可以看做是原型式继承的简写
    var clone = object.create(o);
    //新增属性
    clone.sayName = function () {
        console.log('hi');
    }
    return clone;
}

(6) 寄生组合式继承

简介

「核心:」

实际上是3中继承方式的组合:寄生式,原型链继承,构造继承

  • 寄生:在函数内返回对象然后调用
  • 组合:
  • 1、函数的原型等于另一个实例。
  • 2、在函数中用apply或者call引入另一个构造函数,可传参

「优点:」

修复了组合继承的问题

「缺点:」

  • 跟借用构造函数模式一样,每次创建对象都会创建一遍方法。
  • 没用到原型,无法复用。

手写代码

//父类
function Parent (name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
    console.log(this.name)
}
//子类
function Child (name, age) {
    //构造继承
    Parent.call(this, name);
    this.age = age;
}

// 关键的三步
// 套了一层函数,寄生式继承
var F = function () {};

F.prototype = Parent.prototype;
//原型链继承
Child.prototype = new F();


var child1 = new Child('kevin', '18');

console.log(child1);