JS基础

179 阅读56分钟

浅拷贝与深拷贝

浅拷贝就是创建一个新对象,该对象有着原始对象属性值的一份精准拷贝,如果是基本数据类型,拷贝的是值,如果是引用数据类型,拷贝的是内存地址,数据改变会影响原对象的值。

深拷贝是将一个对象从内存中完整拷贝一份出来,从堆内存中开辟新的地址存放,修改不会影响原对象的值

手写浅拷贝

function clone(target) {
    let cloneTarget = {};
    for (const key in target) {
        cloneTarget[key] = target[key];
    }
    return cloneTarget;
};

手写深拷贝(简易版)

function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

手写深拷贝(详细版)

function deepClone(obj, hash = new WeakMap()) {
  if (obj === null) return obj; // 如果是null或者undefined我就不进行拷贝操作
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj);
  // 可能是对象或者普通的值  如果是函数的话是不需要深拷贝
  if (typeof obj !== "object") return obj;
  // 是对象的话就要进行深拷贝
  if (hash.get(obj)) return hash.get(obj);
  let cloneObj = new obj.constructor();
  // 找到的是所属类原型上的constructor,而原型上的 constructor指向的是当前类本身
  hash.set(obj, cloneObj);
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 实现一个递归拷贝
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  return cloneObj;
}
let obj = { name: 1, address: { x: 100 } };
obj.o = obj; // 对象存在循环引用的情况
let d = deepClone(obj);
obj.address.x = 200;
console.log(d);

函数柯理化

柯理化是能够将接受多个参数的函数变成一系列只接受部分参数的函数的一种技术(在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术

单一职责原则 逻辑复用 参数复用 延迟执行 函数组合 有助于提升代码的可读性、重用性和模块化程度

// 第一种版本
function curry(fn) {
    return function curried(...args) {
      if (args.length >= fn.length) {
        return fn(...args);
      }
      return function (...argss) {
        return curried(......args, ...argss);
      };
    };
}

// 第二种版本
function curry(fn) {
    return function curried(...args) {
      if (args.length >= fn.length) {
        return fn.apply(this, args);
      }
      return function (...argss) {
        return curried.apply(this, args.contact(argss));
      };
    };
}

// 第三种版本
function curry<T extends (...args: any[]) => any>(fn: T) {
    return function curried(...args: Parameters<T>) {
      if (args.length >= fn.length) {
        return fn.apply(this, args);
      }
      return function (...argss: Parameters<T>) {
        return curried.apply(this, args.contact(argss));
      };
    };
}

防抖

防抖,就是指触发事件后 n 秒后才执行函数,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。

应用场景:输入框实时搜索,重复请求后端接口 窗口resize事件 滚动条滚动事件 按钮点击事件

实现思路:非立即执行的思路是设置一个定时器,触发事件时如果存在定时器,则清除定时器,重新绑定定时器,定时时间到了则触发回调;立即执行的思路是看看有没有定时器,有就清除掉重新计时,然后此时执行时如果没有定时器,就马上调用回调,并且在定时器中时间到了后把定时器timer置为null。

注意this指向和e参数

// 非立即执行版
function debounce(fn, wait) {
    let timer = null;
    return function () {
      // 防抖函数的代码使用这两行代码来获取 this 和 参数,是为了让 debounce 函数最终返回的函数 this 指向不变以及依旧能接受到 e 参数。
      const content = this;
      const args = Array.from(arguments);

      // 如果有定时器,就代表触发过,则清除掉定时器,重新计时
      if (timer) {
        clearTimeout(timer);
      }

      timer = setTimeout(() => fn.apply(content, args), wait);
    };
}

// 立即执行版
function debounce(fn, wait) {
    let timer = null;
    return function () {
      // 防抖函数的代码使用这两行代码来获取 this 和 参数,是为了让 debounce 函数最终返回的函数 this 指向不变以及依旧能接受到 e 参数。
      const content = this;
      const args = Array.from(arguments);

      if (timer) {
        clearTimeout(timer);
      }

      const useNow = !timer; // 是否可以立即执行

      timer = setTimeout(() => {
        timer = null;
      }, wait);

      if (useNow) {
        fn.apply(content, args);
      }
    };
}

// 上面两个版本合并的版本
function debounce(fn, wait, immediate = false) {
    let timer = null;
    return function () {
      // 防抖函数的代码使用这两行代码来获取 this 和 参数,是为了让 debounce 函数最终返回的函数 this 指向不变以及依旧能接受到 e 参数。
      const content = this;
      const args = Array.from(arguments);

      if (timer) {
        clearTimeout(timer);
      }

      if (immediate) {
        const useNow = !timer; // 是否可以立即执行
        timer = setTimeout(() => {
          timer = null;
        }, wait);
        if (useNow) {
          fn.apply(content, args);
        }
      } else {
        timer = setTimeout(() => {
          fn.apply(content, args);
        }, wait);
      }
    };
}

// 可以取消的版本
function debounce(fn, wait, immediate = false) {
    let timer = null;
    const debounced = function () {
      // 防抖函数的代码使用这两行代码来获取 this 和 参数,是为了让 debounce 函数最终返回的函数 this 指向不变以及依旧能接受到 e 参数。
      const content = this;
      const args = Array.from(arguments);

      if (timer) {
        clearTimeout(timer);
      }

      if (immediate) {
        const useNow = !timer; // 是否可以立即执行
        timer = setTimeout(() => {
          timer = null;
        }, wait);
        if (useNow) {
          fn.apply(content, args);
        }
      } else {
        timer = setTimeout(() => {
          fn.apply(content, args);
        }, wait);
      }
    };

    debounced.canel = function () {
      clearTimeout(timer);
      timer = null;
    };

    return debounced;
}

节流

节流,就是指连续触发事件但是在 n 秒中只执行一次函数

实现思路:时间戳版本的思路是首先定义pre时间戳为0,然后在触发的时候用当前时间戳减去pre,看看得出的值是否大于wait,如果大于就代表可以执行fn了,并且把pre赋值为now;定时器版本的思路是如果没有定时器timer,就定义一个wait的定时器,定时器里面执行fn,并且把timer置为null

// 时间戳版本
function throttle(fn, wait) {
    let pre = 0; // 初始时间戳

    return function (...args) {
      const content = this;
      const now = Date.now();
      const diff = now - pre;

      if (diff >= wait) {
        fn.apply(content, args);
        pre = now;
      }
    };
}

// 定时器版本
function throttle(fn, wait) {
    let timer = null;

    return function (...args) {
      const content = this;

      if (!timer) {
        timer = setTimeout(() => {
          fn.apply(content, args);
          timer = null;
        }, wait);
      }
    };
}

0.1+0.2 !== 0.3

十进制整数转换为二进制整数:除2取余,直到商为1或0,逆序排序

十进制小数转换为二进制小数:乘2取整,直到小数部分为0,顺序排序

image.png

image.png

JavaScript只有一种数字类型,就是Number,遵守IEEE754标准,采用64位双精度双浮点数来表示

二进制在IEEE中的存储格式为 V = (-1)^S × M × 2^E

  • (-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。 1bit
  • M表示有效数字,尾数位,大于等于1,小于2。 52bits
  • 2^E表示指数位 11 bits 2^(11-1) -1 = 1023

二进制转位科学计数法

111001 = 1.11001 * 2^5

0.001101 = 1.101 * 2^-3

10000 = 1 * 2^4

整数就转为最小的浮点数,往左走了几位,2^后面就是几,其实这里就是求M的计算方式

小数就转为最大的浮点数,小数点后面的0全部不要,以第一次出现的1为准,往右走了几位,2^后面就是-几

M有效位的注意点:IEEE 754 标准规定,在 计算机内部保存 M 时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的部分 。比如保存 1.01 的时候,只保存 01,等到读取的时候,再把第一位的 1 加上去。这样做的目的,是节省 1 位有效数字。

E指数位的注意点:

  • 首先,E为一个无符号整数。这意味着,如果 E 为 8 位,它的取值范围为 02^8-1 = 0255;如果 E 为 11 位,它的取值范围为 02^11-1 = 02047。

  • 但是,我们知道,科学计数法中的 E 是可以出现负数的(例如 0.01 === 1*10^-3 ),所以 IEEE 754 规定,E的真实值必须再减去一个中间数,对于8位的E,这个中间数是127(-127~128);对于11位的E,这个中间数是1023。(也就是 10000000000(二进制) 的实际值是 0)

  • 举个栗子,2^10 的 E 是10,当保存成 32 位浮点数时,必须保存成 10 + 127 = 137,即 10001001。如果要保存成 64 位浮点数的时候,就会保存成 10 + 1023 = 1033,即 10000001001。

  • 指数位计算方式为 E + 1023 再转为二进制

// 0.1的二进制
0.00011 0011 0011...(0011循环)
// 0.2的二进制
0.0011 0011 0011...(0011循环)
IEEE754 64位双精度浮点型存储
// 0.1
(-1)^0 * 1.1 0011...(0011循环) * 2^(-4)
=> S = 0, M= 1.1 0011...(0011循环), E = -4
=> 实际存储指数位:-4 + 1023 = 1019 => 11 1111 1011

//0.2
(-1)^0 * 1.1 0011...(0011循环) * 2^(-3)
=> S = 0, M= 1.1 0011...(0011循环), E = -3
=> 实际存储指数位:-3 + 1023 = 1020 => 11 1111 1100
// 对阶前的实际存储
// 0.1 的存储
0    011 1111 1011    1001100110011001100110011001100110011001100110011010
--------------------------------------------------------------------
S    M(11位)          E(52位)

// 0.2 的存储
0    011 1111 1100    1001100110011001100110011001100110011001100110011010
--------------------------------------------------------------------
S    M(11位)     	  E(52位)
// 科学计数法
0.1 => (−1)^0 * (1.1001100110011001100110011001100110011001100110011010) * 2^-4

0.2 => (−1)^0 * (1.1001100110011001100110011001100110011001100110011010) * 2^-3
// 0.1 的阶码为 -4,0.2 的阶码为 -3,依照小阶对大阶的原则,我们需要将 0.1 的阶码变为 -3,因此其尾数部分需要右移一位。对阶之后 0.1 的存储为
0.1 = 0.11001100110011001100110011001100110011001100110011010 * 2^-3
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3
// 对阶后的实际存储
// 0.1
0  01111111100  1100110011001100110011001100110011001100110011001101 
// 0.2
0  01111111100  1001100110011001100110011001100110011001100110011010

数组拍平

话不多说,直接上图,额,上代码

递归

const arr = [1, 2, [3, 4, [5]]];
// 递归1
function flatten(arr: any[]) {
    let res: any[] = [];
    arr.forEach((a) => {
      if (Array.isArray(a)) {
        res = res.concat(flatten(a));
      } else {
        res.push(a);
      }
    });
    
    return res;
}

// 递归2
function flatten(arr: any[]) {
    let res: any[] = [];
    arr.forEach((a) => {
      if (Array.isArray(a)) {
        res = [...res, ...flatten()]
      } else {
        res.push(a);
      }
    });
    
    return res;
}

reduce + contact

const arr = [1, 2, [3, 4, [5]]];
function flatten(arr: any[]) {
    return arr.reduce((pre, cur) => {
      return pre.concat(Array.isArray(cur) ? flatten(cur) : cur);
    }, []);
  }

some + contact

const arr = [1, 2, [3, 4, [5]]];
function flatten(arr: any[]) {
    while (arr.some((a) => Array.isArray(a))) {
      arr = [].concat(...arr);
    }

    return arr;
  }

toString + split

const arr = [1, 2, [3, 4, [5]]];
function flatten(arr: any[]) {
    const str = arr.toString();
    const newArr = str.split(',').map((item) => {
      return Number(item);
    });

    return newArr;
  }

在原型链上重写flat函数

Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = this.concat();    // 获得调用 fakeFlat 函数的数组
  while (num > 0) {           
    if (arr.some(x => Array.isArray(x))) {
      arr = [].concat.apply([], arr);	// 数组中还有数组元素的话并且 num > 0,继续展开一层数组 
    } else {
      break; // 数组中没有数组元素并且不管 num 是否依旧大于 0,停止循环。
    }
    num--;
  }
  return arr;
};
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
arr.fakeFlat(Infinity)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

栈的思想

// 栈思想
function flat(arr) {
  const result = []; 
  const stack = [].concat(arr);  // 将数组元素拷贝至栈,直接赋值会改变原数组
  //如果栈不为空,则循环遍历
  while (stack.length !== 0) {
    const val = stack.pop(); 
    if (Array.isArray(val)) {
      stack.push(...val); //如果是数组再次入栈,并且展开了一层
    } else {
      result.unshift(val); //如果不是数组就将其取出来放入结果数组中
    }
  }
  return result;
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
flat(arr)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

传入参数控制拍平的层数

const arr = [1, 2, [3, 4, [5]]];
function flatten(arr: any[], num = Infinity) {
    return num > 0 ? arr.reduce((pre, cur) => {
      return pre.concat(Array.isArray(cur) ? flatten(cur, num -1) : cur);
    }, []) : arr.slice();
}
      
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);
    }
  }, []);
}

