原生JS之苦

3,974 阅读17分钟

多受痛苦的折磨,见闻会逐渐增多。 —— 荷马

本文是我一年前写的老文章,这次重新发布是因为回头看发现之前文章有好多错误或者理解不清的地方。时隔一年,以前很多不理解的地方突然清晰了很多,可能这就是在实践中的成长吧。

小刚老师

基本类型和对象类型

js中数据类型分为基本类型和对象类型(基本类型又称原始类型、值类型,对象类型又称引用类型),基本类型有以下几种:

  • number
  • string
  • boolean
  • null
  • undefined
  • symbol (es6)
  • bigInt (es6+)

对象类型包括对象object、数组array、函数function等:

  • object
  • function
  • array
  • setweakSet (es6)
  • mapweakmap(es6)

基本类型

string类型即字符串,除了单引号双引号,es6 中引入了新的反引号 ` ` 来包含字符串。反引号的扩展功能是可以用${…}将变量和表达式嵌入到字符串中。使用方法如下:

let n = 3
let m = () => 4
let str = `m + n = ${m() + n}` // "m + n = 7"

number类型值包括整数、浮点数、NaNInfinity等。其中NaN类型是js中唯一不等于自身的类型,当发生未定义的数学操作的时候,就会返回NaN,如:1+'asdf'Number('asdf')。浮点数的运算可能会出现如0.1 + 0.2 !== 0.3的问题,这是由于浮点运算的精度的问题,一般采用toFixed(10)便可以解决此类问题。

booleanstringnumbersymbolbigInt类型作为基本类型,按理说应该是没有函数可以调用的,因为基本类型没有原型链可以提供方法。但是,这三种类型却能调用toString等对象原型上的方法:

true.toString() // 'true'
`asdf`.toString() // 'asdf'
NaN.toString() // 'NaN'
Symbol(1).toString() // 'Symbol(1)'
bigInt(1).toString() // '1'

你可能会说,那为什么数字1不能调用toString方法呢?其实,不是不能调用:

1 .toString()
1..toString()
(1).toString()

以上三种调用都是可以的,数字后面的第一个点会被解释为小数点,而不是点调用。只不过不推荐这种使用方法,而且这样做也没什么意义。

为什么基本类型却可以直接调用对象类型的方法呢?其实是js引擎在解析上面的语句的时候,会把这三种基本类型解析为包装对象(就是下面的new String()),而包装对象是对象类型可以调用Object.prototype上的方法。大概过程如下:

'asdf'.toString()  ->  new String('asdf').toString()  -> 'asdf'

null含义为“无”、“空”或“值未知”的特殊值。

undefined的含义是“未被赋值”。除了变量已声明未赋值的情况下是undefined,若对象的属性不存在也是undefined。所以应该尽量避免使用var a = undefined; var o = {b: undefined}这样的写法,取而代之用var a = null; var o = {b: null},以与“未被赋值”默认undefined的情况相区分。

Symbol值表示唯一的标识符。可以用Symbol()函数创建:

var a = Symbol('asdf')
var b = Symbol('asdf')
a === b // false

BigInt 用来表示任意大的整数,写法为在数字后面加小写字母n。原本 Javascript中用 Number 表示的最大数字是2的53次方,超过这个数字就会丢失精度。由于在 Number 与 BigInt 之间进行转换会损失精度,因而建议仅在值可能大于253 时使用 BigInt 类型,并且不在两种类型之间进行相互转换。

对象类型

对象是由键值对(Key-value)组成的属性集合,其中key可以是字符串和symbol,value可以是任意类型。

数组和函数是比较特殊的对象,都有length属性(函数还有nameprototype等)。数组主要是各种方法多不胜数,参考 此文。函数是js的一等公民,涉及内容较多,可以参考 此文 。本文不在赘述。

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

变量都是存储在栈内存中,不同的是基本类型在栈中存的是值,而对象类型在栈中存的是指向对象真实内存地址的指针。这个指针指向的真实内存地址其实在堆内存中。

