Js基础知识梳理

2,928 阅读16分钟

前言

文章内容主要是梳理JavaScript的基础知识。

数据类型

到目前为止,JavaScript规定了 8 种类型,这 8 种类型又分为两大类:基础类型和对象类型。

基础类型和对象类型

基础类型目前有 7 种,Null、Undefined、Boolen、Number、String、Symbol、Bigint,其中 Symbol和Bigintes6es10 新增的基础类型。

对象类型就是 Object,在JavaScript里,我们常用的Function、Array、Date、RegExp、包装类型 等都属于对象类型。

基础类型和对象类型的区别

在JavaScript里,每一个变量的存储都需要内存空间。内存空间分为两种:栈内存和堆内存

  • 栈内存是用来储存基础类型的,它的存储大小是固定的,占用空间较小、运行效率高。
  • 堆内存是用来储存对象类型的,它的存储大小不固定,可以动态调整,占用空间大,运行效率没有栈内存高。

基础类型本身是独立的,代表值本身是不可改变的,即不存在所谓的拷贝;对象类型也称引用类型,引用类型的值是存储在堆内存里,所以当我们把原有的对象重新赋值时,只是把地址指向内存中的值。

基础类型的拷贝

var a = 5
var b = a

当我们拷贝基础类型时,两者是没有任何关系的,它们两者指向的内存空间是不同的,它们之间互不影响。

对象类型的拷贝

let a = { a: 5 }
let b = a

对象类型的拷贝与基础类型不同,当我们拷贝引用类型时,它们的所指向的堆内存是一样的,因此,无论我们改变 a 变量还是 b 变量,它们本质都是同一个堆内存,所以对象类型才会有深拷贝和浅拷贝的说法。

类型的判断

通常我们判断类型常用的有三种方法 typeof instanceof Object.prototype.toString.call()

typeof

typeof 可以准确的判断基础类型,但是对于对象类型就无能为力了。

typeof 'ConardLi'  // string
typeof 123         // number
typeof true        // boolean
typeof Symbol()    // symbol
typeof undefined   // undefined

typeof Function    // function
typeof null        // object
typeof []          // object
typeof {}          // object
typeof new Date()  // object

typeof 对于对象类型都会被判定为 object ,除了函数以外;对于 typeof null === 'object' 是历史遗留问题,下面引用一句话解释。

不同的对象在底层都表示为二进制,在 JavaScript 中二进制前三位都为 0 的话会被判 断为 object 类型, null 的二进制表示是全 0, 自然前三位也是 0, 所以执行 typeof 时会返回“ object ”。

instanceof

instanceof 的原理是能在实例的 原型对象链 中找到该构造函数的prototype属性所指向的 原型对象,就返回true。所以 instanceof 无法检测基础类型,并且所有对象类型 instanceof Object 都是 true。

[] instanceof Array  // true
[] instanceof Object // true
{} instanceof Object // true

Object.prototype.toString.call()

每一个继承 Object 的对象都有 toString 方法,如果 toString 方法没有重写的话,会返回 [Object type],其中 type 为对象的类型。但当除了 Object 类型的对象外,其他类型直接使用 toString 方法时,会直接返回都是内容的字符串,所以我们需要使用 call 或者 apply 方法来改变toString 方法的执行上下文。

// 对于所有基本的数据类型都能进行判断,即使是 null 和 undefined 。
Object.prototype.toString.call('芒果')           // "[object String]"
Object.prototype.toString.call(1)               // "[object Number]"
Object.prototype.toString.call(Symbol(1))       // "[object Symbol]"
Object.prototype.toString.call(null)            // "[object Null]"
Object.prototype.toString.call(undefined)       // "[object Undefined]"
Object.prototype.toString.call(function(){})    // "[object Function]"
Object.prototype.toString.call(true)            // "[object Boolean]"
Object.prototype.toString.call({name: '芒果'})   // "[object Object]"

Object.prototype.toString.call(new Date())      // '[object Date]'
Object.prototype.toString.call(new Set())       // '[object Set]'
Object.prototype.toString.call(new WeakSet())   // '[object WeakSet]'
Object.prototype.toString.call(new Map())       // '[object Map]'
Object.prototype.toString.call(new WeakMap())   // '[object WeakMap]'
// ...

函数参数的传递

首先声明:JavaSript中所有的函数的参数都是按值传递的、记住,都是按值传递

下面来看三个例子

let name = '芒果'
function changeName (name) {
  name = '布丁'
  console.log(name)
}
changeName(name)
console.log(name)
// 布丁 芒果

上面的代码最终输入的 name 是互不相同的,既改变这个局部变量 name 不会对外部变量产生影响。

