Listentolife

简单就好

  • 首页
  • 标签
  • 分类
  • 归档

Canvas 射击小游戏详解系列(三)

发表于 2018-09-02 | 分类于 JavaScript , 对象设计

上一篇讲了一下创建对象的三种模式,本篇就来讲一下继承。

继承

这里说的继承是指构造函数的继承。一般继承是在设计中把一些不同类型的对象的共同点抽象出来,或者是在原有的构造函数中继承出新的构造函数,减少代码冗余。

继承可以使子类拥有父类的属性跟方法,而且不会出现重复的代码。

最简单的方式就是在子类的构造函数中创建一个父类的实例对象,再把子类自有的属性跟对象添加到这个实例对象上,在返回出来。

代码1 项目中怪兽子类继承飞机父类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Plane (option, context) {
this.opts = option;
this.context = context;
}

Plane.prototype.drawing = function () {
// 画布上渲染图片
}

Plane.prototype.move = function () {
// 飞机每帧移动
}
// 其他方法省略

function Enemy (option, context) {
this.prototype = new Plane(option, context);
}

Enemy.prototype.drawing = function () {
// 重写方法
}

这种方案虽然能实现子类对父类的继承,但是出现了以下的几个问题:子类的实例对象的constructor指向了父类的构造函数,原因是子类构造函数中先创建了一个父类的实例对象,这个对象的constructor肯定指向父类的构造函数。
因此,在上面的代码基础上,还需要把constructor指向自身,可以解决这个问题了。

代码2 项目中怪兽子类继承的优化

1
2
3
4
5
6
7
8
function Enemy (option, context) {
this.prototype = new Plane(option, context);
this.protptype.constructor = Enemy;
}

Enemy.prototype.drawing = function () {
// 重写方法
}

但是,这里可以看到,有些方法可能会需要重写,这个问题不大,但是会有属性覆盖父类属性的问题,所以这点也是需要优化的,减少创建出来的对象的冗余。

这里要补充一个,还可以直接通过call()绑定父类构造函数进行继承,但是写在原型上的方法都没法用。但是也是我们参考的一个点

代码3 项目中怪兽子类绑定飞机父类构造函数

1
2
3
4
function Enemy (option, context) {
Plane.call(this, option, context);
// 其他省略
}

以上的问题点其实都是在如何可以准确的继承父类的方法,减少属性覆盖,又保证子类constructor指向子类的构造函数。这里我用到了这个项目前学习到的继承方案,包括以下的函数

代码4 inheritPrototype()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @description 继承父类原型对象的函数
* @param {Object} subType 子类对象
* @param {Object} subType 父类对象
*/
var inheritPrototype = function (subType, superType) {
// 把父类对象的原型对象赋值给proto
var protoType = Object.create(superType.prototype);
// proto的constructor指向子类对象,进行重置
protoType.constructor = subType;
// 把子类的原型指向原型
subType.prototype = protoType;
}

这个方案利用了构造函数继承跟上面的inheritPrototype()方法。前者继承了父类的属性,后者函数继承了父类原型中的所有方法。最终的效果如下:

代码5 项目中的继承设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Plane (option,context) {
this.opts = option;
this.context = context;
}

Plane.prototype.drawing = function () {
// 画布上渲染图片
}

function Enemy(options,context) {
// 继承父类Plane的属性
Plane.call(this, options, context);
}
// 继承父类Plane的原型
inheritPrototype(Enemy, Plane);

Enemy.prototype.drawing = function () {
// 重写方法
}

下一篇介绍项目中对象的设计。

Canvas 射击小游戏详解系列(二)

发表于 2018-09-02 | 分类于 JavaScript , 对象设计

接下来会介绍关于游戏项目中关于对象的设计,但在这之前想先对JavaScript对象做一些总结,然后再介绍项目的对象设计。这里会分三篇介绍。

JavaScript创建对象的三种模式

js创建对象的三种模式有工厂模式,构造函数模式及原型模式。

其实正常来说,创建一个对象是可以直接声明一个Object类型,但是如果需要创建多个对象的时候,这个方法显然不利于开发。而且很多时候需要创建的对象都有一些相似点,比如同一类型,同样的属性跟方法。如果能有一种方式可以用相同的代码处理创建对象的话,就可以提高代码的性能了。上面提及的三种模式就是解决方案。

工厂模式

工厂模式就是通过传参给一个函数方法,在函数中进行对象的创建,赋值,然后返回。

代码1 项目中飞机对象根据工厂模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createPlane (option, context) {
var plane = new Object();
plane.opts = option;
plane.context = context;
plane.drawing = function () {
// 画布上渲染图片
}
plane.move = function () {
// 飞机每帧移动
}
// 其他方法省略
return plane;
}

var plane1 = createPlane(opts, context);

这种方式创建对象比较简单,只需要知道创建对象的函数跟传入需要的参数即可。其不足在于两点:工厂模式是通过函数方法来创建对象,而不是通过new方法来创建,这样的创建方式不够直观,还是会有一些冗余的代码;工厂模式所返回的对象,在后面的程序中,无法识别对象类型,只能识别是Object类型。

所以就有了构造函数模式。

构造函数模式

这种模式是先声明一个构造函数,然后通过new来创建对象。

代码2 项目中飞机对象根据工厂模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
function Plane (option, context) {
this.opts = option;
this.context = context;
this.drawing = function () {
// 画布上渲染图片
}
this.move = function () {
// 飞机每帧移动
}
// 其他方法省略
}

var plane2 = new Plane(opts, context);

代码2跟代码1的对比中可以发现,工厂模式是在函数内部通过new一个对象再返回,但是构造函数模式中,new是在构造函数外面,而且函数内部是通过this来进行赋值的。

在构造函数模式中,程序是先执行了new关键词,即创建了一个对象。然后把构造函数的作用域赋值给了这个对象,也就是构造函数的this指针指向这个对象。然后执行函数,这期间会把属性跟方法全部赋值给这个对象。最后再把对象返回出来。

构造函数的优点也比较明显,它避免了工厂模式的两个不足。通过构造函数创建的对象,其实就是这个构造函数的一个实例。我们就可以通过instanceof方法判断一个对象的构造函数。

代码3 instanceof判断对象是否是某个构造函数的实例

1
console.log(plane2 instanceof Plane); // true

