轻松理解Javascript变量的相关问题


Posted in Javascript onJanuary 20, 2017

前言

再说本文的内容之前,我们先回溯到1995年,当Brendan Eich在设计第一版JavaScript时,他搞错了许多东西,当然这也包括曾属于语言本身的一部分,例如Date对象,对象相乘被自动转换为NaN等。然而现在回过头看,语言最重要的部分都是设计合理的:对象、原型、具有词法作用域的一等函数、默认情况下的可变性等。语言的骨架非常优秀,甚至超越了人们对它的初步印象。

话说回来,正是Brendan当初的设计错误才诞生了今天这篇文章。我们这次关注的目标非常小,在你使用这门语言多年后可能根本不会注意到这个问题,但是它又如此重要,因为我们可能会误认为这个错误就是语言设计中的“the good parts”(译者注:请参考《JavaScript语言精粹》一书中附录A:毒瘤中有关作用域的描述)。

今天我们一定要把这些与变量有关的问题拿下。

问题 #1:JS没有块级作用域

请看这样一条规则: 在JS函数中的var声明,其 作用域 是函数体的全部 。乍一听没什么问题,但是如果碰到以下两种情况就不会得到令人满意的结果。

其一,在代码块内声明的变量,其作用域是整个函数作用域而不是块级作用域。

你之前可能没有关注到这一点,但我担心这个问题确实是你不能够轻易忽视的。我们一起重现一下由这个问题引发的bug。

假如你现在的代码使用了一个变量t:

function runTowerExperiment(tower, startTime) {
 var t = startTime;
 tower.on("tick", function () {
 ... 使用了变量t的代码 ...
 });
 ... 更多代码 ...
 }

到目前为止,一切都很顺利。现在你想添加测量保龄球速度的功能,所以你在回调函数内部添加了一个简单的if语句。

function runTowerExperiment(tower, startTime) {
 var t = startTime;
 tower.on("tick", function () {
 ... 使用了变量t的代码 ...
 if (bowlingBall.altitude() <= 0) {
  var t = readTachymeter();
  ...
 }
 });
 ... 更多代码 ...
 }

哦,亲爱的,之前那段“使用了变量t的代码”运行良好,现在你无意中添加了第二个变量t,这里的t指向的是一个新的内部变量t而不是原来的外部变量。

JavaScript中var声明的作用域像是Photoshop中的油漆桶工具,从声明处开始向前后两个方向扩散,直到触及函数边界才停止扩散。你想啊,这种变量t的作用域甚广,所以一进入函数就要马上将它创建出来。这就是所谓的提升(hoisting)。变量提升就好比是,JS引擎用一个很小的代码起重机将所有var声明和function函数声明都举起到函数内的最高处。

现在看来,提升特性自有它的优点。如果没有提升的动作,许多在全局作用域范围内看似合理的完美技术在立即调用函数表达式( IIFE )中通通失效。但在上面演示的这种情况下,提升会引发令人不愉快的bug:所有使用变量t进行的计算最终的结果都是NaN。这种问题极难定位,尤其是当你的代码量远超上面这个玩具一般的示例,你会发狂到崩溃。

在原有代码块之前添加新的代码块会导致诡异的错误,这时候我就会想,到底是谁的问题,我的还是系统的?我们可不希望自己搞砸了系统。

而这个问题与接下来这个问题相比就相形见绌了。

问题 #2:循环内变量过度共享

你可以猜一下当执行以下这段代码时会发生什么,非常简单:

var messages = ["嗨!", "我是一个web页面!", "alert()方法非常有趣!"];
 for (var i = 0; i < messages.length; i++) {
 alert(messages[i]);
 }

如果你一直跟随这个专栏的文章,你知道我喜欢在示例代码中使用alert()方法。可能你也知道alert()不是一个好的API,它是一个同步方法,所以当弹出一个警告对话框时,输入事件不会触发,你的JS代码,包括你的整个UI,直到用户点击OK确认之前完全处于暂停状态。

请不要轻易使用alert()来实现Web页面中的功能,我之所以在代码中使用是因为alert()特性使它变成一个非常有教学意义的工具。

而且,如果放弃所有笨重的方法和糟糕的行为就可以做出一只会说话的猫,何乐而不为呢?

