前言
文章内容主要是梳理JavaScript的基础知识。
数据类型
到目前为止,JavaScript规定了 8
种类型,这 8
种类型又分为两大类:基础类型和对象类型。
基础类型和对象类型
基础类型目前有 7
种,Null、Undefined、Boolen、Number、String、Symbol、Bigint
,其中 Symbol和Bigint
是 es6
和 es10
新增的基础类型。
对象类型就是 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('芒果冰')
区别
class
内所定义的方法都是不可枚举的。class
必须使用new
关键字执行。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
上面四条规则中,我们可以按照以下顺序去进行判断:
- 函数是否在 new 中调用,如果是则 this 绑定的是新创建的对象。
- 函数是否通过显式绑定调用,如果是则 this 绑定的是指向的对象。
- 函数是否在某个上下文对象中调用(隐式调用),如果是则 this 绑定的是上下文对象。
- 如果以上都不是,则使用默认绑定。
- 如果是箭头函数,箭头函数的
this
继承的是上一层代码块的this。
DOM事件类
事件模型主要由两部分组成:
- 捕获:从上到下
- 冒泡:从下到上
一个事件的响应,分为三个阶段
- 捕获
- 目标元素
- 冒泡
事件捕获的具体流程:
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
开始,再到 document
、html
、body
、 目标元素
,冒泡反之。
事件委托
事件委托实际是利用事件冒泡的机制,把监听事件绑定在父容器上,不需要把事件绑定到每一个元素上,起到了性能优化的效果。
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应用采用的模块规范,是同步的。
特点:
-
每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
-
browserify
和webpack 1
可以直接加载 cjs 模块形式。
UMD
UMD(Universal Module Definition)提供了支持多种风格的“通用”模式,在兼容CommonJS和AMD(异步)规范的同时,还兼容全局引用的方式。
特点:
- 先判断是否支持AMD(define 是否存在),存在则使用 AMD 方式加载模块;
- 再判断是否支持 Node.js 模块格式(exports是否存在),存在则使用 Node.js 模块格式;
- 前两个都不存在,则将模块公开到全局(window 或 global);
- 可以直接使用
<script>
标签引用;
ES Module
ECMAScript 6 的一个目标是解决作用域的问题,也为了使JS应用程序显得有序,于是引进了模块。
特点:
- 模块是编译时加载的,与commonjs和amd等模块化的实现不同,他们是运行时加载的;
- 模块可以只加载模块中的方法,而不加载模块文件本身。而commonjs是加载一个模块文件,再取得模块文件所返回的对象和方法;
- 导入导出的值都指向同一个内存地址,既不可修改模块加载的对象,只可读;
Rollup
和webpack 2+
可以直接加载的模块形式;
参考
-
《你不知道的JavaScript》上 中
-
《JS高级程序设计》