高阶JavaScript笔记(一)

59 阅读16分钟

一、浏览器基础

1、浏览器的工作原理

image-20220215222644207

2.浏览器的内核

不同的浏览器有不同的内核组成

  • Gecko:早期被Netscape和Mozilla Firefox浏览器浏览器使用;
  • Trident:微软开发,被IE4~IE11浏览器使用,但是Edge浏览器已经转向Blink;
  • Webkit:苹果基于KHTML开发、开源的,用于Safari,Google Chrome之前也在使用;
  • Blink:是Webkit的一个分支,Google开发,目前应用于Google Chrome、Edge、Opera等;

3.浏览器渲染过程

image-20220217152033306

4.认识JavaScript引擎

为什么需要JavaScript引擎呢?

  • 我们前面说过,高级的编程语言都是需要转成最终的机器指令来执行的;
  • 事实上我们编写的JavaScript无论你交给浏览器或者Node执行,最后都是需要被CPU执行的;
  • 但是CPU只认识自己的指令集,实际上是机器语言,才能被CPU所执行;
  • 所以我们需要JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令来执行;

5.浏览器内核和JS引擎的关系

这里我们先以WebKit为例,WebKit事实上由两部分组成的:

  • WebCore:负责HTML解析、布局、渲染等等相关的工作;
  • JavaScriptCore:解析、执行JavaScript代码;

image-20220217152250749

6.V8引擎的原理

  • V8是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
  • 它实现ECMAScript和WebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理器的Linux系统上运行。
  • V8可以独立运行,也可以嵌入到任何C ++应用程序中

image-20220217152626046

  • Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;

    • 如果函数没有被调用,那么是不会被转换成AST的;
  • Ignition是一个解释器,会将AST转换成ByteCode(字节码)

    • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
    • 如果函数只调用一次,Ignition会执行解释执行ByteCode;
  • TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;

    • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
    • 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;

V8执行的细节

  • 我们的JavaScript源码是如何被解析(Parse过程)的呢?

  • Blink将源码交给V8引擎,Stream获取到源码并且进行编码转换;

  • Scanner会进行词法分析(lexical analysis),词法分析会将代码转换成tokens;

  • 接下来tokens会被转换成AST树,经过Parser和PreParser:

    • Parser就是直接将tokens转成AST树架构;

    • PreParser称之为预解析,为什么需要预解析呢?

      • 这是因为并不是所有的JavaScript代码,在一开始时就会被执行。那么对所有的JavaScript代码进行解析,必然会影响网页的运行效率;
      • 所以V8引擎就实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容,而对函数的全量解析是在函数被调用时才会进行;
      • 比如我们在一个函数outer内部定义了另外一个函数inner,那么inner函数就会进行预解析;
  • 生成AST树后,会被Ignition转成字节码(bytecode),之后的过程就是代码的执行过程(后续会详细分析)。

7.JavaScript的执行过程

初始化全局对象

js引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)

  • 该对象 所有的作用域(scope)都可以访问;
  • 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
  • 其中还有一个window属性指向自己;

image-20220217153337244

执行上下文栈(调用栈)

  • js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。

  • 那么现在它要执行谁呢?执行的是全局的代码块:

    • 全局的代码块为了执行会构建一个 Global Execution Context(GEC);
    • GEC会 被放入到ECS中 执行
  • GEC被放入到ECS中里面包含两部分内容:

    • 第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中但是并不会赋值;

      • 这个过程也称之为变量的作用域提升(hoisting)
    • 第二部分:在代码执行中,对变量赋值,或者执行其他的函数;

GEC被放入到ECS中

image-20220217153817642

GEC开始执行代码

image-20220217153926742