不过,构造函数模式也有它的一些不足。构造函数所创建出来的每个实例对象都会包含一些属性跟方法,而方法其实在所有实例对象中的功能都是一样的。而这些方法实际上也是Function的实例对象,这样就出现了不必要的冗余,这些方法应该实现复用。

下面的原型模式就能解决这个不足。

原型模式

原型模式就是利用到JavaScript中对象的原型prototype来实现的。

每个构造函数上都会有一个属性prototype。prototype就是原型,也是一个对象,而且这个对象prototype是所有通过同一构造函数创建(new)的实例对象所共享的(实际上在浏览器console窗口打印每个实例对象,显示的是属性__proto__指向prototype,实际开发中只要用prototype就好)。prototype下的constructor指向实例对象的构造函数。

代码4 判断实例的prototype

1
Object.prototype.isPrototypeOf(object1) // 判断object1挂载的prototype是不是Object的

原型模式根据JavaScript的原型原理,在构造函数的基础上,把方法放到了prototype对象下。创建对象的方法跟构造函数模式一样,内部创建对象的流程也差不多,也是先执行new关键词,创建一个对象,然后把函数的this指向这个对象,在执行属性赋值跟方法声明时,把方法都放在this.prototype原型对象中。

代码5 项目中飞机对象根据工厂模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Plane (option, context) {
this.opts = option;
this.context = context;
}

Plane.prototype.drawing = function () {
// 画布上渲染图片
}

Plane.prototype.move = function () {
// 飞机每帧移动
}
// 其他方法省略

var plane3 = new Plane(opts, context);

这样的创建对象模式,可以使构造函数创建的实例对象,共享原型中的方法。但是也会有问题存在,这个问题是共享本身的问题。当所有的实例对象共享原型时,就可能出现数据污染的情况。

数据污染的原因在于JavaScript的数据存储方式。JavaScript的数据分为值类型跟引用类型,值类型就是一个变量储存一个值,而引用类型就是一个变量指向内存的一个地址,这个地址储存一个值。值类型变量的赋值是深赋值,赋值完成后两个变量的操作互不影响,但是引用类型的变量之间的赋值其实是复制了指针,这就导致一个变量对指针指向的值进行修改时,另一个变量的值也会跟着改变,就会出现数据污染。

所以,在使用原型模式时,一般把函数方法放在原型prototype中。

下一篇介绍继承。

Canvas 射击小游戏详解系列(一)

发表于 2018-08-16 | 分类于 JavaScript , 对象设计

前面根据腾讯的一个项目,自己做了一个Canvas设计小游戏,这个小游戏为玩家操作小飞机射击怪兽。小飞机可以左右移动,然后可以射出子弹。怪兽有若干个,从屏幕上方左右移动,当移动到侧边界会下移一行。如果怪兽存活并移动到与飞机一行,则游戏失败,如果飞机能在此之前射击到所有怪兽,则游戏通关。游戏总共设计6关,每一关难度不一样。

整个项目最初只有一个html文件,一个配置文件及一个app入口文件,入口文件的框架已经建好,主要的功能逻辑代码及对象文件都是自己设计实现的。整个项目的关键点是JavaScript中的面向对象设计跟Canvas。页面的渲染都是通过Canvas进行渲染,然后把整个游戏设计成一个对象,里面的角色及零件也都是设计成一个个的对象。下面就给整个项目做一个详细的分析。

项目整体分析

虽然项目的框架并不是自己设计的,但是毕竟整个设计还是非常好的,值得我们去分析,所以我就先把整个游戏的框架跟自己的设计做一下分析。

游戏页面分为四个部分,根据游戏的状态进行切换展示:游戏首页,游戏成功页面,游戏失败页面及游戏通关页面,通过状态start, failed, success及all-success来控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data-status="start"] .game-intro {
display: block;
padding-top: 180px;
background: url(./img/bg.png) no-repeat 430px 180px;
background-size: 200px;
}

[data-status="failed"] .game-failed,
[data-status="success"] .game-success,
[data-status="all-success"] .game-all-success {
display: block;
padding-top: 180px;
background: url(./img/bg-end.png) no-repeat 380px 190px;
background-size: 250px;
}

由此,整个游戏就设计成一个名为GAME的对象进行管理。当页面渲染完之后,GAME对象就会进行初始化,设定最初的状态为start,即展示游戏首页,并初始化四个部分的按钮,设置监听事件。

这就是项目原有的框架部分。后面为我根据框架跟游戏设计实现的部分。

不同的按钮会根据游戏状态设置不同的触发内容,不过因为游戏最初,游戏失败后及游戏通关后重玩的触发内容都是从一个新的游戏开始玩起,所以内容都是初始化游戏数据dataInit()及开始游戏play(),只有游戏通关后继续不需要初始化游戏数据即可进入游戏。

初始化游戏数据dataInit()主要是实现一些游戏的配置数据,比如游戏关卡数,游戏分数及怪兽和飞机的配置数据等。

开始游戏play()操作比较多,所以把步骤都拆分成独立的函数方法:setState()用于设置游戏状态,dataReset()用于重置游戏关卡数据,包括难度,位置及键盘监听,createObject()用于创建初始化画布上的对象,比如小飞机,怪兽等,最后的animate()则设置动画效果,涉及渲染画布上所有对象每一帧的变化及位移,并判断游戏的状态。

1
2
3
4
5
6
7
8
9
play: function () {
this.setStatus('playing');
// 重置每一关数据
this.dataReset();
// 创建画布上飞机,怪兽军团及分数的初始化对象
this.createObject();
// 开始动画
this.animate();
}

这里就大致的说明了一下这个小游戏项目框架的设计。下一篇会分析项目中对象的设计。

《高性能JavaScript》读书笔记(四)

发表于 2018-06-26 | 分类于 JavaScript , 读书笔记

《高性能JavaScript》第四章主要涉及算法跟流程控制,包括循环,条件语句和递归的优化。下面就开始吧。

循环

四种循环类型

四种循环类型包括 for循环,while循环,do-while循环及for-in循环。

这四种循环类型都有各自的特点,适用于不同的实现需求。比如while循环是前侧循环,适合需要在进入循环前开始判断的操作,而do-while循环则适合最少需要循环一次才开始判断的操作。for循环比较直观,当然也并不是最简单的,而for-in是针对对象的,可以枚举任何对象的属性名,但是是性能最差的。

循环优化

循环的优化上,给出了这几点建议:

尽量不使用for-in循环