Generator实现

const arr = [1, 2, [3, 4, [5]]];
function* flatten(arr: any[]) {
    arr.forEach((item) => {
      if (Array.isArray(item)) {
        yield * flatten(item);
      } else {
        yield item;
      }
    });
  }
const flated = [...flatten(arr)];

数组去重

Set

// 可以区分NaN 无法去重相同的对象和数组
Array.from(new Set(arr))

indexOf + forEach

// 无法区分NaN
function unique(arr: any[]) {
    const res = [];

    arr.forEach((a) => {
      if (res.indexOf(a) === -1) {
        res.push(a);
      }
    });
}

includes + forEach

// 可以区分NaN 和`indexOf`方法相比耗时较长
function unique(arr: any[]) {
    const res = [];

    arr.forEach((a) => {
      if (res.includes(a)) {
        res.push(a);
      }
    });
}

filter + indexOf

无法区分NaN
function unique(arr: any[]) {
    return arr.filter((item, index, array) => array.indexOf(item) === index);
}

reduce + includes

// 耗时
function unique(arr: any[]) {
    return arr.reduce((pre, cur) => (pre.includes(cur) ? pre : [...pre, cur]), []);
}

对象

// 可以区分相同的对象数组函数
function unique(arr: any[]) {
    const obj = {};
    return arr.filter((a) =>
      obj.hasOwnProperty(typeof a + a) ? false : (obj[typeof a + a] = true),
    );
  }

Map

function unique(arr: any[]) {
    const map = new Map();
    return arr.filter((a) =>
      !map.has(a) ? map.set(a, true) : false,
    );
  }

排序后去重

function unique(array) {
    var res = [];
    var sortedArray = array.concat().sort();
    var seen;
    for (var i = 0, len = sortedArray.length; i < len; i++) {
        // 如果是第一个元素或者相邻的元素不相同
        if (!i || seen !== sortedArray[i]) {
            res.push(sortedArray[i])
        }
        seen = sortedArray[i];
    }
    return res;
}

原型原型链

js的每个构造函数在创建的时候,都会生成一个属性prototype,这个属性指向一个对象,这个对象就是该函数的原型对象,原型中有个属性constructor指向该函数,这个函数的实例都可以访问原型上的属性和方法。

注意只有函数才有Prototype

Object.proto === Function.proto

Object.proto === Function.prototype

Function.proto === Function.prototype

以上三个等式都是true