遇到函数如何执行?

  • 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称FEC),并且压入到ECS Stack中。

  • FEC中包含三部分内容:

    • 第一部分:在解析函数成为AST树结构时,会创建一个Activation Object(AO):

      • AO中包含形参、arguments、函数定义和指向函数对象、定义的变量;
    • 第二部分:作用域链:由VO(在函数中就是AO对象)和父级VO组成,查找时会一层层查找;

    • 第三部分:this绑定的值:这个我们后续会详细解析;

image-20220217154444223

再FEC被放入到ECS中

image-20220217154530974

image-20220217165340560

8、内存管理

  • 不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存:

  • 不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期

    • 第一步:分配申请你需要的内存(申请);
    • 第二步:使用分配的内存(存放一些东西,比如对象等);
    • 第三步:不需要使用时,对其进行释放;

JS中的内存调用

  • JavaScript会在定义变量时为我们分配内存。

  • 但是内存分配方式是一样的吗?

    • JS对于基本数据类型内存的分配会在执行时,直接在栈空间进行分配;
    • JS对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;

image-20220219224851774

JS的垃圾回收

  • 因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。

  • 在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如free函数:

    • 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
    • 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露;
  • 所以大部分现代的编程语言都是有自己的垃圾回收机制:

    • 垃圾回收的英文是Garbage Collection,简称GC;
    • 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
    • 而我们的语言运行环境,比如Java的运行环境JVM,JavaScript的运行环境js引擎都会内存 垃圾回收器;
    • 垃圾回收器我们也会简称为GC,所以在很多地方你看到GC其实指的是垃圾回收器;
常见的GC算法 – 引用计数

引用计数:

  • 当一个对象有一个引用指向它时,那么这个对象的引用就+1,当一个对象的引用为0时,这个对象就可以被销毁掉;
  • 这个算法有一个很大的弊端就是会产生循环引用;

image-20220219225104388

常见的GC算法 – 标记清除

标记清除:

  • 这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于哪些没有引用到的对象,就认为是不可用的对象;

  • 这个算法可以很好的解决循环引用的问题;

  • JS引擎比较广泛的采用的就是标记清除算法,当然类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法

    image-20220219225308651

二、闭包

JS中函数是一等公民

定义

  • 一个普通的函数function,如果它可以访问外层作用于的自由变量,那么这个函数就是一个闭包;
  • 从广义的角度来说:JavaScript中的函数都是闭包;
  • 从狭义的角度来说:JavaScript中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;

闭包的访问过程

image-20220224001521304

闭包的执行过程

  • 这个时候makeAdder函数执行完毕,正常情况下我们的AO对象会被释放;
  • 但是因为在0xb00的函数中有作用域引用指向了这个AO对象,所以它不会被释放掉

image-20220224001521304

闭包的内存泄露

  • 那么我们为什么经常会说闭包是有内存泄露的呢?

    • 在上面的案例中,如果后续我们不再使用add10函数了,那么该函数对象应该要被销毁掉,并且其引用着的父作用域AO也应该被销毁掉;
    • 但是目前因为在全局作用域下add10变量对0xb00的函数对象有引用,而0xb00的作用域中AO(0x200)有引用,所以最终会造成这些内存都是无法被释放的;
    • 所以我们经常说的闭包会造成内存泄露,其实就是刚才的引用链中的所有对象都是无法释放的;
  • 那么,怎么解决这个问题呢?

    • 因为当将add10设置为null时,就不再对函数对象0xb00有引用,那么对应的AO对象0x200也就不可达了;

    • 在GC的下一次检测中,它们就会被销毁掉;

      add10 = null
      

AO不使用的属性

  • 我们来研究一个问题:AO对象不会被销毁时,是否里面的所有属性都不会被释放?

    • 下面这段代码中name属于闭包的父作用域里面的变量;
    • 我们知道形成闭包之后count一定不会被销毁掉,那么name是否会被销毁掉呢?
    • 这里我打上了断点,我们可以在浏览器上看看结果;
function foo() {
  var name = "why"
  var age = 18
​
  function bar() {
    debugger
    console.log(age)
  }
​
  return bar
}
​
var fn = foo()
fn()