对象类型操作两种情况:

  • 情况 1, 对象属性的新增/修改/删除操作是在堆内存中进行的,会影响所有引用该堆内存地址的对象
const obj = {
  a: 'a',
  b: 'b'
}
const newObj = obj

newObj.c = 'cccc'
newObj.a = 'aaaa'
delete newObj.b
// newObj === obj === { a: 'aaaa', c: 'cccc' }
  • 情况 2, 对象被直接重新赋值,这时其在栈中存储的指针被修改,该对象会和原堆内存地址失去联系
const obj = {
  a: 'a',
  b: 'b',
}
const newObj = obj

newObj = {}
// newObj !== obj

函数传参是对象类型的时候,传递的参数其实是对象的指针:

function fn (item) {
  item.c = 'c'
}
const obj = {
  a: 'a',
  b: 'b'
}
fn(obj)
// obj.c === 'c'
// 当执行fn(obj)时,相当于在函数内部新声明了一个变量item,并赋值为obj

类型判断

判断对象类型和基本类型的类型是不同的,判断基本类型可以用typeof

typeof 1 // 'number'
typeof 'asdf' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof null // 'object'
typeof BigInt(1) // 'bigint'

可以看到除了null其他基本类型的判断都是正常的,typeof(null) === 'object'是一个历史悠久的 bug,就是在 JS 的最初版本中null的内存存储信息是000开头的,而000开头的会被判断为object类型。虽然现在内部类型判断代码已经改变了,但是这个 bug 却不得不随着版本保留了下来,因为修改这个 bug 会导致巨多的网站出现 bug 。

typeof对对象类型,除了函数返回function,其他都返回object。但我们开发中数组肯定是要返回array类型的,所以typeof对对象类型来说并不是很适用。判断对象类型一般用instanceof

var obj = {}
var arr = []
var fun = () => {}
typeof obj // 'object'
typeof arr // 'object'
typeof fun // 'function'
obj instanceof Object // true
arr instanceof Array // true
fun instanceof Function // true

可以看到instanceof操作符可以正确判断出对象类型的类型。instanceof本质上是判断右边的构造函数的prototype对象是否存在于左边的原型链上,是的话返回true。所以不论数组、对象还是函数,... instanceof Object都返回true

最后来一种全能型判断类型方法:Object.prototype.toString.call(...),可以自行尝试。

隐式类型转换

强类型和弱类型

强类型语言要求所有变量都必须先定义后使用,且一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。强类型语言不存在隐式类型转换。弱类型语言相反,变量声明时不需要特别指定类型,且在运行过程中往往会随着需要发生隐式类型转换。

静态类型和动态类型

静态语言是在编译期间进行类型检查的,动态语言是在运行期间进行类型检查的。

Python是动态语言,是强类型定义语言(类型安全的语言);JAVA是静态语言,是强类型定义语言(类型安全的语言);

JS 是动态弱类型语言,不同类型之间在一定情况下会发生隐式类型转换,比如在相等性比较的时候。

相等性比较中的隐式类型转换

基本类型的相等性比较的是值是否一样,对象相等性比较的是内存的引用地址是否相同。下面来看一个有意思的比较把:

[] == [] // ?
[] == ![] // ?

对于[] {} function (){}这样的没有被赋值给变量的引用类型来说,他们只在当前语句中有效,而且不相等于其他任何对象。因为根本无法找到他们的内存地址的指针。所以[] == []false

对于[] == ![],因为涉及到隐式类型转换,所以复杂的多了。

不同类型操作数比较相等性规则如下:

  • 先判断是否在对比 null 和 undefined,是的话就会返回 true。null和undefined不相等于其他任何值。
null == undefined // true
null == 0 // false
undefined == 0 // false
  • 判断两者类型是否为 string 和 number,是的话就会将字符串转换为 number;