for-in循环的性能最差,因为其主要针对对象,虽然表面上很简单,但是在循环的时候却因每次迭代操作都会同时搜索实例或原型属性,所以会产品更多的开销,所以是最慢的。针对对象的遍历,书中提出一种方案:如果知道对象的所有属性,则可以用一个数组存储所有的属性名,然后遍历数组会更快:

1
2
3
4
5
6
var props = ["prop1", "prop2"],
i = 0;

while (i < props.length) {
process(object[props[i++]]);
}
减少迭代的工作量

一般的数组循环,其循环体中都会有以下的操作:

  1. 在控制条件中查找一次属性 (item.length);
  2. 在控制条件中执行一次数值比较 (i < items.length);
  3. 一次比较操作查看控制条件的计算结果是否为true (i < item.length == true);
  4. 一次自增操作 (i++);
  5. 一次数组查找 (items[i]);
  6. 一次函数调用 (process(items[i]))。

一个本来迭代次数有限的循环,是没办法考虑从迭代上做优化的,所以可以从每一次都会执行的循环体中着手优化,每优化一个操作,就可以得到客观的性能提升。书中给出的两条优化建议是:

  1. 把查找属性的操作在初始化时就复制到一个局部变量中,即可减少第一个操作;
  2. 如果,循环数组的顺序跟结果无关,则可以考虑倒序处理。因为倒序的时候,只需要判断控制条件是否为true(除非控制条件为0,则都是true),不需要再做一次比较。
减少迭代次数

如果遇到一个迭代次数非常多,则可以考虑下如何减少迭代次数上下手。书中介绍一种限制迭代次数的模式,“达夫设备(Duff’s Device)”。这种模式可以在一次迭代中执行多次迭代的操作。下面是例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var iterations = Math.floor(items.length / 8),
startAt = items.length % 8,
i = 0;

do {
switch(startAt){
case 0: process(items[i++]);
case 7: process(items[i++]);
case 6: process(items[i++]);
case 5: process(items[i++]);
case 4: process(items[i++]);
case 3: process(items[i++]);
case 2: process(items[i++]);
case 1: process(items[i++]);
}
startAt = 0;
} while (--iterations);

假设原来的迭代次数为n(8(x) < n <= 8(n+1)),则这种模式可以把迭代次数减少至(n+1)次。书中还提供了用while代替switch的方案,还可以在优化迭代的速度。

基于函数的迭代

基于函数的迭代方法foreach()在使用上是非常方便的,但是因为每个循环中都必须调用一个外部方法,所以性能上还是比基于循环的迭代要慢一些。

条件语句

两种条件语句的选择

条件语句分if-else跟switch。当判断条件跟分支操作较少时,if-else语句更易读,而当判断条件跟分支操作较多时,switch语句则更易读。当判断条件跟分支操作较少时,if-else跟switch性能上没有什么区别,只有判断更加复杂的情况下才会让switch的性能突出。

if-else优化

如果中需要做大量的顺序或大小的判断,不管是if-else或者switch语句,最多都是要遍历完所有条件。书中提出可以考虑二分法的优化思路,根据判断条件的顺序或大小,优先跟居中的判断值做比较,从而把最大的判断次数减半。实现的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
if (value < 6) {
if (value < 3) {
if (value == 0) {
return result0;
} else if (value == 1) {
return result1;
} else {
return result2;
}
} else {
if (value == 3) {
return result3;
} else if (value == 4) {
return result4;
} else {
return result5;
}
}
} else {
if (value < 8) {
if (value == 6) {
return result6;
} else {
return result7;
}
} else {
if (value == 8) {
return result8;
} else if (value == 9) {
return result9;
} else {
return result10;
}
}
}

查找表

如果是判断大量的离散值,判断的结果只需要一个值而不是一系列的操作,那还可以通过构建查找表进行优化。查找表的优势在于它是通过键值对直接找到处理值,不需要通过判断语句,即使离散值增加,也不会产生更多的性能开销。

递归

递归可以把复杂的算法变得很简单,可以把很复杂的执行过程大大的简化。但是递归也有一些问题点。比如当执行的计算较长时,会使整个系统进入假死状态,而且会有浏览器调用栈的限制。而这些问题,都可能是在设计的初期并没有意识到的。

一般递归有两种模式,一种的直接递归,函数调用函数自身,如比较经典的阶乘函数。另一种是“隐伏模式”,是两个函数互相调用,这种模式在出错调试时会非常棘手。

一般递归函数出现调用栈溢出的原因在于不正确的终止条件,如果终止条件没有问题,则需要考虑为了浏览器安全。书中给出了两种方案:迭代跟Memoization。

迭代虽然在速度上快不过递归,但是由于它相对对浏览器友好,所以一旦程序遇到调用栈溢出的问题,就需要考虑迭代的实现方案。

Memoization是一种缓存阶段计算的方案。在方案中,会有一个数组专门存储阶段性的计算值,这些计算值本身可能在不被存储的时候回因程序需要而不断的被计算获得。当存在一个数组能存储下这些阶段性的计算值时,函数被调用的越多,其重复的计算工作会得到控制,整个函数的性能会逐渐稳定。

最后

这章内容主要是在程序流程上的优化,减少代码的执行量跟解决代码的风险问题。现在比较多的项目都会需要考虑大规模的并发跟流量,所以程序上的小小优化,风险上的细心处理,都能对项目有很大的帮助。值得学习。

《高性能JavaScript》读书笔记(三)

发表于 2018-06-25 | 分类于 JavaScript , 读书笔记

《高性能JavaScript》第三章主要讲的是DOM操作的优化。上两篇的读书笔记自己读过之后感觉更像是对内容的自我消化后的按原文复述,所以这篇调整一下内容的结构,只讲关键点。

DOM操作的优化问题

DOM操作涉及以下三类问题:

  1. 访问和修改DOM元素
  2. 修改DOM元素的样式所导致的重绘(repaint)跟重排(reflow)
  3. 通过DOM事件处理与用户的交互

DOM

DOM,文档对象模型,是独立于语言,用于操作XML跟HTML文档的程序接口。DOM是有规范的,但是实现是由各个浏览器方独自实现支持,因此,我们在开发页面时,需要对不同的浏览器做不同的兼容处理(比如IE,不过现在IE已经很少了)。

DOM是程序接口,我们在开发的时候,会根据需求去实现页面与用户的互动。但因为DOM是独立的,每次需要获取节点都需要各种调用,因此这些调用的增多会影响页面的性能。