var messages = ["喵!", "我是一只会说话的猫!", "回调(callback)非常有趣!"];
 for (var i = 0; i < messages.length; i++) {
 setTimeout(function () {
 cat.say(messages[i]);
 }, i * 1500);
 }

然而一定是哪里不对,这只会说话的猫并没有按照预期连说三条消息,它说了三次“undefined”。

你知道问题出在哪里么?

轻松理解Javascript变量的相关问题

你能看到树上的毛毛虫(bug)吗?(图片来源: nevil saveri )

事实上,这个问题的答案是,循环本身及三次timeout回调均共享唯一的变量i。当循环结束执行时,i的值为3(因为messages.length的值为3),此时回调尚未被触发。

所以当第一个timeout执行时,调用cat.say(messages[i]) ,此时i的值为3,所以猫咪最终打印出来的是messages[3]的值亦即undefined。

解决这个问题有很多种方法( 这里有一种 ),但是你想,var作用域规则接连给你添麻烦,如果能在第一时间彻底解决掉这个问题多好啊!

let是更完美的var

JavaScript的设计错误(其它语言也有,奈何JavaScript太突出)多半不能被修复。保持向后兼容性意味着永不改变JS代码在Web平台上的行为,即使连标准委员会都无权要求修复JavaScript中自动插入分号这种怪异的特性;浏览器厂商也从来不会做出突破性的改变,因为如此一来伤害的是他们的忠实用户。

所以大约十年以前,Brendan Eich决定修复这个问题,但只有唯一的解决方案。

他添加了一个新的关键词:let。let与var一样,也可以用来声明变量,但它有着更好的作用域规则。

它看起来是这样的:

let t = readTachymeter();

或者这样的:

for (let i = 0; i < messages.length; i++) {
 ...
 }

let与var还是有不同之处的,所以如果你只是在代码中将var全局搜索替换为let,一些依赖var声明的独特特性(可能你不是故意这样写)的代码可能无法正常运行。但对于绝大多数代码来说,在ES6的新代码模式下,你应该停止使用var声明变量,能使用let就用吧!从现在起,请记住这句口号:“let是更完美的var”。

那到底let和var有什么不同呢?非常高兴你提出这个问题!

这一规则可以帮助你捕捉bug,除了NaN错误以外,每一个异常都会在当前行抛出。

let声明的变量拥有块级作用域。也就是说用let声明的变量的作用域只是外层块,而不是整个外层函数。

let声明仍然保留了提升的特性,但不会盲目提升。在runTowerExperiment这个示例中,通过将var替换为let可以快速修复问题,如果你处处使用let进行声明,就不会遇到类似的bug。

let声明的全局变量不是全局对象的属性。这就意味着,你不可 以通过window.变量名的方式访问这些变量。它们只存在于一个不可见的块的作用域中,这个块理论上是Web页面中运行的所有JS代码的外层块。

形如for (let x...)的循环在每次迭代时都为x创建新的绑定。

这是一个非常微妙的区别,拿我们的会说话的猫的例子来说,如果一个for (let...)循环执行多次并且循环保持了一个闭包,那么每个闭包将捕捉一个循环变量的不同值作为副本,而不是所有闭包都捕捉循环变量的同一个值。

所以在会说话的猫示例中,也可以通过将var替换为let修复bug。

这种情况适用于现有的三种循环方式:for-of、for-in、以及传统的用分号分隔的类C循环。

let声明的变量直到控制流到达该变量被定义的代码行时才会被装载,所以在到达之前使用该变量会触发错误。举个例子:

function update() {
 console.log("当前时间:", t); // 引用错误(ReferenceError)
 ...
 let t = readTachymeter()
 }

不可访问的这段时间变量一直处于作用域中,但是尚未装载,它们位于临时死区(Temporal Dead Zone,简称TDZ)中。我一直想用科幻小说来类比这个脑洞大开的行话,但是还没想好怎么搞。

(脆弱的性能细节:在大多数情况下,查看代码就可以区分声明是否已经执行,所以事实上,JavaScript引擎不需要在每次代码运行时都额外执行 一次变量可访问检查来确保变量已经被初始化。然而在闭包内部有时不是透明的,这时JavaScript引擎将会做一个运行时检查,也就意味着let相对var而言比较慢。)