image-20220504220428040

三、JS函数的this指向

this绑定规则

  • 绑定一:默认绑定;

    独立的函数调用我们可以理解成函数没有被绑定到某个对象上进行调用

    function foo() {
      console.log(this)
    }
    var obj = {
      name: "why",
      foo: foo
    }
    ​
    var bar = obj.foo
    bar() // window
    
  • 绑定二:隐式绑定;

    它的调用位置中,是通过某个对象发起的函数调用

    var obj1 = {
      name: "obj1",
      foo: function() {
        console.log(this)
      }
    }
    ​
    var obj2 = {
      name: "obj2",
      bar: obj1.foo
    }
    ​
    obj2.bar()  // { name: 'obj2', bar: [Function: foo] }
    
  • 绑定三:显示绑定;

    前提:

    • 必须在调用的对象内部有一个对函数的引用(比如一个属性);
    • 如果没有这样的引用,在进行调用时,会报找不到该函数的错误;
    • 正是通过这个引用,间接的将this绑定到了这个对象上;

    强制调用(显示绑定):JavaScript所有的函数都可以使用call和apply方法(这个和Prototype有关)

    call和apply的区别:第一个参数是相同的,后面的参数,apply为数组,call为参数列表;

    这两个函数的第一个参数都要求是一个对象,这个对象的作用是什么呢?就是给this准备的。

    在调用这个函数时,会将this绑定到这个传入的对象上

  • 绑定四:new绑定;

    • JavaScript中的函数可以当做一个类的构造函数来使用,也就是使用new关键字。

    • 使用new关键字来调用函数是,会执行如下的操作:

    1. 创建一个全新的对象;
    2. 这个新对象会被执行prototype连接;
    3. 这个新对象会绑定到函数调用的this上(this的绑定在这个步骤完成);
    4. 如果函数没有返回其他对象,表达式会返回这个新对象;
// 我们通过一个new关键字调用一个函数时(构造器), 这个时候this是在调用这个构造器时创建出来的对象
// this = 创建出来的对象
// 这个绑定过程就是new 绑定function Person(name, age) {
  this.name = name
  this.age = age
}
​
var p1 = new Person("why", 18)
console.log(p1.name, p1.age)
​
var p2 = new Person("kobe", 30)
console.log(p2.name, p2.age)
​
​
var obj = {
  foo: function() {
    console.log(this)
  }
}

规则优先级

  1. 默认规则的优先级最低
  2. 显示绑定优先级高于隐式绑定
  3. .new绑定优先级高于隐式绑定
  4. new绑定优先级高于bind

new绑定 > 显示绑定(apply/call/bind) > 隐式绑定(obj.foo()) > 默认绑定(独立函数调用)

this规则之外

忽略显示绑定

如果在显示绑定中,我们传入一个null或者undefined,那么这个显示绑定会被忽略,使用默认规则:

function foo() {
  console.log(this)
}
​
foo.apply("abc")
foo.apply({})
​
// apply/call/bind: 当传入null/undefined时, 自动将this绑定成全局对象
foo.apply(null)
foo.apply(undefined)
​
var bar = foo.bind(null)
bar()

间接函数引用

创建一个函数的 间接引用,这种情况使用默认绑定规则

// 争论: 代码规范 ;var obj1 = {
  name: "obj1",
  foo: function () {
    console.log(this)
  }
}
​
var obj2 = {
  name: "obj2"
}; 
​
// obj2.bar = obj1.foo
// obj2.bar()
​
(obj2.bar = obj1.foo)()

ES6箭头函数

箭头函数不使用this的四种标准规则(也就是不绑定this),而是根据外层作用域来决定this

我们来看一个模拟网络请求的案例:

  • 这里我使用setTimeout来模拟网络请求,请求到数据后如何可以存放到data中呢?
  • 我们需要拿到obj对象,设置data;
  • 但是直接拿到的this是window,我们需要在外层定义:var _this = this
  • 在setTimeout的回调函数中使用_this就代表了obj对象