NaN == NaN // false     NaN不等于任何值
  • 判断其中一方是否为 boolean,是的话就会把 boolean 转为 number 再进行判断;
  • 判断其中一方是否为 object 且另一方为 string、number 或者 symbol,是的话就会把 object 转为原始类型再进行判断。

现在来揭开 [] == ![] 返回true的真相把:

[] == ![] // true
/*
 * 首先,布尔操作符!优先级更高,所以被转变为:[] == false
 * 其次,操作数存在布尔值false,将布尔值转为数字:[] == 0
 * 再次,操作数[]是对象,转为原始类型(先调用valueOf(),得到的还是[],再调用toString(),得到空字符串''):'' == 0
 * 最后,字符串和数字比较,转为数字:0 == 0
*/

在 JS 中类型转换只有三种情况:toNumbertoStringtoBoolean 。正常情况下转换规则如下:

原始值/类型目标类型:number结果
nullnumber0
symbolnumber抛错
stringnumber'1'=>1 '1a'=>NaN ,含非数字则为NaN
数组number[]=>0 ['1']=>1 ['1', '2']=>NaN
object/function/undefinednumberNaN
原始值/类型目标类型:string结果
numberstring1=>'1'
arraystring[1, 2]=>'1,2'
布尔值/函数/symbolstring原始值直接加上引号,如:'true'
objectstring{}=>'[object Object]'
原始值/类型目标类型:boolean结果
numberboolean除了0NaNfalse,其他都是true
stringboolean除了空字符串为false,其他都为true
null/undefinedbooleanfalse
对象类型booleantrue

想要更加详细了隐式类型转换转换可以看我这篇文章

作用域和执行上下文

作用域

js中的作用域是词法作用域,即由函数声明时所在的位置决定的(区别于词法作用域,动态作用域是在函数执行的时候确认的,js的没有动态作用域,但js的this很像动态作用域)。词法作用域是在编译阶段就产生的,一整套函数内标识符的访问规则。 说到底作用域只是一个“空地盘”,其中并没有真实的变量,但是却定义了变量如何访问的规则。

作用域链 本质上是一个指向变量对象的指针列表,它只引用不包含实际变量对象。作用域链定义了当变量在当前上下文访问不到的时候如何沿作用域链继续查询的一套规则。

执行上下文

执行上下文是指 函数调用时 在执行栈中产生的变量对象,这个变量对象我们无法直接访问,但是可以访问其中的变量、this对象等。例如:

let fn, bar; // 1、进入全局上下文环境
bar = function(x) {
  let b = 5;
  fn(x + b); // 3、进入fn函数上下文环境
};
fn = function(y) {
  let c = 5;
  console.log(y + c); //4、fn出栈,bar出栈
};
bar(10); // 2、进入bar函数上下文环境

每次函数调用时,执行栈栈顶都会产生一个新的执行上下文环境,JavaScript引擎会以栈的方式来处理它们,这个栈,我们称其为函数调用栈(call stack)。栈底永远都是全局上下文,而栈顶就是当前处于活动状态的正在执行的上下文,也称为活动对象(running execution context,图中蓝色的块),区别与底下被挂起的变量对象(执行上下文)。

区别:作用域是在函数声明的时候就确定的一整套函数内标识符的访问规则,而执行上下文是函数执行时才产生的一系列变量的环境。一个是定义时就产生的,一个是执行时产生的。

理解函数的执行过程

函数的执行过程分成两部分,一部分用来生成执行上下文环境,确定this的指向、声明变量以及生成作用域链;另一部分则是按顺序逐行执行代码。

  • 建立执行上下文阶段(发生在 函数被调用时 && 函数体内的代码执行前 )
  1. 生成变量对象,顺序:创建 arguments 对象 --> 创建function函数声明 --> 创建var变量声明
  2. 生成作用域链
  3. 确定this的指向
  • 函数执行阶段
  1. 逐行顺序执行代码,遇到赋值操作进行变量赋值,遇到函数调用进行函数引用调用,遇到条件判断和表达式进行条件判断和表达式计算等