DOM访问与修改

其实DOM的访问跟修改可以联系上一章提及的嵌套对象的内容。DOM中的每个节点都是一个对象,如果节点下存在子节点,则这个节点就是一个嵌套对象的例子。其一是DOM是程序接口,相对独立,调用接口都是存在一定的响应时间;其二是DOM中存在大量的嵌套对象,在搜索查询上也是需要一定的处理时间。因此,DOM的访问与修改会存在以下优化点:

  1. DOM的重复调用
  2. DOM集合元素的重复查询
  3. 准确的获得DOM元素集合

DOM的重复调用

简单的说,开发中经常会把后端穿来的数据修改渲染到页面上,书中提到最坏的情况是循环访问或修改元素:

1
2
3
4
5
function innerHTMLLoop () {
for (var count = 0; count < 15000; count++) {
document.getElementById('here').innerHTML += 'a';
}
}

代码的问题在于每次循环中都会读取一次跟重写一次innerHTML,而且循环次数越多,读取跟重写都会不断增加。

所以,优化的方案是,先把需要添加的节点保存在局部变量中,然后在一次性读取跟重写页面结构。
如果出现大量的重复节点,则可以考虑克隆已有的元素,即使用element.cloneNode()替代document.createElement()。

DOM元素集合的重复查询

HTML集合是包含DOM节点引用的类数组对象,包括我们常用的document.getElementByName(),document.images等类似的方法所得到的元素集合。这些元素集合只是一个类似数组的列表,但同样提供了类似数组的length属性。

首先,在利用document.getElementByName()这类查询方法的时候,因为每次查询都会调用document,所以查询的速度回比较慢。但当因为需要多次获取元素集合的属性时,则会形成一个非常大的性能消耗。

另外,元素集合的length属性不同于数组的length属性,不仅当HTML文档发生更新的时候会自动更新,当调用它的时候也会重复查询,加上集合中的元素都是DOM对象,查询速度更慢,消耗更大。

解决的方案,重点放在减少对document的调用跟文档查询。当遇到需要重复调动document.getElementByName()这类查询方法,可以根据不同的情况,进行不同的优化。比如当需要重复查询同一个DOM节点时,可以考虑用局部变量去复制;当代码中多次需要调动document的方法时,也可以先把document复制到局部变量中(这个做法也有风险,当HTML文档结构发生改变时,局部变量是不会改变的);当需要多次调用length属性时,同样先用局部变量存储下来,再执行其他操作。

准确的获得DOM元素集合

大多数情况下,我们都是需要获得一些准确的DOM元素集合进行操作,但是存在两类问题:有些方法得到的元素集合会比我们需要的要多(比如空的文本节点),这个时候就会在处理集合是需要花时间去过滤筛选的;另外,获取DOM元素集合的调用太复杂。因此,有两类方法可以更准确的获取DOM元素集合:

  1. 使用以下表格(书中给出的)左边的属性,替代右边的属性,可以更准确的获得DOM元素集合:
属性名 被替代的属性
children childNodes
ChildElementCount childNodes.length
firstElementChild firstChild
lastElementChild lastChild
nextElementSibling nextSibling
previousElementSibling previousSibling
  1. 使用querySeletorALL()方法跟querySeletor()方法。这两个方法可以免掉多次使用getElementById()跟getElementByClass()这类方法,通过CSS选择器一步到位。

重排(reflow)与重绘(repaint)

简单的说,HTML文档跟CSS文件在首次加载时,得到DOM树跟渲染树,然后才会开始绘制。当DOM出现变化时,浏览器就需要重新计算元素的几何属性,并重新构造渲染树,这个过程叫重排(reflow)。浏览器也会重新绘制受影响的部分,这个过程叫重绘(repaint)。但有些情况下只会发生重绘,不需要重排。每次重排跟重绘都会导致页面的反应迟钝,使客户体验变差。所以我们要了解什么情况会触发重排跟重绘。

触发重排的条件

这些情况下会发生重排:

  1. 添加或删除可见的DOM元素
  2. 元素位置改变
  3. 元素尺寸改变(包括:外边距、内边距、边框厚度、宽度、高度等属性改变)
  4. 内容改变,例如:文本改变或图片被另一个不同尺寸的图片替代
  5. 页面渲染器初始化
  6. 浏览器窗口尺寸改变

也有当滚动条出现时触发整个页面的重排。

触发重绘的条件

重绘只发生在当元素的可见的外观被改变,但并没有影响到布局的时候。比如背景颜色,字体颜色。

渲染树渲染队列与刷新

大多数浏览器是通过队列来修改并批量执行重排过程,这样可以优化并降低消耗,但同时也会让程序在不知不觉中就触发了重排。以下获取布局信息的方法会导致列队刷新:

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight
  2. scrollTop, scrollLeft, scrollWidth, scrollHeight
  3. clientTop, clientLeft, clientWidth, clientHeight
  4. getComputedStyle() (currentStyle in IE)

这些方法需要返回最新的布局信息,浏览器就会不得不执行渲染队列中待处理的变化并触发重排以返回正确的值。

最小化重绘与重排

以下的几种方式可以减少重绘与重排的次数:

  1. 改变样式。改变样式时,如果要对元素进行多个样式的修改,可以用element.style.cssText来一次处理,代替掉对每个样式单独的修改;
  2. 修改DOM。修改DOM时,有几个思路可以参考:可以把需要修改的DOM元素脱离文档流,再做修改,最后再放回文档中;可以把需要修改的DOM元素复制出来,修改完毕后直接替换到文档中;
  3. 减少使用会触发渲染队列刷新的方法;
  4. 让元素脱离动画流。当页面有带动画交互效果的元素时,最好在动画开始前把动画的元素做绝对定位,同时把其他元素先移到动画结束时的位置,再执行动画效果。这样可以减少动画过程中这个页面的大规模重排;
  5. 针对IE,减少大规模使用:hover。

通过DOM事件处理与用户的交互

现在很多页面都是存在大量的交互,而这些交互如果都要单独绑定事件处理器,则会应该性能。因此,书中提出建议使用事件代理,只需在外层元素绑定一个处理器,来处理所有子元素上触发的事件。

最后

本章主要是针对DOM做优化,更多的是讨论如何减少DOM的调用修改。每种操作都可以根据不同的需求进行不同的实现以达到优化的目的。