谨记一个原则,构造函数也是实例,所以它的__proto__是Function.prototype,因为它是由函数实例化来的。Function有点特殊,它的__proto__是等于它的prototype的,因为Function也是个实例,它是由Function实例化来的,有点绕了哈,仔细揣摩。

并非所有的对象都有原型 Object.create(null)创建的对象就没有原型

原型链

当我们访问对象的一个属性或方法时,它会先在对象自身中寻找,如果有则直接使用,如果没有则会去原型对象中寻找,如果找到则直接使用。如果没有则去原型的原型中寻找,直到找到Object对象的原型,Object对象的原型没有原型,是null,如果在Object原型中依然没有找到,则返回undefined。

image.png

image.png

new 到底做了什么

创建空对象

将构造函数的作用域赋给新的对象(因此this就指向了这个新对象)

执行构造函数中的代码,为新对象添加属性

绑定原型

返回这个对象

function myNew(ctor, ...args) {
  if (typeof ctor !== 'function') {
    throw 'ctor must be a function';
  }
  // 创建新的对象
  let newObj = new Object();
  // 让新创建的对象可以访问构造函数原型(constructor.prototype)所在原型链上的属性;
  newObj.__proto__ = Object.create(ctor.prototype);
  // 将构造函数的作用域赋给新对象(this指向新对象);
  // 执行构造函数中的代码
  let res = ctor.apply(newObj, [...args]);

  let isObject = typeof res === 'object' && res !== null;
  let isFunction = typeof res === 'function';
  return isObject || isFunction ? res : newObj;
}

function myNew (fn,...args){
    // 1.创建一个js对象,并且将该js对象的隐式原型指向构造函数的原型
    const newObj = Object.create(fn.prototype);
    // 2.调用函数,把函数的this绑定给这个新对象
    const result = fn.apply(newObj,args);
    // 3.判断函数是否返回有返回对象,如果有就返回该函数的返回对象,否则返回新对象
    return result && typeof result === "object" ? result : newObj;
}

function myNew(fn,...args){
  //1,先创建空对象
  //var obj = Object.create(null)
  var obj = {}
  //2,obj的__proto__指向构造函数的原型
  Object.setPrototypeOf(obj,fn.prototype)
  //3,改变this指向,执行构造函数内部函数
  var result = fn.apply(obj,args)
  //4,判断return
  return result instanceof Object?result:obj
}

function mynew(Func, ...args) {
    // 1.创建一个新对象
    const obj = {}
    // 2.新对象原型指向构造函数原型对象
    obj.__proto__ = Func.prototype
    // 3.将构建函数的this指向新对象
    let result = Func.apply(obj, args)
    // 4.根据返回值判断
    return result instanceof Object ? result : obj
}

Function.prototype.myNew = function () {
  let obj = Object.create(this.prototype)
  let result = this.call(obj, ...arguments)
  return result instance of Object ? result : obj
}

function myNew(Con, ...args) {
  // 创建一个新的空对象
  let obj = {};
  // 将这个空对象的__proto__指向构造函数的原型
  // obj.__proto__ = Con.prototype;
  Object.setPrototypeOf(obj, Con.prototype);
  // 将this指向空对象
  let res = Con.apply(obj, args);
  // 对构造函数返回值做判断,然后返回对应的值
  return res instanceof Object ? res : obj;
}

this指向

this指向跟在哪里定义无关,跟如何调用,通过什么样的形式调用有关

默认绑定

// 独立函数调用 严格模式下为undefined
function bar() {
  console.log(this) // window
}

隐式绑定

一般是对象里面的某个方法,通过这个对象直接调用

const info = {
  fullName: 'ice',
  getName: function() {
    console.log(this.fullName)
  }
}

info.getName() // 'ice'


// 隐式绑定this丢失
const info = {
  fullName: 'ice',
  getName: function() {
    console.log(this.fullName)
  }
}
const fn = info.getName
fn() //undefined


// 隐式绑定this丢失 回调函数
//申明变量关键字必须为var - 因为只有`var`申明的变量才会加入到全局`window`对象上
var fullName = 'panpan'
const info = {
  fullName: 'ice',
  getName: function() {
    console.log(this.fullName)
  }
}

function bar(fn) {
  fn() // panpan
}
bar(info.getName)

显示绑定

call/apply/bind

var fullName = 'panpan'
const info = {
  fullName: 'ice',
  getName: function(age, height) {
    console.log(this.fullName, age, height)
  }
}

function bar(fn) {
  fn.call(info, 20, 1.88) //ice 20 1.88
}
bar(info.getName)

function bar(fn) {
  fn.apply(info, [20, 1.88]) //ice 20 1.88
}
bar(info.getName)

function bar(fn) {
  let newFn = fn.bind(info, 20)
  newFn(1.88)
}
bar(info.getName)

new绑定指向实例

箭头函数

箭头函数的this指向它上一层作用域

var fullName = 'global ice'

const info = {
  fullName: 'ice',
  getName: () => {
    console.log(this.fullName)
  }
}

info.getName() //global ice

如何准确的判断this指向

  • 函数是否在new中调用(new绑定),如果是,那么this绑定的是新创建的对象。

  • 函数是否通过call,apply调用,或者使用了bind(即硬绑定),如果是,那么this绑定的就是指定的对象。

  • 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this绑定的是那个上下文对象。一般是obj.foo()

  • 如果以上都不是,那么使用默认绑定。如果在严格模式下,则绑定到undefined,否则绑定到全局对象。

  • 如果把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

  • 如果是箭头函数,箭头函数的this继承的是外层代码块的this。(注意外层代码指的是箭头函数外面能打印到this的地方),箭头函数的this是由它定义时的this决定的,但是可以通过修改它外层函数的this来间接修改它

绑定优先级: new绑定 bind call/apply 隐式绑定 默认绑定

箭头函数的特点:

(1)函数体内的this对象,继承的是外层代码块的this。

(2)不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。

