基础

2021/03/08

# 内置类型

JS 中分为七种内置类型,七种内置类型又分为两大类型:基本类型和对象(Object)。

基本类型有六种: null, undefined, boolean, number, string, symbol

其中 JS 的数字类型是浮点类型的,没有整型。并且浮点类型基于 IEEE754 标准实现,在使用中会遇到某些 Bug。NaN 也属于 number 类型,并且 NaN 不等于自身。

对于基本类型来说,如果使用字面量的方式,那么这个变量只是个字面量,只有在必要的时候才会转换为对应的类型:

let a = 111; // 这只是字面量,不是 number 类型
a.toString(); // 使用时候才会转换为对象类型

对象(Object)是引用类型,在使用过程中会遇到浅拷贝和深拷贝的问题:

let a = { name: 'FE' };
let b = a;
b.name = 'EF';
console.log(a.name) // EF

# Typeof

typeof 对于基本类型,除了 null 都可以显示正确的类型:

typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof b // b 没有声明,但是还会显示 undefined

typeof 对于对象,除了函数都会显示 object

typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'

# 类型转换

# 转Boolean

在条件判断时,除了 undefined, null, false, NaN, '', 0, -0, 其他所有值都转为 true,包括所有对象。

# 对象转基本类型

对象在转换基本类型时,首先会调用 valueOf 然后调用 toString。并且这两个方法你是可以重写的。

let a = {
  valueOf() {
    return 0
  }
}

当然你也可以重写 Symbol.toPrimitive,该方法在转基本类型时调用优先级最高。

let a = {
  valueOf() {
    return 0;
  },
  toString() {
    return '1';
  },
  [Symbol.toPrimitive]() {
    return 2;
  }
}
1 + a // => 3
'1' + a // => '12'

# 四则运算符

只有当加法运算时,其中一方是字符串类型,就会把另一个也转为字符串类型。其他运算只要其中一方是数字,那么另一方就转为数字。并且加法运算会触发三种类型转换:将值转换为原始值,转换为数字,转换为字符串。

1 + '1' // '11'
2 * '2' // 4
[1, 2] + [2, 1] // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'

对于加号需要注意这个表达式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"
// 因为 + 'b' -> NaN
// 你也许在一些代码中看到过 + '1' -> 1

# == 操作符

比较运算 x == y,其中 xy 是值,产生 ture 或者 false。这样的比较按如下方式进行:

  1. 若 Type(x) 与 Type(y) 相同,则: a. 若 Type(x) 为 Undefined,返回 true b. 若 Type(x) 为 Null,返回 true c. 若 Type(x) 为 Number,则: i. 若 x 为 null,返回 false ii: 若 y 为 null,返回 false iii: 若 x 与 y 为相等数值,返回 true iv: 若 x 为 +0,y 为 -0,返回 true v: 若 x 为 -0,y 为 +0,返回 true vi: 其他返回 false d: 若 Type(x) 为 String,则当 x 和 y 为完全相同的字符序列(长度相等且相同字符在相同位置)时返回 true。否则返回 false e: 若 Type(x) 为 Boolean,当 x 和 y 同为 true 或同为 false 时返回 true。否则,返回 false f: 当 x 和 y 为引用同一对象时返回 true。否则,返回 false
  2. 若 x 为 null 且 y 为 undefined,返回 true
  3. 若 x 为 undefined 且 y 为 null,返回 true
  4. 若 Type(x) 为 Number 且 Type(y) 为 String,返回 x == ToNumber(y) 的结果
  5. 若 Type(x) 为 String 且 Type(y) 为 Number,返回 ToNumber(x) == y 的结果
  6. 若 Type(x) 为 Boolean,返回比较 ToNumber(x) == y 的结果
  7. 若 Type(y) 为 Boolean,返回比较 x == ToNumber(y) 的结果
  8. 若 Type(x) 为 StringNumber,且 Type(y) 为 Object,返回比较 x == ToPrimitive(y) 的结果
  9. 若 Type(x) 为 Object,且 Type(y) 为 StringNumber,返回比较 ToPrimitive(x) == y 的结果
  10. 返回 true

上图中的 toPrimitive 就是对象转基本类型。

这里来解析一道题目 [] == ![] // -> true ,下面是这个表达式为何为 true 的步骤:

// [] 转成 true,然后取反变成 false
[] == false
// 根据第 8 条得出
[] == ToNumber(false)
[] == 0
// 根据第 10 条得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根据第 6 条得出
0 == 0 // -> true