文中用到的代码参考《高性能JavaScript》第三章。

《高性能JavaScript》读书笔记(二)

发表于 2018-06-18 | 分类于 JavaScript , 读书笔记

《高性能JavaScript》第二章数据存取,涉及很多优化点,需要花点时间吸收。下面就开始吧。

数据存储不同方式之间的性能比较

第二章一开始就提到了数据存储的问题。js的数据存储分四类:字面量,本地变量,数组元素及对象成员。字面量跟本地变量在存取数据的性能消耗差不多,而数组元素跟对象成员消耗要大一些,但是也跟浏览器的优化有关。所以建议是尽量使用字面量和局部变量。

作用域链

实际开发中,我们会根据项目需求使用各种变量跟方法。为了了解如何去优化代码的性能,就要了解作用域链。

关于作用域链,我在我的第一篇博文《JavaScript作用域》 中有提到过,当时是用链表的形式对全部作用域跟函数作用域做了模拟。这里就不再赘述。

书中的图示(下图1.1)应该是更接近真实JavaScript作用域链的表现形式的,这里需要做一下说明。

图1 书中举例的add()的作用域链
图1 书中举例的add()的作用域链

什么叫作用域链

书中给出的定义,作用域链是一个包含一个函数被创建的作用域中的对象集合,决定了哪些数据能被函数访问。作用域链在函数实例中是以一个内部属性[[Scope]]被储存的。当一个函数被创建时,它的作用域链会先被创建该函数的作用域中可访问的数据对象所填充。作用域链在创建时所填充的数据对象为全局对象。

在函数创建完成并开始执行时,会对执行的变量按照出现在函数中的顺序,被复制(中文是写复制,但是我有个问题,按照变量提升的规则,函数中的局部变量应该在这个时候进行声明,那是不是就不仅仅是复制全局中的变量,还有声明了的变量呢?)到“活动对象”的新对象中。这个对象会被推入作用域链的最前端。如

然后当函数执行完毕,执行环境被销毁,作用域链也会被销毁。如下图1.2

图2 书中举例函数add()执行期的作用域链
图2 书中举例函数add()执行期的作用域链

作用域链涉及的性能问题

当函数在对变量操作时,每一次执行都会需要进行标识符的解析,而这就是性能优化的点。标识符(变量)的位置越深,它的读写速度就越慢。参考图1.1到图1.2的过程,在函数创建完毕,先创建“活动对象”时,如arguments是局部变量,则是直接声明,执行时不需要到全局对象中查找;而window则需要到最顶层的全局对象中查找。

所以我们的性能优化就在于,尽可能的使用局部变量。使用局部变量来赋值全局变量的值,执行的时候只要找局部变量的标识符即可完成查询的性能优化。

with & try-catch

with语句跟catch语句是书中提及的两个可以改变函数作用域链的语句。with语句可以创建一个新的变量对象,这个对象包含了参数指定的对象的所有属性。这个变量对象会推入作用域链的首位,即在活动对象的前面。这个语句使函数中可以避免多次书写参数,同时可以最优先访问参数的所有属性。问题在于函数的局部变量则降低了优先级,导致访问的代价变高了。

如果函数没有什么局部变量而都是操作某个对象的属性为主,那么使用with语句有可取之处,但是如果函数以操作局部变量为主,则建议不要使用with语句来影响查询性能。

同样的道理,catch语句有同样的问题。当try代码捕获错误是,代码的执行会直接跳转到catch语句,并把异常对象推入一个变量对象置于作用域的首位,同样的,catch代码块中,局部变量会降低优先级。

如果要降低查询性能的影响,最好的方案是将这个异常对象作为参数直接委托给一个函数进行处理,这样,在catch代码块内,不会出现局部变量,也不会出现由于作用域链的变化而导致的性能问题。

闭包

闭包的特点我在写《JavaScript作用域》的时候有提到,简单的说,就是可以在函数的外部调用内部的局部变量,并且闭包在执行完之后不会被马上销毁(换句话说,这个也是闭包的弊端:内存泄漏,内存开销)。

实际上,因为闭包会一直保持在内存中,所以其在执行时,闭包创建的活动对象会一直在作用域链的最顶层,而闭包所调用的变量一般是所在执行环境或者更顶层环境的全局变量。这样就出现了查询的性能影响。

如果要降低闭包对代码的性能影响,其一是要慎重的使用闭包,其二是在闭包中使用局部变量复制全局变量。

对象

因为JavaScript很多操作都是对对象的操作,所以对JavaScript对象的了解能对优化代码是有很大的帮助的。

属性及方法

JavaScript对象包括了属性跟方法,区分的点在于对象的成员引用的是一个函数还是非函数类型。

原型及原型链

JavaScript中的对象是基于原型的。原型是所有对象的基础,它已经定义并实现了一个对象所必需包含的成员列表,而且原型对象是为所有对象实例所共享。

下图1.3及代码是书中举的一个例子

1
2
3
4
5
6
var book = {
title: "High Performance JavaScript",
publisher: "Yahoo! Press"
};

alter(book.toString()); // "[object object]"

图3 书中实例book与原型的关系
图3 书中实例book与原型的关系

图中可以看到,实例book虽然创建的时候只声明了两个实例成员,但是并没有定义toString(),然而toString()方法还是被正确执行了,是因为虽然实例book没有定义,但是book的原型对象已经定义了toString()方法。

当book.toString()被调用时,先会从实例成员开始搜索,如果实例成员中没有搜索到,则从原型成员搜索,如果能搜索到,则能成功执行,否则才会报错。

JavaScript有两个方案可以检测对象的成员,其中in操作符可以检测对象是否包含某个成员,不管这个成员是属于实例成员还是原型成员;而如果需要检测对象是否包含某个实例成员,则需要用hasOwnProperty()方法。

JavaScript有原型对象,开发者也可以根据项目的需求通过构造函数来创建另一种类型的原型。下图1.4及代码是书中举的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Book(title, publisher){
this.title = title;
this.publisher = publisher;
}

Book.prototype.sayTitle = function(){
alert(this.title);
}

var book1 = new Book("High Performance JavaScript", "Yahoo! Press");
var book2 = new Book("JavaScipt: The Good Parts", "Yahoo! Press");

alert(book1 instanceof Book); // true
alert(book1 instanceof Object); // true

book1.sayTitle(); //"High Performance JavaScript"
alert(book1.toString()); // "[object object]"