(3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

(4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

(5)箭头函数没有自己的this,所以不能用call()、apply()、bind()这些方法去改变this的指向

(6)没有new.target,没有super

var obj = {
    hi: function(){
        console.log(this);
        return ()=>{
            console.log(this);
        }
    },
    sayHi: function(){
        return function() {
            console.log(this);
            return ()=>{
                console.log(this);
            }
        }
    },
    say: ()=>{
        console.log(this);
    }
}
let hi = obj.hi();  //输出obj对象
hi();               //输出obj对象
let sayHi = obj.sayHi();
let fun1 = sayHi(); //输出window
fun1();             //输出window
obj.say();          //输出window



var obj = {
    hi: function(){
        console.log(this);
        return ()=>{
            console.log(this);
        }
    },
    sayHi: function(){
        return function() {
            console.log(this);
            return ()=>{
                console.log(this);
            }
        }
    },
    say: ()=>{
        console.log(this);
    }
}
let sayHi = obj.sayHi();
let fun1 = sayHi(); //输出window
fun1();             //输出window

let fun2 = sayHi.bind(obj)();//输出obj
fun2();                      //输出obj

执行上下文

是一种当前代码执行环境的抽象概念,js运行的任何代码都在执行上下文中运行。

  • 全局执行上下文。这是一个默认的或者说基础的执行上下文,所有不在函数中的代码都会在全局执行上下文中执行。它会做两件事:创建一个全局的window对象(浏览器环境下),并将this的值设置为该全局对象,一个程序中只能有一个全局上下文。

  • 函数执行上下文。每次调用函数时,都会为该函数创建一个执行上下文,每一个函数都有自己的一个执行上下文,但注意是该执行上下文是在函数被调用的时候才会被创建。函数执行上下文会有很多个,每当一个执行上下文被创建的时候,都会按照他们定义的顺序去执行相关代码。

  • Eval函数执行上下文。在eval函数中执行的代码也会有自己的执行上下文,但由于eval函数不会被经常用到,这里就不做讨论了。(译者注eval函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢,因此不推荐使用)。

执行栈,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。

当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行上下文并将其推入当前的执行栈。每当发生一个函数调用,引擎都会为该函数创建一个新的执行上下文并将其推到当前执行栈的顶端。

引擎会运行执行上下文在执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文。

创建阶段→执行阶段→回收阶段

创建阶段

创建变量对象:初始化函数的参数,提升函数声明和变量声明

创建作用于链

确定this指向

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明

执行上下文栈(Execution Context Stack)

可以把执行上下文栈认为是一个存储函数调用的栈结构,遵循先进后出的原则

image.png

当浏览器首次载入你的脚本,它将默认进入全局执行上下文。如果,你在你的全局代码中调用一个函数,你程序的时序将进入被调用的函数,并创建一个新的执行上下文,并将新创建的上下文压入执行栈的顶部。

如果你调用当前函数内部的其他函数,相同的事情会在此上演。代码的执行流程进入内部函数,创建一个新的执行上下文并把它压入执行栈的顶部。浏览器总会执行位于栈顶的执行上下文,一旦当前上下文函数执行结束,它将被从栈顶弹出,并将上下文控制权交给当前的栈。这样,堆栈中的上下文就会被依次执行并且弹出堆栈,直到回到全局的上下文。

作用域和作用域链

变量、函数、对象有限的访问权限,作用域决定了代码中变量的可访问性和可见性。

全局作用域:任何不在大括号中或者函数中的变量都是全局作用域,大括号中定义的var也是全局作用域,最外层函数和在最外层函数外面定义的变量所有末定义直接赋值的变量自动声明为拥有全局作用域

函数作用域:函数内部定义的变量只能函数内部访问

块级作用域:{}内用let const定义的变量

js使用的是词法作用域(静态作用域),在定义时就确定了作用域,和在哪里调用无关;和静态对应的是动态作用域。

作用域链是当查找变量的时候,会从当前作用域进行查找,如果没有,就从父级作用域查找,一直查找到全局作用域。

作用域和执行上下文之间最大的区别是: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变

闭包

函数内返回一个函数,内层函数在执行时能记住它定义时的词法作用域,外层变量不会及时回收

function counterFactory() {
  let count = 0;

  return function() {
    return ++count;
  }
}

const counter = counterFactory();
console.log(counter()); // 输出 1
console.log(counter()); // 输出 2
console.log(counter()); // 输出 3

闭包的用途

  1. 实现私有变量和方法: 可以用闭包在函数内部定义变量和方法,并且只允许外部通过特定的接口访问它们。这样可以实现数据封装和信息隐藏。

    function createCounter() {
      let count = 0; // 私有变量
    
      return {
        increment: function() { // 公共方法
          count++;
        },
        decrement: function() { // 公共方法
          count--;
        },
        getCount: function() { // 公共方法
          return count;
        }
      };
    }
    
    const counter = createCounter();
    counter.increment();
    counter.increment();
    console.log(counter.getCount()); // 输出 2
    

    在这个例子中,createCounter() 函数返回了一个包含了三个方法的对象。只有这些方法能够访问 createCounter() 函数中的私有变量 count,外部代码无法直接访问 count。这就是闭包实现数据封装和信息隐藏的一种方式。

  2. 事件处理和回调函数: 当我们需要在事件处理函数或回调函数中访问外部变量时,闭包就派上用场了。

    function addClickHandler(element, data) {
      element.addEventListener('click', function() {
        console.log(data); // 可以访问 data 参数
      });
    }
    
    const myElement = document.getElementById('my-element');
    addClickHandler(myElement, 'some data');
    

    在这个例子中,addClickHandler() 函数接受一个 DOM 元素和一些数据作为参数,并为元素添加一个点击事件处理函数。

    这个事件处理函数是一个闭包,它能够访问 addClickHandler() 函数的 data 参数——即使在事件触发时 addClickHandler() 函数已经执行完毕了。

    这种闭包的应用在事件处理和异步回调中非常常见。

  3. 函数柯里化: 闭包可以用于实现函数柯里化,即部分应用一个函数,返回一个新函数,新函数可以记住原函数的参数。

    function multiply(a, b) {
      return a * b;
    }
    
    function createMultiplier(a) {
      return function(b) {
        return multiply(a, b);
      }
    }
    
    const double = createMultiplier(2);
    console.log(double(5)); // 输出 10
    console.log(double(10)); // 输出 20
    

    在这个例子中,createMultiplier() 函数返回了一个新函数function(b) { return multiply(a, b); },这个新函数记住了 createMultiplier() 函数的 a 参数,并将整个函数体赋值给了只读变量double

    这就是函数柯里化的实现方式。double 所指向的函数记住了 a 参数为 2,所以每次调用它时,只需要提供 b 参数就可以计算出结果。这种方式可用于创建更具可重用性的函数。

变量提升

变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量被提升后,会给变量设置默认值为 undefined。

正是由于 JavaScript 存在变量提升这种特性,导致了很多与直觉不太相符的代码,这也是 JavaScript 的一个设计缺陷。虽然 ECMAScript6 已经通过引入块级作用域并配合使用 let、const 关键字,避开了这种设计缺陷,但是由于 JavaScript 需要向下兼容,所以变量提升在很长时间内还会继续存在。

console.log(num) 
var num = 1

// 等价于
var num
console.log(num)
num = 1

function getNum() {
  console.log(num) 
  var num = 1  
}
getNum()

// 等价于
function getNum() {
  var num 
  console.log(num) 
  num = 1  
}
getNum()

函数也会提升:函数声明式会提升,函数表达式不会提升

fn()
var fn = function () {
	console.log(1)  
}
// 输出结果:Uncaught TypeError: fn is not a function

foo()
function foo () {
	console.log(2)
}
// 输出结果:2

为什么 JavaScript 中会存在变量提升这个特性呢?

首先要从作用域说起。作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,作用域分为两种:

  • 全局作用域中的对象在代码中的任何地方都可以访问,其生命周期伴随着页面的生命周期。
  • 函数作用域是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

相较而言,其他语言则普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至一个单独的{}都可以被看作是一个块级作用域(注意,对象声明中的{}不是块级作用域)。简单来说,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

ES6 之前是不支持块级作用域的,没有块级作用域,将作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。

暂时性死区 ES6 规定:如果区块中存在 let 和 const,这个区块对这两个关键字声明的变量,从一开始就形成了封闭作用域。假如尝试在声明前去使用这类变量,就会报错。这一段会报错的区域就是暂时性死区

var name = 'JavaScript';
{
	name = 'CSS';
	let name;
}

// 输出结果:Uncaught ReferenceError: Cannot access 'name' before initialization

事件循环(Event Loop)

同步任务:在主线程上排队执行的任务,只有前一个任务执行完成,才会执行下一个任务

异步任务:不进入主线程,进入异步任务队列,当主线程中的任务执行完成,才会从异步任务队列中取出异步任务,放入主线程中执行

image.png

  • 为了解决单个任务执行时间过长,把js任务分为同步和异步,同步任务直接执行,异步任务放入任务队列等待执行

  • 为了解决异步队列中等待任务的执行优先级的问题,所以把异步任务分为微任务micro-task(jobs)和宏任务macro-task(task),同步任务执行完后,就先执行微任务

  • 宏任务包括:script(全局任务) , setTimeout, setInterval, setImmediate, I/O, UI rendering。

  • 微任务包括: new Promise().then(回调), process.nextTick, Object.observe(已废弃), MutationObserver(html5新特性)

image.png

image.png

  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。

  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。

  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。

  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

事件循环过程(第一版)

  • 所有同步任务都在主线程上依次执行,形成一个执行栈(调用栈),异步任务处理完后则放入一个任务队列

  • 当执行栈中任务执行完,再去检查微任务队列里的微任务是否为空,有就执行,如果执行微任务过程中又遇到微任务,就添加到微任务队列末尾继续执行,把微任务全部执行完

  • 微任务执行完后,再到任务队列检查宏任务是否为空,有就取出最先进入队列的宏任务压入执行栈中执行其同步代码

  • 然后回到第2步执行该宏任务中的微任务,如此反复,直到宏任务也执行完,如此循环

事件循环过程(第二版)

  • 执行全局Script同步代码

  • 全局Script代码执行完毕后,调用栈Stack会清空;

  • 微队列中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;

  • 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到把microtask queue中的所有任务都执行完毕。如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行;

  • microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空;

  • 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行; 执行完毕后,调用栈Stack为空;

  • 重复第3-6个步骤

//字节面试题
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

setTimeout(function () {
  console.log("setTimeout");
}, 0);

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});