(脆弱的平行宇宙作用域细节:在一些编程语言中,一个变量的作用域始于声明之处,而非前后覆盖整个封闭代码块。标准委员会曾考虑过将这种作用域准则赋予let关键词,但是一旦使用这种准则,原本提前使用变量的语句会导致引用错误(ReferenceError),现在该语句不位于let t的声明作用域中,根本不会引用此处的变量t,而是引用外层作用域的相应变量。但是这个方法无法与闭包和函数提升很好得结合,所以该提案最终被否决了。)

用let重定义变量会抛出一个语法错误(SyntaxError)。

这一条规则也可以帮助你检测琐碎的小问题。诚然,这亦是var与let的不同之处,当你全局搜索var替换为let时也会导致let重定义语法错误,因为这一规则对全局let变量也有效。

如果你的多个脚本中都声明了相同的全局变量,你最好继续用var声明这些变量。如果你换用了let,后加载的脚本都会执行失败并抛出错误。

或者你可以考虑使用ES6内建的模块机制,后面的文章中会详细讲解。

(脆弱的语法细节:let是一个严格模式下的保留词。在非严格模式下,出于向后兼容的目的,你仍可以用let命名来声明变量、函数和参数,虽然你不会犯傻,但是你确实可以编写var let = 'q';这样的代码!不过let let;无论如何都是非法的。)

在那些不同之外,let和var几乎很相似了。举个例子,它们都支持使用逗号分隔声明多重变量,它们也都支持 解构 特性。

注意,class类声明的行为与var不同而与let一致。如果你加载一段包含同名类的脚本,后定义的类会抛出重定义错误。

const

是的,还有一个新的关键词!

ES6引入的第三个声明类关键词与let类似:const。

const声明的变量与let声明的变量类似,它们的不同之处在于,const声明的变量只可以在声明时赋值,不可随意修改,否则会导致SyntaxError(语法错误)。

const MAX_CAT_SIZE_KG = 3000; // 正确

 MAX_CAT_SIZE_KG = 5000; // 语法错误(SyntaxError)
 MAX_CAT_SIZE_KG++; // 虽然换了一种方式,但仍然会导致语法错误

当然,规范设计的足够明智,用const声明变量后必须要赋值,否则也抛出语法错误。

const theFairest; // 依然是语法错误,你这个倒霉蛋

神秘的代理命名空间

“命名空间是一种绝妙的理念,我们应当多加利用!”——Tim Peters,“这是Python之禅”

嵌套作用域是编程语言背后的核心理念之一,这个理念始于大约57年前的 ALGOL,现在回过头看当时的决定无比正确。

在ES3之前,JavaScript中只有全局作用域和函数作用域。(让我们忽略with语句吧。)ES3中引入了try-catch语句,意味着语言中诞生一种新的作用域,只用于catch块中的异常变量。ES5添加了用于严格的eval()方法的作用域。ES6添加了块作用域,for循环作用域,新的全局let作用域,模块作用域,以及求参数的默认值时使用的附加作用域。

所有自ES3开始添加的其它作用域非常重要,它们的加入使得JavaScript面向过程与面向对象的特性运行得犹如闭包一样平稳、精准,当然闭包也可以无缝衔接这些作用域实现各种功能。或许你在阅读这篇文章之前从未注意到这些作用域规则的存在,如果真的这样,那这门语言就恰如其分地完成了它的本职工作。

我现在可以使用let和const了么?

可以。如果要在Web上使用let和const特性,你需要使用一个诸如 Babel 、 Traceur或 TypeScript 的ES6转译器。(Babel和Traceur暂不支持临时死区特性。)

io.js支持let和const,但是只在严格模式下编码可以使用。Node.js同样支持,但是需要启用--harmony选项。

九年前 ,Brendan Eich在Firefox中实现了初版的let关键词。这个特性在随后的标准化进程中彻底地被重新设计了。Shu-yu Guo正在按照新标准对原有实现进行升级,该项目由Jeff Walden和其他人做代码审查。

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流。