图4 书中列举的原型链
图4 书中列举的原型链

代码中,先是定义了一个 构造函数Book,然后在通过Book来创建实例book1跟book2。其中,book1跟book2的成员title跟publisher都是各自的成员,拥有不同的值,而两个实例的原型__proto__都是指向Book.prototype,而Book.prototype的__proto__又指向原型对象object。整个就是原型链。两个实例共享着一个原型链。

从对原型链的认识,我们可以看到,当执行实例对象的成员时,明显的,成员的位置越深,搜索并访问的时间越长。

嵌套成员

简单的说,对象的成员本身可能就是一个对象,所以会包含有自己的成员,也就是嵌套成员。这种结构会导致访问嵌套成员是,需要从最底层对象的成员开始搜索,而且成员的嵌套得越深,搜索读取的速度就越慢。

因此,优化的方案,主要是在调用对象成员及嵌套成员时,可以把需要多次读取的对象成员复制到局部变量中进行使用。

最后

本章涉及都是JavaScript的一些数据处理。虽然每种数据存储的方式都有访问速度的快慢区别,但是并不代表开发中就应该避免使用访问速度慢的数据存储方式,二是应该根据项目要求进行慎重的使用。而最常见的优化方案仍然是利用局部变量来复制搜索访问比较慢但又需要经常调用的数据。

文中用到的图片跟代码参考《高性能JavaScript》第二章。

《高性能JavaScript》读书笔记(一)

发表于 2018-06-10 | 分类于 JavaScript , 读书笔记

《高性能JavaScript》这本书很多人推荐,我对这本书的印象也是值得学习前端的童鞋的细读吸收。作为学习者,同样也是要认真的学习这本书中为我们总结的一些开发中的经验,能提高我们开发代码的效率。

第一章主要针对JavaScript的加载跟执行的优化。总结一下就是推迟加载JavaScript代码,减少JavaScript文件加载数量及异步加载JavaScript代码。

优化加载JavaScript代码的原因

一般我们在入门前端初期,都会学到,<head>标签中可以放<script>标签,用来引入js文件或js代码。然后大部分人都会习惯性的把js代码从<head>标签引入。

操作上是正确的,确实可以完成js代码的引入,但是由于js代码具有阻塞的特性,会影响页面的解析与加载时间。一般页面的加载,首先会HTML代码的<head>标签开始。当代码执行到<head>标签的<script>标签时,由于浏览器无法判断<script>标签中的代码是否会影响DOM节点,所以会暂停页面的加载,而优先执行<script>标签中的代码。

如果<script>标签的代码是直接出现在标签中,则直接执行;而如果是通过引入的方式,则还需要下载之后再执行。一旦下载的文件较大,加上加载的时间,那将让网页的客户体验很糟糕。

于是,就有了优化加载JavaScript代码的必要。从上面我们可以看到,一般可以优化的方案,就是把下载JavaScript文件的时间推迟,或者把执行JavaScript代码的时间推迟。下面我们就来总结一下优化的方案

加载JavaScript代码优化的方案

阻塞问题的优化

之所以会阻塞,就是因为JavaScript代码早于html的DOM代码执行了,再进一步说,因为JavaScript代码在<head>标签中,比<body>标签更早被获取,所以才会有阻塞的问题。因此,可以考虑让JavaScript代码被滞后获取。

最简单的想法就是把 <script>标签放到<body>标签底部 ,这样可以减少对页面渲染的阻塞。这个也是目前开发中比较常见的一种优化方案。

1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<title>Example</title>
<link rel="stylesheet" type="text/css" herf="style.css">
</head>
<body>
<p>Hello World</p>

<script type="text/javascript" src="file1.js"></script>
</body>
</html>

同样也要一种类似的方案,就是通过给 <body>标签创建<script>标签,再引入js文件 的方案。这个方案看起来比上面的方案复杂了,但是在需要引入多个js文件的情况下,还是比较好的方案。

1
2
3
4
var script = document.createElement("srcipt");
script.type = "text/javascript";
script.src = "file1.js";
document.getElementsByTagName("head")[0].appendChild(script);

另一种方案则是采用了 XMLHTTPRequest脚本注入 ,先创建一个XHR对象,然后用这个对象下载js文件,然后再通过创建<script>元素把js代码引入到页面中。

这种方案的优点就是可以下载JavaScript代码但不会立即执行,而且所有主流浏览器都能正常工作。但是也会有一个问题,就是因为涉及异步请求文件,所以需要遵循同域。

1
2
3
4
5
6
7
8
9
10
11
12
13
var xhr = new XMLHttpRequest();
xhr.open("get", "file1.js", "true);
xhr.noreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status >= 200 & xhr.status < 300 || xhr.status == 304) {
var script = document.createElement("script");
script.type = "text/javascript";
script.src = xhr.responseText;
document.body.appendChild(script);
}
}
};
xhr.send(null);

在《高性能JavaScript》的后面《推荐的无阻塞模式》一节中也是对这个方案做了封装,使这个方案适合添加大良的JavaScript代码。

加载JavaScript的优化

一般一个项目加载JavaScript文件都会有好几个文件(特别是强调模块化工程化的前端开发),如果遇到一个HTML页面需要加载多个js文件,则每个js文件都会需要单独的下载跟执行,会影响页面的总体性能。因此,书中提及了雅虎提供的合并处理器,可以用一个URL包含多个文件,从而使URL上的文件可以同时加载。
不过,从现在前端开发的做法上来看,虽然开发上强调模块化,但是实际已经把开发跟上线分离,实际上线的项目中,已经通过打包工具减少了文件(一般可以看到打包的js文件就剩一个bound.js了),所以书中的这个方案可以说已经通过工程化得到更好更广泛的解决了。

执行JavaScript的优化

书中也提及如何延迟js代码的执行。HTML 4为<script>标签定义了一个扩展属性:defer,这个属性主要是表明当前元素的脚本不影响DOM,所以浏览器会在页面加载完成后在执行<script>标签中的脚本。

最后

书中还提及几个库,都是针对无阻塞脚本加载开发的,这里就没在深入了解了。

总结一下,优化JavaScript代码的加载跟执行对页面的客户体验会有很大的提升。从当前前端开发来看,更多的方案是把多个js文件打包成少量的js文件,然后把<script>标签放到<body>标签底部。书中也提及了一些其他的方案,这些方案也可以在一些小项目中使用,一样能提升页面性能。