this 指向

let fn = function(){
  alert(this.name)
}
let obj = {
  name: '',
  fn
}
fn() // 方法1
obj.fn() // 方法2
fn.call(obj) // 方法3
let instance = new fn() // 方法4
  1. 方法1中直接调用函数fn(),这种看着像光杆司令的调用方式,this指向window(严格模式下是undefined)。
  2. 方法2中是点调用obj.fn(),此时this指向obj对象。点调用中this指的是点前面的对象。
  3. 方法3中利用call函数把fn中的this指向了第一个参数,这里是obj。即利用callapplybind函数可以把函数的this变量指向第一个参数。
  4. 方法4中用new实例化了一个对象instance,这时fn中的this就指向了实例instance

如果同时发生了多个规则怎么办?其实上面四条规则的优先级是递增的:

fn() < obj.fn() < fn.call(obj) < new fn()

首先,new调用的优先级最高,只要有new关键字,this就指向实例本身;接下来如果没有new关键字,有call、apply、bind函数,那么this就指向第一个参数;然后如果没有new、call、apply、bind,只有obj.foo()这种点调用方式,this指向点前面的对象;最后是光杆司令foo() 这种调用方式,this指向window(严格模式下是undefined)。

es6 中新增了箭头函数,而箭头函数最大的特色就是没有自己的this、arguments、super、new.target,并且箭头函数没有原型对象prototype不能用作构造函数(new一个箭头函数会报错)。因为没有自己的this,所以箭头函数中的this其实指的是包含函数中的this。无论是点调用,还是call调用,都无法改变箭头函数中的this

es6 还新增了模块。es6 模块之中,顶层的 this 指向 undefined,而不是全局对象。

闭包

很长时间以来我对闭包都停留在“定义在一个函数内部的函数”这样肤浅的理解上。事实上这只是闭包形成的必要条件之一。直到后来看了kyle大佬的《你不知道的javascript》上册关于闭包的定义,我才豁然开朗:

当函数能够记住并访问所在的词法作用域时,就产生了闭包。

let single = (function(){
  let count = 0
  return {
    plus(){
      count++
      return count
    },
    minus(){
      count--
      return count
    }
  }
})()
single.plus() // 1
single.minus() // 0

这是个单例模式,这个模式返回了一个对象并赋值给变量single,变量single中包含两个函数plusminus,而这两个函数都用到了所在词法作用域中的变量count。正常情况下count和所在的执行上下文会在函数执行结束时被销毁,但是由于count还在被外部环境使用,所以在函数执行结束时count和所在的执行上下文不会被销毁,这就产生了闭包。每次调用single.plus()或者single.minus(),就会对闭包中的count变量进行修改,这两个函数就保持住了对所在的词法作用域的引用。

闭包其实是一种特殊的函数,它可以访问函数内部的变量,还可以让这些变量的值始终保持在内存中,不会在函数调用后被垃圾回收机制清除。

看个经典安利:

// 方法1
for (var i = 1; i <= 5; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}
// 方法2
for (let i = 1; i <= 5; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}