console.log("script end");

/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/



console.log('script start')

async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
    return Promise.resolve().then(()=>{
        console.log('async2 end1')
    })
}
async1()

setTimeout(function() {
    console.log('setTimeout')
}, 0)

new Promise(resolve => {
    console.log('Promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})

console.log('script end')

// script start => async2 end => Promise => script end => async2 end1 => promise1 => promise2 => async1 end => setTimeout

call、bind、apply

callapplybind是JavaScript中用于改变普通函数this指向(无法改变箭头函数this指向)的方法,这三个函数实际上都是绑定在Function构造函数的prototype上,而每一个函数都是Function的实例,因此每一个函数都可以直接调用call,apply,bind

call方法

  • 语法: function.call(thisArg, arg1, arg2, ...)。 其中thisArg是要设置为函数执行上下文的对象,也就是this要指向的对象,从第二个参数开始,arg1, arg2, ... 是传递给函数的参数。通过使用call方法,可以将一个对象的方法应用到另一个对象上。
// 定义一个对象
const person1 = {
  name: 'Alice',
  greet: function() {
    console.log(`Hello, ${this.name}!`);
  }
};

// 定义另一个对象
const person2 = {
  name: 'Bob'
};

// 使用call方法将person1的greet方法应用到person2上
person1.greet.call(person2); // 输出:Hello, Bob!

apply方法

  • 语法:function.apply(thisArg, [argsArray])。 其中thisArg是要设置为函数执行上下文的对象,也就是this要指向的对象,argsArray是一个包含参数的数组。通过使用apply方法,可以将一个对象的方法应用到另一个对象上,并使用数组作为参数。
function greet(name) {
  console.log(`Hello, ${name}!`);
}

const person = { name: 'John' };
greet.apply(person, ['Mary']); // 输出:Hello, Mary!

bind方法

  • 语法:function.bind(thisArg, arg1, arg2, ...)。 其中thisArg是要绑定到函数执行上下文的对象,也就是this要指向的对象,从第二个参数开始,arg1, arg2, ...是传递给函数的参数。与call和apply方法不同,bind方法并不会立即执行函数,而是返回一个新函数,可以稍后调用。这对于事件处理程序和setTimeout函数等场景非常有用。
function greet(name) {
  console.log("Hello, " + name);
}

const delayedGreet = greet.bind(null, "John");
setTimeout(delayedGreet, 2000);  // 2秒后输出:Hello, John

call、bind、apply的区别

  1. 调用方式:
  • call:使用函数的call方法可以直接调用函数,并传递参数列表。
  • bind:使用函数的bind方法可以返回一个新的函数,这个新函数的this值被绑定到指定的对象,但不会立即执行。
  • apply:使用函数的apply方法可以直接调用函数,并传递参数列表,与call方法类似,但参数需要以数组或类数组的形式传递。
  1. 参数传递方式:
  • call:使用call方法时,参数需要一个一个地列举出来,通过逗号分隔。
  • bind:使用bind方法时,可以传递任意数量的参数,可以在绑定时传递参数,也可以在调用时传递参数。
  • apply:使用apply方法时,参数需要以数组或类数组的形式传递。
  1. 执行时机:
  • call:调用call方法时,函数会立即执行。
  • bind:调用bind方法时,返回一个新函数,需要后续再调用这个新函数才会执行。
  • apply:调用apply方法时,函数会立即执行。

总结:

  • call可以直接调用函数,并传递参数列表,立即执行。
  • bind返回一个新函数,将绑定的对象作为this值,可以在绑定时或调用时传递参数,需要手动调用新函数执行。
  • apply可以直接调用函数,并传递参数列表,立即执行,参数以数组或类数组的形式传递。

JS异步解决方案

image.png

进程和线程

进程是资源分配的最小单位,线程是CPU调度的最小单位。

进程和线程的关系特点

进程中的任一线程出错,都会导致整个进程崩溃

线程之间共享进程的数据

当一个进程关闭后,操作系统会回收进程所占用的内存

进程之间的内容相互隔离

一个进程包含一个或多个线程

打开一个页面需要哪些进程

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

  • 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。

  • GPU 进程:其实, GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。

  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。

  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

渲染进程的线程 (1)GUI渲染线程

  • 负责渲染浏览器页面,解析HTML、CSS,构建DOM树、CSSOM树、渲染树和绘制页面
  • 当界面需要重绘或由于某种操作引发回流时,该线程就会执行

注意:GUI渲染线程和JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

(2)JS引擎线程

  • JS引擎线程也称为JS内核,负责处理Javascript脚本程序,解析Javascript脚本,运行代码;
  • JS引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个Tab页中无论什么时候都只有一个JS引擎线程在运行JS程序;

注意:GUI渲染线程与JS引擎线程的互斥关系,所以如果JS执行的时间过长,会造成页面的渲染不连贯,导致页面渲染加载阻塞。

(3)时间触发线程

  • 属于浏览器而不是JS引擎,用来控制事件循环;
  • 当JS引擎执行代码块如setTimeOut时(也可是来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件触发线程中;
  • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理;

注意:由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行);

(4)定时器触发进程

  • 即setInterval与setTimeout所在线程;
  • 浏览器定时计数器并不是由JS引擎计数的,因为JS引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确性;
  • 因此使用单独线程来计时并触发定时器,计时完毕后,添加到事件队列中,等待JS引擎空闲后执行,所以定时器中的任务在设定的时间点不一定能够准时执行,定时器只是在指定时间点将任务添加到事件队列中;

注意:W3C在HTML标准中规定,定时器的定时时间不能小于4ms,如果是小于4ms,则默认为4ms。

(5)异步http请求线程

  • XMLHttpRequest连接后通过浏览器新开一个线程请求;
  • 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将回调函数放入事件队列中,等待JS引擎空闲后执行;

进程之间的通信方式

  • 管道通信:就是操作系统在内核中开辟一段缓冲区,进程1可以将需要交互的数据拷贝到这个缓冲区里,进程2就可以读取了

  • 消息队列通信:消息队列就是用户可以添加和读取消息的列表,消息队列里提供了一种从一个进程向另一个进程发送数据块的方法,不过和管道通信一样每个数据块有最大长度限制

  • 共享内存通信:就是映射一段能被其他进程访问的内存,由一个进程创建,但多个进程都可以访问,共享进程最快的是IPC方式

  • 信号量通信:比如信号量初始值是1,进程1来访问一块内存的时候,就把信号量设为0,然后进程2也来访问的时候看到信号量为0,就知道有其他进程在访问了,就不访问了

  • socket:其他的都是同一台主机之间的进程通信,而在不同主机的进程通信就要用到socket的通信方式了,比如发起http请求,服务器返回数据

孤儿进程和僵尸进程

  • 孤儿进程:父进程退出了,而它的一个或多个进程还在运行,那这些子进程都会成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
  • 僵尸进程: 子进程比父进程先结束,而父进程又没有释放子进程占用的资源,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

数据类型

JavaScript有哪些数据类型,它们的区别? JavaScript共有八种数据类型,分别是 Undefined、Null、Boolean、Number、String、Object、Symbol、BigInt。

其中 Symbol 和 BigInt 是ES6 中新增的数据类型:

  • Symbol 代表创建后独一无二且不可变的数据类型,它主要是为了解决可能出现的全局变量冲突的问题。
  • BigInt 是一种数字类型的数据,它可以表示任意精度格式的整数,使用 BigInt 可以安全地存储和操作大整数,即使这个数已经超出了 Number 能够表示的安全整数范围。

这些数据可以分为原始数据类型和引用数据类型:

  • 栈:原始数据类型(Undefined、Null、Boolean、Number、String)
  • 堆:引用数据类型(对象、数组和函数)

两种类型的区别在于存储位置的不同:

  • 原始数据类型直接存储在栈(stack)中的简单数据段,占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储;
  • 引用数据类型存储在堆(heap)中的对象,占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能;引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

堆和栈的概念存在于数据结构和操作系统内存中,在数据结构中:

  • 在数据结构中,栈中数据的存取方式为先进后出。
  • 堆是一个优先队列,是按优先级来进行排序的,优先级可以按照大小来规定。

在操作系统中,内存被分为栈区和堆区:

  • 栈区内存由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
  • 堆区内存一般由开发着分配释放,若开发者不释放,程序结束时可能由垃圾回收机制回收。

检测数据类型的方法 typeof:数组、null、对象返回object,其他返回正常的

instanceof: 判断右边的原型是否在左边的原型链上。只能判断引用数据类型,不能判断基本数据类型

constructor:不准确,constructor可能会被篡改

Object.prototype.toString.call

怎么判断是数组 Object.prototype.toString.call(obj).slice(8,-1) === 'Array';

obj.proto = Array.prototype

Array.isArray

obj instanceof Array

数据类型转换

//基本数据类型
let num = 123  //数字
let str = 'hello world'  //字符串
let flag = true  //布尔
let un = undefined  //未定义
let nu = null  //空值
let bigint = 9007199254740991n  //大整数
let sy = Symbol('symbol')  //符号

//引用数据类型
var person = { //对象
    name: "John",
    age: 30, 
    isStudent: false, 
    hobbies: ["reading", "coding"] 
};
var numbers = [1, 2, 3, 4, 5]; //数组
function foo() { //函数
    return "你好!掘友";
}
//等等

JS 类型转换主要分为两种:隐式类型转换显式类型转换

1. 隐式类型转换:在隐式类型转换中,JS 引擎自动地将一种数据类型转换为另一种类型,通常发生在运算或比较的过程中。这种转换是隐式的,开发者不需要明确地进行操作,而是由 JS 引擎在必要的时候自动完成。

2. 显式类型转换:显式类型转换是由开发者明确指定的类型转换,通过调用相应的转换函数或使用一些特定的语法进行。这种转换是开发者有意识地进行的,用于确保数据在特定上下文中具有期望的类型。

原始值转换为Boolean

console.log(Boolean()); // false
console.log(Boolean(1)); // true
console.log(Boolean(0)); // false
console.log(Boolean(-1)); // true
console.log(Boolean(undefined)); // false
console.log(Boolean(null)); // false
console.log(Boolean('123')); // true
console.log(Boolean('')); // false
console.log(Boolean(' ')); // true

原始值转换为Number

console.log(Number('123')); //123
console.log(Number('abc')); //NaN
console.log(Number()); //0
console.log(Number(true)); //1
console.log(Number(false)); //0
console.log(Number(null)); //0
console.log(Number(undefined)); //NaN

原始值转换为String

console.log(String()) //''
console.log(String(123)) //'123'
console.log(String(NaN)) //'NaN'
console.log(String(undefined)) //'undefined'
console.log(String(null)) //'null'
console.log(String(true)) //'true'

原始值转换为对象

console.log(Object(true)) //[Boolean: true]
console.log(Object(123)) //[Number: 123]
console.log(Object('123')) //[String: '123']
console.log(Object(undefined)) //{}
console.log(Object(null)) //{}

对象转换为数字 ToPrimitive(obj, Number) 是 ECMAScript 规范中描述的一种抽象操作,用于将给定对象 obj 转换为一个原始值,在进行数据转换的时候才会触发。这里我们目标是要将对象转换成数字,所以第二个参数是Number,字符串则是String。 简化一下:

ToPrimitive(obj, Number) ==> Number({})

  1. 如果 obj 是基本类型,直接返回
  2. 否则,调用 valueOf 方法,如果得到原始值,则返回
  3. 否则,调用 toString 方法,如果得到原始值,则返回
  4. 否则,报错
a = {}
console.log(Number(a)); //NaN

套公式:

  1. 如果 a 是基本类型:

    • a 是一个对象,不是基本类型。
  2. 调用 valueOf 方法:

    • 尝试调用 valueOf 方法,看是否可以获取到原始值。
    • 由于 a 是一个空对象 {},而普通的对象(没有重写 valueOf 方法的情况下)的 valueOf 方法会返回对象本身,而不是一个原始值。
  3. 调用 toString 方法:

    • 由于 valueOf 方法没有返回原始值,接下来调用 toString 方法。
    • 对于普通的空对象 {}toString 方法通常会返回 "[object Object]" 字符串。

调用toString方法的时候得到了原始值字符串"[object Object]",于是ToPrimitive这个函数将其返回,最后就相当于 Number("[object Object]") 执行了这段代码。在 JS 引擎内部,ToPrimitive这个函数相当于进行了将 Number(a) 转化成了 Number("[object Object]") ,相当于将字符串转换成数字,所以最终打印结果为 NaN

对象转字符串 与对象转数字类似,唯一的区别就是转字符串时在ToPrimitive函数中是先调用 toString 方法,后调用 typeOf 方法。

ToPrimitive(obj, String) ==> String({})

  1. 如果 obj 是基本类型,直接返回
  2. 否则,调用 toString 方法,如果得到原始值,则返回
  3. 否则,调用 valueOf 方法,如果得到原始值,则返回
  4. 否则,报错

对象转Boolean 全部都是true

隐式转换

对于某些运算符, 当A <operator> B的时候, 如果AB类型不一致, 那么将会触发隐式类型转换, 这些运算符汇总如下:

  • 宽松相等运算符==, !=
  • 关系运算符(>, <, <=, >=)
  • 逻辑运算符(&&, ||, !)
  • if, while, for, ? : + (condition)中的条件表达式(condition)
  • 加性运算符 +
  • 算数运算符(-, *, /, %)
  • 一元 +, - 操作

两等号A == B的隐式类型转换规则其实不是很难, 个人总结的规则如下:

❌ 不会发生隐式类型转换的情形:

  • A和B均为{undefined, null}当中的一种, A == B -> true
  • A为{undefined, null}当中的一种, B为{string, boolean, number, 对象}当中的一种, A == B -> false
  • A和B对象均为对象, 则比较对象的地址是否相等

✅ 发生隐式类型转换的情形, 以及类型转换规则:

  1. A 和 B均为{string, boolean, number}当中的一种, 且 A 和 B 的类型不一致. 那么,string和boolean都会先转为number, 然后再进行比较
  2. A 和 B 其中有一个为对象, 另一个为{string, boolean, number}中的一种. 此时一个为对象, 一个为原始值, 当两者进行相等比较的时候, 会对对象进行 to primitive 的类型转换(👉 to primitive的类型转换规则参考2.4节. ❗注意: 在隐式类型转换当中, 规则稍有不同, 注意项见下面).

注意, 在隐式类型转换当中, 如果对象发生to primitive操作

  • 总是优先调用[Symbol.toPrimitive]. 如果没有定义则调用valueOf, 在valueOf返回对象的时候, 会继续调用toString. (显式类型转换to string是先toString然后valueOf, to number是先valueOf然后toString, 隐式类型转换to primitive总是先valueOf, 然后toString)
  • [Symbol.toPrimitive], toString, valueOf这三个方法在返回原始值的时候, 不会将原始值再转为其他的原始值类型, 而是直接返回结果, 作为对象的to primitive 类型转换结果
{} == 1;  // false
// 首先`{}`先被`ToPrimitive`转换成字符串`"[object Object]"`,就相当于直接判断 `"[object Object]" == 1`,字符串与数字的比较中,又要将字符串转换成数字,"[object Object]"转换成数字为 `NaN`,而`NaN` 与任何值比较都为 `false`。

[] == ![] // true
从左到右进行扫描, 首先`![]`触发类型转换, 将 `[]`转为boolean, 结果为false
然后判断`[] == false`, 此时两端类型不一致, 因而触发数组对象`[]`的to primitive隐式类型转换, 调用`[].prototype.valueOf()`返回数组对象自身, 因而继续调用`[].prototype.toString()`返回`""`
然后判断`"" == false`, 将`""`和`false`都转换为number, 再进行比较, 最终为`0 == 0`, 结果为true

重绘与回流

1. 回流与重绘的概念及触发条件

(1)回流

当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流

下面这些操作会导致回流:

  • 页面的首次渲染
  • 浏览器的窗口大小发生变化
  • 元素的内容发生变化
  • 元素的尺寸或者位置发生变化
  • 元素的字体大小发生变化
  • 激活CSS伪类
  • 查询某些属性或者调用某些方法
  • 添加或者删除可见的DOM元素

在触发回流(重排)的时候,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:

  • 全局范围:从根节点开始,对整个渲染树进行重新布局
  • 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局

(2)重绘

当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘

下面这些操作会导致回流:

  • color、background 相关属性:background-color、background-image 等
  • outline 相关属性:outline-color、outline-width 、text-decoration
  • border-radius、visibility、box-shadow

注意: 当触发回流时,一定会触发重绘,但是重绘不一定会引发回流。

2. 如何避免回流与重绘?

减少回流与重绘的措施:

  • 操作DOM时,尽量在低层级的DOM节点进行操作
  • 不要使用table布局, 一个小的改动可能会使整个table进行重新布局
  • 使用CSS的表达式
  • 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
  • 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
  • 避免频繁操作DOM,可以创建一个文档片段documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中
  • 将元素先设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
  • 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制

浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列

浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。

上面,将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。

Web Worker

众所周知,js最初设计是运行在浏览器中的,为了防止多个线程同时操作DOM,带来渲染冲突问题,所以js执行器被设计成单线程。但随着前端技术的发展,js能力远不止如此,当我们遇到需要大量计算的场景时(比如图像处理、视频解码等),js线程往往会被长时间阻塞,甚至造成页面卡顿,影响用户体验。为了解决单线程带来的这一弊端,Web Worker 应运而生。

Web Worker 是 HTML5 标准的一部分,这一规范定义了一套 API,允许我们在 js 主线程之外开辟新的 Worker 线程,并将一段 js 脚本运行其中,它赋予了开发者利用 js 操作多线程的能力。

因为是独立的线程,Worker 线程与 js 主线程能够同时运行,互不阻塞。所以,在我们有大量运算任务时,可以把运算任务交给 Worker 线程去处理,当 Worker 线程计算完成,再把结果返回给 js 主线程。这样,js 主线程只用专注处理业务逻辑,不用耗费过多时间去处理大量复杂计算,从而减少了阻塞时间,也提高了运行效率,页面流畅度和用户体验自然而然也提高了。

虽然 Worker 线程是在浏览器环境中被唤起,但是它与当前页面窗口运行在不同的全局上下文中,我们常用的顶层对象 window,以及 parent 对象在 Worker 线程上下文中是不可用的。另外,在 Worker 线程上下文中,操作 DOM 的行为也是不可行的,document对象也不存在。但是,locationnavigator对象可以以可读方式访问。除此之外,绝大多数 Window 对象上的方法和属性,都被共享到 Worker 上下文全局对象 WorkerGlobalScope 中。同样,Worker 线程上下文也存在一个顶级对象 self

// main.js(主线程)

const myWorker = new Worker('/worker.js'); // 创建worker

myWorker.addEventListener('message', e => { // 接收消息
    console.log(e.data); // Greeting from Worker.js,worker线程发送的消息
});

// 这种写法也可以
// myWorker.onmessage = e => { // 接收消息
//    console.log(e.data);
// };

myWorker.postMessage('Greeting from Main.js'); // 向 worker 线程发送消息,对应 worker 线程中的 e.data


// 监听错误
// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
    console.log(e.data); // Greeting from Main.js,主线程发送的消息
    self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
});

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker

myWorker.addEventListener('error', err => {
    console.log(err.message);
});
myWorker.addEventListener('messageerror', err => {
    console.log(err.message)
});

// 关闭worker
// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
myWorker.terminate(); // 关闭worker

// worker.js(worker线程)
self.close(); // 直接执行close方法就ok了

Service Worker

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:

// index.js
if (navigator.serviceWorker) {
  navigator.serviceWorker
    .register('sw.js')
    .then(function(registration) {
      console.log('service worker 注册成功')
    })
    .catch(function(err) {
      console.log('servcie worker 注册失败')
    })
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener('install', e => {
  e.waitUntil(
    caches.open('my-cache').then(function(cache) {
      return cache.addAll(['./index.html', './index.js'])
    })
  )
})
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener('fetch', e => {
  e.respondWith(
    caches.match(e.request).then(function(response) {
      if (response) {
        return response
      }
      console.log('fetch source')
    })
  )
})

async和defer

  • async 是在下载完成之后,立即异步加载,加载好后立即执行,多个带async属性的标签,不能保证加载的顺序;

  • defer 是在下载完成之后,立即异步加载。加载好后,如果 DOM 树还没构建好,则先等 DOM 树解析好再执行;如果DOM树已经准备好,则立即执行。多个带defer属性的标签,按照顺序执行。

弱引用和强引用

弱引用是不能确保其引用的对象不会被垃圾回收机制回收的引用,强引用是确保其引用对象不会被垃圾回收机制回收的引用

js引擎在执行代码时,对象通过变量直接赋值形成的引用会被视为强引用,垃圾回收机制不会回收这类对象;通过weakMap和weakSet建立的引用视为弱引用。

当一个对象被设置为null时,会断开该变量与原对象间的引用。该对象就会被垃圾回收机制回收。

垃圾回收机制不考虑 WeakSet、 WeakMap 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet、 WeakMap 之中。

React-Router

react-router-domreact-routerhistory库三者什么关系

history 可以理解为react-router的核心,也是整个路由原理的核心,里面集成了popState,history.pushState等底层路由实现的原理方法,接下来我们会一一解释。

react-router可以理解为是react-router-dom的核心,里面封装了Router,Route,Switch等核心组件,实现了从路由的改变到组件的更新的核心功能,在我们的项目中只要一次性引入react-router-dom就可以了。

react-router-dom,在react-router的核心基础上,添加了用于跳转的Link组件,和histoy模式下的BrowserRouter和hash模式下的HashRouter组件等。所谓BrowserRouter和HashRouter,也只不过用了history库中createBrowserHistory和createHashHistory方法

class App extends Component {
  render() {
    return (
      <BrowserRouter>
          <Header />
          <Route path='/' exact component={Home}></Route>
          <Route path='/login' exact component={Login}></Route>
          <Route path='/detail/:id' exact component={Detail}></Route>
      </BrowserRouter>
    )
  }
}

BrowserRouter:只要利用h5的 history api实现,pushState popState replaceState

HashRouter:只要利用window.location.hash实现。监听onHashchange,hash值的变化不会引起浏览器刷新

Route:用来控制路径对应显示的组件

path:指定路由跳转路径

exact:是否精准匹配路由

component:路由对应的组件

render:自定义Route的返回

Switch:渲染与该地址匹配的第一个子节点 <Route> 或者 <Redirect>,匹配到第一个路由后,就不再继续匹配。

注意:如果路由 Route 外部包裹 Switch 时,路由匹配到对应的组件后,就不会继续渲染其他组件了。但是如果外部不包裹 Switch 时,所有路由组件会先渲染一遍,然后选择到匹配的路由进行显示。

模拟hashRouter

<body>
    <!-- 模拟单页页面应用 -->
    <ul>
        <li><a href="#/home">首页</a></li> 
        <li><a href="#/about">关于</a></li>
        <!-- 判断url的变化,绑定点击事件不好,页面过多就很累赘,有个hashchange的官方方法 -->
    </ul>

    <div id="routeView">
        <!-- 放一个代码片段 点击首页首页代码片段生效,反之关于生效-->

    </div>
    <script>
        const routes = [
            {
                path: '#/home',
                component: '首  容'
            },
            {
                path: '#/about',
                component: '关于页面内容'
            }
        ]
        
        const routeView = document.getElementById('routeView')
        window.addEventListener('DOMContentLoaded', onHashChange) // 与vue的声明周期一个道理,dom一加载完毕就触发
        window.addEventListener('hashchange', onHashChange)
        
        function onHashChange() {
            console.log(location) // url详情,里面就有个hash值  liveserver可以帮你把html跑成服务器
            routes.forEach((item, index) => {
                if(item.path === location.hash) {
                    routeView.innerHTML = item.component
                }
            })
        }
    </script>
</body>

模拟BrowserRouter

文档的 history 对象出现变化时,就会触发 popstate 事件  history.pushState 可以使浏览器地址改变,但是无需刷新页面。注意⚠️的是:用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件popstate 事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮或者调用 history.back()、history.forward()、history.go()方法。

<body>
    <ul>
        <li><a href="/home">首页</a></li> 
        <li><a href="/about">关于</a></li>
    </ul>

    <div id="routeView">

    </div>

    <script>
        const routes = [
            {
                path: '/home',
                component: '首页内容'
            },
            {
                path: '/about',
                component: '<h1>关于页面内容</h1>'
            }
        ]
        
        const routeView = document.getElementById('routeView')

        window.addEventListener('DOMContentLoaded', onLoad)
        window.addEventListener('popstate', onPopState)

        function onLoad() {
            const links = document.querySelectorAll('li a') // 获取所有的li下的a标签
            // console.log(links)
            links.forEach((a) => {
                // 禁用a标签的默认跳转行为
                a.addEventListener('click', (e) => {
                    console.log(e)
                    e.preventDefault() // 阻止a的跳转行为
                    history.pushState(null, '', a.getAttribute('href')) // 核心方法  a.getAttribute('href')获取a标签下的href属性
                    // 映射对应的dom
                    onPopState()
                })
            })
        }

        function onPopState() {
            console.log(location.pathname)
            routes.forEach((item) => {
                if(item.path === location.pathname) {
                    routeView.innerHTML = item.component
                }
            })
        }
    </script>
</body>

垃圾回收机制

JavaScript 的垃圾回收机制是一种自动内存管理机制,它用于自动清理不再使用的内存以释放内存资源。这个机制对于防止内存泄漏和优化内存使用至关重要。以下是关于 JavaScript 垃圾回收机制的一些关键点:

垃圾回收的目的

垃圾回收的主要目的是识别并释放那些不再被任何作用域内的变量引用的对象所占用的内存,从而让这些内存可以被重新利用。

垃圾回收的方法

JavaScript 中常用的垃圾回收方法包括:

  1. 引用计数(Reference Counting)

    • 这种方法通过跟踪每个对象被引用的次数来决定何时释放内存。当一个对象的引用计数降为零时,它就会被标记为可回收。
    • 引用计数的一个问题是它无法处理循环引用的情况。如果一组对象互相引用,那么即使没有其他对象引用它们,它们的引用计数也不会降为零。
  2. 标记清除(Mark and Sweep)

    • 这种方法分为两个阶段:标记和清除。

      • 标记阶段:垃圾回收器遍历所有根节点(通常是全局对象、执行上下文栈中的局部变量等),从这些根节点出发,沿着对象图递归地访问每一个对象,并标记它们。
      • 清除阶段:垃圾回收器再次遍历整个堆,释放那些未被标记的对象所占用的空间。
    • 标记清除算法可以解决引用计数无法处理的循环引用问题。

  3. 分代收集(Generational Collection)

    • 在现代的 JavaScript 引擎中,垃圾回收通常结合了分代收集的思想。这个思想认为,新创建的对象很快就会被丢弃,而存活时间较长的对象更有可能继续存活下去。
    • 基于这个假设,垃圾回收器会将对象根据其存活时间划分为不同的“代”,并对不同代的对象采取不同的回收策略。

H5和App通信

//iOS 交互声明
function connectWebViewJavascriptBridgeIOS(callback) {
  if (window.WebViewJavascriptBridge) {
    return callback(window.WebViewJavascriptBridge)
  }
  if (window.WVJBCallbacks) {
    return window.WVJBCallbacks.push(callback)
  }
  window.WVJBCallbacks = [callback] // 创建一个 WVJBCallbacks 全局属性数组,并将 callback 插入到数组中。
  let WVJBIframe = document.createElement('iframe') // 创建一个 iframe 元素
  WVJBIframe.style.display = 'none'  // 不显示
  WVJBIframe.src = 'wvjbscheme://__BRIDGE_LOADED__' // 设置 iframe 的 src 属性
  document.documentElement.appendChild(WVJBIframe) // 把 iframe 添加到当前文导航上。
  setTimeout(() => {
    document.documentElement.removeChild(WVJBIframe)
  }, 0)
}


//Android 交互声明
function connectWebViewJavascriptBridgeANDROID(callback) {
  if (window.WebViewJavascriptBridge) {
    callback(WebViewJavascriptBridge);
  } else {
    document.addEventListener(
      "WebViewJavascriptBridgeReady",
      function () {
        callback(WebViewJavascriptBridge);
      },
      false
    );
  }
}

export default {
  //H5调用Native
  // name是App提供给js的方法名
  // data是js提供给app的参数
  // callback是js给App的回调,接收一个response参数
  callhandler(name, data, callback) {
    //iOS的方法
    if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) {
      connectWebViewJavascriptBridgeIOS(function (bridge) {
        bridge.callHandler(name, data, callback)
      })
    }
    //Android方法
    if (/(Android)/i.test(navigator.userAgent)) {
      connectWebViewJavascriptBridgeANDROID(function (bridge) {
        bridge.callHandler(name, data, callback)
      })
    }
  },
  //Native调用H5
  // name是js提供给app的方法名
  // callback是app调用了js的方法后js这边执行的回调
  registerhandler(name, callback) {
    //iOS的方法
    if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) {
      connectWebViewJavascriptBridgeIOS(function (bridge) {
        // name后面的方法就是js给app调用的方法,接收data参数和responseCallback
        // responseCallback是js这边的逻辑处理完后给app的回调
        bridge.registerHandler(name, function (data, responseCallback) {
          callback(data, responseCallback)
        })
      })
    }
    //Android方法
    if (/(Android)/i.test(navigator.userAgent)) {
      connectWebViewJavascriptBridgeANDROID(function (bridge) {
        bridge.init(function (message, responseCallback) {
          if (responseCallback) {
            // responseCallback(data);
          }
        });
        bridge.registerHandler(name, function (data, responseCallback) {
          callback(data, responseCallback)
        })
      })
    }

  },

}

// js获取app的数据
jsBridge.callHandler('getAppUserInfo', { title: '首页' }, (data) => {
    console.log('获取app返回的数据', data);
 });
 
 app获取js的数据
  jsBridge.registerHandler('getInfo', (data, responseCallback) => {
    console.log('打印***get app data', data);
    responseCallback('我是返回的数据');
  });

axios取消请求

const controller = new AbortController();

axios.get('/foo/bar', {
   signal: controller.signal
}).then(function(response) {
   //...
});
// 取消请求
controller.abort()


const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function (thrown) {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    // 处理错误
  }
});
// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');