上面使用的代码示例都是书中的代码示例。

Hello World

发表于 2018-04-24

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

JavaScript 作用域

发表于 2018-04-23 | 分类于 JavaScript , 杂谈

本篇是我搭建博客的第一篇,其实之前我在自己的微信号已经有写过一些小文章,后面会慢慢的移到这边来。这里废话不多说吧。JavaScript的作用域是一个很关键也很常见的技术点,很关键是因为所有编程语言都有一套作用域的规则,理解所使用的编程语言的作用域可以在开发时少走点弯路,不会踩到奇奇怪怪的坑,很常见是因为作用域存在整个开发项目的任何地方,避不开。再者说面试官也很喜欢考这个问题呢。

刚好我看了《你不知道的JavaScript(上)》,对作用域有了一些更深入的了解,所以就想整理一下,对作用域的认识做一个详细的介绍。

一般对js作用域的了解

怎么理解作用域

首先,我要说明一下,现在讨论的JavaScript作用域指的是ES5的作用域。在ES5中,只有全局作用域跟函数作用域,没有块作用域,这个会放到ES6的作用域一节中做介绍。

代码1.1:

1
2
3
4
5
var i = 10;
function a() {
alert(i);
}
a();

代码1.2:

1
2
3
4
5
6
var i = 10;
function a() {
alert(i);
var i = 5;
}
a();

代码1.1跟1.2最终输出是不同的。这个就跟作用域有关。

代码1.1中,在全局声明变量i并赋值10,然后声明了一个函数a()。函数a()中调用了全局变量i,所以alert的结果是10。这个没有什么异议。一般不明白的是代码1.2。

变量提升

代码1.2的最终输出为undefined,原因是虽然函数a()中是在alert(i)之后才声明函数中的局部变量i并赋值5,但实际上JavaScript在处理函数a()代码时是先把var i = undefined提前到函数执行其他代码前声明了。也就是说,JavaScript代码在执行时,会在每个作用域内,优先把声明变量,函数的代码提前执行(预处理),然后在执行其他代码(逐行解读)。这就是变量提升。

变量提升中,函数的声明是怎么处理的?其实这个问题还得分两种情况:如果函数的声明是写成var a = function () {},则a是一个变量,在预处理中,只会声明var a = undefined,在后面的逐行解读中,就有可能出现a未定义的情况;如果函数声明是写成function a() {},则a()是一个函数,在预处理中,会把整个函数进行预处理,当逐行解读,解读到a()时,就会正常执行函数。

当然,千万不要理解成代码1.2中a()在执行时把var i = 5提前了。变量提升只是提升了变量声明。对于为什么只提前执行了变量声明,我们留一个悬念。

注意:

1
2
3
4
5
6
7
8
// 如果是在函数(局部作用域)中,出现了如下的情况:
alert(n);
function f1() {
n = 999;
}
f1();
// 虽然n会变成全局变量,但是alert(n)在这个位置会报错,具体原因也会在后面做解释。
// 一般应该防止变量n这种情况出现

作用域链

代码1.1其实本身也涉及另外一个内容,就是作用域链。简单的说,全局作用域在执行到a()时,会先创建a()的作用域,然后将该作用域放在链表的头部,然后把指针指向全局作用域。当执行到alert(i)时,先会在当前作用域内查找已声明的变量,看是否存在变量i,存在则直接使用,不存在则根据作用域链向上一级父级作用域查找,直到顶级作用域window(网页在浏览器运行,实际是在浏览器这个全局作用域下运行,所以window是网页作用域链的最高作用域)。

闭包

闭包是个很有趣的概念,它有一些特点。我们先举个例子:

代码1.3:

1
2
3
4
5
6
7
8
9
var foo = function(){
var i = 0;
return function inner(){
i++;
console.log(i);
}
}
var bar = foo();
bar();

首先,在代码1.3中,先声明了变量foo并引用一个函数,然后函数内部返回函数inner()。inner()调用了foo()中的i。然后在全局作用域声明了bar并引用了foo(),实际最终是得到了函数inner()的引用。这里可以发现一个问题,就是正常来说,在一个作用域链上,父作用域是调用不了子作用域的变量,但是反之可以。但是这里的全局变量bar()执行之后却得到了foo()中的局部变量i的值,实现了在作用域外变量的调用。

其次,如果再执行一次bar(),会得到2的结果。这个结果表明,当全局变量得到了函数inner()的引用之后,函数inner()会一直保持在内存中,而其依赖的foo(),也因此一直保持在内存中,并没有在调用结束后备垃圾回收机制回收。

闭包的这些有趣的特点让我想起了克莱因瓶(脑洞有点大,这两个事物确实有点类似)。那为什么会出现这么有趣的闭包呢?下面,我们就带上上面的这些问题,一起来再深入一步了解一下js的作用域吧。

深入了解js作用域

想要再深入的了解js作用域,就应该了解js是怎么执行的。简单的说,我们把js代码的执行分成三部分:引擎,编译,作用域。

引擎,这里简单来说就是负责整个js代码的编译跟执行工作。这个工作又分为两部分,一部分是编译,编译程序员编写的代码字符串,另一部分是执行,执行编译后得到的机器指令,这里涉及了作用域。

编译原理

首先,一般编程语言的编译经历了三个步骤:分词/词法分析(Tokenizing/Lexing),解析/语法分析(Parsing)及代码生成。js的编译也差不多。

分词/词法分析(Tokenizing/Lexing)

我们平时写的代码,其实对于机器来说是一段字符串。于是,机器就得先把代码做一次拆解,把每一个独立的,有意义的代码块(词法单元)分解出来。比如说,var n;,会被拆解成var,n,;。

解析/语法分析(Parsing)

当完成词法分析之后,就要开始做语法分析了。原本一个个词法单元就需要拼接成一个具有程序语法的结构。那什么结构可以达到这样的效果呢?这个结构我们都应该会想到的吧,就是树,叫抽象语法树(Abstract Syntax Tree, AST)。比如上面的var n;,在拆解成var,n,;之后,就可以开始语法分析。那分析的结果就会解析成一个顶级节点(VariableDeclaration)下,有一个节点(AssignmentExpression)的值为n,这个节点下有一个节点(NumericLiteral)的值为undefined(因为这个代码只是声明了一个变量,所以这里就假定值为undefined)。

代码生成