Javascript 相关文章推荐
Extjs学习笔记之九 数据模型(上)
Jan 11 Javascript
原生js和jquery中有关透明度设置的相关问题
Jan 08 Javascript
使用POST方式弹出窗口的两种方法示例介绍
Jan 29 Javascript
Javascript 按位左移运算符使用介绍(
Feb 04 Javascript
jQuery实现图片渐入渐出切换展示效果
Aug 15 Javascript
JS+CSS实现六级网站导航主菜单效果
Sep 28 Javascript
JavaScript中ES6 Babel正确安装过程
Jul 18 Javascript
React-router中结合webpack实现按需加载实例
May 25 Javascript
用Vue写一个分页器的示例代码
Apr 22 Javascript
vue.js层叠轮播效果的实例代码
Nov 08 Javascript
微信小程序实现树莓派(raspberry pi)小车控制
Feb 12 Javascript
vue-路由精讲 二级路由和三级路由的作用
Aug 06 Javascript
js+css3实现旋转效果
Jan 20 #Javascript
Bootstrap 手风琴菜单的实现代码
Jan 20 #Javascript
浅谈angularjs $http提交数据探索
Jan 20 #Javascript
原生js实现无限循环轮播图效果
Jan 20 #Javascript
原生js实现弹出层效果
Jan 20 #Javascript
jQuery中DOM节点删除之empty与remove
Jan 20 #Javascript
原生js实现图片放大缩小计时器效果
Jan 20 #Javascript
You might like
PHP strip_tags()去除HTML、XML以及PHP的标签介绍
2014/02/18 PHP
PHP安全的URL字符串base64编码和解码
2014/06/19 PHP
PHP实现在线阅读PDF文件的方法
2015/06/17 PHP
CentOS 7.2 下编译安装PHP7.0.10+MySQL5.7.14+Nginx1.10.1的方法详解(mini版本)
2016/09/01 PHP
iframe自适应宽度、高度 ie6 7 8,firefox 3.86下测试通过
2010/07/29 Javascript
JQuery select(下拉框)操作方法汇总
2015/04/15 Javascript
JavaScript里实用的原生API汇总
2015/05/14 Javascript
JavaScript简单修改窗口大小的方法
2015/08/03 Javascript
理解javascript对象继承
2016/04/17 Javascript
详解基于angular-cli配置代理解决跨域请求问题
2017/07/05 Javascript
bootstrap基本配置_动力节点Java学院整理
2017/07/14 Javascript
javaScript和jQuery自动加载简单代码实现方法
2017/11/24 jQuery
Angularjs过滤器实现动态搜索与排序功能示例
2017/12/13 Javascript
详解.vue文件中style标签的几个标识符
2018/07/17 Javascript
jQuery动态生成的元素绑定事件操作实例分析
2019/05/04 jQuery
微信小程序云开发实现云数据库读写权限
2019/05/17 Javascript
vue发送websocket请求和http post请求的实例代码
2019/07/11 Javascript
vue的注意规范之v-if 与 v-for 一起使用教程
2019/08/04 Javascript
vue-dplayer 视频播放器实例代码
2019/11/08 Javascript
angular组件间通讯的实现方法示例
2020/05/07 Javascript
js实现简易拖拽的示例
2020/10/26 Javascript
Python3基础之基本运算符概述
2014/08/13 Python
python执行使用shell命令方法分享
2017/11/08 Python
Django如何配置mysql数据库
2018/05/04 Python
pandas 把数据写入txt文件每行固定写入一定数量的值方法
2018/12/28 Python
python利用selenium进行浏览器爬虫
2019/04/25 Python
int在python中的含义以及用法
2019/06/27 Python
python 字典套字典或列表的示例
2019/12/16 Python
基于Python快速处理PDF表格数据
2020/06/03 Python
StubHub意大利:购买和出售全球演唱会和体育赛事门票
2017/11/21 全球购物
日本索尼音乐商店:Sony Music Shop
2018/07/17 全球购物
简单英文演讲稿
2014/01/01 职场文书
二年级学生评语大全
2014/04/23 职场文书
市级三好学生评语
2014/12/29 职场文书
再读《皇帝的新衣》的读后感悟!
2019/08/07 职场文书
python 调用js的四种方式
2021/04/11 Python