let obj = {
  name: '芒果'
}
function changeName (obj) {
  obj.name = '布丁'
  console.log(obj)
}
changeName(obj)
console.log(obj)
// { name: '布丁' } { name: '布丁' }

上面代码输出的 obj 都是同样的,但是这不代表是引用的传递,当函数的参数是引用类型时,js编译也是同样的将参数复制了一个副本到局部变量,但是复制的这个副本是指向同一个堆内存地址,所以当我们去修改局部变量的时候,它们之间会相互影响,再看下面的例子。

let obj = {
  name: '芒果'
}
function changeName (obj) {
  obj = { name: '布丁' }
  console.log(obj)
}
changeName(obj)
console.log(obj)
// { name: '布丁' } { name: '芒果' } 

当我们直接修改函数参数的局部变量时,就会重新去指向一个堆内存,这样两个变量并没有直接的关系。所以我们只要记住,JavaSript中所有的函数的参数都是按值传递的,去区别这三种情况足以。

函数/变量提升

JavaScript在编译过程中,像变量和函数声明从它们在代码中出现的位置被“移动” 到了最上面。这个过程就叫作 提升 。

下面看个例子

fruits()
function foo() {
  console.log(name) // undefined 
  var name = '芒果'
}

fruits 函数的声明,以及函数的变量都被提升了,所以我们的函数才能正常被执行。

函数声明和变量声明都会被提升。但是要注意函数的优先级最高,函数会首先被提升,然后才是变量。

fruits() // 芒果
var fruits
function fruits() {
  console.log('芒果')
}

上面代码中,函数声明优先级最高,在编译解析过程中,函数声明优先提升,尽管声明了 var fruits,但是它的声明是重复的,所以会被忽略,因为函数声明会被提升到普通变量之前。

现在前端eslint、typescript等工具可以帮我们规范化,不会写出这样的代码,但是我们还是要理解好JavaScript编译的一些小细节,这样对我们调试开发有一定帮助。

原型和原型链

基本概念:

  • prototype:每个函数都有一个 prototype 属性,prototype指向一个对象,可以理解为调用构造函数而创建的那个对象实例的原型对象。
  • 原型对象:每个JavaScript实例对象的创建都会关联到另一个对象,这个对象就是我们说的原型对象,继承的实现是依赖于原型对象的。
  • __proto__:每个实例对象都有一个__proto__属性,这个属性执行该对象的原型对象,原型对象同样也有这个属性,执行它的原型对象。
  • constructor:原型对象有一个constructor属性,执行它关联的构造函数。

为了理清楚上面的关系,我们来看个例子

function Person() {
}
var person = new Person()

console.log(person.__proto__ === Person.prototype) // true
console.log(Person.prototype.constructor === Person) // true
console.log(person.constructor === Person) // true

上面的例子中, person 实例是没有 constructor属性的。但是上面的结果为true

当我们访问一个对象的属性或者方法的时,首先会在对象自身开始查找,如果查不到会到原型对象 __proto__中 去查找,直到 __proto__null 时停止。

所以我们在实现继承的时候,都会把属性和方法挂载到构造函数的 prototype 上,从而实现继承。

原型的原型是什么

在JavaScript里,原型对象它也是对象,其实本质上它就是通过 Object 构造函数去生成的,如下例子:

let obj = new Object()
obj.name = '芒果'

所以上述例子中定义的function Person 也是通过 new Object()创建的,由于原型对象也有 __proto__ 属性,指向一个原型对象,所以我们可以得到:

Person.prototype.__proto__ === Object.prototype // true

那么 Object.prototype 的原型是什么呢

Object.prototype.__proto__ === null // true

所以我们可以得知最上层的原型 Object 了,下面一张总图来屡清楚关系。

new操作符与class

new操作符

在es6之前,我们一般会使用 new 来调用构造函数,会自动执行下面的操作。

  • 创建一个全新的对象。
  • 这个新对象会被执行[[原型]]连接。
  • 这个新对象会绑定到函数调用的 this
  • 如果函数没有返回其他对象,那么 new 表达式中的函数调用会返回这个新对象。

下面是 new 操作符的实现:

// 构造器函数
function Parent (name) {
  this.name = name
}
function myNew (Con, ...rest) {
  // 创建一个空对象,继承函数的原型
  let child = Object.create(Con.prototype)
  // 通过 this 将属性和方法添加至这个对象(将this和调用参数传给构造器执行)
  let result = Con.apply(child, rest)
  // 最后返回 this 指向的新对象
  return typeof result === 'object' ? result : child
}

class

在es6引入了新的关键字 class ,可以让我们更加简单的定义一个类。

class Parent {
  constructor(name) {
    this.name = name
  }
  getName() {
    return this.name
  }
}
var parent = new Parent('芒果冰')

