前端基础知识整理——JS原理篇

269 阅读7分钟

原型和原型链

js 对象有一个特殊的内置属性 [[prototype]](原型),其实就是对其他对象的引用。可通过 __proto__ 直接访问 [[prototype]]

访问对象属性

当引用对象的属性时,会先在对象本身的属性上进行查找。如果无法在对象本身找到需要的属性,就会访问对象的 [[prototype]]。如果其 [[prototype]] 所指向的对象也找不到的话,则会继续查找该对象的 [[prototype]],直到查找完整条 [[prototype]] 链。

使用 for...in 遍历对象与查找对象属性类似,[[prototype]] 链上可枚举(enumerable:true)的属性都会被遍历。

所有普通的 [[prototype]] 链最终都会指向内置的 Object.prototypetoString()valueOf() 和其他一些通用功能都存在于 Object.prototype 对象上,因此语言中的所有对象都可以使用它们。

设置对象属性

当设置对象的属性时,如果对象和 [[prototype]] 都不存在该属性,则会直接添加到对象上。如果对象中不存在,而 [[prototype]] 中存在,则需要分多种情况:

  1. [[prototype]] 链中存在,且没有被标记为只读(writable:false),则会在对象中添加一个属性。
  2. [[prototype]] 链中存在,但被标记为只读(writable:true),则赋值语句无效,在严格模式下会报错。
  3. [[prototype]] 链中存在, 并且它是一个 setter,则这个 setter 一定会被调用。

“类”

JavaScript 并不是一门基于类的语言,但我们常常用原型来模拟类。但与真正的类还是有很多不同。

类可以被实例化,实例化时会将类的属性复制,实例化出的对象互不关联。在 JavaScript 中没有类,只有对象。通过 new 作用于构造函数来模拟类的实例化。而所谓的构造函数也是一个普通的函数,当被 new 调用时会返回一个对象,这个对象会关联构造函数的原型对象。上面说到原型 [[prototype]] 其实是一种对象之间的联系,通过 new 可以建立这种联系。如 new Foo() 生成的对象其 [[prototype]] 都会指向 Foo.prototype

另一种创建对象联系的方法是 Object.create()。该方法传入一个对象作为参数,返回一个新的对象,该对象的原型指向传入对象的原型。如果用类来理解,可以说是通过一个实例创建另一个实例。

通过建立这种联系,构造函数原型上的属性(一般是一些公共方法)能被 new 生成的对象访问。包括 constructor 属性(该属性的默认值为构造函数),也是在构造函数原型上,生成的对象通过原型链访问。

作用域和闭包

作用域

作用域是查找变量的一套规则,也可理解为变量和声明的作用范围。对变量进行赋值或者获取变量值时,会先在当前作用域查找,如果没有找到,则会在上一级作用域查找,自上而下形成一条作用域链。因此能通过作用域链访问父级作用域中声明的变量。

JavaScript 的作用域为词法作用域(另一种为动态作用域)。编译器有一个叫词法化的工作阶段,词法作用域就是定义在词法阶段的作用域。即词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

evalwith 可以改变词法作用域,但这两个方法会被严格模式use strict限制。同时,使用这两个方法会导致性能问题。因 JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。evalwith 能动态改变词法作用域,使得词法分析优化失效。

JavaScript 常见的作用域是函数作用域(具名函数、匿名函数、立即执行函数)。通过 withtry/catch 可以形成块级作用域。ES6中的 letconst 关键字可以使其声明的变量拥有块级作用域。

变量提升: 函数声明和变量声明会被提升到作用域顶部(变量声明只提升声明,不提升赋值)。当有多个重复声明时,函数声明会被提升到变量声明之前。

闭包

函数对定义时词法作用的引用即为闭包。通过某些手段将函数在其词法作用域外调用,该函数仍能访问到其词法作用域。闭包同时使得其词法作用域的变量可能不会被垃圾回收机制回收。

var a = {}
;(function() { // 匿名函数形成一个作用域,该作用域为函数print的词法作用域
    var str = 'hello world'
    
    function print() {
        console.log(str)
    }
    
    a.print = print
})()

// 函数print在词法作用域外调用,仍能访问其词法作用域内的str变量
a.print() // 'hello world'

回调函数

回调函数是闭包常见的例子。

var a = 'hello world'
var b = function (cb) {
    cb()
}

b(function() {
    // 该匿名函数的词法作用域为全局作用域
    // 该函数作为 b 函数的回调函数,并非在全局作用域内调用,而是在 b 内部调用
    // 该函数仍能访问到其词法作用域定义的变量 a
  console.log(a)
})

循环

循环是闭包另一个常见的例子

for (var i = 1; i <= 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, i*1000);
}

上述代码在执行之后会每秒打印一个6,而不是1到6。这是因为定时器接受的匿名函数在定义时共享同一个作用域,该作用域内只有一个 i。循环结束,定时器开始执行的时候,此时共享作用域内的 i 为6。

解决方法如下,通过循环创建不同的作用域,用于保存不同的 i

// 以下方法是行不通的,此时的 i 仍处于我们构造的匿名函数的外部
for (var i = 1; i <= 5; i++) {
    (function () {
        setTimeout(function () {
          console.log(i);
      }, i * 1000);
    })()
}
// 将 i 赋值给我们构造的匿名函数内部的一个变量
// 每个循环都将创建一个作用域,每个作用域中保存不同的 i
for (var i = 1; i <= 5; i ++) {
    (function () {
        var j = i
        setTimeout(function() {
          console.log(j);
      }, j * 1000);
    })()
}
// 简化如下
for (var i = 1; i <= 5; i++) {
    (function (j) {
        setTimeout(function() {
          console.log(j);
      }, j * 1000);
    })(i)
}
// 通过let关键词创建的块级作用域也可以实现
for (let i = 1; i <= 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, i * 1000);
}

