进阶之道-js

内置类型

Js 中分为七种内置类型,七种内置类型又分为两大类型:基本类型和对象(Object)。
基本类型有六种: null、undefined、boolean、number、string、symbol。
其中 js 的数字类型是浮点类型的,没有整型。并且浮点类型基于 IEEE 754 标准实现,在使用中会遇到某些 Bug。NaN 也属于 number 类型,并且 NaN 不等于自身。
对于基本类型来说,如果使用字面量的方式,那么这个变量只是个字面量,只有在必要的时候才会转换为对应的类型。

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

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

1
2
3
4
let a = { name: "FE" };
let b = a;
b.name = "EF";
console.log(a.name); // EF

Typeof

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

1
2
3
4
5
6
typeof 1; // 'number'
typeof "1"; // 'string'
typeof undefined; // 'undefined'
typeof true; // 'boolean'
typeof Symbol(); // 'symbol'
typeof b; // b 没有声明,但是还会显示 undefined

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

1
2
3
typeof []; // 'object'
typeof {}; // 'object'
typeof console.log; // 'function'

对于 null 来说,虽然他是基本类型,但是会显示 object,这是一个存在了很久的 bug。

1
typeof null; // 'object'

Ps:为什么会出现这种情况呢?因为在 Js 的最初版本中,使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表对象,然后 null 表示为全零,所以将它错误的判断为 object。虽然现在内部类型判断代码已经改变了,但是这个 bug 却一直流传下来。

如果我们想获得一个变量的正确类型,可以通过 Object.prototype.toString.call(xx)。这样我们就可以获得类型[object Type] 的字符串。

1
2
3
4
5
6
7
8
9
10
let a;
// 我们也可以这样判断 undefined
a === undefined;
// 但是 undefined 不是保留字,能够在低版本浏览器被赋值
let undefined = 1;
// 这样判断就会出错
// 所以可以用下面的方式来判断,并且代码量更少
// 因为 void 后面随便跟上一个组成表达式
// 返回就是 undefined
a === void 0;

类型转换

转 Boolean

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

对象转基本类型

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

1
2
3
4
5
let a = {
valueOf() {
return 0;
}
};

但让你也可以重写 Sybol.toPrimtive,该方法在转基本类型时调用优先级最高。

1
2
3
4
5
6
7
8
9
10
11
12
13
let a = {
valueOf() {
return 0;
},
toString() {
return "1";
},
[Symbol.toPrimitive]() {
return 2;
}
};
1 + a; // => 3
"1" + a; // => '12'

四则运算符

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

1
2
3
4
5
6
1 + "1"; // '11'
2 * "2"[(1, 2)] + // 4
[2, 1]; // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'

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

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

==操作符

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

1.若 type(x)与 type(y)相同,则

a.若type(x)为Undefined,返回true。
b.若type(x)为null,返回true。
c.若type(x)为Number 则
                i.若x为NaN,返回false。
                ii.若y为NaN,返回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,返回 comparsion x == ToNumber(y)的结果。

5.若 type(x)为 String 且 Type(y)为 Number,返回 ToNumber(x)== y 的结果。

6.若 type(x)为 Boolean,返回 x == ToNumber(y)的结果。

7.若 type(y)为 Boolean,返回 ToNumber(x)== y 的结果。

8.若 type(x)为 String 或为 Number,且 Type(y)为 Object,返回比较 x == ToPrimitive(y)的结果。

9.若 type(y)为 String 或为 Number,且 Type(x)为 Object,返回比较 y == ToPrimitive(x)的结果。

10.返回 false。

上面的 toPrimitive 就是对象转基本类型。
这里来解析一道题目 [] == ![] // -> true ,下面是这个表达式为何为 true 的步骤:

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

比较运算符

  1. 如果是对象,就是 ToPrimitive 转换对象。
  2. 如果是字符串,就是 unicode 字符索引来比较。

原型

每个函数都有 prototype 属性,除了 Function.prototype.bind() 该属性指向原型。
每个对象都有proto 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 prototype,但是 prototype 是内部属性,我们并不能访问到,所以使用proto来访问。
对象可以通过 proto 来寻找不属于该对象的属性,proto 将对象连接起来组成了原型链。

new

  1. 生成了一个对象。
  2. 链接到原型。
  3. 绑定 this。
  4. 返回新对象。
    在调用 new 的过程中会发生以上四件事情,可以试着自己实现一个 new:
1
2
3
4
5
6
7
8
9
10
11
12
function create() {
// 创建一个空的对象
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,但是使用字面量就没有这个问题。

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

对于 new 来说,还需要注意下运算符的优先级。

1
2
3
4
5
6
7
8
9
10
11
12
function Foo() {
return this;
}
Foo.getName = function() {
console.log("1");
};
Foo.prototype.getName = function() {
console.log("2");
};

new Foo.getName(); // -> 1
new Foo().getName(); // -> 2

对于第一个函数来说,先执行了 Foo.getName() ,所以结果为 1;对于后者来说,先执行 new Foo() 产生了一个实例,然后通过原型链找到了 Foo 上的 getName 函数,所以结果为 2。

instanceof

可以正确判断对象类型,因为内部机制是通过判断对象的原型链中能不能找到类型的 prototype。
我们也可以试着实现 instanceof:

1
2
3
4
5
6
7
8
9
10
11
12
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

this 是很多人会混淆的的概念,但是其实他一点都不难,你只需要记住几个规则就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

1
2
3
4
5
6
7
8
function a() {
return () => {
return () => {
console.log(this);
};
};
}
console.log(a()()());

箭头函数没有 this,这个函数中的 this 只取决于他外面的第一个不是箭头函数的函数 this。因为调用 a 符合前面代码中的第一个情况,所以 this 是 window,且 this 一旦绑定了上下文就不会被任何代码改变。

执行上下文

当 js 执行是,会产生三种执行上下文。

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文
    每个执行上下文中都有三个重要的属性。
  • 变量对象(vo),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问
  • 作用域链(js 采用词法作用域,也就是说变量的作用域是在定义时就决定了)
  • this
1
2
3
4
5
var a = 10;
function foo(i) {
var b = 20;
}
foo();

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

1
stack = [globalContext, fooContext];

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

1
2
3
4
5
globalContext.VO === globe
globalContext.VO = {
a: undefined,
foo: <Function>,
}

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

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

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

1
2
3
4
5
6
7
8
fooContext.[[Scope]] = [
globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
fooContext.VO,
globalContext.VO
]

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

1
2
3
4
5
6
7
8
b(); // call b
console.log(a); // undefined

var a = "Hello world";

function b() {
console.log("call b");
}

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

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

1
2
3
4
5
6
7
8
9
b(); // call b second

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

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

1
2
3
4
5
6
var foo = 1(
(function foo() {
foo = 10;
console.log(foo);
})()
); // -> ƒ foo() { foo = 10 ; console.log(foo) }

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

1
2
3
4
5
6
7
8
9
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

闭包

函数 a 返回了一个函数 b,并且函数 b 中使用了函数 a 的变量。函数 b 就被成为闭包。

1
2
3
4
5
6
7
function A() {
let a = 1;
function B() {
console.log(a);
}
return B;
}

你是否会疑惑,为什么函数 a 已经弹出调用栈了,为什么函数 b 还能引用到函数 a 中的变量。因为函数 a 中的变量这时候是存储在堆上的。现在的 js 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。
经典面试题目,循环中使用闭包解决 var 定义函数的问题。

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

首先因为 settimeout 是个异步函数,所有会把循环全部执行完毕,这时候 i 就是 6 了,所以会输出一堆 6。
解决办法两种,第一种使用闭包

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}

第二种就是使用 settimeout 的第三个参数

1
2
3
4
5
6
7
8
9
for (var i = 1; i <= 5; i++) {
setTimeout(
function timer(j) {
console.log(j);
},
i * 1000,
i
);
}

第三种就是使用 let 定义 i 了

1
2
3
4
5
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

因为对于 let 来说,他会创建一个块级作用域,相当于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{ // 形成块级作用域
let i = 0
{
let ii = i
setTimeout( function timer() {
console.log( ii );
}, i*1000 );
}
i++
{
let ii = i
}
i++
{
let ii = i
}
...
}

深浅拷贝

1
2
3
4
5
6
let a = {
age: 1
};
let b = a;
a.age = 2;
console.log(b.age); // 2

从上述例子中我们可以发现,如果一个变量赋值给一个对象,那么两者的值会是同一个引用,其中一方改变,另一方也会相应改变。
通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个问题。

浅拷贝

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

1
2
3
4
5
6
let a = {
age: 1
};
let b = Object.assign({}, a);
a.age = 2;
console.log(b.age); // 1

当然我们也可以通过展开运算符(…)来解决

1
2
3
4
5
6
let a = {
age: 1
};
let b = { ...a };
a.age = 2;
console.log(b.age); // 1

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

1
2
3
4
5
6
7
8
9
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)) 来解决。

1
2
3
4
5
6
7
8
9
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
  • 不能序列化函数
  • 不能解决循环引用的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);

如果你有这么一个循环引用对象,你会发现你不能通过该方法深拷贝
在遇到函数、 undefined 或者 symbol 的时候,该对象也不能正常的序列化

1
2
3
4
5
6
7
8
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 的深拷贝函数。

本文标题:进阶之道-js

文章作者:Jonathon

发布时间:2019年10月24日 - 16:10

最后更新:2019年11月17日 - 17:11

原始链接:https://www.jonathon.cn/fe-js.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

苟富贵,勿相忘!