# 比较运算符

如果是对象,就通过 toPrimitive 转换对象 如果是字符串,就通过 unicode 字符索引来比较

# 原型

# 原型链

每个函数都有 prototype 属性,除了 Function.prototype.bind(),该属性指向原型。

每个对象都有 __proto__ 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]],但是 [[prototype]] 是内部属性,我们并不能访问到,所以使用 __proto__ 来访问。

对象可以通过 __proto__ 来寻找不属于该对象的属性,__proto__ 将对象连接起来组成了原型链。

构造函数、原型和实例的关系:

  1. 对象是通过函数创建的,函数是对象 Object.__proto__ === Function.prototype, Function.__proto__ === Function.prototype
  2. 每个函数都有 prototype 属性,除了用这个方法创建的函数 let fun = Function.prototype.bind()
  3. 每个对象都有一个隐藏属性 __proto__,这个属性指向了创建这个对象的构造函数的原型(这是一个非标准属性,不可以用于编程,是供浏览器自己使用的)
  4. prototype 属性里面默认有一个 constructor 属性,该属性指向原型对象所属的构造函数

prototype_proto_ 的关系:

  1. prototype 是构造函数的属性
  2. __proto__ 是实例对象的属性

这两者都指向同一个对象

属性搜索:

  1. 在访问对象的某个成员的时候会先在对象中查找是否存在
  2. 如果当前对象中没有就在构造函数的原型对象中找
  3. 如果构造函数的原型对象中没有找到就到原型对象的原型上找
  4. 直到 Object 的原型对象的原型是 null 为止

# new 的过程

  1. 声明一个中间对象(新生成一个对象)
  2. 将该中间对象的原型指向构造函数的原型(链接到原型)
  3. 将构造函数的 this,指向该中间对象(绑定 this
  4. 返回该中间对象,即返回实例对象(返回新对象)

在调用 new 的过程中会发生以上四件事情,我们也可以试着来自己实现一个 new

function xnew() {
    // 创建一个空的对象
    let obj = new Object()
    // 获得构造函数
    let Con = [].shift.call(arguments)
    // 链接到原型
    obj.__proto__ = Con.prototype
    // 绑定 this,执行构造函数
    let result = Con.apply(obj, arguments)
    // 确保 new 出来的是个对象
    return typeof result === 'object' ? result : obj
}

对于实例对象来说,都是通过 new 产生的,无论是 function Foo() 还是 let a = { b : 1 }

对于创建一个对象来说,更推荐使用字面量的方式创建对象。因为你使用 new Object() 的方式创建对象需要通过作用域链一层层找到 Object,但是你使用字面量的方式就没这个问题。

function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()

# instanceof

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

function Car(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
}

const auto = new Car('Honda', 'Accord', 1998);

console.log(auto instanceof Car); // expected output: true
console.log(auto instanceof Object); // expected output: true

我们也可以试着实现一下 instanceof

function instanceof(left, right) {
    // 获得类型的原型
    let prototype = right.prototype;
    // 获得对象的原型
    left = left.__proto__;
    // 判断对象的类型是否等于类型的原型
    while (true) {
        if (left === null) return false;
        if (prototype === left) return true;

        left = left.__proto__;
    }
}

# 参考资料

# this 关键字

在 Javascript 里,函数被调用的时候,除了接受声明时定义的形式参数,每一个函数还接受两个附加的参数: thisarguments。随着函数使用场合的不同,this 的值会发生变化。但是有一个总的原则,那就是 this 指的是,调用函数的那个对象。

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

var obj = {
	a: 2,
	foo: foo
}
obj.foo();

// 以上两者情况 `this` 只依赖于调用函数前的对象,优先级是第二个情况大于第一个情况

// 以下情况是优先级最高的,`this` 只会绑定在 `c` 上,不会被任何方式修改 `this` 指向
var c = new foo();
c.a = 3;
console.log(c.a);

// 还有种就是利用 call, apply, bind 改变 this,这个优先级仅次于 new

以上几种情况明白了,很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 this

function a() {
    return () => {
        return () => {
        	console.log(this);
        }
    }
}
console.log(a()()());  // window

箭头函数和普通函数之间有一个重要的差别 - 箭头函数没有自己的 this 值,其 this 值是继承外域的 this 值,默认指向定义它时所处的对象(宿主对象),而不是执行时的对象。所以箭头函数不仅仅是从外观上简化了函数的写法,更解决了普通函数中 this 的 hack 问题。

# 箭头函数和普通函数的区别

  • 关于 this
    • 箭头函数没有自己的 this 值,其 this 值指向外围作用域
    • 箭头函数的 this 是声明时确定,不可变;普通函数是运行时确定并且可改变(call, apply, bind
    • 箭头函数不可以当做构造函数,也就是说不可以使用 new 命令
  • 关于 arguments:箭头函数中没有 arguments 对象,若要使用,可用 rest 参数代替
  • 关于 yield:箭头函数不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数
  • 关于 prototype:箭头函数没有 prototype 属性

# 参考资料

# 闭包

闭包的定义很简单:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。

function A() {
    let a = 1;
    function B() {
        console.log(a);
    }
    return B;
}

闭包的应用场景:

  1. 使用闭包可以在 JavaScript 中模拟块级作用域;
  2. 闭包可以用于在对象中创建私有变量。
for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i); // 5,5,5,5,5,5
    }, 1000);
}