闭包能够隐藏词法作用域内的变量,因此也可以用来实现模块化。

this关键字

this 既不指向函数自身,也不指向词法作用域,this 在函数调用时绑定,完全取决于函数的调用位置。this 可以让我们在函数内部访问当前的运行环境,它是执行上下文的一部分。this 指向函数的调用者(一个对象)。this 有以下四个绑定规则。

默认绑定

当没有其他绑定规则时,采用的绑定规则。直接调用函数时,函数内部的 this 指向全局对象 window

隐式绑定

当函数作为对象属性调用时,函数内部的 this 指向该对象。

var obj = {
    a: 1,
    foo: function () {
      console.log(this.a)
    }
}

obj.foo() // 1

显式绑定

通过函数的 callapply 方法可以直接指定this的绑定对象,称之为显式绑定。

function foo(x, y) {
    console.log( this.a, x, y );
}
var obj = { a: 2 };

foo.call( obj, 1, 2 ); // 2 1 2
foo.apply( obj, [1, 2] ); // 2 1 2

new 绑定

使用 new 操作符调用函数时,会创建一个新对象并把它绑定到函数调用中的 this 上。js 中的构造函数其实是被 new 调用的普通函数,或者说是“构造调用”。因此 new 改变了函数调用时 this 的指向,称之为 new 绑定。

function Foo () {
    this.a = 1
    console.log(this.a)
}
var foo = new Foo()

箭头函数

箭头函数中的this不遵循上述四种规则,而是根据当前的词法作用域来决定。这与 es5 中使用变量来保存 this 的做法的模式是一样的。

var obj = {
    a: 1,
    foo: function() {
        setTimeout(() => {
            console.log(this.a)
        })
    }
}
obj.foo() // 1

// 等价于

var obj = {
    a: 1,
    foo: function() {
        var self = this
        setTimeout(function() {
            console.log(self.a)
        })
    }
}
obj.foo() // 1

类型判断

类型

JavaScript 有六种内置的数据类型:Null, Undefined, Number, String, Boolean, Object。除了 Object 为引用类型外,其他的都是原始类型。ES6 又新增了一种原始类型 Symbol。现在新的提案又提出一种新的数据类型 BigInt

typeof

typeof 运算符可以判断变量属于哪种类型,但是 typeof 不能用来判断数组和 null,却可以用来判断函数。

typeof null // 'object'
typeof undefined // 'undefined'
typeof 42 // 'number'
typeof '42' // 'string'
typeof true // 'boolean'
typeof {} // 'object'
typeof Symbol() // 'symbol'
typeof [] // 'object'
typeof (function(){}) // 'function'

instanceof

另一种判断类型的方法是 instanceof 运算符。instanceof 用于判断构造函数的 prototype 属性是否在对象的原型链上,因此,instanceof 只能用来判断有构造函数的变量,不能判断数字、字符串和布尔值等字面量。将原始值包装成对象后,也可以用 instanceof 判断。

[] instanceof Array // true
/a/ instanceof RegExp // true
new Date() instanceof Date // true

1 instanceof Number // false
Number(1) instanceof Number // true

// 自定义对象也可以用 instanceof 判断
function A () {}
new A() instanceof A // true

Object.prototype.toString

Object 对象的原型上有一个 toString 方法,可以返回对象类型。一般情况下,对象原型链的顶端都是 Object.prototype,因此任意对象都能调用这个方法。但是有些对象会对这个方法进行重写。

var obj = { foo: 1 }
obj.toString() // "[object Object]"

var date = new Date()
date.toString() // "Wed Sep 23 2020 11:31:13 GMT+0800 (中国标准时间)"

var arr = [1, 2, 3]
arr.toString() // "1,2,3"

因此,如果要获取数据类型,只能调用 Object.prototype 上未被改写的 toString 方法。

Object.prototype.toString.call(true)  // "[object Boolean]"
Object.prototype.toString.call(1)  // "[object Number]"
Object.prototype.toString.call("")  // "[object String]"
Object.prototype.toString.call({})  // "[object Object]"
Object.prototype.toString.call([])  // "[object Array]"
Object.prototype.toString.call(function(){})  // "[object Function]"
Object.prototype.toString.call(null)  // "[object Null]"
Object.prototype.toString.call(undefined)  // "[object Undefined]"
Object.prototype.toString.call(new Set())  // "[object Set]"
Object.prototype.toString.call(new Map())  // "[object Map]"
Object.prototype.toString.call(Symbol(1))  // "[object Symbol]"
Object.prototype.toString.call(new Date())  // "[object Date]"
Object.prototype.toString.call(/s/)  // "[object RegExp]"
Object.prototype.toString.call(document)  // "[object HTMLDocument]"
Object.prototype.toString.call(document.getElementsByTagName('body'))  // "[object HTMLCollection]"

可以看出真正有用的是括号中的第二个单词,可以用字符串截取的方法获取这个单词,封装成 getType 方法。

const getType = (data) =>
    Object.prototype.toString.call(data).substring(8, str.length - 1)

如果要判断某一个特定类型,可以用柯里化的思想,封装一个函数用来生成各个判断类型的函数。

const isType = (type) =>
    (target) => Object.prototype.toString.call(target) === `[object ${type}]`

const isNumber = isType('isNumber')
isNumber(1) // true

const isArray = isType('isArray')
isArray([]) // true

Array.isArray

判断是否是数组,除了上面的 instanceofObject.prototype.toString 方法外,ES6 还提供了一个新的方法 Array.isArray

Array.isArray([]) // true

类型转换

待续...