var obj = {
  data: [],
  getData: function() {
    // 发送网络请求, 将结果放到上面data属性中
    // 在箭头函数之前的解决方案
    // var _this = this
    // setTimeout(function() {
    //   var result = ["abc", "cba", "nba"]
    //   _this.data = result
    // }, 2000);
    // 箭头函数之后
    setTimeout(() => {
      var result = ["abc", "cba", "nba"]
      this.data = result
    }, 2000);
  }
}

bind,apply,call的区别

  • apply接受两个参数,第一个参数是this的指向,第二个参数是函数接受的参数,以数组的形式传入,且当第一个参数为null、undefined的时候,默认指向window(在浏览器中),使用apply方法改变this指向后原函数会立即执行,且此方法只是临时改变this指向一次。
  • call方法的第一个参数也是this的指向,后面传入的是一个参数列表(注意和apply传参的区别)。当一个参数为null或undefined的时候,表示指向window(在浏览器中),和apply一样,call也只是临时改变一次this指向,并立即执行。
  • bind方法和call很相似,第一参数也是this的指向,后面传入的也是一个参数列表(但是这个参数列表可以分多次传入,call则必须一次性传入所有参数),但是它改变this指向后不会立即执行,而是返回一个永久改变this指向的函数。

用JS代码模拟bind,apply,call的源码实现:

call的实现

// 给所有的函数添加一个hycall的方法
Function.prototype.hycall = function(thisArg, ...args) {
  // 在这里可以去执行调用的那个函数(foo)
  // 问题: 得可以获取到是哪一个函数执行了hycall
  // 1.获取需要被执行的函数
  var fn = this
​
  // 2.对thisArg转成对象类型(防止它传入的是非对象类型)
  thisArg = (thisArg !== null && thisArg !== undefined) ? Object(thisArg): window
​
  // 3.调用需要被执行的函数 相当于thisArg的隐式绑定了fn(原来要执行的函数foo())
  thisArg.fn = fn
  var result = thisArg.fn(...args)  //将args展开执行fn()
  delete thisArg.fn
​
  // 4.将最终的结果返回出去
  return result
}
​
​
function foo() {
  console.log("foo函数被执行", this)
}
​
function sum(num1, num2) {
  console.log("sum函数被执行", this, num1, num2)
  return num1 + num2
}
​
​
// 系统的函数的call方法
foo.call(undefined)
var result = sum.call({}, 20, 30)
console.log("系统调用的结果:", result)
​
​
// 自己实现的函数的hycall方法
// 默认进行隐式绑定
// foo.hycall({name: "why"})
foo.hycall(undefined)
var result = sum.hycall("abc", 20, 30)
console.log("hycall的调用:", result)
​

apply的实现

// 自己实现hyapply
Function.prototype.hyapply = function(thisArg, argArray) {
  // 1.获取到要执行的函数
  var fn = this
​
  // 2.处理绑定的thisArg 当thisArg=0时,都不会判断为false
  thisArg = (thisArg !== null && thisArg !== undefined) ? Object(thisArg): window
​
  // 3.执行函数
  thisArg.fn = fn
  var result
  // if (!argArray) { // argArray是没有值(没有传参数)
  //   result = thisArg.fn()
  // } else { // 有传参数
  //   result = thisArg.fn(...argArray)
  // }
​
  // argArray = argArray ? argArray: []
  argArray = argArray || []     //防止argArray没有参数为undefined
  result = thisArg.fn(...argArray)
​
  delete thisArg.fn
​
  // 4.返回结果
  return result
}
​
function sum(num1, num2) {
  console.log("sum被调用", this, num1, num2)
  return num1 + num2
}
​
function foo(num) {
  return num
}
​
function bar() {
  console.log("bar函数被执行", this)
}
​
// 系统调用
// var result = sum.apply("abc", 20)
// console.log(result)// 自己实现的调用
// var result = sum.hyapply("abc", [20, 30])
// console.log(result)// var result2 = foo.hyapply("abc", [20])
// console.log(result2)