上面这段代码中,for 循环中的 i 变量被内部循环的函数使用,只能获得该变量最后保留的值。也就是说,闭包中所记录的自由变量,只是对这个变量的一个引用,而非变量的值,当这个变量被改变了,闭包里获取到的变量值,也会被改变。

解决的方法之一,是让内部函数在循环创建的时候立即执行,并且捕捉当前的索引值,然后记录在自己的一个本地变量里,然后利用返回函数的方法,重写内部函数,让下一次调用的时候,返回本地变量的值。

如果我们约定,用箭头 -> 表示其前后的两次输出之间有 1 秒的时间间隔,而逗号 , 表示其前后的两次输出之间的时间间隔可以忽略,代码运行结果 5 -> 0,1,2,3,4 有如下解法:

// 解法一
for (var i = 0; i < 5; i++) {
    (function(j) {  // j = i
        setTimeout(function() {
            console.log(new Date, j); // 0,1,2,3,4
        }, 1000);
    })(i);
}

console.log(new Date, i); // 5

// 解法二
var output = function (i) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
};

for (var i = 0; i < 5; i++) {
    output(i);  // 这里传过去的 i 值被复制了
}

console.log(new Date, i);

// 解法三(利用 ES6 let 块级作用域)
for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}

console.log(new Date, i);

如果期望代码的输出变成 0 -> 1 -> 2 -> 3 -> 4 -> 5,该怎么改造代码?

// 解法一
for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000 * j));  // 这里修改 0~4 的定时器时间
    })(i);
}

setTimeout(function() { // 这里增加定时器,超时设置为 5 秒
    console.log(new Date, i);
}, 1000 * i);

// 解法二
const tasks = []; // 这里存放异步操作的 Promise
const output = (i) => new Promise((resolve) => {
    setTimeout(() => {
        console.log(new Date, i);
        resolve();
    }, 1000 * i);
});

// 生成全部的异步操作
for (var i = 0; i < 5; i++) {
    tasks.push(output(i));
}

// 异步操作完成之后,输出最后的 i
Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);
});

还可以利用 ES7 中的 async/await 特性来实现:

// 模拟其他语言中的 sleep,实际上可以是任何异步操作
const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});

(async () => {  // 声明即执行的 async 函数表达式
    for (var i = 0; i < 5; i++) {
        await sleep(1000);
        console.log(new Date, i);
    }

    await sleep(1000);
    console.log(new Date, i);
})();

# 模块化

在有 Babel 的情况下,我们可以直接使用 ES6 的模块化:

// file a.js
export function a() {}
export function b() {}
// file b.js
export default function() {}

import {a, b} from './a.js'
import XXX from './b.js'

# CommonJS

CommonJs 是 Node 独有的规范,浏览器中使用就需要用到 Browserify 解析了。

// a.js
module.exports = {
    a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1

在上述代码中,module.exportsexports 很容易混淆,让我们来看看大致内部实现:

var module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
    a: 1
}
// 基本实现
var module = {
  exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
    // 导出的东西
    var a = 1
    module.exports = a
    return module.exports
};

再来说说 module.exportsexports,用法其实是相似的,但是不能对 exports 直接赋值,不会有任何效果。

对于 CommonJS 和 ES6 中的模块化的两者区别是:

  • 前者支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案
  • 前者是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
  • 前者在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是后者采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
  • 后者会编译成 require/exports 来执行的

# AMD

AMD 是由 RequireJS 提出的:

// AMD
define(['./a', './b'], function(a, b) {
    a.do()
    b.do()
})
define(function(require, exports, module) {
    var a = require('./a')
    a.doSomething()
    var b = require('./b')
    b.doSomething()
})