方法1中,循环设置了五个定时器,一秒后定时器中回调函数将执行,打印变量i的值。毋庸置疑,一秒之后i已经递增到了5,所以定时器打印了五次5 。(定时器中并没有找到当前作用域的变量i,所以沿作用域链找到了全局作用域中的i

方法2中,由于es6的let会创建局部作用域,所以循环设置了五个作用域,而五个作用域中的变量i分布是1-5,每个作用域中又设置了一个定时器,打印一秒后变量i的值。一秒后,定时器从各自父作用域中分别找到的变量i是1-5 。这是个利用闭包解决循环中变量发生异常的新方法。

原型和原型链

js 中的对象都是由构造函数创造出来的(对象字面量其实是一种语法糖,本质上也是由构造函数创造的)。而除了箭头函数,所有函数都存在一个叫prototype的属性。js 内置的函数都在 prototype 上定义了很多方法,如 Array.prototypeslice splice join split filter reduce 等等等等。

js 中的几乎所有对象都有一个特殊的[[Prototype]]内置属性,用来指定对象的原型对象,这个属性实质上是对其他对象的引用。在浏览器中一般都会暴露一个私有属性 __proto__,其实就是[[Prototype]]的浏览器实现。对象本身有内置的[[Prototype]]指向一个原型对象,而这个原型对象也有自己的[[Prototype]]指向别的原型对象,这样串接起来,就组成了原型链。

const arr = [1, 2, 3]
arr.__proto__ === Array.prototype // true
Array.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true

可以看出,上例中存在一个从arrnull的原型链,如下:

arr----__proto__---->Array.prototype----__proto__---->Object.prototype----__proto__---->null

该变量arr可以访问 Aarray.prototypeObject.prototype 上的方法。

原型链还是 js 实现继承的本质所在,下一小节再讲。

上面我说“js 中的几乎所有对象都有一个特殊的[[Prototype]]内置属性”,为什么不是全部呢?因为 js 可以创建没有内置属性[[Prototype]]的对象:

var o = Object.create(null)
o.__proto__ // undefined

Object.create是 es5 的方法,所有浏览器都已支持。该方法创建并返回一个新对象,并将新对象的原型对象赋值为第一个参数。在上例中,Object.create(null)创建了一个没有没有内置属性[[Prototype]]的新对象。

js 的继承

js 的继承是通过原型链实现的,具体可以参考我的这篇文章,这里我只讲一讲大家可能比较陌生的“行为委托”。行为委托是《你不知道的JavaScript》系列作者 kyle 大佬推荐的一种方式,该模式主要利用setPrototypeOf方法把一个对象的内置原型[[Protytype]]关联到另一个对象上,从而达到继承的目的。

let SuperType = {
  initSuper(name) {
    this.name = name
    this.color = [1,2,3]
  },
  sayName() {
    alert(this.name)
  }
}
let SubType = {
  initSub(age) {
    this.age = age
  },
  sayAge() {
    alert(this.age)
  }
}
Object.setPrototypeOf(SubType,SuperType)
// 此时 SubType.__proto__ === SuperType
SubType.initSub('17')
SubType.initSuper('gim')
SubType.sayAge() // 'gim'
SubType.sayName() // '17'

上例就是把父对象SuperType关联到子对象SubType的内置原型上,这样就可以在子对象上直接调用父对象上的方法。行为委托生成的原型链比class继承生成的原型链的关系简单清晰,一目了然。

行为委托

event loop

js 是单线程的,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。但是IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),js 不可能等待IO设备执行完成才继续执行下一个的任务,这样就失去了这门语言的意义。所以 js 的任务分为同步任务和异步任务。

  1. 所有同步任务都是在主线程执行,形成一个“执行栈”(execution context stack);
  2. 所有的异步任务都会暂时挂起,等待运行有了结果之后,其回调函数就会进入“任务队列”(task queue)排队等待;
  3. 当执行栈中的所有同步任务都执行完成之后,就会读取任务队列中的第一个的回调函数,并将该回调函数推入执行栈开始执行;
  4. 主线程不断循环重复第三步,这就是“event loop”的运行机制。

上图中,主线程运行的时候,产生堆(heap)和栈(stack),堆用来存放数组对象等引用类型,栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

任务队列中有两种异步任务,一种是宏任务,包括script setTimeout setInterval等,另一种是微任务,包括Promise process.nextTick MutationObserver等。每当一个 js 脚本运行的时候,都会先执行script中的整体代码;当执行栈中的同步任务执行完毕,就会执行微任务中的第一个任务并推入执行栈执行,当执行栈为空,则再次读取执行微任务,循环重复直到微任务列表为空。等到微任务列表为空,才会读取宏任务中的第一个任务并推入执行栈执行,当执行栈为空则再读取执行微任务,微任务为空才再读取执行宏任务,如此循环。