区别

  1. class 内所定义的方法都是不可枚举的。
  2. class 必须使用 new 关键字执行。
  3. class 使用严格模式,且不存在变量提升。

继承

下面列出了主要的四种继承方式。

原型链继承

// 原型链继承就是将父类的实例赋给子类的原型对象
function parent(name){
  this.name = name
}
parent.prototype.getName = function(){
  console.log(this.name)
}
function child(){}
child.prototype = new parent()
const child1 = new child()

缺点:

  • 不能向父类传参
  • 引用属性会被所有实例共享

并不是语法上不能实现对构造函数的参数传递,而是这样做不符合面向对象编程的规则:对象(实例)才是属性的拥有者。

如果在子类定义时就将属性赋了值,对象实例就不能再更改自己的属性了。这样就变成了类拥有属性,而不是对象拥有属性了。

构造函数继承

// 构造函数继承就是在子类调用父类,
function parent(name){
  this.name = name
}
function child(name){
  parent.call(this,name)
}
const child1 = new child('child')

缺点:

  • 不能继承父类原型上的方法
  • 方法都在构造函数中定义,每次创建实例都会创建一遍方法

组合继承

function parent(name){
  this.name = name
}
parent.prototype.getName = function(){
  console.log(this.name)
}
function child(name){
  parent.call(this,name)
}
child.prototype = new parent()

缺点:

  • 一个实例会实例化父类两次(parent.call(this,name)调用一次,child.prototype = new parent()调用一次)
  • constructor 指向了 parent

寄生组合式继承

function Parent () {
  this.name = 'Parent'
}
function Child () {
  Parent.call(this)
  this.type = 'Child'
}

// Object.create()生成一个空对象,继承参数。这样就可以隔离开Parent、Child
Child.prototype = Object.create(Parent.prototype)
// 修正构造函数指向
Object.defineProperty( Child.prototype, "constructor" , {
    enumerable: false ,
    writable: true ,
    configurable: true ,
    value: Child
} )
const s1 = new Child()

在es6里,我们可以用Object.setPrototypeOf()方法去修改prototype,但是用Object.create()会更短而且可读性更高。

this

this 的绑定规则分为四种。

  • 默认绑定
  • 隐式绑定
  • 显式绑定
  • new 绑定

默认绑定

默认绑定这条规则可以看作是无法应用其他规则时的默认规则。

此时 this 指向全局对象。

function foo () {
  console.log(this.name)
}
var name = '芒果'
foo() // 芒果

在代码中,foo函数是直接调用且没有任何修饰的函数引用进行调用的,这种方式只能使用默认绑定

如果使用严格模式,那么全局对象将无法使用默认绑定,因此此时的 this 为 undefined。

隐式绑定

函数的调用是在某个对象上触发,比如 xxx.fun()、xxx.xxx.fun() ,无论有嵌套了多少层,在判断this的时候我们只需要关注最后一层,既是这个上下文对象。

function foo() { 
  console.log(this.name)
}

var obj = { 
  name: '芒果', 
  foo: foo
}

obj.foo() // 芒果

这种绑定方式,会把函数调用中的 this 绑定到这个上下文对象,所以我们调用 foo()this 会被绑定到 obj

下面再看一个例子:

function foo () {
  console.log(this.name)
}
var obj = {
  name: '芒果',
  foo: foo
}
var bar = obj.foo
var a = '全局芒果'
bar() // 全局芒果

这个我们称为隐式丢失,这里的 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。我们常用的 setTimeout 也会发生隐式丢失。

function foo () {
  console.log(this.name)
}
var obj = {
  name: '芒果',
  foo: foo
}
var a = '全局芒果'
setTimeout(obj.foo, 100) // 全局芒果

上面的例子中,我们看似此时的 this 是绑定到 obj 咯。但是本质上不是这样的,我们可以理解 setTimeout 方法的内部实现是这样的。

function setTimeout(fn, delay) {
  // 等待 delay 毫秒 
  fn() // 默认绑定
}

显式绑定

当我们使用 call()、apply()、bind() 方法调用时,我们可以明确指定 this 的绑定对象,这种方式就是显式绑定。

function foo () {
  console.log(this.name)
}
var obj = {
  name: '芒果',
  foo: foo
}
var name = '(个_个)'
var fruits = obj.foo
fruits.call(obj) // 芒果
fruits() // (个_个)

new绑定

上面有介绍 new 操作符做了什么,所以new 也是一种可以影响函数调用时 this 绑定的行为。

function Foo (name) {
  this.name = name
}
var fruits = new Foo('芒果')
console.log(fruits.name) // 芒果

箭头函数