在生成AST之后,就要把它转换为可执行的代码,这个过程叫做代码生成。比如说,上例中var n;的AST转换后,将变成机器指令,创建变量n。

JavaScript的编译过程在某些步骤上要复杂一些,但是其编译过程也是大同小异。为了后面比较容易理解作用域,我们可以暂时先这么理解。

作用域

重点来了,机器指令生成之后,作用域是要干嘛呢?其实,作用域就是要把这些机器指令中涉及的标识符,按一套严格的规则,进行分类维护形成一系列查询,确认机器指令对他们的访问权限。

怎么理解作用域呢?我这里做一个假设,方便理解。

作用域是要能把变量跟函数划分作用范围的,比如全局作用域中的变量可以在全局调用,计算跟赋值,函数可以被掉用,而局部作用域中的变量只能在当前作用域及下级作用域中调用计算等等,函数亦是如此,比如:

代码2.1:

1
2
3
4
5
6
7
8
var i = 10;
var x = 5;
function test () {
var x = i;
console.log(x);
}
console.log(x);
test();

上例中,在完成代码的编译后,会先扫描出全局作用域所有的变量声明跟函数声明(函数表达式其实只会提升变量声明部分)形成这样一个作用域:

代码2.1全局作用域(parentScope):

变量/方法 地址 值
i 地址1 undefined
x 地址2 undefined
test() 地址3 {…}

当执行到test();时,又会扫描test函数的函数作用域:

test函数作用域:

变量/方法/上级作用域 地址 值
x 地址4 undefined
parentScope 地址0 —

这里我把作用域模拟成以链表的形式,方便理解。

然后,开始执行机器指令。

变量的查找

如当执行到x = 5的指令时,引擎会去找当前作用域,即全局作用域进行变量查询,查询是否存在变量x(准确的说法是引擎为变量x进行LHS查询)。由于我把作用域理解成链表了,所以查询的模拟就是对链表上当前结点的查询。当查询到变量x已存在,就会返回其内存地址,然后进行赋值。

如执行到x = i的指令时,引擎同样会去当前作用域,即test函数作用域进行LHS查询,返回内存地址。这里就出现了函数作用域中的变量x对全局作用域中的变量x的遮蔽。然后引擎再对i进行查询(引擎为变量i进行RHS查询)。先是从当前作用域开始查询,结果没有找到,因此拿到全局作用域parentScope的地址,找到全局作用域,继续查询找到之后,得到储存的值,然后再做赋值。

其他概念的理解

上面的代码从编译到机器指令执行的模拟,还可以对一些概念的做一些理解:变量提升,其实是代码执行运算前的解析,并形成作用域;变量的遮蔽,其实是作用域权限的效果;作用域链,其实是变量查询的规则导致的。当然,还有一些更深的内容,比如上面简单提及的LHS查询,得到的是变量容器(我的理解就是得到变量的内存地址),RHS查询,得到的是储存的值(并不需要得到变量的内存地址,因为只是做赋值,当然不同的数据类型返回的值也可能是内存地址,比如数组)。

这里也可以对闭包有了更深入的理解:闭包的操作上,是把一个局部作用域赋值给一个全局变量,使这个局部作用域中的变量可以以一种特殊的方式在全局作用域中获得。但因为这个局部作用域被保留在一个全局变量中,所以它也将无法再调用结束后被回收,并一直保持原有的变量跟值。

ES6的作用域

这里之所以要提到ES6的作用域,是因为ES6已经是前端工程师必需学习的内容了,再者ES6的作用域确实跟我们所知道的JavaScript(ES5)的作用域有所不同。这里就做一些介绍。

let

估计很多人都知道ES6 let的存在了,这里就只是对一些点做介绍,给后面ES6的作用域做铺垫。

首先,let所声明的变量只在所在的代码块内有效。这个就跟var有比较大的区别了,比如下面的代码:

代码3.1:

1
2
3
4
5
6
{
var a = 5;
let b = 10;
}
console.log(a); // 5
console.log(b); // ReferenceError: b is not defined

上面的代码就可以看出,在{}内用let声明的b在全局作用域上无法调用,直接报错了。

代码3.2:

1
2
3
4
5
6
{
console.log(a); // undefined
console.log(b); // ReferenceError: b is not defined
var a = 5;
let b = 10;
}

把代码3.1修改成代码3.2,运行的结果也很明显了:let也不支持变量提升。let声明的变量必须在声明后使用(包括赋值,运算等),否则会报错。

代码3.3:

1
2
3
4
5
6
7
8
9
var b = 15;
var c = 20;
{
console.log(a); // undefined
console.log(c); // 20
console.log(b); // ReferenceError: b is not defined
var a = 5;
let b = 10;
}

再改成改成代码3.3,运行的结果就可以得到另一个结论:存在let声明的区域不受外部的影响,被锁区了,叫暂时性死区。

当然,还有一个特性,就是let声明的变量不可以重复声明,这个大家就自己去尝试了。

const

const很多特性跟let一样,比如上面提及let的四个特性也是const的特性。但const跟let有一个比较大的区别,就是const声明的是常量,这个常量只能被读取,不能二次修改写入。而且必须要在声明的同时进行赋值。

块级作用域

上面的let跟const其实都是铺垫,最终还是要回归到作用域这个话题上来。ES6跟ES5在作用域上最大的不同就是支持块级作用域,而let跟const就是块级作用域的坚强后盾。

从我的理解看来,暂时性死区其实就是让全局作用域跟函数作用域中的某一块代码变成相对独立的块级作用域,这个块级作用域的形成需要有let或const的声明作为标志。

块级作用域的特点从let跟const的四个特性可见一斑。外部作用域无法获得块级作用域内let变量跟const常量,块级作用域内部let声明的变量也不会覆盖外部作用域声明的变量。这样就可以防止像for循环中i变量的全局污染。

还有一个特点可以自己去尝试一下的,函数本身的作用域在其所在的块级作用域之内。这是一个需要注意的知识点。

结语

关于JavaScript的作用域写了很多,上述一些内容,有来自《你不知道的JavaScript(上)》前面几章,也有来自阮一峰老师的《ES6标准入门(第二版)》相关的章节(目前已经出了第三版了),掺杂着一些个人的理解跟学习心得。我相信对作用域有这样深一层的理解,会对相关的问题有更准确的判断。

1…45

Listentolife

Listentolife's Blog

49 日志
19 分类
61 标签
GitHub E-Mail
© 2020 Listentolife
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4
   |