bind的实现

Function.prototype.hybind = function(thisArg, ...argArray) {
  // 1.获取到真实需要调用的函数
  var fn = this
​
  // 2.绑定this
  thisArg = (thisArg !== null && thisArg !== undefined) ? Object(thisArg): window
​
  function proxyFn(...args) {
    // 3.将函数放到thisArg中进行调用
    thisArg.fn = fn
    // 特殊: 对两个传入的参数进行合并  展开拼接在一起
    var finalArgs = [...argArray, ...args]
    var result = thisArg.fn(...finalArgs)
    delete thisArg.fn
​
    // 4.返回结果
    return result
  }
​
  return proxyFn
}
​
function foo() {
  console.log("foo被执行", this)
  return 20
}
​
function sum(num1, num2, num3, num4) {
  console.log(num1, num2, num3, num4)
}
​
// 系统的bind使用
var bar = foo.bind("abc")
bar()
​
// var newSum = sum.bind("aaa", 10, 20, 30, 40)
// newSum()// var newSum = sum.bind("aaa")
// newSum(10, 20, 30, 40)// var newSum = sum.bind("aaa", 10)
// newSum(20, 30, 40)
​
​
// 使用自己定义的bind
// var bar = foo.hybind("abc")
// var result = bar()
// console.log(result)var newSum = sum.hybind("abc", 10, 20)
var result = newSum(30, 40)

展开运算符 ... spread

function sum(...nums) {
  console.log(nums)
}
​
sum(10)
sum(10, 20)
sum(10, 20, 30)
sum(10, 20, 30, 40, 50)
​
// 展开运算符 spread
var names = ["abc", "cba", "nba"]
// var newNames = [...names]
function foo(name1, name2, name3) {}
foo(...names)

Arguments对象

arguments的使用

虽然arguments对象并不是一个数组(类数组),但是访问单个参数的方式与访问数组元素的方式相同

function foo(num1, num2, num3) {
  // 类数组对象中(长的像是一个数组, 本质上是一个对象): arguments
  // console.log(arguments)
​
  // 常见的对arguments的操作是三个
  // 1.获取参数的长度
  console.log(arguments.length)
​
  // 2.根据索引值获取某一个参数
  console.log(arguments[2])
  console.log(arguments[3])
  console.log(arguments[4])
​
  // 3.callee获取当前arguments所在的函数
  console.log(arguments.callee)
  // arguments.callee() 无限死循环
}
​
foo(10, 20, 30, 40, 50)

arguments转Array

function foo(num1, num2) {
  // 1.自己遍历
  var newArr = []
  for (var i = 0; i < arguments.length; i++) {
    newArr.push(arguments[i] * 10)
  }
  console.log(newArr)
​
  // 2.arguments转成array类型
  // 2.1.自己遍历arguments中所有的元素
  // 2.2.Array.prototype.slice将arguments转成array
  var newArr2 = Array.prototype.slice.call(arguments)
  console.log(newArr2)
​
  // 这种方式也可以
  var newArr3 = [].slice.call(arguments)
  console.log(newArr3)
​
  // 2.3.ES6的语法
  var newArr4 = Array.from(arguments)
  console.log(newArr4)
  var newArr5 = [...arguments]
  console.log(newArr5)
}
​
foo(10, 20, 30, 40, 50)

箭头函数没有arguments

function foo() {
  var bar = () => {
    console.log(arguments)
  }
  return bar
}
​
var fn = foo(123)
fn()  //[Arguments] { '0': 123 }
​
​
var foo = (num1, num2, ...args) => {
  console.log(args)
}
​
foo(10, 20, 30, 40, 50) //[ 30, 40, 50 ]