箭头函数是 es6 新增的语法糖,它有以下特点:

  • 箭头函数的的 this ,在定义函数的时候绑定,继承于外部的 this
  • 一旦绑定了上下文,就不可改变(call、apply、bind 都不能改变箭头函数内部 this 的指向)。
  • 由于 this 指向问题,所以:箭头函数不能当作构造函数,不能使用 new 命令。
  • 箭头函数没有 arguments ,需要手动使用 ...args 参数代替。
  • 箭头函数不能用作 Generator 函数。
var name = '(个_个)'
var obj = {
  name: '芒果',
  getName: () => {
    console.log(this.name)
  }
}
obj.getName() // (个_个)

这里会打印出 window.name 的原因,可以理解为箭头函数定义在 obj 对象中,而 obj 父执行上下文是 window ,所以这里会输出全局的 name

如何判断this

上面四条规则中,我们可以按照以下顺序去进行判断:

  1. 函数是否在 new 中调用,如果是则 this 绑定的是新创建的对象。
  2. 函数是否通过显式绑定调用,如果是则 this 绑定的是指向的对象。
  3. 函数是否在某个上下文对象中调用(隐式调用),如果是则 this 绑定的是上下文对象。
  4. 如果以上都不是,则使用默认绑定。
  5. 如果是箭头函数,箭头函数的 this 继承的是上一层代码块的this。

DOM事件类

事件模型主要由两部分组成:

  • 捕获:从上到下
  • 冒泡:从下到上

一个事件的响应,分为三个阶段

  1. 捕获
  2. 目标元素
  3. 冒泡

事件捕获的具体流程:

const ev = document.getElementById('el')
window.addEventListener('click', () => {
    console.log('window captrue')
}, true)
document.addEventListener('click', () => {
    console.log('document captrue')
}, true)
document.documentElement.addEventListener('click', () => {
    console.log('html captrue')
}, true)
document.body.addEventListener('click', () => {
    console.log('body captrue')
}, true)
ev.addEventListener('click', () => {
    console.log('ev captrue')
}, true)
// window captrue
// document captrue
// html captrue
// body captrue
// ev captrue

一个事件的捕获会先从 window 开始,再到 documenthtmlbody目标元素,冒泡反之。

事件委托

事件委托实际是利用事件冒泡的机制,把监听事件绑定在父容器上,不需要把事件绑定到每一个元素上,起到了性能优化的效果。

Event对象常用的方法和属性

event.preventDefault() // 阻止默认事件
event.stopPropagation() // 阻止冒泡
event.stopImmediatePropagation() // 阻止事件冒泡并且阻止相同事件的其他侦听器被调用
event.currentTarget // 当前被点击的元素
event.target // 当前所绑定的事件的元素

内存管理和垃圾回收机制(GC)

v8 引擎里,采用的是自动回收策略,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放。

V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。

新生代算法

新生代中用Scavenge 算法来处理,把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

老生代算法

老生代中用 标记 - 清除(Mark-Sweep)和 标记 - 整理(Mark-Compact)的算法来处理。标记阶段就是从一组根元素开始,递归遍历这组根元素(遍历调用栈),能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据,然后在遍历过程中标记,标记完成后就进行清除过程。

标记清除

在函数中声明一个变量,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

引用计数

引用计数的含义就是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,这个值的引用次数就是 1。如果同一个值又被赋值给另一个变量,则引用次数加 1。相反,如果包含对这个值的引用的变量有取了另一个值,则引用次数减 1。当这个值的引用次数变为 0 时,说明已经没法再访问这个值了,因此可以将其占用的内存回收了。

引用计数很容易会由于变量的相互引用,导致变量无法回收,最终形成内存泄露。

模块化相关

CommonJS

cjs是Node应用采用的模块规范,是同步的。

特点:

  1. 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

  2. browserifywebpack 1 可以直接加载 cjs 模块形式。

UMD

UMD(Universal Module Definition)提供了支持多种风格的“通用”模式,在兼容CommonJS和AMD(异步)规范的同时,还兼容全局引用的方式。

特点:

  1. 先判断是否支持AMD(define 是否存在),存在则使用 AMD 方式加载模块;
  2. 再判断是否支持 Node.js 模块格式(exports是否存在),存在则使用 Node.js 模块格式;
  3. 前两个都不存在,则将模块公开到全局(window 或 global);
  4. 可以直接使用<script>标签引用;

ES Module

ECMAScript 6 的一个目标是解决作用域的问题,也为了使JS应用程序显得有序,于是引进了模块。

特点:

  1. 模块是编译时加载的,与commonjs和amd等模块化的实现不同,他们是运行时加载的;
  2. 模块可以只加载模块中的方法,而不加载模块文件本身。而commonjs是加载一个模块文件,再取得模块文件所返回的对象和方法;
  3. 导入导出的值都指向同一个内存地址,既不可修改模块加载的对象,只可读;
  4. Rollupwebpack 2+ 可以直接加载的模块形式;

参考