# CMD

CMD 是 SeaJS 在推广过程中对模块定义的规范化产出:

define(function(require, exports, module) {
    var a = require('./a');
    a.doSomething();
    // 此处略去 100 行
    var b = require('./b')
    // 依赖可以就近书写
    b.doSomething()
    // ...
});

# 参考资料

# 执行上下文

当执行 JS 代码时,会产生三种执行上下文

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文

每个执行上下文中都有三个重要的属性:

  • 变量对象(VO),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问
  • 作用域链(JS 采用词法作用域,也就是说变量的作用域是在定义时就决定了)
  • this
var a = 10;
function foo(i) {
    var b = 20;
}
foo();

对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。

stack = [
    globalContext,
    fooContext
];

对于全局上下文来说,VO 大概是这样的

globalContext.VO === globe
globalContext.VO = {
    a: undefined,
	foo: <Function>,
}

对于函数 foo 来说,VO 不能访问,只能访问到活动对象(AO)

fooContext.VO === foo.AO
fooContext.AO {
    i: undefined,
	b: undefined,
    arguments: <>
}
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是一个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调用者

对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过 [[Scope]] 属性查找上级变量

fooContext.[[Scope]] = [
    globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
    fooContext.VO,
    globalContext.VO
]

接下来让我们看一个老生常谈的例子,var

b() // call b
console.log(a) // undefined

var a = 'Hello world'

function b() {
	console.log('call b')
}

想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建 VO),JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。

在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升

b() // call b second

function b() {
	console.log('call b fist')
}
function b() {
	console.log('call b second')
}
var b = 'Hello world'

var 会产生很多错误,所以在 ES6中引入了 letlet 不能在声明前使用,但是这并不是常说的 let 不会提升,let 提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。

对于非匿名的立即执行函数需要注意以下一点:

var foo = 1
(function foo() {
    foo = 10
    console.log(foo)
}()) // -> ƒ foo() { foo = 10 ; console.log(foo) }

因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。

specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // remove specialObject from the front of scope chain

# 深浅拷贝

let a = {
    age: 1
}
let b = a
a.age = 2
console.log(b.age) // 2

从上述例子中我们可以发现,如果给一个变量赋值一个对象,那么两者的值会是同一个引用,其中一方改变,另一方也会相应改变。

通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个问题。

# 浅拷贝

首先可以通过 Object.assign 来解决这个问题。

let a = {
    age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1

当然我们也可以通过展开运算符 ... 来解决:

let a = {
    age: 1
}
let b = { ...a }
a.age = 2
console.log(b.age) // 1

通常浅拷贝就能解决大部分问题了,但是当我们遇到如下情况就需要使用到深拷贝了:

let a = {
    age: 1,
    jobs: {
        first: 'FE'
    }
}
let b = {...a}
a.jobs.first = 'native'
console.log(b.jobs.first) // native

浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到刚开始的话题了,两者享有相同的引用。要解决这个问题,我们需要引入深拷贝。

# 深拷贝

这个问题通常可以通过 JSON.parse(JSON.stringify(object)) 来解决。

let a = {
    age: 1,
    jobs: {
        first: 'FE'
    }
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE

但是该方法也是有局限性的:

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 不能解决循环引用的对象
let obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
}
obj.c = obj.b
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c
let newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)

如果你有这么一个循环引用对象,你会发现你不能通过该方法深拷贝

深拷贝 error

在遇到函数、 undefined 或者 symbol 的时候,该对象也不能正常的序列化

let a = {
    age: undefined,
    sex: Symbol('male'),
    jobs: function() {},
    name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}

你会发现在上述情况中,该方法会忽略掉函数和 undefined

但是在通常情况下,复杂数据都是可以序列化的,所以这个函数可以解决大部分问题,并且该函数是内置函数中处理深拷贝性能最快的。当然如果你的数据中含有以上三种情况下,可以使用 lodash 的深拷贝函数 (opens new window)

如果你所需拷贝的对象含有内置类型并且不包含函数,可以使用 MessageChannel

function structuralClone(obj) {
  return new Promise(resolve => {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => resolve(ev.data);
    port1.postMessage(obj);
  });
}

var obj = {a: 1, b: {
    c: b
}}
// 注意该方法是异步的
// 可以处理 undefined 和循环引用对象
(async () => {
  const clone = await structuralClone(obj)
})()
上次更新: 2021/11/18 上午3:28:02