阮一峰JavaScript教程(上1)
0.入门篇
导论
什么是 JavaScript 语言?
JavaScript 是一种轻量级的脚本语言。所谓“脚本语言”(script language),指的是它不具备开发操作系统的能力,而是只用来编写控制其他大型应用程序(比如浏览器)的“脚本”。
JavaScript 也是一种嵌入式(embedded)语言。它本身提供的核心语法不算很多,只能用来做一些数学和逻辑运算。JavaScript 本身不提供任何与 I/O(输入/输出)相关的 API,都要靠宿主环境(host)提供,所以 JavaScript 只合适嵌入更大型的应用程序环境,去调用宿主环境提供的底层 API。
目前,已经嵌入 JavaScript 的宿主环境有多种,最常见的环境就是浏览器,另外还有服务器环境,也就是 Node 项目。
从语法角度看,JavaScript 语言是一种“对象模型”语言。各种宿主环境通过这个模型,描述自己的功能和操作接口,从而通过 JavaScript 控制这些功能。但是,JavaScript 并不是纯粹的“面向对象语言”,还支持其他编程范式(比如函数式编程)。这导致几乎任何一个问题,JavaScript 都有多种解决方法。阅读本书的过程中,你会诧异于 JavaScript 语法的灵活性。
JavaScript 的核心语法部分相当精简,只包括两个部分:基本的语法构造(比如操作符、控制结构、语句)和标准库(就是一系列具有各种功能的对象比如Array
、Date
、Math
等)。除此之外,各种宿主环境提供额外的 API(即只能在该环境使用的接口),以便 JavaScript 调用。以浏览器为例,它提供的额外 API 可以分成三大类。
- 浏览器控制类:操作浏览器
- DOM 类:操作网页的各种元素
- Web 类:实现互联网的各种功能
如果宿主环境是服务器,则会提供各种操作系统的 API,比如文件操作 API、网络通信 API等等。这些你都可以在 Node 环境中找到。
本书主要介绍 JavaScript 核心语法和浏览器网页开发的基本知识,不涉及 Node。全书可以分成以下四大部分。
- 基本语法
- 标准库
- 浏览器 API
- DOM
JavaScript 语言有多个版本。本书的内容主要基于 ECMAScript 5.1 版本,这是学习 JavaScript 语法的基础。ES6 和更新的语法请参考我写的《ECMAScript 6入门》。
为什么学习 JavaScript?
JavaScript 语言有一些显著特点,使得它非常值得学习。它既适合作为学习编程的入门语言,也适合当作日常开发的工作语言。它是目前最有希望、前途最光明的计算机语言之一。
操控浏览器的能力
JavaScript 的发明目的,就是作为浏览器的内置脚本语言,为网页开发者提供操控浏览器的能力。它是目前唯一一种通用的浏览器脚本语言,所有浏览器都支持。它可以让网页呈现各种特殊效果,为用户提供良好的互动体验。
目前,全世界几乎所有网页都使用 JavaScript。如果不用,网站的易用性和使用效率将大打折扣,无法成为操作便利、对用户友好的网站。
对于一个互联网开发者来说,如果你想提供漂亮的网页、令用户满意的上网体验、各种基于浏览器的便捷功能、前后端之间紧密高效的联系,JavaScript 是必不可少的工具。
广泛的使用领域
近年来,JavaScript 的使用范围,慢慢超越了浏览器,正在向通用的系统语言发展。
(1)浏览器的平台化
随着 HTML5 的出现,浏览器本身的功能越来越强,不再仅仅能浏览网页,而是越来越像一个平台,JavaScript 因此得以调用许多系统功能,比如操作本地文件、操作图片、调用摄像头和麦克风等等。这使得 JavaScript 可以完成许多以前无法想象的事情。
(2)Node
Node 项目使得 JavaScript 可以用于开发服务器端的大型项目,网站的前后端都用 JavaScript 开发已经成为了现实。有些嵌入式平台(Raspberry Pi)能够安装 Node,于是 JavaScript 就能为这些平台开发应用程序。
(3)数据库操作
JavaScript 甚至也可以用来操作数据库。NoSQL 数据库这个概念,本身就是在 JSON(JavaScript Object Notation)格式的基础上诞生的,大部分 NoSQL 数据库允许 JavaScript 直接操作。基于 SQL 语言的开源数据库 PostgreSQL 支持 JavaScript 作为操作语言,可以部分取代 SQL 查询语言。
(4)移动平台开发
JavaScript 也正在成为手机应用的开发语言。一般来说,安卓平台使用 Java 语言开发,iOS 平台使用 Objective-C 或 Swift 语言开发。许多人正在努力,让 JavaScript 成为各个平台的通用开发语言。
PhoneGap 项目就是将 JavaScript 和 HTML5 打包在一个容器之中,使得它能同时在 iOS 和安卓上运行。Facebook 公司的 React Native 项目则是将 JavaScript 写的组件,编译成原生组件,从而使它们具备优秀的性能。
Mozilla 基金会的手机操作系统 Firefox OS,更是直接将 JavaScript 作为操作系统的平台语言,但是很可惜这个项目没有成功。
(5)内嵌脚本语言
越来越多的应用程序,将 JavaScript 作为内嵌的脚本语言,比如 Adobe 公司的著名 PDF 阅读器 Acrobat、Linux 桌面环境 GNOME 3。
(6)跨平台的桌面应用程序
Chromium OS、Windows 8 等操作系统直接支持 JavaScript 编写应用程序。Mozilla 的 Open Web Apps 项目、Google 的 Chrome App 项目、GitHub 的 Electron 项目、以及 TideSDK 项目,都可以用来编写运行于 Windows、Mac OS 和 Android 等多个桌面平台的程序,不依赖浏览器。
(7)小结
可以预期,JavaScript 最终将能让你只用一种语言,就开发出适应不同平台(包括桌面端、服务器端、手机端)的程序。早在2013年9月的统计之中,JavaScript 就是当年 GitHub 上使用量排名第一的语言。
著名程序员 Jeff Atwood 甚至提出了一条 “Atwood 定律”:
“所有可以用 JavaScript 编写的程序,最终都会出现 JavaScript 的版本。”(Any application that can be written in JavaScript will eventually be written in JavaScript.)
易学性
相比学习其他语言,学习 JavaScript 有一些有利条件。
(1)学习环境无处不在
只要有浏览器,就能运行 JavaScript 程序;只要有文本编辑器,就能编写 JavaScript 程序。这意味着,几乎所有电脑都原生提供 JavaScript 学习环境,不用另行安装复杂的 IDE(集成开发环境)和编译器。
(2)简单性
相比其他脚本语言(比如 Python 或 Ruby),JavaScript 的语法相对简单一些,本身的语法特性并不是特别多。而且,那些语法中的复杂部分,也不是必需要学会。你完全可以只用简单命令,完成大部分的操作。
(3)与主流语言的相似性
JavaScript 的语法很类似 C/C++ 和 Java,如果学过这些语言(事实上大多数学校都教),JavaScript 的入门会非常容易。
必须说明的是,虽然核心语法不难,但是 JavaScript 的复杂性体现在另外两个方面。
首先,它涉及大量的外部 API。JavaScript 要发挥作用,必须与其他组件配合,这些外部组件五花八门,数量极其庞大,几乎涉及网络应用的各个方面,掌握它们绝非易事。
其次,JavaScript 语言有一些设计缺陷。某些地方相当不合理,另一些地方则会出现怪异的运行结果。学习 JavaScript,很大一部分时间是用来搞清楚哪些地方有陷阱。Douglas Crockford 写过一本有名的书,名字就叫《JavaScript: The Good Parts》,言下之意就是这门语言不好的地方很多,必须写一本书才能讲清楚。另外一些程序员则感到,为了更合理地编写 JavaScript 程序,就不能用 JavaScript 来写,而必须发明新的语言,比如 CoffeeScript、TypeScript、Dart 这些新语言的发明目的,多多少少都有这个因素。
尽管如此,目前看来,JavaScript 的地位还是无法动摇。加之,语言标准的快速进化,使得 JavaScript 功能日益增强,而语法缺陷和怪异之处得到了弥补。所以,JavaScript 还是值得学习,况且它的入门真的不难。
强大的性能
JavaScript 的性能优势体现在以下方面。
(1)灵活的语法,表达力强。
JavaScript 既支持类似 C 语言清晰的过程式编程,也支持灵活的函数式编程,可以用来写并发处理(concurrent)。这些语法特性已经被证明非常强大,可以用于许多场合,尤其适用异步编程。
JavaScript 的所有值都是对象,这为程序员提供了灵活性和便利性。因为你可以很方便地、按照需要随时创造数据结构,不用进行麻烦的预定义。
JavaScript 的标准还在快速进化中,并不断合理化,添加更适用的语法特性。
(2)支持编译运行。
JavaScript 语言本身,虽然是一种解释型语言,但是在现代浏览器中,JavaScript 都是编译后运行。程序会被高度优化,运行效率接近二进制程序。而且,JavaScript 引擎正在快速发展,性能将越来越好。
此外,还有一种 WebAssembly 格式,它是 JavaScript 引擎的中间码格式,全部都是二进制代码。由于跳过了编译步骤,可以达到接近原生二进制代码的运行速度。各种语言(主要是 C 和 C++)通过编译成 WebAssembly,就可以在浏览器里面运行。
(3)事件驱动和非阻塞式设计。
JavaScript 程序可以采用事件驱动(event-driven)和非阻塞式(non-blocking)设计,在服务器端适合高并发环境,普通的硬件就可以承受很大的访问量。
开放性
JavaScript 是一种开放的语言。它的标准 ECMA-262 是 ISO 国际标准,写得非常详尽明确;该标准的主要实现(比如 V8 和 SpiderMonkey 引擎)都是开放的,而且质量很高。这保证了这门语言不属于任何公司或个人,不存在版权和专利的问题。
语言标准由 TC39 委员会负责制定,该委员会的运作是透明的,所有讨论都是开放的,会议记录都会对外公布。
不同公司的 JavaScript 运行环境,兼容性很好,程序不做调整或只做很小的调整,就能在所有浏览器上运行。
社区支持和就业机会
全世界程序员都在使用 JavaScript,它有着极大的社区、广泛的文献和图书、丰富的代码资源。绝大部分你需要用到的功能,都有多个开源函数库可供选用。
作为项目负责人,你不难招聘到数量众多的 JavaScript 程序员;作为开发者,你也不难找到一份 JavaScript 的工作。
实验环境
本教程包含大量的示例代码,只要电脑安装了浏览器,就可以用来实验了。读者可以一边读一边运行示例,加深理解。
推荐安装 Chrome 浏览器,它的“开发者工具”(Developer Tools)里面的“控制台”(console),就是运行 JavaScript 代码的理想环境。
进入 Chrome 浏览器的“控制台”,有两种方法。
- 直接进入:按下
Option + Command + J
(Mac)或者Ctrl + Shift + J
(Windows / Linux) - 开发者工具进入:开发者工具的快捷键是 F12,或者
Option + Command + I
(Mac)以及Ctrl + Shift + I
(Windows / Linux),然后选择 Console 面板
进入控制台以后,就可以在提示符后输入代码,然后按Enter
键,代码就会执行。如果按Shift + Enter
键,就是代码换行,不会触发执行。建议阅读本教程时,将代码复制到控制台进行实验。
作为尝试,你可以将下面的程序复制到“控制台”,按下回车后,就可以看到运行结果。
function greetMe(yourName) {
console.log('Hello ' + yourName);
}
greetMe('World')
// Hello World
JavaScript 语言的历史
诞生
JavaScript 因为互联网而生,紧跟着浏览器的出现而问世。回顾它的历史,就要从浏览器的历史讲起。
1990年底,欧洲核能研究组织(CERN)科学家 Tim Berners-Lee,在全世界最大的电脑网络——互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。最早的网页只能在操作系统的终端里浏览,也就是说只能使用命令行操作,网页都是在字符窗口中显示,这当然非常不方便。
1992年底,美国国家超级电脑应用中心(NCSA)开始开发一个独立的浏览器,叫做 Mosaic。这是人类历史上第一个浏览器,从此网页可以在图形界面的窗口浏览。
1994年10月,NCSA 的一个主要程序员 Marc Andreessen 联合风险投资家 Jim Clark,成立了 Mosaic 通信公司(Mosaic Communications),不久后改名为 Netscape。这家公司的方向,就是在 Mosaic 的基础上,开发面向普通用户的新一代的浏览器 Netscape Navigator。
1994年12月,Navigator 发布了1.0版,市场份额一举超过90%。
Netscape 公司很快发现,Navigator 浏览器需要一种可以嵌入网页的脚本语言,用来控制浏览器行为。当时,网速很慢而且上网费很贵,有些操作不宜在服务器端完成。比如,如果用户忘记填写“用户名”,就点了“发送”按钮,到服务器再发现这一点就有点太晚了,最好能在用户发出数据之前,就告诉用户“请填写用户名”。这就需要在网页中嵌入小程序,让浏览器检查每一栏是否都填写了。
管理层对这种浏览器脚本语言的设想是:功能不需要太强,语法较为简单,容易学习和部署。那一年,正逢 Sun 公司的 Java 语言问世,市场推广活动非常成功。Netscape 公司决定与 Sun 公司合作,浏览器支持嵌入 Java 小程序(后来称为 Java applet)。但是,浏览器脚本语言是否就选用 Java,则存在争论。后来,还是决定不使用 Java,因为网页小程序不需要 Java 这么“重”的语法。但是,同时也决定脚本语言的语法要接近 Java,并且可以支持 Java 程序。这些设想直接排除了使用现存语言,比如 Perl、Python 和 TCL。
1995年,Netscape 公司雇佣了程序员 Brendan Eich 开发这种网页脚本语言。Brendan Eich 有很强的函数式编程背景,希望以 Scheme 语言(函数式语言鼻祖 LISP 语言的一种方言)为蓝本,实现这种新语言。
1995年5月,Brendan Eich 只用了10天,就设计完成了这种语言的第一版。它是一个大杂烩,语法有多个来源。
- 基本语法:借鉴 C 语言和 Java 语言。
- 数据结构:借鉴 Java 语言,包括将值分成原始值和对象两大类。
- 函数的用法:借鉴 Scheme 语言和 Awk 语言,将函数当作第一等公民,并引入闭包。
- 原型继承模型:借鉴 Self 语言(Smalltalk 的一种变种)。
- 正则表达式:借鉴 Perl 语言。
- 字符串和数组处理:借鉴 Python 语言。
为了保持简单,这种脚本语言缺少一些关键的功能,比如块级作用域、模块、子类型(subtyping)等等,但是可以利用现有功能找出解决办法。这种功能的不足,直接导致了后来 JavaScript 的一个显著特点:对于其他语言,你需要学习语言的各种功能,而对于 JavaScript,你常常需要学习各种解决问题的模式。而且由于来源多样,从一开始就注定,JavaScript 的编程风格是函数式编程和面向对象编程的一种混合体。
Netscape 公司的这种浏览器脚本语言,最初名字叫做 Mocha,1995年9月改为 LiveScript。12月,Netscape 公司与 Sun 公司(Java 语言的发明者和所有者)达成协议,后者允许将这种语言叫做 JavaScript。这样一来,Netscape 公司可以借助 Java 语言的声势,而 Sun 公司则将自己的影响力扩展到了浏览器。
之所以起这个名字,并不是因为 JavaScript 本身与 Java 语言有多么深的关系(事实上,两者关系并不深,详见下节),而是因为 Netscape 公司已经决定,使用 Java 语言开发网络应用程序,JavaScript 可以像胶水一样,将各个部分连接起来。当然,后来的历史是 Java 语言的浏览器插件失败了,JavaScript 反而发扬光大。
1995年12月4日,Netscape 公司与 Sun 公司联合发布了 JavaScript 语言,对外宣传 JavaScript 是 Java 的补充,属于轻量级的 Java,专门用来操作网页。
1996年3月,Navigator 2.0 浏览器正式内置了 JavaScript 脚本语言。
JavaScript 与 Java 的关系
这里专门说一下 JavaScript 和 Java 的关系。它们是两种不一样的语言,但是彼此存在联系。
JavaScript 的基本语法和对象体系,是模仿 Java 而设计的。但是,JavaScript 没有采用 Java 的静态类型。正是因为 JavaScript 与 Java 有很大的相似性,所以这门语言才从一开始的 LiveScript 改名为 JavaScript。基本上,JavaScript 这个名字的原意是“很像Java的脚本语言”。
JavaScript 语言的函数是一种独立的数据类型,以及采用基于原型对象(prototype)的继承链。这是它与 Java 语法最大的两点区别。JavaScript 语法要比 Java 自由得多。
另外,Java 语言需要编译,而 JavaScript 语言则是运行时由解释器直接执行。
总之,JavaScript 的原始设计目标是一种小型的、简单的动态语言,与 Java 有足够的相似性,使得使用者(尤其是 Java 程序员)可以快速上手。
JavaScript 与 ECMAScript 的关系
1996年8月,微软模仿 JavaScript 开发了一种相近的语言,取名为JScript(JavaScript 是 Netscape 的注册商标,微软不能用),首先内置于IE 3.0。Netscape 公司面临丧失浏览器脚本语言的主导权的局面。
1996年11月,Netscape 公司决定将 JavaScript 提交给国际标准化组织 ECMA(European Computer Manufacturers Association),希望 JavaScript 能够成为国际标准,以此抵抗微软。ECMA 的39号技术委员会(Technical Committee 39)负责制定和审核这个标准,成员由业内的大公司派出的工程师组成,目前共25个人。该委员会定期开会,所有的邮件讨论和会议记录,都是公开的。
1997年7月,ECMA 组织发布262号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript。这个版本就是 ECMAScript 1.0 版。之所以不叫 JavaScript,一方面是由于商标的关系,Java 是 Sun 公司的商标,根据一份授权协议,只有 Netscape 公司可以合法地使用 JavaScript 这个名字,且 JavaScript 已经被 Netscape 公司注册为商标,另一方面也是想体现这门语言的制定者是 ECMA,不是 Netscape,这样有利于保证这门语言的开放性和中立性。因此,ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现。在日常场合,这两个词是可以互换的。
ECMAScript 只用来标准化 JavaScript 这种语言的基本语法结构,与部署环境相关的标准都由其他标准规定,比如 DOM 的标准就是由 W3C组织(World Wide Web Consortium)制定的。
ECMA-262 标准后来也被另一个国际标准化组织 ISO(International Organization for Standardization)批准,标准号是 ISO-16262。
JavaScript 的版本
1997年7月,ECMAScript 1.0发布。
1998年6月,ECMAScript 2.0版发布。
1999年12月,ECMAScript 3.0版发布,成为 JavaScript 的通行标准,得到了广泛支持。
2007年10月,ECMAScript 4.0版草案发布,对3.0版做了大幅升级,预计次年8月发布正式版本。草案发布后,由于4.0版的目标过于激进,各方对于是否通过这个标准,发生了严重分歧。以 Yahoo、Microsoft、Google 为首的大公司,反对 JavaScript 的大幅升级,主张小幅改动;以 JavaScript 创造者 Brendan Eich 为首的 Mozilla 公司,则坚持当前的草案。
2008年7月,由于对于下一个版本应该包括哪些功能,各方分歧太大,争论过于激进,ECMA 开会决定,中止 ECMAScript 4.0 的开发(即废除了这个版本),将其中涉及现有功能改善的一小部分,发布为 ECMAScript 3.1,而将其他激进的设想扩大范围,放入以后的版本,由于会议的气氛,该版本的项目代号起名为 Harmony(和谐)。会后不久,ECMAScript 3.1 就改名为 ECMAScript 5。
2009年12月,ECMAScript 5.0版 正式发布。Harmony 项目则一分为二,一些较为可行的设想定名为 JavaScript.next 继续开发,后来演变成 ECMAScript 6;一些不是很成熟的设想,则被视为 JavaScript.next.next,在更远的将来再考虑推出。TC39 的总体考虑是,ECMAScript 5 与 ECMAScript 3 基本保持兼容,较大的语法修正和新功能加入,将由 JavaScript.next 完成。当时,JavaScript.next 指的是ECMAScript 6。第六版发布以后,将指 ECMAScript 7。TC39 预计,ECMAScript 5 会在2013年的年中成为 JavaScript 开发的主流标准,并在此后五年中一直保持这个位置。
2011年6月,ECMAScript 5.1版发布,并且成为 ISO 国际标准(ISO/IEC 16262:2011)。到了2012年底,所有主要浏览器都支持 ECMAScript 5.1版的全部功能。
2013年3月,ECMAScript 6 草案冻结,不再添加新功能。新的功能设想将被放到 ECMAScript 7。
2013年12月,ECMAScript 6 草案发布。然后是12个月的讨论期,听取各方反馈。
2015年6月,ECMAScript 6 正式发布,并且更名为“ECMAScript 2015”。这是因为 TC39 委员会计划,以后每年发布一个 ECMAScript 的版本,下一个版本在2016年发布,称为“ECMAScript 2016”,2017年发布“ECMAScript 2017”,以此类推。
周边大事记
JavaScript 伴随着互联网的发展一起发展。互联网周边技术的快速发展,刺激和推动了 JavaScript 语言的发展。下面,回顾一下 JavaScript 的周边应用发展。
1996年,样式表标准 CSS 第一版发布。
1997年,DHTML(Dynamic HTML,动态 HTML)发布,允许动态改变网页内容。这标志着 DOM 模式(Document Object Model,文档对象模型)正式应用。
1998年,Netscape 公司开源了浏览器,这导致了 Mozilla 项目的诞生。几个月后,美国在线(AOL)宣布并购 Netscape。
1999年,IE 5部署了 XMLHttpRequest 接口,允许 JavaScript 发出 HTTP 请求,为后来大行其道的 Ajax 应用创造了条件。
2000年,KDE 项目重写了浏览器引擎 KHTML,为后来的 WebKit 和 Blink 引擎打下基础。这一年的10月23日,KDE 2.0发布,第一次将 KHTML 浏览器包括其中。
2001年,微软公司时隔5年之后,发布了 IE 浏览器的下一个版本 Internet Explorer 6。这是当时最先进的浏览器,它后来统治了浏览器市场多年。
2001年,Douglas Crockford 提出了 JSON 格式,用于取代 XML 格式,进行服务器和网页之间的数据交换。JavaScript 可以原生支持这种格式,不需要额外部署代码。
2002年,Mozilla 项目发布了它的浏览器的第一版,后来起名为 Firefox。
2003年,苹果公司发布了 Safari 浏览器的第一版。
2004年,Google 公司发布了 Gmail,促成了互联网应用程序(Web Application)这个概念的诞生。由于 Gmail 是在4月1日发布的,很多人起初以为这只是一个玩笑。
2004年,Dojo 框架诞生,为不同浏览器提供了同一接口,并为主要功能提供了便利的调用方法。这标志着 JavaScript 编程框架的时代开始来临。
2004年,WHATWG 组织成立,致力于加速 HTML 语言的标准化进程。
2005年,苹果公司在 KHTML 引擎基础上,建立了 WebKit 引擎。
2005年,Ajax 方法(Asynchronous JavaScript and XML)正式诞生,Jesse James Garrett 发明了这个词汇。它开始流行的标志是,2月份发布的 Google Maps 项目大量采用该方法。它几乎成了新一代网站的标准做法,促成了 Web 2.0时代的来临。
2005年,Apache 基金会发布了 CouchDB 数据库。这是一个基于 JSON 格式的数据库,可以用 JavaScript 函数定义视图和索引。它在本质上有别于传统的关系型数据库,标识着 NoSQL 类型的数据库诞生。
2006年,jQuery 函数库诞生,作者为John Resig。jQuery 为操作网页 DOM 结构提供了非常强大易用的接口,成为了使用最广泛的函数库,并且让 JavaScript 语言的应用难度大大降低,推动了这种语言的流行。
2006年,微软公司发布 IE 7,标志重新开始启动浏览器的开发。
2006年,Google推出 Google Web Toolkit 项目(缩写为 GWT),提供 Java 编译成 JavaScript 的功能,开创了将其他语言转为 JavaScript 的先河。
2007年,Webkit 引擎在 iPhone 手机中得到部署。它最初基于 KDE 项目,2003年苹果公司首先采用,2005年开源。这标志着 JavaScript 语言开始能在手机中使用了,意味着有可能写出在桌面电脑和手机中都能使用的程序。
2007年,Douglas Crockford 发表了名为《JavaScript: The good parts》的演讲,次年由 O'Reilly 出版社出版。这标志着软件行业开始严肃对待 JavaScript 语言,对它的语法开始重新认识。
2008年,V8 编译器诞生。这是 Google 公司为 Chrome 浏览器而开发的,它的特点是让 JavaScript 的运行变得非常快。它提高了 JavaScript 的性能,推动了语法的改进和标准化,改变外界对 JavaScript 的不佳印象。同时,V8 是开源的,任何人想要一种快速的嵌入式脚本语言,都可以采用 V8,这拓展了 JavaScript 的应用领域。
2009年,Node.js 项目诞生,创始人为 Ryan Dahl,它标志着 JavaScript 可以用于服务器端编程,从此网站的前端和后端可以使用同一种语言开发。并且,Node.js 可以承受很大的并发流量,使得开发某些互联网大规模的实时应用变得容易。
2009年,Jeremy Ashkenas 发布了 CoffeeScript 的最初版本。CoffeeScript 可以被转换为 JavaScript 运行,但是语法要比 JavaScript 简洁。这开启了其他语言转为 JavaScript 的风潮。
2009年,PhoneGap 项目诞生,它将 HTML5 和 JavaScript 引入移动设备的应用程序开发,主要针对 iOS 和 Android 平台,使得 JavaScript 可以用于跨平台的应用程序开发。
2009,Google 发布 Chrome OS,号称是以浏览器为基础发展成的操作系统,允许直接使用 JavaScript 编写应用程序。类似的项目还有 Mozilla 的 Firefox OS。
2010年,三个重要的项目诞生,分别是 NPM、BackboneJS 和 RequireJS,标志着 JavaScript 进入模块化开发的时代。
2011年,微软公司发布 Windows 8操作系统,将 JavaScript 作为应用程序的开发语言之一,直接提供系统支持。
2011年,Google 发布了 Dart 语言,目的是为了结束 JavaScript 语言在浏览器中的垄断,提供更合理、更强大的语法和功能。Chromium浏览器有内置的 Dart 虚拟机,可以运行 Dart 程序,但 Dart 程序也可以被编译成 JavaScript 程序运行。
2011年,微软工程师Scott Hanselman提出,JavaScript 将是互联网的汇编语言。因为它无所不在,而且正在变得越来越快。其他语言的程序可以被转成 JavaScript 语言,然后在浏览器中运行。
2012年,单页面应用程序框架(single-page app framework)开始崛起,AngularJS 项目和 Ember 项目都发布了1.0版本。
2012年,微软发布 TypeScript 语言。该语言被设计成 JavaScript 的超集,这意味着所有 JavaScript 程序,都可以不经修改地在 TypeScript 中运行。同时,TypeScript 添加了很多新的语法特性,主要目的是为了开发大型程序,然后还可以被编译成 JavaScript 运行。
2012年,Mozilla 基金会提出 asm.js 规格。asm.js 是 JavaScript 的一个子集,所有符合 asm.js 的程序都可以在浏览器中运行,它的特殊之处在于语法有严格限定,可以被快速编译成性能良好的机器码。这样做的目的,是为了给其他语言提供一个编译规范,使其可以被编译成高效的 JavaScript 代码。同时,Mozilla 基金会还发起了 Emscripten 项目,目标就是提供一个跨语言的编译器,能够将 LLVM 的位代码(bitcode)转为 JavaScript 代码,在浏览器中运行。因为大部分 LLVM 位代码都是从 C / C++ 语言生成的,这意味着 C / C++ 将可以在浏览器中运行。此外,Mozilla 旗下还有 LLJS (将 JavaScript 转为 C 代码)项目和 River Trail (一个用于多核心处理器的 ECMAScript 扩展)项目。目前,可以被编译成 JavaScript 的语言列表,共有将近40种语言。
2013年,Mozilla 基金会发布手机操作系统 Firefox OS,该操作系统的整个用户界面都使用 JavaScript。
2013年,ECMA 正式推出 JSON 的国际标准,这意味着 JSON 格式已经变得与 XML 格式一样重要和正式了。
2013年5月,Facebook 发布 UI 框架库 React,引入了新的 JSX 语法,使得 UI 层可以用组件开发,同时引入了网页应用是状态机的概念。
2014年,微软推出 JavaScript 的 Windows 库 WinJS,标志微软公司全面支持 JavaScript 与 Windows 操作系统的融合。
2014年11月,由于对 Joyent 公司垄断 Node 项目、以及该项目进展缓慢的不满,一部分核心开发者离开了 Node.js,创造了 io.js 项目,这是一个更开放、更新更频繁的 Node.js 版本,很短时间内就发布到了2.0版。三个月后,Joyent 公司宣布放弃对 Node 项目的控制,将其转交给新成立的开放性质的 Node 基金会。随后,io.js 项目宣布回归 Node,两个版本将合并。
2015年3月,Facebook 公司发布了 React Native 项目,将 React 框架移植到了手机端,可以用来开发手机 App。它会将 JavaScript 代码转为 iOS 平台的 Objective-C 代码,或者 Android 平台的 Java 代码,从而为 JavaScript 语言开发高性能的原生 App 打开了一条道路。
2015年4月,Angular 框架宣布,2.0 版将基于微软公司的TypeScript语言开发,这等于为 JavaScript 语言引入了强类型。
2015年5月,Node 模块管理器 NPM 超越 CPAN,标志着 JavaScript 成为世界上软件模块最多的语言。
2015年5月,Google 公司的 Polymer 框架发布1.0版。该项目的目标是生产环境可以使用 WebComponent 组件,如果能够达到目标,Web 开发将进入一个全新的以组件为开发基础的阶段。
2015年6月,ECMA 标准化组织正式批准了 ECMAScript 6 语言标准,定名为《ECMAScript 2015 标准》。JavaScript 语言正式进入了下一个阶段,成为一种企业级的、开发大规模应用的语言。这个标准从提出到批准,历时10年,而 JavaScript 语言从诞生至今也已经20年了。
2015年6月,Mozilla 在 asm.js 的基础上发布 WebAssembly 项目。这是一种 JavaScript 引擎的中间码格式,全部都是二进制,类似于 Java 的字节码,有利于移动设备加载 JavaScript 脚本,执行速度提高了 20+ 倍。这意味着将来的软件,会发布 JavaScript 二进制包。
2016年6月,《ECMAScript 2016 标准》发布。与前一年发布的版本相比,它只增加了两个较小的特性。
2017年6月,《ECMAScript 2017 标准》发布,正式引入了 async 函数,使得异步操作的写法出现了根本的变化。
2017年11月,所有主流浏览器全部支持 WebAssembly,这意味着任何语言都可以编译成 JavaScript,在浏览器运行。
参考链接
- Axel Rauschmayer, The Past, Present, and Future of JavaScript
- John Dalziel, The race for speed part 4: The future for JavaScript
- Axel Rauschmayer, Basic JavaScript for the impatient programmer
- balena.io, Happy 18th Birthday JavaScript! A look at an unlikely past and bright future
JavaScript 的基本语法
语句
JavaScript 程序的执行单位为行(line),也就是一行一行地执行。一般情况下,每一行就是一个语句。
语句(statement)是为了完成某种任务而进行的操作,比如下面就是一行赋值语句。
var a = 1 + 3;
这条语句先用var
命令,声明了变量a
,然后将1 + 3
的运算结果赋值给变量a
。
1 + 3
叫做表达式(expression),指一个为了得到返回值的计算式。语句和表达式的区别在于,前者主要为了进行某种操作,一般情况下不需要返回值;后者则是为了得到返回值,一定会返回一个值。凡是 JavaScript 语言中预期为值的地方,都可以使用表达式。比如,赋值语句的等号右边,预期是一个值,因此可以放置各种表达式。
语句以分号结尾,一个分号就表示一个语句结束。多个语句可以写在一行内。
var a = 1 + 3 ; var b = 'abc';
分号前面可以没有任何内容,JavaScript 引擎将其视为空语句。
;;;
上面的代码就表示3个空语句。
表达式不需要分号结尾。一旦在表达式后面添加分号,则 JavaScript 引擎就将表达式视为语句,这样会产生一些没有任何意义的语句。
1 + 3;
'abc';
上面两行语句只是单纯地产生一个值,并没有任何实际的意义。
变量
概念
变量是对“值”的具名引用。变量就是为“值”起名,然后引用这个名字,就等同于引用这个值。变量的名字就是变量名。
var a = 1;
上面的代码先声明变量a
,然后在变量a
与数值1之间建立引用关系,称为将数值1“赋值”给变量a
。以后,引用变量名a
就会得到数值1。最前面的var
,是变量声明命令。它表示通知解释引擎,要创建一个变量a
。
注意,JavaScript 的变量名区分大小写,A
和a
是两个不同的变量。
变量的声明和赋值,是分开的两个步骤,上面的代码将它们合在了一起,实际的步骤是下面这样。
var a;
a = 1;
如果只是声明变量而没有赋值,则该变量的值是undefined
。undefined
是一个特殊的值,表示“无定义”。
var a;
a // undefined
如果变量赋值的时候,忘了写var
命令,这条语句也是有效的。
var a = 1;
// 基本等同
a = 1;
但是,不写var
的做法,不利于表达意图,而且容易不知不觉地创建全局变量,所以建议总是使用var
命令声明变量。
如果一个变量没有声明就直接使用,JavaScript 会报错,告诉你变量未定义。
x
// ReferenceError: x is not defined
上面代码直接使用变量x
,系统就报错,告诉你变量x
没有声明。
可以在同一条var
命令中声明多个变量。
var a, b;
JavaScript 是一种动态类型语言,也就是说,变量的类型没有限制,变量可以随时更改类型。
var a = 1;
a = 'hello';
上面代码中,变量a
起先被赋值为一个数值,后来又被重新赋值为一个字符串。第二次赋值的时候,因为变量a
已经存在,所以不需要使用var
命令。
如果使用var
重新声明一个已经存在的变量,是无效的。
var x = 1;
var x;
x // 1
上面代码中,变量x
声明了两次,第二次声明是无效的。
但是,如果第二次声明的时候还进行了赋值,则会覆盖掉前面的值。
var x = 1;
var x = 2;
// 等同于
var x = 1;
var x;
x = 2;
变量提升
JavaScript 引擎的工作方式是,先解析代码,获取所有被声明的变量,然后再一行一行地运行。这造成的结果,就是所有的变量的声明语句,都会被提升到代码的头部,这就叫做变量提升(hoisting)。
console.log(a);
var a = 1;
上面代码首先使用console.log
方法,在控制台(console)显示变量a
的值。这时变量a
还没有声明和赋值,所以这是一种错误的做法,但是实际上不会报错。因为存在变量提升,真正运行的是下面的代码。
var a;
console.log(a);
a = 1;
最后的结果是显示undefined
,表示变量a
已声明,但还未赋值。
标识符
标识符(identifier)指的是用来识别各种值的合法名称。最常见的标识符就是变量名,以及后面要提到的函数名。JavaScript 语言的标识符对大小写敏感,所以a
和A
是两个不同的标识符。
标识符有一套命名规则,不符合规则的就是非法标识符。JavaScript 引擎遇到非法标识符,就会报错。
简单说,标识符命名规则如下。
- 第一个字符,可以是任意 Unicode 字母(包括英文字母和其他语言的字母),以及美元符号(
$
)和下划线(_
)。 - 第二个字符及后面的字符,除了 Unicode 字母、美元符号和下划线,还可以用数字
0-9
。
下面这些都是合法的标识符。
arg0
_tmp
$elem
π
下面这些则是不合法的标识符。
1a // 第一个字符不能是数字
23 // 同上
*** // 标识符不能包含星号
a+b // 标识符不能包含加号
-d // 标识符不能包含减号或连词线
中文是合法的标识符,可以用作变量名。
var 临时变量 = 1;
JavaScript 有一些保留字,不能用作标识符:arguments、break、case、catch、class、const、continue、debugger、default、delete、do、else、enum、eval、export、extends、false、finally、for、function、if、implements、import、in、instanceof、interface、let、new、null、package、private、protected、public、return、static、super、switch、this、throw、true、try、typeof、var、void、while、with、yield。
注释
源码中被 JavaScript 引擎忽略的部分就叫做注释,它的作用是对代码进行解释。JavaScript 提供两种注释的写法:一种是单行注释,用//
起头;另一种是多行注释,放在/*
和*/
之间。
// 这是单行注释
/*
这是
多行
注释
*/
此外,由于历史上 JavaScript 可以兼容 HTML 代码的注释,所以<!--
和-->
也被视为合法的单行注释。
x = 1; <!-- x = 2;
--> x = 3;
上面代码中,只有x = 1
会执行,其他的部分都被注释掉了。
需要注意的是,-->
只有在行首,才会被当成单行注释,否则会当作正常的运算。
function countdown(n) {
while (n --> 0) console.log(n);
}
countdown(3)
// 2
// 1
// 0
上面代码中,n --> 0
实际上会当作n-- > 0
,因此输出2、1、0。
区块
JavaScript 使用大括号,将多个相关的语句组合在一起,称为“区块”(block)。
对于var
命令来说,JavaScript 的区块不构成单独的作用域(scope)。
{
var a = 1;
}
a // 1
上面代码在区块内部,使用var
命令声明并赋值了变量a
,然后在区块外部,变量a
依然有效,区块对于var
命令不构成单独的作用域,与不使用区块的情况没有任何区别。在 JavaScript 语言中,单独使用区块并不常见,区块往往用来构成其他更复杂的语法结构,比如for
、if
、while
、function
等。
条件语句
JavaScript 提供if
结构和switch
结构,完成条件判断,即只有满足预设的条件,才会执行相应的语句。
if 结构
if
结构先判断一个表达式的布尔值,然后根据布尔值的真伪,执行不同的语句。所谓布尔值,指的是 JavaScript 的两个特殊值,true
表示“真”,false
表示“伪”。
if (布尔值)
语句;
// 或者
if (布尔值) 语句;
上面是if
结构的基本形式。需要注意的是,“布尔值”往往由一个条件表达式产生的,必须放在圆括号中,表示对表达式求值。如果表达式的求值结果为true
,就执行紧跟在后面的语句;如果结果为false
,则跳过紧跟在后面的语句。
if (m === 3)
m = m + 1;
上面代码表示,只有在m
等于3时,才会将其值加上1。
这种写法要求条件表达式后面只能有一个语句。如果想执行多个语句,必须在if
的条件判断之后,加上大括号,表示代码块(多个语句合并成一个语句)。
if (m === 3) {
m += 1;
}
建议总是在if
语句中使用大括号,因为这样方便插入语句。
注意,if
后面的表达式之中,不要混淆赋值表达式(=
)、严格相等运算符(===
)和相等运算符(==
)。尤其是赋值表达式不具有比较作用。
var x = 1;
var y = 2;
if (x = y) {
console.log(x);
}
// "2"
上面代码的原意是,当x
等于y
的时候,才执行相关语句。但是,不小心将严格相等运算符写成赋值表达式,结果变成了将y
赋值给变量x
,再判断变量x
的值(等于2)的布尔值(结果为true
)。
这种错误可以正常生成一个布尔值,因而不会报错。为了避免这种情况,有些开发者习惯将常量写在运算符的左边,这样的话,一旦不小心将相等运算符写成赋值运算符,就会报错,因为常量不能被赋值。
if (x = 2) { // 不报错
if (2 = x) { // 报错
至于为什么优先采用“严格相等运算符”(===
),而不是“相等运算符”(==
),请参考《运算符》章节。
if...else 结构
if
代码块后面,还可以跟一个else
代码块,表示不满足条件时,所要执行的代码。
if (m === 3) {
// 满足条件时,执行的语句
} else {
// 不满足条件时,执行的语句
}
上面代码判断变量m
是否等于3,如果等于就执行if
代码块,否则执行else
代码块。
对同一个变量进行多次判断时,多个if...else
语句可以连写在一起。
if (m === 0) {
// ...
} else if (m === 1) {
// ...
} else if (m === 2) {
// ...
} else {
// ...
}
else
代码块总是与离自己最近的那个if
语句配对。
var m = 1;
var n = 2;
if (m !== 1)
if (n === 2) console.log('hello');
else console.log('world');
上面代码不会有任何输出,else
代码块不会得到执行,因为它跟着的是最近的那个if
语句,相当于下面这样。
if (m !== 1) {
if (n === 2) {
console.log('hello');
} else {
console.log('world');
}
}
如果想让else
代码块跟随最上面的那个if
语句,就要改变大括号的位置。
if (m !== 1) {
if (n === 2) {
console.log('hello');
}
} else {
console.log('world');
}
// world
switch 结构
多个if...else
连在一起使用的时候,可以转为使用更方便的switch
结构。
switch (fruit) {
case "banana":
// ...
break;
case "apple":
// ...
break;
default:
// ...
}
上面代码根据变量fruit
的值,选择执行相应的case
。如果所有case
都不符合,则执行最后的default
部分。需要注意的是,每个case
代码块内部的break
语句不能少,否则会接下去执行下一个case
代码块,而不是跳出switch
结构。
var x = 1;
switch (x) {
case 1:
console.log('x 等于1');
case 2:
console.log('x 等于2');
default:
console.log('x 等于其他值');
}
// x等于1
// x等于2
// x等于其他值
上面代码中,case
代码块之中没有break
语句,导致不会跳出switch
结构,而会一直执行下去。正确的写法是像下面这样。
switch (x) {
case 1:
console.log('x 等于1');
break;
case 2:
console.log('x 等于2');
break;
default:
console.log('x 等于其他值');
}
switch
语句部分和case
语句部分,都可以使用表达式。
switch (1 + 3) {
case 2 + 2:
f();
break;
default:
neverHappens();
}
上面代码的default
部分,是永远不会执行到的。
需要注意的是,switch
语句后面的表达式,与case
语句后面的表示式比较运行结果时,采用的是严格相等运算符(===
),而不是相等运算符(==
),这意味着比较时不会发生类型转换。
var x = 1;
switch (x) {
case true:
console.log('x 发生类型转换');
break;
default:
console.log('x 没有发生类型转换');
}
// x 没有发生类型转换
上面代码中,由于变量x
没有发生类型转换,所以不会执行case true
的情况。这表明,switch
语句内部采用的是“严格相等运算符”,详细解释请参考《运算符》一节。
三元运算符 ?:
JavaScript 还有一个三元运算符(即该运算符需要三个运算子)?:
,也可以用于逻辑判断。
(条件) ? 表达式1 : 表达式2
上面代码中,如果“条件”为true
,则返回“表达式1”的值,否则返回“表达式2”的值。
var even = (n % 2 === 0) ? true : false;
上面代码中,如果n
可以被2整除,则even
等于true
,否则等于false
。它等同于下面的形式。
var even;
if (n % 2 === 0) {
even = true;
} else {
even = false;
}
这个三元运算符可以被视为if...else...
的简写形式,因此可以用于多种场合。
var myVar;
console.log(
myVar ?
'myVar has a value' :
'myVar does not have a value'
)
// myVar does not have a value
上面代码利用三元运算符,输出相应的提示。
var msg = '数字' + n + '是' + (n % 2 === 0 ? '偶数' : '奇数');
上面代码利用三元运算符,在字符串之中插入不同的值。
循环语句
循环语句用于重复执行某个操作,它有多种形式。
while 循环
while
语句包括一个循环条件和一段代码块,只要条件为真,就不断循环执行代码块。
while (条件)
语句;
// 或者
while (条件) 语句;
while
语句的循环条件是一个表达式,必须放在圆括号中。代码块部分,如果只有一条语句,可以省略大括号,否则就必须加上大括号。
while (条件) {
语句;
}
下面是while
语句的一个例子。
var i = 0;
while (i < 100) {
console.log('i 当前为:' + i);
i = i + 1;
}
上面的代码将循环100次,直到i
等于100为止。
下面的例子是一个无限循环,因为循环条件总是为真。
while (true) {
console.log('Hello, world');
}
for 循环
for
语句是循环命令的另一种形式,可以指定循环的起点、终点和终止条件。它的格式如下。
for (初始化表达式; 条件; 递增表达式)
语句
// 或者
for (初始化表达式; 条件; 递增表达式) {
语句
}
for
语句后面的括号里面,有三个表达式。
- 初始化表达式(initialize):确定循环变量的初始值,只在循环开始时执行一次。
- 条件表达式(test):每轮循环开始时,都要执行这个条件表达式,只有值为真,才继续进行循环。
- 递增表达式(increment):每轮循环的最后一个操作,通常用来递增循环变量。
下面是一个例子。
var x = 3;
for (var i = 0; i < x; i++) {
console.log(i);
}
// 0
// 1
// 2
上面代码中,初始化表达式是var i = 0
,即初始化一个变量i
;测试表达式是i < x
,即只要i
小于x
,就会执行循环;递增表达式是i++
,即每次循环结束后,i
增大1。
所有for
循环,都可以改写成while
循环。上面的例子改为while
循环,代码如下。
var x = 3;
var i = 0;
while (i < x) {
console.log(i);
i++;
}
for
语句的三个部分(initialize、test、increment),可以省略任何一个,也可以全部省略。
for ( ; ; ){
console.log('Hello World');
}
上面代码省略了for
语句表达式的三个部分,结果就导致了一个无限循环。
do...while 循环
do...while
循环与while
循环类似,唯一的区别就是先运行一次循环体,然后判断循环条件。
do
语句
while (条件);
// 或者
do {
语句
} while (条件);
不管条件是否为真,do...while
循环至少运行一次,这是这种结构最大的特点。另外,while
语句后面的分号注意不要省略。
下面是一个例子。
var x = 3;
var i = 0;
do {
console.log(i);
i++;
} while(i < x);
break 语句和 continue 语句
break
语句和continue
语句都具有跳转作用,可以让代码不按既有的顺序执行。
break
语句用于跳出代码块或循环。
var i = 0;
while(i < 100) {
console.log('i 当前为:' + i);
i++;
if (i === 10) break;
}
上面代码只会执行10次循环,一旦i
等于10,就会跳出循环。
for
循环也可以使用break
语句跳出循环。
for (var i = 0; i < 5; i++) {
console.log(i);
if (i === 3)
break;
}
// 0
// 1
// 2
// 3
上面代码执行到i
等于3,就会跳出循环。
continue
语句用于立即终止本轮循环,返回循环结构的头部,开始下一轮循环。
var i = 0;
while (i < 100){
i++;
if (i % 2 === 0) continue;
console.log('i 当前为:' + i);
}
上面代码只有在i
为奇数时,才会输出i
的值。如果i
为偶数,则直接进入下一轮循环。
如果存在多重循环,不带参数的break
语句和continue
语句都只针对最内层循环。
标签(label)
JavaScript 语言允许,语句的前面有标签(label),相当于定位符,用于跳转到程序的任意位置,标签的格式如下。
label:
语句
标签可以是任意的标识符,但不能是保留字,语句部分可以是任意语句。
标签通常与break
语句和continue
语句配合使用,跳出特定的循环。
top:
for (var i = 0; i < 3; i++){
for (var j = 0; j < 3; j++){
if (i === 1 && j === 1) break top;
console.log('i=' + i + ', j=' + j);
}
}
// i=0, j=0
// i=0, j=1
// i=0, j=2
// i=1, j=0
上面代码为一个双重循环区块,break
命令后面加上了top
标签(注意,top
不用加引号),满足条件时,直接跳出双层循环。如果break
语句后面不使用标签,则只能跳出内层循环,进入下一次的外层循环。
标签也可以用于跳出代码块。
foo: {
console.log(1);
break foo;
console.log('本行不会输出');
}
console.log(2);
// 1
// 2
上面代码执行到break foo
,就会跳出区块。
continue
语句也可以与标签配合使用。
top:
for (var i = 0; i < 3; i++){
for (var j = 0; j < 3; j++){
if (i === 1 && j === 1) continue top;
console.log('i=' + i + ', j=' + j);
}
}
// i=0, j=0
// i=0, j=1
// i=0, j=2
// i=1, j=0
// i=2, j=0
// i=2, j=1
// i=2, j=2
上面代码中,continue
命令后面有一个标签名,满足条件时,会跳过当前循环,直接进入下一轮外层循环。如果continue
语句后面不使用标签,则只能进入下一轮的内层循环。
参考链接
- Axel Rauschmayer, Basic JavaScript for the impatient programmer
1.数据类型
数据类型概述
简介
JavaScript 语言的每一个值,都属于某一种数据类型。JavaScript 的数据类型,共有六种。(ES6 又新增了第七种 Symbol 类型的值,本教程不涉及。)
- 数值(number):整数和小数(比如
1
和3.14
)。 - 字符串(string):文本(比如
Hello World
)。 - 布尔值(boolean):表示真伪的两个特殊值,即
true
(真)和false
(假)。 undefined
:表示“未定义”或不存在,即由于目前没有定义,所以此处暂时没有任何值。null
:表示空值,即此处的值为空。- 对象(object):各种值组成的集合。
通常,数值、字符串、布尔值这三种类型,合称为原始类型(primitive type)的值,即它们是最基本的数据类型,不能再细分了。对象则称为合成类型(complex type)的值,因为一个对象往往是多个原始类型的值的合成,可以看作是一个存放各种值的容器。至于undefined
和null
,一般将它们看成两个特殊值。
对象是最复杂的数据类型,又可以分成三个子类型。
- 狭义的对象(object)
- 数组(array)
- 函数(function)
狭义的对象和数组是两种不同的数据组合方式,除非特别声明,本教程的“对象”都特指狭义的对象。函数其实是处理数据的方法,JavaScript 把它当成一种数据类型,可以赋值给变量,这为编程带来了很大的灵活性,也为 JavaScript 的“函数式编程”奠定了基础。
typeof 运算符
JavaScript 有三种方法,可以确定一个值到底是什么类型。
typeof
运算符instanceof
运算符Object.prototype.toString
方法
instanceof
运算符和Object.prototype.toString
方法,将在后文介绍。这里介绍typeof
运算符。
typeof
运算符可以返回一个值的数据类型。
数值、字符串、布尔值分别返回number
、string
、boolean
。
typeof 123 // "number"
typeof '123' // "string"
typeof false // "boolean"
函数返回function
。
function f() {}
typeof f
// "function"
undefined
返回undefined
。
typeof undefined
// "undefined"
利用这一点,typeof
可以用来检查一个没有声明的变量,而不报错。
v
// ReferenceError: v is not defined
typeof v
// "undefined"
上面代码中,变量v
没有用var
命令声明,直接使用就会报错。但是,放在typeof
后面,就不报错了,而是返回undefined
。
实际编程中,这个特点通常用在判断语句。
// 错误的写法
if (v) {
// ...
}
// ReferenceError: v is not defined
// 正确的写法
if (typeof v === "undefined") {
// ...
}
对象返回object
。
typeof window // "object"
typeof {} // "object"
typeof [] // "object"
上面代码中,空数组([]
)的类型也是object
,这表示在 JavaScript 内部,数组本质上只是一种特殊的对象。这里顺便提一下,instanceof
运算符可以区分数组和对象。instanceof
运算符的详细解释,请见《面向对象编程》一章。
var o = {};
var a = [];
o instanceof Array // false
a instanceof Array // true
null
返回object
。
typeof null // "object"
null
的类型是object
,这是由于历史原因造成的。1995年的 JavaScript 语言第一版,只设计了五种数据类型(对象、整数、浮点数、字符串和布尔值),没考虑null
,只把它当作object
的一种特殊值。后来null
独立出来,作为一种单独的数据类型,为了兼容以前的代码,typeof null
返回object
就没法改变了。
参考链接
- Axel Rauschmayer, Improving the JavaScript typeof operator
null, undefined 和布尔值
null 和 undefined
概述
null
与undefined
都可以表示“没有”,含义非常相似。将一个变量赋值为undefined
或null
,老实说,语法效果几乎没区别。
var a = undefined;
// 或者
var a = null;
上面代码中,变量a
分别被赋值为undefined
和null
,这两种写法的效果几乎等价。
在if
语句中,它们都会被自动转为false
,相等运算符(==
)甚至直接报告两者相等。
if (!undefined) {
console.log('undefined is false');
}
// undefined is false
if (!null) {
console.log('null is false');
}
// null is false
undefined == null
// true
从上面代码可见,两者的行为是何等相似!谷歌公司开发的 JavaScript 语言的替代品 Dart 语言,就明确规定只有null
,没有undefined
!
既然含义与用法都差不多,为什么要同时设置两个这样的值,这不是无端增加复杂度,令初学者困扰吗?这与历史原因有关。
1995年 JavaScript 诞生时,最初像 Java 一样,只设置了null
表示"无"。根据 C 语言的传统,null
可以自动转为0
。
Number(null) // 0
5 + null // 5
上面代码中,null
转为数字时,自动变成0。
但是,JavaScript 的设计者 Brendan Eich,觉得这样做还不够。首先,第一版的 JavaScript 里面,null
就像在 Java 里一样,被当成一个对象,Brendan Eich 觉得表示“无”的值最好不是对象。其次,那时的 JavaScript 不包括错误处理机制,Brendan Eich 觉得,如果null
自动转为0,很不容易发现错误。
因此,他又设计了一个undefined
。区别是这样的:null
是一个表示“空”的对象,转为数值时为0
;undefined
是一个表示"此处无定义"的原始值,转为数值时为NaN
。
Number(undefined) // NaN
5 + undefined // NaN
用法和含义
对于null
和undefined
,大致可以像下面这样理解。
null
表示空值,即该处的值现在为空。调用函数时,某个参数未设置任何值,这时就可以传入null
,表示该参数为空。比如,某个函数接受引擎抛出的错误作为参数,如果运行过程中未出错,那么这个参数就会传入null
,表示未发生错误。
undefined
表示“未定义”,下面是返回undefined
的典型场景。
// 变量声明了,但没有赋值
var i;
i // undefined
// 调用函数时,应该提供的参数没有提供,该参数等于 undefined
function f(x) {
return x;
}
f() // undefined
// 对象没有赋值的属性
var o = new Object();
o.p // undefined
// 函数没有返回值时,默认返回 undefined
function f() {}
f() // undefined
布尔值
布尔值代表“真”和“假”两个状态。“真”用关键字true
表示,“假”用关键字false
表示。布尔值只有这两个值。
下列运算符会返回布尔值:
- 前置逻辑运算符:
!
(Not) - 相等运算符:
===
,!==
,==
,!=
- 比较运算符:
>
,>=
,<
,<=
如果 JavaScript 预期某个位置应该是布尔值,会将该位置上现有的值自动转为布尔值。转换规则是除了下面六个值被转为false
,其他值都视为true
。
undefined
null
false
0
NaN
""
或''
(空字符串)
布尔值往往用于程序流程的控制,请看一个例子。
if ('') {
console.log('true');
}
// 没有任何输出
上面代码中,if
命令后面的判断条件,预期应该是一个布尔值,所以 JavaScript 自动将空字符串,转为布尔值false
,导致程序不会进入代码块,所以没有任何输出。
注意,空数组([]
)和空对象({}
)对应的布尔值,都是true
。
if ([]) {
console.log('true');
}
// true
if ({}) {
console.log('true');
}
// true
更多关于数据类型转换的介绍,参见《数据类型转换》一章。
参考链接
- Axel Rauschmayer, Categorizing values in JavaScript
数值
概述
整数和浮点数
JavaScript 内部,所有数字都是以64位浮点数形式储存,即使整数也是如此。所以,1
与1.0
是相同的,是同一个数。
1 === 1.0 // true
这就是说,JavaScript 语言的底层根本没有整数,所有数字都是小数(64位浮点数)。容易造成混淆的是,某些运算只有整数才能完成,此时 JavaScript 会自动把64位浮点数,转成32位整数,然后再进行运算,参见《运算符》一章的“位运算”部分。
由于浮点数不是精确的值,所以涉及小数的比较和运算要特别小心。
0.1 + 0.2 === 0.3
// false
0.3 / 0.1
// 2.9999999999999996
(0.3 - 0.2) === (0.2 - 0.1)
// false
数值精度
根据国际标准 IEEE 754,JavaScript 浮点数的64个二进制位,从最左边开始,是这样组成的。
- 第1位:符号位,
0
表示正数,1
表示负数 - 第2位到第12位(共11位):指数部分
- 第13位到第64位(共52位):小数部分(即有效数字)
符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。
指数部分一共有11个二进制位,因此大小范围就是0到2047。IEEE 754 规定,如果指数部分的值在0到2047之间(不含两个端点),那么有效数字的第一位默认总是1,不保存在64位浮点数之中。也就是说,有效数字这时总是1.xx...xx
的形式,其中xx..xx
的部分保存在64位浮点数之中,最长可能为52位。因此,JavaScript 提供的有效数字最长为53个二进制位。
(-1)^符号位 * 1.xx...xx * 2^指数部分
上面公式是正常情况下(指数部分在0到2047之间),一个数在 JavaScript 内部实际的表示形式。
精度最多只能到53个二进制位,这意味着,绝对值小于2的53次方的整数,即-253到253,都可以精确表示。
Math.pow(2, 53)
// 9007199254740992
Math.pow(2, 53) + 1
// 9007199254740992
Math.pow(2, 53) + 2
// 9007199254740994
Math.pow(2, 53) + 3
// 9007199254740996
Math.pow(2, 53) + 4
// 9007199254740996
上面代码中,大于2的53次方以后,整数运算的结果开始出现错误。所以,大于2的53次方的数值,都无法保持精度。由于2的53次方是一个16位的十进制数值,所以简单的法则就是,JavaScript 对15位的十进制数都可以精确处理。
Math.pow(2, 53)
// 9007199254740992
// 多出的三个有效数字,将无法保存
9007199254740992111
// 9007199254740992000
上面示例表明,大于2的53次方以后,多出来的有效数字(最后三位的111
)都会无法保存,变成0。
数值范围
根据标准,64位浮点数的指数部分的长度是11个二进制位,意味着指数部分的最大值是2047(2的11次方减1)。也就是说,64位浮点数的指数部分的值最大为2047,分出一半表示负数,则 JavaScript 能够表示的数值范围为21024到2-1023(开区间),超出这个范围的数无法表示。
如果一个数大于等于2的1024次方,那么就会发生“正向溢出”,即 JavaScript 无法表示这么大的数,这时就会返回Infinity
。
Math.pow(2, 1024) // Infinity
如果一个数小于等于2的-1075次方(指数部分最小值-1023,再加上小数部分的52位),那么就会发生为“负向溢出”,即 JavaScript 无法表示这么小的数,这时会直接返回0。
Math.pow(2, -1075) // 0
下面是一个实际的例子。
var x = 0.5;
for(var i = 0; i < 25; i++) {
x = x * x;
}
x // 0
上面代码中,对0.5
连续做25次平方,由于最后结果太接近0,超出了可表示的范围,JavaScript 就直接将其转为0。
JavaScript 提供Number
对象的MAX_VALUE
和MIN_VALUE
属性,返回可以表示的具体的最大值和最小值。
Number.MAX_VALUE // 1.7976931348623157e+308
Number.MIN_VALUE // 5e-324
数值的表示法
JavaScript 的数值有多种表示方法,可以用字面形式直接表示,比如35
(十进制)和0xFF
(十六进制)。
数值也可以采用科学计数法表示,下面是几个科学计数法的例子。
123e3 // 123000
123e-3 // 0.123
-3.1E+12
.1e-23
科学计数法允许字母e
或E
的后面,跟着一个整数,表示这个数值的指数部分。
以下两种情况,JavaScript 会自动将数值转为科学计数法表示,其他情况都采用字面形式直接表示。
(1)小数点前的数字多于21位。
1234567890123456789012
// 1.2345678901234568e+21
123456789012345678901
// 123456789012345680000
(2)小数点后的零多于5个。
// 小数点后紧跟5个以上的零,
// 就自动转为科学计数法
0.0000003 // 3e-7
// 否则,就保持原来的字面形式
0.000003 // 0.000003
数值的进制
使用字面量(literal)直接表示一个数值时,JavaScript 对整数提供四种进制的表示方法:十进制、十六进制、八进制、二进制。
- 十进制:没有前导0的数值。
- 八进制:有前缀
0o
或0O
的数值,或者有前导0、且只用到0-7的八个阿拉伯数字的数值。 - 十六进制:有前缀
0x
或0X
的数值。 - 二进制:有前缀
0b
或0B
的数值。
默认情况下,JavaScript 内部会自动将八进制、十六进制、二进制转为十进制。下面是一些例子。
0xff // 255
0o377 // 255
0b11 // 3
如果八进制、十六进制、二进制的数值里面,出现不属于该进制的数字,就会报错。
0xzz // 报错
0o88 // 报错
0b22 // 报错
上面代码中,十六进制出现了字母z
、八进制出现数字8
、二进制出现数字2
,因此报错。
通常来说,有前导0的数值会被视为八进制,但是如果前导0后面有数字8
和9
,则该数值被视为十进制。
0888 // 888
0777 // 511
前导0表示八进制,处理时很容易造成混乱。ES5 的严格模式和 ES6,已经废除了这种表示法,但是浏览器为了兼容以前的代码,目前还继续支持这种表示法。
特殊数值
JavaScript 提供了几个特殊的数值。
正零和负零
前面说过,JavaScript 的64位浮点数之中,有一个二进制位是符号位。这意味着,任何一个数都有一个对应的负值,就连0
也不例外。
JavaScript 内部实际上存在2个0
:一个是+0
,一个是-0
,区别就是64位浮点数表示法的符号位不同。它们是等价的。
-0 === +0 // true
0 === -0 // true
0 === +0 // true
几乎所有场合,正零和负零都会被当作正常的0
。
+0 // 0
-0 // 0
(-0).toString() // '0'
(+0).toString() // '0'
唯一有区别的场合是,+0
或-0
当作分母,返回的值是不相等的。
(1 / +0) === (1 / -0) // false
上面的代码之所以出现这样结果,是因为除以正零得到+Infinity
,除以负零得到-Infinity
,这两者是不相等的(关于Infinity
详见下文)。
NaN
(1)含义
NaN
是 JavaScript 的特殊值,表示“非数字”(Not a Number),主要出现在将字符串解析成数字出错的场合。
5 - 'x' // NaN
上面代码运行时,会自动将字符串x
转为数值,但是由于x
不是数值,所以最后得到结果为NaN
,表示它是“非数字”(NaN
)。
另外,一些数学函数的运算结果会出现NaN
。
Math.acos(2) // NaN
Math.log(-1) // NaN
Math.sqrt(-1) // NaN
0
除以0
也会得到NaN
。
0 / 0 // NaN
需要注意的是,NaN
不是独立的数据类型,而是一个特殊数值,它的数据类型依然属于Number
,使用typeof
运算符可以看得很清楚。
typeof NaN // 'number'
(2)运算规则
NaN
不等于任何值,包括它本身。
NaN === NaN // false
数组的indexOf
方法内部使用的是严格相等运算符,所以该方法对NaN
不成立。
[NaN].indexOf(NaN) // -1
NaN
在布尔运算时被当作false
。
Boolean(NaN) // false
NaN
与任何数(包括它自己)的运算,得到的都是NaN
。
NaN + 32 // NaN
NaN - 32 // NaN
NaN * 32 // NaN
NaN / 32 // NaN
Infinity
(1)含义
Infinity
表示“无穷”,用来表示两种场景。一种是一个正的数值太大,或一个负的数值太小,无法表示;另一种是非0数值除以0,得到Infinity
。
// 场景一
Math.pow(2, 1024)
// Infinity
// 场景二
0 / 0 // NaN
1 / 0 // Infinity
上面代码中,第一个场景是一个表达式的计算结果太大,超出了能够表示的范围,因此返回Infinity
。第二个场景是0
除以0
会得到NaN
,而非0数值除以0
,会返回Infinity
。
Infinity
有正负之分,Infinity
表示正的无穷,-Infinity
表示负的无穷。
Infinity === -Infinity // false
1 / -0 // -Infinity
-1 / -0 // Infinity
上面代码中,非零正数除以-0
,会得到-Infinity
,负数除以-0
,会得到Infinity
。
由于数值正向溢出(overflow)、负向溢出(underflow)和被0
除,JavaScript 都不报错,所以单纯的数学运算几乎没有可能抛出错误。
Infinity
大于一切数值(除了NaN
),-Infinity
小于一切数值(除了NaN
)。
Infinity > 1000 // true
-Infinity < -1000 // true
Infinity
与NaN
比较,总是返回false
。
Infinity > NaN // false
-Infinity > NaN // false
Infinity < NaN // false
-Infinity < NaN // false
(2)运算规则
Infinity
的四则运算,符合无穷的数学计算规则。
5 * Infinity // Infinity
5 - Infinity // -Infinity
Infinity / 5 // Infinity
5 / Infinity // 0
0乘以Infinity
,返回NaN
;0除以Infinity
,返回0
;Infinity
除以0,返回Infinity
。
0 * Infinity // NaN
0 / Infinity // 0
Infinity / 0 // Infinity
Infinity
加上或乘以Infinity
,返回的还是Infinity
。
Infinity + Infinity // Infinity
Infinity * Infinity // Infinity
Infinity
减去或除以Infinity
,得到NaN
。
Infinity - Infinity // NaN
Infinity / Infinity // NaN
Infinity
与null
计算时,null
会转成0,等同于与0
的计算。
null * Infinity // NaN
null / Infinity // 0
Infinity / null // Infinity
Infinity
与undefined
计算,返回的都是NaN
。
undefined + Infinity // NaN
undefined - Infinity // NaN
undefined * Infinity // NaN
undefined / Infinity // NaN
Infinity / undefined // NaN
与数值相关的全局方法
parseInt()
(1)基本用法
parseInt
方法用于将字符串转为整数。
parseInt('123') // 123
如果字符串头部有空格,空格会被自动去除。
parseInt(' 81') // 81
如果parseInt
的参数不是字符串,则会先转为字符串再转换。
parseInt(1.23) // 1
// 等同于
parseInt('1.23') // 1
字符串转为整数的时候,是一个个字符依次转换,如果遇到不能转为数字的字符,就不再进行下去,返回已经转好的部分。
parseInt('8a') // 8
parseInt('12**') // 12
parseInt('12.34') // 12
parseInt('15e2') // 15
parseInt('15px') // 15
上面代码中,parseInt
的参数都是字符串,结果只返回字符串头部可以转为数字的部分。
如果字符串的第一个字符不能转化为数字(后面跟着数字的正负号除外),返回NaN
。
parseInt('abc') // NaN
parseInt('.3') // NaN
parseInt('') // NaN
parseInt('+') // NaN
parseInt('+1') // 1
所以,parseInt
的返回值只有两种可能,要么是一个十进制整数,要么是NaN
。
如果字符串以0x
或0X
开头,parseInt
会将其按照十六进制数解析。
parseInt('0x10') // 16
如果字符串以0
开头,将其按照10进制解析。
parseInt('011') // 11
对于那些会自动转为科学计数法的数字,parseInt
会将科学计数法的表示方法视为字符串,因此导致一些奇怪的结果。
parseInt(1000000000000000000000.5) // 1
// 等同于
parseInt('1e+21') // 1
parseInt(0.0000008) // 8
// 等同于
parseInt('8e-7') // 8
(2)进制转换
parseInt
方法还可以接受第二个参数(2到36之间),表示被解析的值的进制,返回该值对应的十进制数。默认情况下,parseInt
的第二个参数为10,即默认是十进制转十进制。
parseInt('1000') // 1000
// 等同于
parseInt('1000', 10) // 1000
下面是转换指定进制的数的例子。
parseInt('1000', 2) // 8
parseInt('1000', 6) // 216
parseInt('1000', 8) // 512
上面代码中,二进制、六进制、八进制的1000
,分别等于十进制的8、216和512。这意味着,可以用parseInt
方法进行进制的转换。
如果第二个参数不是数值,会被自动转为一个整数。这个整数只有在2到36之间,才能得到有意义的结果,超出这个范围,则返回NaN
。如果第二个参数是0
、undefined
和null
,则直接忽略。
parseInt('10', 37) // NaN
parseInt('10', 1) // NaN
parseInt('10', 0) // 10
parseInt('10', null) // 10
parseInt('10', undefined) // 10
如果字符串包含对于指定进制无意义的字符,则从最高位开始,只返回可以转换的数值。如果最高位无法转换,则直接返回NaN
。
parseInt('1546', 2) // 1
parseInt('546', 2) // NaN
上面代码中,对于二进制来说,1
是有意义的字符,5
、4
、6
都是无意义的字符,所以第一行返回1,第二行返回NaN
。
前面说过,如果parseInt
的第一个参数不是字符串,会被先转为字符串。这会导致一些令人意外的结果。
parseInt(0x11, 36) // 43
parseInt(0x11, 2) // 1
// 等同于
parseInt(String(0x11), 36)
parseInt(String(0x11), 2)
// 等同于
parseInt('17', 36)
parseInt('17', 2)
上面代码中,十六进制的0x11
会被先转为十进制的17,再转为字符串。然后,再用36进制或二进制解读字符串17
,最后返回结果43
和1
。
这种处理方式,对于八进制的前缀0,尤其需要注意。
parseInt(011, 2) // NaN
// 等同于
parseInt(String(011), 2)
// 等同于
parseInt(String(9), 2)
上面代码中,第一行的011
会被先转为字符串9
,因为9
不是二进制的有效字符,所以返回NaN
。如果直接计算parseInt('011', 2)
,011
则是会被当作二进制处理,返回3。
JavaScript 不再允许将带有前缀0的数字视为八进制数,而是要求忽略这个0
。但是,为了保证兼容性,大部分浏览器并没有部署这一条规定。
parseFloat()
parseFloat
方法用于将一个字符串转为浮点数。
parseFloat('3.14') // 3.14
如果字符串符合科学计数法,则会进行相应的转换。
parseFloat('314e-2') // 3.14
parseFloat('0.0314E+2') // 3.14
如果字符串包含不能转为浮点数的字符,则不再进行往后转换,返回已经转好的部分。
parseFloat('3.14more non-digit characters') // 3.14
parseFloat
方法会自动过滤字符串前导的空格。
parseFloat('\t\v\r12.34\n ') // 12.34
如果参数不是字符串,则会先转为字符串再转换。
parseFloat([1.23]) // 1.23
// 等同于
parseFloat(String([1.23])) // 1.23
如果字符串的第一个字符不能转化为浮点数,则返回NaN
。
parseFloat([]) // NaN
parseFloat('FF2') // NaN
parseFloat('') // NaN
上面代码中,尤其值得注意,parseFloat
会将空字符串转为NaN
。
这些特点使得parseFloat
的转换结果不同于Number
函数。
parseFloat(true) // NaN
Number(true) // 1
parseFloat(null) // NaN
Number(null) // 0
parseFloat('') // NaN
Number('') // 0
parseFloat('123.45#') // 123.45
Number('123.45#') // NaN
isNaN()
isNaN
方法可以用来判断一个值是否为NaN
。
isNaN(NaN) // true
isNaN(123) // false
但是,isNaN
只对数值有效,如果传入其他值,会被先转成数值。比如,传入字符串的时候,字符串会被先转成NaN
,所以最后返回true
,这一点要特别引起注意。也就是说,isNaN
为true
的值,有可能不是NaN
,而是一个字符串。
isNaN('Hello') // true
// 相当于
isNaN(Number('Hello')) // true
出于同样的原因,对于对象和数组,isNaN
也返回true
。
isNaN({}) // true
// 等同于
isNaN(Number({})) // true
isNaN(['xzy']) // true
// 等同于
isNaN(Number(['xzy'])) // true
但是,对于空数组和只有一个数值成员的数组,isNaN
返回false
。
isNaN([]) // false
isNaN([123]) // false
isNaN(['123']) // false
上面代码之所以返回false
,原因是这些数组能被Number
函数转成数值,请参见《数据类型转换》一章。
因此,使用isNaN
之前,最好判断一下数据类型。
function myIsNaN(value) {
return typeof value === 'number' && isNaN(value);
}
判断NaN
更可靠的方法是,利用NaN
为唯一不等于自身的值的这个特点,进行判断。
function myIsNaN(value) {
return value !== value;
}
isFinite()
isFinite
方法返回一个布尔值,表示某个值是否为正常的数值。
isFinite(Infinity) // false
isFinite(-Infinity) // false
isFinite(NaN) // false
isFinite(undefined) // false
isFinite(null) // true
isFinite(-1) // true
除了Infinity
、-Infinity
、NaN
和undefined
这几个值会返回false
,isFinite
对于其他的数值都会返回true
。
参考链接
- Dr. Axel Rauschmayer, How numbers are encoded in JavaScript
- Humphry, JavaScript 中 Number 的一些表示上/下限
字符串
概述
定义
字符串就是零个或多个排在一起的字符,放在单引号或双引号之中。
'abc'
"abc"
单引号字符串的内部,可以使用双引号。双引号字符串的内部,可以使用单引号。
'key = "value"'
"It's a long journey"
上面两个都是合法的字符串。
如果要在单引号字符串的内部,使用单引号,就必须在内部的单引号前面加上反斜杠,用来转义。双引号字符串内部使用双引号,也是如此。
'Did she say \'Hello\'?'
// "Did she say 'Hello'?"
"Did she say \"Hello\"?"
// "Did she say "Hello"?"
由于 HTML 语言的属性值使用双引号,所以很多项目约定 JavaScript 语言的字符串只使用单引号,本教程遵守这个约定。当然,只使用双引号也完全可以。重要的是坚持使用一种风格,不要一会使用单引号表示字符串,一会又使用双引号表示。
字符串默认只能写在一行内,分成多行将会报错。
'a
b
c'
// SyntaxError: Unexpected token ILLEGAL
上面代码将一个字符串分成三行,JavaScript 就会报错。
如果长字符串必须分成多行,可以在每一行的尾部使用反斜杠。
var longString = 'Long \
long \
long \
string';
longString
// "Long long long string"
上面代码表示,加了反斜杠以后,原来写在一行的字符串,可以分成多行书写。但是,输出的时候还是单行,效果与写在同一行完全一样。注意,反斜杠的后面必须是换行符,而不能有其他字符(比如空格),否则会报错。
连接运算符(+
)可以连接多个单行字符串,将长字符串拆成多行书写,输出的时候也是单行。
var longString = 'Long '
+ 'long '
+ 'long '
+ 'string';
如果想输出多行字符串,有一种利用多行注释的变通方法。
(function () { /*
line 1
line 2
line 3
*/}).toString().split('\n').slice(1, -1).join('\n')
// "line 1
// line 2
// line 3"
上面的例子中,输出的字符串就是多行。
转义
反斜杠(\)在字符串内有特殊含义,用来表示一些特殊字符,所以又称为转义符。
需要用反斜杠转义的特殊字符,主要有下面这些。
\0
:null(\u0000
)\b
:后退键(\u0008
)\f
:换页符(\u000C
)\n
:换行符(\u000A
)\r
:回车键(\u000D
)\t
:制表符(\u0009
)\v
:垂直制表符(\u000B
)\'
:单引号(\u0027
)\"
:双引号(\u0022
)\\
:反斜杠(\u005C
)
上面这些字符前面加上反斜杠,都表示特殊含义。
console.log('1\n2')
// 1
// 2
上面代码中,\n
表示换行,输出的时候就分成了两行。
反斜杠还有三种特殊用法。
(1)\HHH
反斜杠后面紧跟三个八进制数(000
到377
),代表一个字符。HHH
对应该字符的 Unicode 码点,比如\251
表示版权符号。显然,这种方法只能输出256种字符。
(2)\xHH
\x
后面紧跟两个十六进制数(00
到FF
),代表一个字符。HH
对应该字符的 Unicode 码点,比如\xA9
表示版权符号。这种方法也只能输出256种字符。
(3)\uXXXX
\u
后面紧跟四个十六进制数(0000
到FFFF
),代表一个字符。XXXX
对应该字符的 Unicode 码点,比如\u00A9
表示版权符号。
下面是这三种字符特殊写法的例子。
'\251' // "©"
'\xA9' // "©"
'\u00A9' // "©"
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true
如果在非特殊字符前面使用反斜杠,则反斜杠会被省略。
'\a'
// "a"
上面代码中,a
是一个正常字符,前面加反斜杠没有特殊含义,反斜杠会被自动省略。
如果字符串的正常内容之中,需要包含反斜杠,则反斜杠前面需要再加一个反斜杠,用来对自身转义。
"Prev \\ Next"
// "Prev \ Next"
字符串与数组
字符串可以被视为字符数组,因此可以使用数组的方括号运算符,用来返回某个位置的字符(位置编号从0开始)。
var s = 'hello';
s[0] // "h"
s[1] // "e"
s[4] // "o"
// 直接对字符串使用方括号运算符
'hello'[1] // "e"
如果方括号中的数字超过字符串的长度,或者方括号中根本不是数字,则返回undefined
。
'abc'[3] // undefined
'abc'[-1] // undefined
'abc'['x'] // undefined
但是,字符串与数组的相似性仅此而已。实际上,无法改变字符串之中的单个字符。
var s = 'hello';
delete s[0];
s // "hello"
s[1] = 'a';
s // "hello"
s[5] = '!';
s // "hello"
上面代码表示,字符串内部的单个字符无法改变和增删,这些操作会默默地失败。
length 属性
length
属性返回字符串的长度,该属性也是无法改变的。
var s = 'hello';
s.length // 5
s.length = 3;
s.length // 5
s.length = 7;
s.length // 5
上面代码表示字符串的length
属性无法改变,但是不会报错。
字符集
JavaScript 使用 Unicode 字符集。JavaScript 引擎内部,所有字符都用 Unicode 表示。
JavaScript 不仅以 Unicode 储存字符,还允许直接在程序中使用 Unicode 码点表示字符,即将字符写成\uxxxx
的形式,其中xxxx
代表该字符的 Unicode 码点。比如,\u00A9
代表版权符号。
var s = '\u00A9';
s // "©"
解析代码的时候,JavaScript 会自动识别一个字符是字面形式表示,还是 Unicode 形式表示。输出给用户的时候,所有字符都会转成字面形式。
var f\u006F\u006F = 'abc';
foo // "abc"
上面代码中,第一行的变量名foo
是 Unicode 形式表示,第二行是字面形式表示。JavaScript 会自动识别。
我们还需要知道,每个字符在 JavaScript 内部都是以16位(即2个字节)的 UTF-16 格式储存。也就是说,JavaScript 的单位字符长度固定为16位长度,即2个字节。
但是,UTF-16 有两种长度:对于码点在U+0000
到U+FFFF
之间的字符,长度为16位(即2个字节);对于码点在U+10000
到U+10FFFF
之间的字符,长度为32位(即4个字节),而且前两个字节在0xD800
到0xDBFF
之间,后两个字节在0xDC00
到0xDFFF
之间。举例来说,码点U+1D306
对应的字符为𝌆,
它写成 UTF-16 就是0xD834 0xDF06
。
JavaScript 对 UTF-16 的支持是不完整的,由于历史原因,只支持两字节的字符,不支持四字节的字符。这是因为 JavaScript 第一版发布的时候,Unicode 的码点只编到U+FFFF
,因此两字节足够表示了。后来,Unicode 纳入的字符越来越多,出现了四字节的编码。但是,JavaScript 的标准此时已经定型了,统一将字符长度限制在两字节,导致无法识别四字节的字符。上一节的那个四字节字符𝌆
,浏览器会正确识别这是一个字符,但是 JavaScript 无法识别,会认为这是两个字符。
'𝌆'.length // 2
上面代码中,JavaScript 认为𝌆
的长度为2,而不是1。
总结一下,对于码点在U+10000
到U+10FFFF
之间的字符,JavaScript 总是认为它们是两个字符(length
属性为2)。所以处理的时候,必须把这一点考虑在内,也就是说,JavaScript 返回的字符串长度可能是不正确的。
Base64 转码
有时,文本里面包含一些不可打印的符号,比如 ASCII 码0到31的符号都无法打印出来,这时可以使用 Base64 编码,将它们转成可以打印的字符。另一个场景是,有时需要以文本格式传递二进制数据,那么也可以使用 Base64 编码。
所谓 Base64 就是一种编码方法,可以将任意值转成 0~9、A~Z、a-z、+
和/
这64个字符组成的可打印字符。使用它的主要目的,不是为了加密,而是为了不出现特殊字符,简化程序的处理。
JavaScript 原生提供两个 Base64 相关的方法。
btoa()
:任意值转为 Base64 编码atob()
:Base64 编码转为原来的值
var string = 'Hello World!';
btoa(string) // "SGVsbG8gV29ybGQh"
atob('SGVsbG8gV29ybGQh') // "Hello World!"
注意,这两个方法不适合非 ASCII 码的字符,会报错。
btoa('你好') // 报错
要将非 ASCII 码字符转为 Base64 编码,必须中间插入一个转码环节,再使用这两个方法。
function b64Encode(str) {
return btoa(encodeURIComponent(str));
}
function b64Decode(str) {
return decodeURIComponent(atob(str));
}
b64Encode('你好') // "JUU0JUJEJUEwJUU1JUE1JUJE"
b64Decode('JUU0JUJEJUEwJUU1JUE1JUJE') // "你好"
参考链接
- Mathias Bynens, JavaScript’s internal character encoding: UCS-2 or UTF-16?
- Mathias Bynens, JavaScript has a Unicode problem
- Mozilla Developer Network, Window.btoa
对象
概述
生成方法
对象(object)是 JavaScript 语言的核心概念,也是最重要的数据类型。
什么是对象?简单说,对象就是一组“键值对”(key-value)的集合,是一种无序的复合数据集合。
var obj = {
foo: 'Hello',
bar: 'World'
};
上面代码中,大括号就定义了一个对象,它被赋值给变量obj
,所以变量obj
就指向一个对象。该对象内部包含两个键值对(又称为两个“成员”),第一个键值对是foo: 'Hello'
,其中foo
是“键名”(成员的名称),字符串Hello
是“键值”(成员的值)。键名与键值之间用冒号分隔。第二个键值对是bar: 'World'
,bar
是键名,World
是键值。两个键值对之间用逗号分隔。
键名
对象的所有键名都是字符串(ES6 又引入了 Symbol 值也可以作为键名),所以加不加引号都可以。上面的代码也可以写成下面这样。
var obj = {
'foo': 'Hello',
'bar': 'World'
};
如果键名是数值,会被自动转为字符串。
var obj = {
1: 'a',
3.2: 'b',
1e2: true,
1e-2: true,
.234: true,
0xFF: true
};
obj
// Object {
// 1: "a",
// 3.2: "b",
// 100: true,
// 0.01: true,
// 0.234: true,
// 255: true
// }
obj['100'] // true
上面代码中,对象obj
的所有键名虽然看上去像数值,实际上都被自动转成了字符串。
如果键名不符合标识名的条件(比如第一个字符为数字,或者含有空格或运算符),且也不是数字,则必须加上引号,否则会报错。
// 报错
var obj = {
1p: 'Hello World'
};
// 不报错
var obj = {
'1p': 'Hello World',
'h w': 'Hello World',
'p+q': 'Hello World'
};
上面对象的三个键名,都不符合标识名的条件,所以必须加上引号。
对象的每一个键名又称为“属性”(property),它的“键值”可以是任何数据类型。如果一个属性的值为函数,通常把这个属性称为“方法”,它可以像函数那样调用。
var obj = {
p: function (x) {
return 2 * x;
}
};
obj.p(1) // 2
上面代码中,对象obj
的属性p
,就指向一个函数。
如果属性的值还是一个对象,就形成了链式引用。
var o1 = {};
var o2 = { bar: 'hello' };
o1.foo = o2;
o1.foo.bar // "hello"
上面代码中,对象o1
的属性foo
指向对象o2
,就可以链式引用o2
的属性。
对象的属性之间用逗号分隔,最后一个属性后面可以加逗号(trailing comma),也可以不加。
var obj = {
p: 123,
m: function () { ... },
}
上面的代码中,m
属性后面的那个逗号,有没有都可以。
属性可以动态创建,不必在对象声明时就指定。
var obj = {};
obj.foo = 123;
obj.foo // 123
上面代码中,直接对obj
对象的foo
属性赋值,结果就在运行时创建了foo
属性。
对象的引用
如果不同的变量名指向同一个对象,那么它们都是这个对象的引用,也就是说指向同一个内存地址。修改其中一个变量,会影响到其他所有变量。
var o1 = {};
var o2 = o1;
o1.a = 1;
o2.a // 1
o2.b = 2;
o1.b // 2
上面代码中,o1
和o2
指向同一个对象,因此为其中任何一个变量添加属性,另一个变量都可以读写该属性。
此时,如果取消某一个变量对于原对象的引用,不会影响到另一个变量。
var o1 = {};
var o2 = o1;
o1 = 1;
o2 // {}
上面代码中,o1
和o2
指向同一个对象,然后o1
的值变为1,这时不会对o2
产生影响,o2
还是指向原来的那个对象。
但是,这种引用只局限于对象,如果两个变量指向同一个原始类型的值。那么,变量这时都是值的拷贝。
var x = 1;
var y = x;
x = 2;
y // 1
上面的代码中,当x
的值发生变化后,y
的值并不变,这就表示y
和x
并不是指向同一个内存地址。
表达式还是语句?
对象采用大括号表示,这导致了一个问题:如果行首是一个大括号,它到底是表达式还是语句?
{ foo: 123 }
JavaScript 引擎读到上面这行代码,会发现可能有两种含义。第一种可能是,这是一个表达式,表示一个包含foo
属性的对象;第二种可能是,这是一个语句,表示一个代码区块,里面有一个标签foo
,指向表达式123
。
为了避免这种歧义,JavaScript 引擎的做法是,如果遇到这种情况,无法确定是对象还是代码块,一律解释为代码块。
{ console.log(123) } // 123
上面的语句是一个代码块,而且只有解释为代码块,才能执行。
如果要解释为对象,最好在大括号前加上圆括号。因为圆括号的里面,只能是表达式,所以确保大括号只能解释为对象。
({ foo: 123 }) // 正确
({ console.log(123) }) // 报错
这种差异在eval
语句(作用是对字符串求值)中反映得最明显。
eval('{foo: 123}') // 123
eval('({foo: 123})') // {foo: 123}
上面代码中,如果没有圆括号,eval
将其理解为一个代码块;加上圆括号以后,就理解成一个对象。
属性的操作
属性的读取
读取对象的属性,有两种方法,一种是使用点运算符,还有一种是使用方括号运算符。
var obj = {
p: 'Hello World'
};
obj.p // "Hello World"
obj['p'] // "Hello World"
上面代码分别采用点运算符和方括号运算符,读取属性p
。
请注意,如果使用方括号运算符,键名必须放在引号里面,否则会被当作变量处理。
var foo = 'bar';
var obj = {
foo: 1,
bar: 2
};
obj.foo // 1
obj[foo] // 2
上面代码中,引用对象obj
的foo
属性时,如果使用点运算符,foo
就是字符串;如果使用方括号运算符,但是不使用引号,那么foo
就是一个变量,指向字符串bar
。
方括号运算符内部还可以使用表达式。
obj['hello' + ' world']
obj[3 + 3]
数字键可以不加引号,因为会自动转成字符串。
var obj = {
0.7: 'Hello World'
};
obj['0.7'] // "Hello World"
obj[0.7] // "Hello World"
上面代码中,对象obj
的数字键0.7
,加不加引号都可以,因为会被自动转为字符串。
注意,数值键名不能使用点运算符(因为会被当成小数点),只能使用方括号运算符。
var obj = {
123: 'hello world'
};
obj.123 // 报错
obj[123] // "hello world"
上面代码的第一个表达式,对数值键名123
使用点运算符,结果报错。第二个表达式使用方括号运算符,结果就是正确的。
属性的赋值
点运算符和方括号运算符,不仅可以用来读取值,还可以用来赋值。
var obj = {};
obj.foo = 'Hello';
obj['bar'] = 'World';
上面代码中,分别使用点运算符和方括号运算符,对属性赋值。
JavaScript 允许属性的“后绑定”,也就是说,你可以在任意时刻新增属性,没必要在定义对象的时候,就定义好属性。
var obj = { p: 1 };
// 等价于
var obj = {};
obj.p = 1;
属性的查看
查看一个对象本身的所有属性,可以使用Object.keys
方法。
var obj = {
key1: 1,
key2: 2
};
Object.keys(obj);
// ['key1', 'key2']
属性的删除:delete 命令
delete
命令用于删除对象的属性,删除成功后返回true
。
var obj = { p: 1 };
Object.keys(obj) // ["p"]
delete obj.p // true
obj.p // undefined
Object.keys(obj) // []
上面代码中,delete
命令删除对象obj
的p
属性。删除后,再读取p
属性就会返回undefined
,而且Object.keys
方法的返回值也不再包括该属性。
注意,删除一个不存在的属性,delete
不报错,而且返回true
。
var obj = {};
delete obj.p // true
上面代码中,对象obj
并没有p
属性,但是delete
命令照样返回true
。因此,不能根据delete
命令的结果,认定某个属性是存在的。
只有一种情况,delete
命令会返回false
,那就是该属性存在,且不得删除。
var obj = Object.defineProperty({}, 'p', {
value: 123,
configurable: false
});
obj.p // 123
delete obj.p // false
上面代码之中,对象obj
的p
属性是不能删除的,所以delete
命令返回false
(关于Object.defineProperty
方法的介绍,请看《标准库》的 Object 对象一章)。
另外,需要注意的是,delete
命令只能删除对象本身的属性,无法删除继承的属性(关于继承参见《面向对象编程》章节)。
var obj = {};
delete obj.toString // true
obj.toString // function toString() { [native code] }
上面代码中,toString
是对象obj
继承的属性,虽然delete
命令返回true
,但该属性并没有被删除,依然存在。这个例子还说明,即使delete
返回true
,该属性依然可能读取到值。
属性是否存在:in 运算符
in
运算符用于检查对象是否包含某个属性(注意,检查的是键名,不是键值),如果包含就返回true
,否则返回false
。它的左边是一个字符串,表示属性名,右边是一个对象。
var obj = { p: 1 };
'p' in obj // true
'toString' in obj // true
in
运算符的一个问题是,它不能识别哪些属性是对象自身的,哪些属性是继承的。就像上面代码中,对象obj
本身并没有toString
属性,但是in
运算符会返回true
,因为这个属性是继承的。
这时,可以使用对象的hasOwnProperty
方法判断一下,是否为对象自身的属性。
var obj = {};
if ('toString' in obj) {
console.log(obj.hasOwnProperty('toString')) // false
}
属性的遍历:for...in 循环
for...in
循环用来遍历一个对象的全部属性。
var obj = {a: 1, b: 2, c: 3};
for (var i in obj) {
console.log('键名:', i);
console.log('键值:', obj[i]);
}
// 键名: a
// 键值: 1
// 键名: b
// 键值: 2
// 键名: c
// 键值: 3
for...in
循环有两个使用注意点。
- 它遍历的是对象所有可遍历(enumerable)的属性,会跳过不可遍历的属性。
- 它不仅遍历对象自身的属性,还遍历继承的属性。
举例来说,对象都继承了toString
属性,但是for...in
循环不会遍历到这个属性。
var obj = {};
// toString 属性是存在的
obj.toString // toString() { [native code] }
for (var p in obj) {
console.log(p);
} // 没有任何输出
上面代码中,对象obj
继承了toString
属性,该属性不会被for...in
循环遍历到,因为它默认是“不可遍历”的。关于对象属性的可遍历性,参见《标准库》章节中 Object 一章的介绍。
如果继承的属性是可遍历的,那么就会被for...in
循环遍历到。但是,一般情况下,都是只想遍历对象自身的属性,所以使用for...in
的时候,应该结合使用hasOwnProperty
方法,在循环内部判断一下,某个属性是否为对象自身的属性。
var person = { name: '老张' };
for (var key in person) {
if (person.hasOwnProperty(key)) {
console.log(key);
}
}
// name
with 语句
with
语句的格式如下:
with (对象) {
语句;
}
它的作用是操作同一个对象的多个属性时,提供一些书写的方便。
// 例一
var obj = {
p1: 1,
p2: 2,
};
with (obj) {
p1 = 4;
p2 = 5;
}
// 等同于
obj.p1 = 4;
obj.p2 = 5;
// 例二
with (document.links[0]){
console.log(href);
console.log(title);
console.log(style);
}
// 等同于
console.log(document.links[0].href);
console.log(document.links[0].title);
console.log(document.links[0].style);
注意,如果with
区块内部有变量的赋值操作,必须是当前对象已经存在的属性,否则会创造一个当前作用域的全局变量。
var obj = {};
with (obj) {
p1 = 4;
p2 = 5;
}
obj.p1 // undefined
p1 // 4
上面代码中,对象obj
并没有p1
属性,对p1
赋值等于创造了一个全局变量p1
。正确的写法应该是,先定义对象obj
的属性p1
,然后在with
区块内操作它。
这是因为with
区块没有改变作用域,它的内部依然是当前作用域。这造成了with
语句的一个很大的弊病,就是绑定对象不明确。
with (obj) {
console.log(x);
}
单纯从上面的代码块,根本无法判断x
到底是全局变量,还是对象obj
的一个属性。这非常不利于代码的除错和模块化,编译器也无法对这段代码进行优化,只能留到运行时判断,这就拖慢了运行速度。因此,建议不要使用with
语句,可以考虑用一个临时变量代替with
。
with(obj1.obj2.obj3) {
console.log(p1 + p2);
}
// 可以写成
var temp = obj1.obj2.obj3;
console.log(temp.p1 + temp.p2);
参考链接
- Dr. Axel Rauschmayer,Object properties in JavaScript
- Lakshan Perera, Revisiting JavaScript Objects
- Angus Croll, The Secret Life of JavaScript Primitives
- Dr. Axel Rauschmayer, JavaScript’s with statement and why it’s deprecated
函数
函数是一段可以反复调用的代码块。函数还能接受输入的参数,不同的参数有唯一对应的返回值。
概述
函数的声明
JavaScript 有三种声明函数的方法。
(1)function 命令
function
命令声明的代码区块,就是一个函数。function
命令后面是函数名,函数名后面是一对圆括号,里面是传入函数的参数。函数体放在大括号里面。
function print(s) {
console.log(s);
}
上面的代码命名了一个print
函数,以后使用print()
这种形式,就可以调用相应的代码。这叫做函数的声明(Function Declaration)。
(2)函数表达式
除了用function
命令声明函数,还可以采用变量赋值的写法。
var print = function(s) {
console.log(s);
};
这种写法将一个匿名函数赋值给变量。这时,这个匿名函数又称函数表达式(Function Expression),因为赋值语句的等号右侧只能放表达式。
采用函数表达式声明函数时,function
命令后面不带有函数名。如果加上函数名,该函数名只在函数体内部有效,在函数体外部无效。
var print = function x(){
console.log(typeof x);
};
x
// ReferenceError: x is not defined
print()
// function
上面代码在函数表达式中,加入了函数名x
。这个x
只在函数体内部可用,指代函数表达式本身,其他地方都不可用。这种写法的用处有两个,一是可以在函数体内部调用自身,二是方便除错(除错工具显示函数调用栈时,将显示函数名,而不再显示这里是一个匿名函数)。因此,下面的形式声明函数也非常常见。
var f = function f() {};
需要注意的是,函数的表达式需要在语句的结尾加上分号,表示语句结束。而函数的声明在结尾的大括号后面不用加分号。总的来说,这两种声明函数的方式,差别很细微,可以近似认为是等价的。
(3)Function 构造函数
第三种声明函数的方式是Function
构造函数。
var add = new Function(
'x',
'y',
'return x + y'
);
// 等同于
function add(x, y) {
return x + y;
}
上面代码中,Function
构造函数接受三个参数,除了最后一个参数是add
函数的“函数体”,其他参数都是add
函数的参数。
你可以传递任意数量的参数给Function
构造函数,只有最后一个参数会被当做函数体,如果只有一个参数,该参数就是函数体。
var foo = new Function(
'return "hello world";'
);
// 等同于
function foo() {
return 'hello world';
}
Function
构造函数可以不使用new
命令,返回结果完全一样。
总的来说,这种声明函数的方式非常不直观,几乎无人使用。
函数的重复声明
如果同一个函数被多次声明,后面的声明就会覆盖前面的声明。
function f() {
console.log(1);
}
f() // 2
function f() {
console.log(2);
}
f() // 2
上面代码中,后一次的函数声明覆盖了前面一次。而且,由于函数名的提升(参见下文),前一次声明在任何时候都是无效的,这一点要特别注意。
圆括号运算符,return 语句和递归
调用函数时,要使用圆括号运算符。圆括号之中,可以加入函数的参数。
function add(x, y) {
return x + y;
}
add(1, 1) // 2
上面代码中,函数名后面紧跟一对圆括号,就会调用这个函数。
函数体内部的return
语句,表示返回。JavaScript 引擎遇到return
语句,就直接返回return
后面的那个表达式的值,后面即使还有语句,也不会得到执行。也就是说,return
语句所带的那个表达式,就是函数的返回值。return
语句不是必需的,如果没有的话,该函数就不返回任何值,或者说返回undefined
。
函数可以调用自身,这就是递归(recursion)。下面就是通过递归,计算斐波那契数列的代码。
function fib(num) {
if (num === 0) return 0;
if (num === 1) return 1;
return fib(num - 2) + fib(num - 1);
}
fib(6) // 8
上面代码中,fib
函数内部又调用了fib
,计算得到斐波那契数列的第6个元素是8。
第一等公民
JavaScript 语言将函数看作一种值,与其它值(数值、字符串、布尔值等等)地位相同。凡是可以使用值的地方,就能使用函数。比如,可以把函数赋值给变量和对象的属性,也可以当作参数传入其他函数,或者作为函数的结果返回。函数只是一个可以执行的值,此外并无特殊之处。
由于函数与其他数据类型地位平等,所以在 JavaScript 语言中又称函数为第一等公民。
function add(x, y) {
return x + y;
}
// 将函数赋值给一个变量
var operator = add;
// 将函数作为参数和返回值
function a(op){
return op;
}
a(add)(1, 1)
// 2
函数名的提升
JavaScript 引擎将函数名视同变量名,所以采用function
命令声明函数时,整个函数会像变量声明一样,被提升到代码头部。所以,下面的代码不会报错。
f();
function f() {}
表面上,上面代码好像在声明之前就调用了函数f
。但是实际上,由于“变量提升”,函数f
被提升到了代码头部,也就是在调用之前已经声明了。但是,如果采用赋值语句定义函数,JavaScript 就会报错。
f();
var f = function (){};
// TypeError: undefined is not a function
上面的代码等同于下面的形式。
var f;
f();
f = function () {};
上面代码第二行,调用f
的时候,f
只是被声明了,还没有被赋值,等于undefined
,所以会报错。
注意,如果像下面例子那样,采用function
命令和var
赋值语句声明同一个函数,由于存在函数提升,最后会采用var
赋值语句的定义。
var f = function () {
console.log('1');
}
function f() {
console.log('2');
}
f() // 1
上面例子中,表面上后面声明的函数f
,应该覆盖前面的var
赋值语句,但是由于存在函数提升,实际上正好反过来。
函数的属性和方法
name 属性
函数的name
属性返回函数的名字。
function f1() {}
f1.name // "f1"
如果是通过变量赋值定义的函数,那么name
属性返回变量名。
var f2 = function () {};
f2.name // "f2"
但是,上面这种情况,只有在变量的值是一个匿名函数时才是如此。如果变量的值是一个具名函数,那么name
属性返回function
关键字之后的那个函数名。
var f3 = function myName() {};
f3.name // 'myName'
上面代码中,f3.name
返回函数表达式的名字。注意,真正的函数名还是f3
,而myName
这个名字只在函数体内部可用。
name
属性的一个用处,就是获取参数函数的名字。
var myFunc = function () {};
function test(f) {
console.log(f.name);
}
test(myFunc) // myFunc
上面代码中,函数test
内部通过name
属性,就可以知道传入的参数是什么函数。
length 属性
函数的length
属性返回函数预期传入的参数个数,即函数定义之中的参数个数。
function f(a, b) {}
f.length // 2
上面代码定义了空函数f
,它的length
属性就是定义时的参数个数。不管调用时输入了多少个参数,length
属性始终等于2。
length
属性提供了一种机制,判断定义时和调用时参数的差异,以便实现面向对象编程的“方法重载”(overload)。
toString()
函数的toString()
方法返回一个字符串,内容是函数的源码。
function f() {
a();
b();
c();
}
f.toString()
// function f() {
// a();
// b();
// c();
// }
上面示例中,函数f
的toString()
方法返回了f
的源码,包含换行符在内。
对于那些原生的函数,toString()
方法返回function (){[native code]}
。
Math.sqrt.toString()
// "function sqrt() { [native code] }"
上面代码中,Math.sqrt()
是 JavaScript 引擎提供的原生函数,toString()
方法就返回原生代码的提示。
函数内部的注释也可以返回。
function f() {/*
这是一个
多行注释
*/}
f.toString()
// "function f(){/*
// 这是一个
// 多行注释
// */}"
利用这一点,可以变相实现多行字符串。
var multiline = function (fn) {
var arr = fn.toString().split('\n');
return arr.slice(1, arr.length - 1).join('\n');
};
function f() {/*
这是一个
多行注释
*/}
multiline(f);
// " 这是一个
// 多行注释"
上面示例中,函数f
内部有一个多行注释,toString()
方法拿到f
的源码后,去掉首尾两行,就得到了一个多行字符串。
函数作用域
定义
作用域(scope)指的是变量存在的范围。在 ES5 的规范中,JavaScript 只有两种作用域:一种是全局作用域,变量在整个程序中一直存在,所有地方都可以读取;另一种是函数作用域,变量只在函数内部存在。ES6 又新增了块级作用域,本教程不涉及。
对于顶层函数来说,函数外部声明的变量就是全局变量(global variable),它可以在函数内部读取。
var v = 1;
function f() {
console.log(v);
}
f()
// 1
上面的代码表明,函数f
内部可以读取全局变量v
。
在函数内部定义的变量,外部无法读取,称为“局部变量”(local variable)。
function f(){
var v = 1;
}
v // ReferenceError: v is not defined
上面代码中,变量v
在函数内部定义,所以是一个局部变量,函数之外就无法读取。
函数内部定义的变量,会在该作用域内覆盖同名全局变量。
var v = 1;
function f(){
var v = 2;
console.log(v);
}
f() // 2
v // 1
上面代码中,变量v
同时在函数的外部和内部有定义。结果,在函数内部定义,局部变量v
覆盖了全局变量v
。
注意,对于var
命令来说,局部变量只能在函数内部声明,在其他区块中声明,一律都是全局变量。
if (true) {
var x = 5;
}
console.log(x); // 5
上面代码中,变量x
在条件判断区块之中声明,结果就是一个全局变量,可以在区块之外读取。
函数内部的变量提升
与全局作用域一样,函数作用域内部也会产生“变量提升”现象。var
命令声明的变量,不管在什么位置,变量声明都会被提升到函数体的头部。
function foo(x) {
if (x > 100) {
var tmp = x - 100;
}
}
// 等同于
function foo(x) {
var tmp;
if (x > 100) {
tmp = x - 100;
};
}
函数本身的作用域
函数本身也是一个值,也有自己的作用域。它的作用域与变量一样,就是其声明时所在的作用域,与其运行时所在的作用域无关。
var a = 1;
var x = function () {
console.log(a);
};
function f() {
var a = 2;
x();
}
f() // 1
上面代码中,函数x
是在函数f
的外部声明的,所以它的作用域绑定外层,内部变量a
不会到函数f
体内取值,所以输出1
,而不是2
。
总之,函数执行时所在的作用域,是定义时的作用域,而不是调用时所在的作用域。
很容易犯错的一点是,如果函数A
调用函数B
,却没考虑到函数B
不会引用函数A
的内部变量。
var x = function () {
console.log(a);
};
function y(f) {
var a = 2;
f();
}
y(x)
// ReferenceError: a is not defined
上面代码将函数x
作为参数,传入函数y
。但是,函数x
是在函数y
体外声明的,作用域绑定外层,因此找不到函数y
的内部变量a
,导致报错。
同样的,函数体内部声明的函数,作用域绑定函数体内部。
function foo() {
var x = 1;
function bar() {
console.log(x);
}
return bar;
}
var x = 2;
var f = foo();
f() // 1
上面代码中,函数foo
内部声明了一个函数bar
,bar
的作用域绑定foo
。当我们在foo
外部取出bar
执行时,变量x
指向的是foo
内部的x
,而不是foo
外部的x
。正是这种机制,构成了下文要讲解的“闭包”现象。
参数
概述
函数运行的时候,有时需要提供外部数据,不同的外部数据会得到不同的结果,这种外部数据就叫参数。
function square(x) {
return x * x;
}
square(2) // 4
square(3) // 9
上式的x
就是square
函数的参数。每次运行的时候,需要提供这个值,否则得不到结果。
参数的省略
函数参数不是必需的,JavaScript 允许省略参数。
function f(a, b) {
return a;
}
f(1, 2, 3) // 1
f(1) // 1
f() // undefined
f.length // 2
上面代码的函数f
定义了两个参数,但是运行时无论提供多少个参数(或者不提供参数),JavaScript 都不会报错。省略的参数的值就变为undefined
。需要注意的是,函数的length
属性与实际传入的参数个数无关,只反映函数预期传入的参数个数。
但是,没有办法只省略靠前的参数,而保留靠后的参数。如果一定要省略靠前的参数,只有显式传入undefined
。
function f(a, b) {
return a;
}
f( , 1) // SyntaxError: Unexpected token ,(…)
f(undefined, 1) // undefined
上面代码中,如果省略第一个参数,就会报错。
传递方式
函数参数如果是原始类型的值(数值、字符串、布尔值),传递方式是传值传递(passes by value)。这意味着,在函数体内修改参数值,不会影响到函数外部。
var p = 2;
function f(p) {
p = 3;
}
f(p);
p // 2
上面代码中,变量p
是一个原始类型的值,传入函数f
的方式是传值传递。因此,在函数内部,p
的值是原始值的拷贝,无论怎么修改,都不会影响到原始值。
但是,如果函数参数是复合类型的值(数组、对象、其他函数),传递方式是传址传递(pass by reference)。也就是说,传入函数的原始值的地址,因此在函数内部修改参数,将会影响到原始值。
var obj = { p: 1 };
function f(o) {
o.p = 2;
}
f(obj);
obj.p // 2
上面代码中,传入函数f
的是参数对象obj
的地址。因此,在函数内部修改obj
的属性p
,会影响到原始值。
注意,如果函数内部修改的,不是参数对象的某个属性,而是替换掉整个参数,这时不会影响到原始值。
var obj = [1, 2, 3];
function f(o) {
o = [2, 3, 4];
}
f(obj);
obj // [1, 2, 3]
上面代码中,在函数f()
内部,参数对象obj
被整个替换成另一个值。这时不会影响到原始值。这是因为,形式参数(o
)的值实际是参数obj
的地址,重新对o
赋值导致o
指向另一个地址,保存在原地址上的值当然不受影响。
同名参数
如果有同名的参数,则取最后出现的那个值。
function f(a, a) {
console.log(a);
}
f(1, 2) // 2
上面代码中,函数f()
有两个参数,且参数名都是a
。取值的时候,以后面的a
为准,即使后面的a
没有值或被省略,也是以其为准。
function f(a, a) {
console.log(a);
}
f(1) // undefined
调用函数f()
的时候,没有提供第二个参数,a
的取值就变成了undefined
。这时,如果要获得第一个a
的值,可以使用arguments
对象。
function f(a, a) {
console.log(arguments[0]);
}
f(1) // 1
arguments 对象
(1)定义
由于 JavaScript 允许函数有不定数目的参数,所以需要一种机制,可以在函数体内部读取所有参数。这就是arguments
对象的由来。
arguments
对象包含了函数运行时的所有参数,arguments[0]
就是第一个参数,arguments[1]
就是第二个参数,以此类推。这个对象只有在函数体内部,才可以使用。
var f = function (one) {
console.log(arguments[0]);
console.log(arguments[1]);
console.log(arguments[2]);
}
f(1, 2, 3)
// 1
// 2
// 3
正常模式下,arguments
对象可以在运行时修改。
var f = function(a, b) {
arguments[0] = 3;
arguments[1] = 2;
return a + b;
}
f(1, 1) // 5
上面代码中,函数f()
调用时传入的参数,在函数内部被修改成3
和2
。
严格模式下,arguments
对象与函数参数不具有联动关系。也就是说,修改arguments
对象不会影响到实际的函数参数。
var f = function(a, b) {
'use strict'; // 开启严格模式
arguments[0] = 3;
arguments[1] = 2;
return a + b;
}
f(1, 1) // 2
上面代码中,函数体内是严格模式,这时修改arguments
对象,不会影响到真实参数a
和b
。
通过arguments
对象的length
属性,可以判断函数调用时到底带几个参数。
function f() {
return arguments.length;
}
f(1, 2, 3) // 3
f(1) // 1
f() // 0
(2)与数组的关系
需要注意的是,虽然arguments
很像数组,但它是一个对象。数组专有的方法(比如slice
和forEach
),不能在arguments
对象上直接使用。
如果要让arguments
对象使用数组方法,真正的解决方法是将arguments
转为真正的数组。下面是两种常用的转换方法:slice
方法和逐一填入新数组。
var args = Array.prototype.slice.call(arguments);
// 或者
var args = [];
for (var i = 0; i < arguments.length; i++) {
args.push(arguments[i]);
}
(3)callee 属性
arguments
对象带有一个callee
属性,返回它所对应的原函数。
var f = function () {
console.log(arguments.callee === f);
}
f() // true
可以通过arguments.callee
,达到调用函数自身的目的。这个属性在严格模式里面是禁用的,因此不建议使用。
函数的其他知识点
闭包
闭包(closure)是 JavaScript 语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现。
理解闭包,首先必须理解变量作用域。前面提到,JavaScript 有两种作用域:全局作用域和函数作用域。函数内部可以直接读取全局变量。
var n = 999;
function f1() {
console.log(n);
}
f1() // 999
上面代码中,函数f1
可以读取全局变量n
。
但是,正常情况下,函数外部无法读取函数内部声明的变量。
function f1() {
var n = 999;
}
console.log(n)
// Uncaught ReferenceError: n is not defined(
上面代码中,函数f1
内部声明的变量n
,函数外是无法读取的。
如果出于种种原因,需要得到函数内的局部变量。正常情况下,这是办不到的,只有通过变通方法才能实现。那就是在函数的内部,再定义一个函数。
function f1() {
var n = 999;
function f2() {
console.log(n); // 999
}
}
上面代码中,函数f2
就在函数f1
内部,这时f1
内部的所有局部变量,对f2
都是可见的。但是反过来就不行,f2
内部的局部变量,对f1
就是不可见的。这就是 JavaScript 语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。
既然f2
可以读取f1
的局部变量,那么只要把f2
作为返回值,我们不就可以在f1
外部读取它的内部变量了吗!
function f1() {
var n = 999;
function f2() {
console.log(n);
}
return f2;
}
var result = f1();
result(); // 999
上面代码中,函数f1
的返回值就是函数f2
,由于f2
可以读取f1
的内部变量,所以就可以在外部获得f1
的内部变量了。
闭包就是函数f2
,即能够读取其他函数内部变量的函数。由于在 JavaScript 语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。闭包最大的特点,就是它可以“记住”诞生的环境,比如f2
记住了它诞生的环境f1
,所以从f2
可以得到f1
的内部变量。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
闭包的最大用处有两个,一个是可以读取外层函数内部的变量,另一个就是让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。请看下面的例子,闭包使得内部变量记住上一次调用时的运算结果。
function createIncrementor(start) {
return function () {
return start++;
};
}
var inc = createIncrementor(5);
inc() // 5
inc() // 6
inc() // 7
上面代码中,start
是函数createIncrementor
的内部变量。通过闭包,start
的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看到,闭包inc
使得函数createIncrementor
的内部环境,一直存在。所以,闭包可以看作是函数内部作用域的一个接口。
为什么闭包能够返回外层函数的内部变量?原因是闭包(上例的inc
)用到了外层变量(start
),导致外层函数(createIncrementor
)不能从内存释放。只要闭包没有被垃圾回收机制清除,外层函数提供的运行环境也不会被清除,它的内部变量就始终保存着当前值,供闭包读取。
闭包的另一个用处,是封装对象的私有属性和私有方法。
function Person(name) {
var _age;
function setAge(n) {
_age = n;
}
function getAge() {
return _age;
}
return {
name: name,
getAge: getAge,
setAge: setAge
};
}
var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25
上面代码中,函数Person
的内部变量_age
,通过闭包getAge
和setAge
,变成了返回对象p1
的私有变量。
注意,外层函数每次运行,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。
立即调用的函数表达式(IIFE)
根据 JavaScript 的语法,圆括号()
跟在函数名之后,表示调用该函数。比如,print()
就表示调用print
函数。
有时,我们需要在定义函数之后,立即调用该函数。这时,你不能在函数的定义之后加上圆括号,这会产生语法错误。
function(){ /* code */ }();
// SyntaxError: Unexpected token (
产生这个错误的原因是,function
这个关键字既可以当作语句,也可以当作表达式。
// 语句
function f() {}
// 表达式
var f = function f() {}
当作表达式时,函数可以定义后直接加圆括号调用。
var f = function f(){ return 1}();
f // 1
上面的代码中,函数定义后直接加圆括号调用,没有报错。原因就是function
作为表达式,引擎就把函数定义当作一个值。这种情况下,就不会报错。
为了避免解析的歧义,JavaScript 规定,如果function
关键字出现在行首,一律解释成语句。因此,引擎看到行首是function
关键字之后,认为这一段都是函数的定义,不应该以圆括号结尾,所以就报错了。
函数定义后立即调用的解决方法,就是不要让function
出现在行首,让引擎将其理解成一个表达式。最简单的处理,就是将其放在一个圆括号里面。
(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();
上面两种写法都是以圆括号开头,引擎就会认为后面跟的是一个表达式,而不是函数定义语句,所以就避免了错误。这就叫做“立即调用的函数表达式”(Immediately-Invoked Function Expression),简称 IIFE。
注意,上面两种写法最后的分号都是必须的。如果省略分号,遇到连着两个 IIFE,可能就会报错。
// 报错
(function(){ /* code */ }())
(function(){ /* code */ }())
上面代码的两行之间没有分号,JavaScript 会将它们连在一起解释,将第二行解释为第一行的参数。
推而广之,任何让解释器以表达式来处理函数定义的方法,都能产生同样的效果,比如下面三种写法。
var i = function(){ return 10; }();
true && function(){ /* code */ }();
0, function(){ /* code */ }();
甚至像下面这样写,也是可以的。
!function () { /* code */ }();
~function () { /* code */ }();
-function () { /* code */ }();
+function () { /* code */ }();
通常情况下,只对匿名函数使用这种“立即执行的函数表达式”。它的目的有两个:一是不必为函数命名,避免了污染全局变量;二是 IIFE 内部形成了一个单独的作用域,可以封装一些外部无法读取的私有变量。
// 写法一
var tmp = newData;
processData(tmp);
storeData(tmp);
// 写法二
(function () {
var tmp = newData;
processData(tmp);
storeData(tmp);
}());
上面代码中,写法二比写法一更好,因为完全避免了污染全局变量。
eval 命令
基本用法
eval
命令接受一个字符串作为参数,并将这个字符串当作语句执行。
eval('var a = 1;');
a // 1
上面代码将字符串当作语句运行,生成了变量a
。
如果参数字符串无法当作语句运行,那么就会报错。
eval('3x') // Uncaught SyntaxError: Invalid or unexpected token
放在eval
中的字符串,应该有独自存在的意义,不能用来与eval
以外的命令配合使用。举例来说,下面的代码将会报错。
eval('return;'); // Uncaught SyntaxError: Illegal return statement
上面代码会报错,因为return
不能单独使用,必须在函数中使用。
如果eval
的参数不是字符串,那么会原样返回。
eval(123) // 123
eval
没有自己的作用域,都在当前作用域内执行,因此可能会修改当前作用域的变量的值,造成安全问题。
var a = 1;
eval('a = 2');
a // 2
上面代码中,eval
命令修改了外部变量a
的值。由于这个原因,eval
有安全风险。
为了防止这种风险,JavaScript 规定,如果使用严格模式,eval
内部声明的变量,不会影响到外部作用域。
(function f() {
'use strict';
eval('var foo = 123');
console.log(foo); // ReferenceError: foo is not defined
})()
上面代码中,函数f
内部是严格模式,这时eval
内部声明的foo
变量,就不会影响到外部。
不过,即使在严格模式下,eval
依然可以读写当前作用域的变量。
(function f() {
'use strict';
var foo = 1;
eval('foo = 2');
console.log(foo); // 2
})()
上面代码中,严格模式下,eval
内部还是改写了外部变量,可见安全风险依然存在。
总之,eval
的本质是在当前作用域之中,注入代码。由于安全风险和不利于 JavaScript 引擎优化执行速度,一般不推荐使用。通常情况下,eval
最常见的场合是解析 JSON 数据的字符串,不过正确的做法应该是使用原生的JSON.parse
方法。
eval 的别名调用
前面说过eval
不利于引擎优化执行速度。更麻烦的是,还有下面这种情况,引擎在静态代码分析的阶段,根本无法分辨执行的是eval
。
var m = eval;
m('var x = 1');
x // 1
上面代码中,变量m
是eval
的别名。静态代码分析阶段,引擎分辨不出m('var x = 1')
执行的是eval
命令。
为了保证eval
的别名不影响代码优化,JavaScript 的标准规定,凡是使用别名执行eval
,eval
内部一律是全局作用域。
var a = 1;
function f() {
var a = 2;
var e = eval;
e('console.log(a)');
}
f() // 1
上面代码中,eval
是别名调用,所以即使它是在函数中,它的作用域还是全局作用域,因此输出的a
为全局变量。这样的话,引擎就能确认e()
不会对当前的函数作用域产生影响,优化的时候就可以把这一行排除掉。
eval
的别名调用的形式五花八门,只要不是直接调用,都属于别名调用,因为引擎只能分辨eval()
这一种形式是直接调用。
eval.call(null, '...')
window.eval('...')
(1, eval)('...')
(eval, eval)('...')
上面这些形式都是eval
的别名调用,作用域都是全局作用域。
参考链接
- Ben Alman, Immediately-Invoked Function Expression (IIFE)
- Mark Daggett, Functions Explained
- Juriy Zaytsev, Named function expressions demystified
- Marco Rogers polotek, What is the arguments object?
- Juriy Zaytsev, Global eval. What are the options?
- Axel Rauschmayer, Evaluating JavaScript code via eval() and new Function()
数组
定义
数组(array)是按次序排列的一组值。每个值的位置都有编号(从0开始),整个数组用方括号表示。
var arr = ['a', 'b', 'c'];
上面代码中的a
、b
、c
就构成一个数组,两端的方括号是数组的标志。a
是0号位置,b
是1号位置,c
是2号位置。
除了在定义时赋值,数组也可以先定义后赋值。
var arr = [];
arr[0] = 'a';
arr[1] = 'b';
arr[2] = 'c';
任何类型的数据,都可以放入数组。
var arr = [
{a: 1},
[1, 2, 3],
function() {return true;}
];
arr[0] // Object {a: 1}
arr[1] // [1, 2, 3]
arr[2] // function (){return true;}
上面数组arr
的3个成员依次是对象、数组、函数。
如果数组的元素还是数组,就形成了多维数组。
var a = [[1, 2], [3, 4]];
a[0][1] // 2
a[1][1] // 4
数组的本质
本质上,数组属于一种特殊的对象。typeof
运算符会返回数组的类型是object
。
typeof [1, 2, 3] // "object"
上面代码表明,typeof
运算符认为数组的类型就是对象。
数组的特殊性体现在,它的键名是按次序排列的一组整数(0,1,2...)。
var arr = ['a', 'b', 'c'];
Object.keys(arr)
// ["0", "1", "2"]
上面代码中,Object.keys
方法返回数组的所有键名。可以看到数组的键名就是整数0、1、2。
由于数组成员的键名是固定的(默认总是0、1、2...),因此数组不用为每个元素指定键名,而对象的每个成员都必须指定键名。JavaScript 语言规定,对象的键名一律为字符串,所以,数组的键名其实也是字符串。之所以可以用数值读取,是因为非字符串的键名会被转为字符串。
var arr = ['a', 'b', 'c'];
arr['0'] // 'a'
arr[0] // 'a'
上面代码分别用数值和字符串作为键名,结果都能读取数组。原因是数值键名被自动转为了字符串。
注意,这点在赋值时也成立。一个值总是先转成字符串,再作为键名进行赋值。
var a = [];
a[1.00] = 6;
a[1] // 6
上面代码中,由于1.00
转成字符串是1
,所以通过数字键1
可以读取值。
上一章说过,对象有两种读取成员的方法:点结构(object.key
)和方括号结构(object[key]
)。但是,对于数值的键名,不能使用点结构。
var arr = [1, 2, 3];
arr.0 // SyntaxError
上面代码中,arr.0
的写法不合法,因为单独的数值不能作为标识符(identifier)。所以,数组成员只能用方括号arr[0]
表示(方括号是运算符,可以接受数值)。
length 属性
数组的length
属性,返回数组的成员数量。
['a', 'b', 'c'].length // 3
JavaScript 使用一个32位整数,保存数组的元素个数。这意味着,数组成员最多只有 4294967295 个(232 - 1)个,也就是说length
属性的最大值就是 4294967295。
只要是数组,就一定有length
属性。该属性是一个动态的值,等于键名中的最大整数加上1
。
var arr = ['a', 'b'];
arr.length // 2
arr[2] = 'c';
arr.length // 3
arr[9] = 'd';
arr.length // 10
arr[1000] = 'e';
arr.length // 1001
上面代码表示,数组的数字键不需要连续,length
属性的值总是比最大的那个整数键大1
。另外,这也表明数组是一种动态的数据结构,可以随时增减数组的成员。
length
属性是可写的。如果人为设置一个小于当前成员个数的值,该数组的成员数量会自动减少到length
设置的值。
var arr = [ 'a', 'b', 'c' ];
arr.length // 3
arr.length = 2;
arr // ["a", "b"]
上面代码表示,当数组的length
属性设为2(即最大的整数键只能是1)那么整数键2(值为c
)就已经不在数组中了,被自动删除了。
清空数组的一个有效方法,就是将length
属性设为0。
var arr = [ 'a', 'b', 'c' ];
arr.length = 0;
arr // []
如果人为设置length
大于当前元素个数,则数组的成员数量会增加到这个值,新增的位置都是空位。
var a = ['a'];
a.length = 3;
a[1] // undefined
上面代码表示,当length
属性设为大于数组个数时,读取新增的位置都会返回undefined
。
如果人为设置length
为不合法的值,JavaScript 会报错。
// 设置负值
[].length = -1
// RangeError: Invalid array length
// 数组元素个数大于等于2的32次方
[].length = Math.pow(2, 32)
// RangeError: Invalid array length
// 设置字符串
[].length = 'abc'
// RangeError: Invalid array length
值得注意的是,由于数组本质上是一种对象,所以可以为数组添加属性,但是这不影响length
属性的值。
var a = [];
a['p'] = 'abc';
a.length // 0
a[2.1] = 'abc';
a.length // 0
上面代码将数组的键分别设为字符串和小数,结果都不影响length
属性。因为,length
属性的值就是等于最大的数字键加1,而这个数组没有整数键,所以length
属性保持为0
。
如果数组的键名是添加超出范围的数值,该键名会自动转为字符串。
var arr = [];
arr[-1] = 'a';
arr[Math.pow(2, 32)] = 'b';
arr.length // 0
arr[-1] // "a"
arr[4294967296] // "b"
上面代码中,我们为数组arr
添加了两个不合法的数字键,结果length
属性没有发生变化。这些数字键都变成了字符串键名。最后两行之所以会取到值,是因为取键值时,数字键名会默认转为字符串。
in 运算符
检查某个键名是否存在的运算符in
,适用于对象,也适用于数组。
var arr = [ 'a', 'b', 'c' ];
2 in arr // true
'2' in arr // true
4 in arr // false
上面代码表明,数组存在键名为2
的键。由于键名都是字符串,所以数值2
会自动转成字符串。
注意,如果数组的某个位置是空位,in
运算符返回false
。
var arr = [];
arr[100] = 'a';
100 in arr // true
1 in arr // false
上面代码中,数组arr
只有一个成员arr[100]
,其他位置的键名都会返回false
。
for...in 循环和数组的遍历
for...in
循环不仅可以遍历对象,也可以遍历数组,毕竟数组只是一种特殊对象。
var a = [1, 2, 3];
for (var i in a) {
console.log(a[i]);
}
// 1
// 2
// 3
但是,for...in
不仅会遍历数组所有的数字键,还会遍历非数字键。
var a = [1, 2, 3];
a.foo = true;
for (var key in a) {
console.log(key);
}
// 0
// 1
// 2
// foo
上面代码在遍历数组时,也遍历到了非整数键foo
。所以,不推荐使用for...in
遍历数组。
数组的遍历可以考虑使用for
循环或while
循环。
var a = [1, 2, 3];
// for循环
for(var i = 0; i < a.length; i++) {
console.log(a[i]);
}
// while循环
var i = 0;
while (i < a.length) {
console.log(a[i]);
i++;
}
var l = a.length;
while (l--) {
console.log(a[l]);
}
上面代码是三种遍历数组的写法。最后一种写法是逆向遍历,即从最后一个元素向第一个元素遍历。
数组的forEach
方法,也可以用来遍历数组,详见《标准库》的 Array 对象一章。
var colors = ['red', 'green', 'blue'];
colors.forEach(function (color) {
console.log(color);
});
// red
// green
// blue
数组的空位
当数组的某个位置是空元素,即两个逗号之间没有任何值,我们称该数组存在空位(hole)。
var a = [1, , 1];
a.length // 3
上面代码表明,数组的空位不影响length
属性。虽然这个位置没有值,引擎依然认为这个位置是有效的。
需要注意的是,如果最后一个元素后面有逗号,并不会产生空位。也就是说,有没有这个逗号,结果都是一样的。
var a = [1, 2, 3,];
a.length // 3
a // [1, 2, 3]
上面代码中,数组最后一个成员后面有一个逗号,这不影响length
属性的值,与没有这个逗号时效果一样。
数组的空位是可以读取的,返回undefined
。
var a = [, , ,];
a[1] // undefined
使用delete
命令删除一个数组成员,会形成空位,并且不会影响length
属性。
var a = [1, 2, 3];
delete a[1];
a[1] // undefined
a.length // 3
上面代码用delete
命令删除了数组的第二个元素,这个位置就形成了空位,但是对length
属性没有影响。也就是说,length
属性不过滤空位。所以,使用length
属性进行数组遍历,一定要非常小心。
数组的某个位置是空位,与某个位置是undefined
,是不一样的。如果是空位,使用数组的forEach
方法、for...in
结构、以及Object.keys
方法进行遍历,空位都会被跳过。
var a = [, , ,];
a.forEach(function (x, i) {
console.log(i + '. ' + x);
})
// 不产生任何输出
for (var i in a) {
console.log(i);
}
// 不产生任何输出
Object.keys(a)
// []
如果某个位置是undefined
,遍历的时候就不会被跳过。
var a = [undefined, undefined, undefined];
a.forEach(function (x, i) {
console.log(i + '. ' + x);
});
// 0. undefined
// 1. undefined
// 2. undefined
for (var i in a) {
console.log(i);
}
// 0
// 1
// 2
Object.keys(a)
// ['0', '1', '2']
这就是说,空位就是数组没有这个元素,所以不会被遍历到,而undefined
则表示数组有这个元素,值是undefined
,所以遍历不会跳过。
类似数组的对象
如果一个对象的所有键名都是正整数或零,并且有length
属性,那么这个对象就很像数组,语法上称为“类似数组的对象”(array-like object)。
var obj = {
0: 'a',
1: 'b',
2: 'c',
length: 3
};
obj[0] // 'a'
obj[1] // 'b'
obj.length // 3
obj.push('d') // TypeError: obj.push is not a function
上面代码中,对象obj
就是一个类似数组的对象。但是,“类似数组的对象”并不是数组,因为它们不具备数组特有的方法。对象obj
没有数组的push
方法,使用该方法就会报错。
“类似数组的对象”的根本特征,就是具有length
属性。只要有length
属性,就可以认为这个对象类似于数组。但是有一个问题,这种length
属性不是动态值,不会随着成员的变化而变化。
var obj = {
length: 0
};
obj[3] = 'd';
obj.length // 0
上面代码为对象obj
添加了一个数字键,但是length
属性没变。这就说明了obj
不是数组。
典型的“类似数组的对象”是函数的arguments
对象,以及大多数 DOM 元素集,还有字符串。
// arguments对象
function args() { return arguments }
var arrayLike = args('a', 'b');
arrayLike[0] // 'a'
arrayLike.length // 2
arrayLike instanceof Array // false
// DOM元素集
var elts = document.getElementsByTagName('h3');
elts.length // 3
elts instanceof Array // false
// 字符串
'abc'[1] // 'b'
'abc'.length // 3
'abc' instanceof Array // false
上面代码包含三个例子,它们都不是数组(instanceof
运算符返回false
),但是看上去都非常像数组。
数组的slice
方法可以将“类似数组的对象”变成真正的数组。
var arr = Array.prototype.slice.call(arrayLike);
除了转为真正的数组,“类似数组的对象”还有一个办法可以使用数组的方法,就是通过call()
把数组的方法放到对象上面。
function print(value, index) {
console.log(index + ' : ' + value);
}
Array.prototype.forEach.call(arrayLike, print);
上面代码中,arrayLike
代表一个类似数组的对象,本来是不可以使用数组的forEach()
方法的,但是通过call()
,可以把forEach()
嫁接到arrayLike
上面调用。
下面的例子就是通过这种方法,在arguments
对象上面调用forEach
方法。
// forEach 方法
function logArgs() {
Array.prototype.forEach.call(arguments, function (elem, i) {
console.log(i + '. ' + elem);
});
}
// 等同于 for 循环
function logArgs() {
for (var i = 0; i < arguments.length; i++) {
console.log(i + '. ' + arguments[i]);
}
}
字符串也是类似数组的对象,所以也可以用Array.prototype.forEach.call
遍历。
Array.prototype.forEach.call('abc', function (chr) {
console.log(chr);
});
// a
// b
// c
注意,这种方法比直接使用数组原生的forEach
要慢,所以最好还是先将“类似数组的对象”转为真正的数组,然后再直接调用数组的forEach
方法。
var arr = Array.prototype.slice.call('abc');
arr.forEach(function (chr) {
console.log(chr);
});
// a
// b
// c
参考链接
- Axel Rauschmayer, Arrays in JavaScript
- Axel Rauschmayer, JavaScript: sparse arrays vs. dense arrays
- Felix Bohm, What They Didn’t Tell You About ES5′s Array Extras
- Juriy Zaytsev, How ECMAScript 5 still does not allow to subclass an array
2.运算符
算术运算符
运算符是处理数据的基本方法,用来从现有的值得到新的值。JavaScript 提供了多种运算符,覆盖了所有主要的运算。
概述
JavaScript 共提供10个算术运算符,用来完成基本的算术运算。
- 加法运算符:
x + y
- 减法运算符:
x - y
- 乘法运算符:
x * y
- 除法运算符:
x / y
- 指数运算符:
x ** y
- 余数运算符:
x % y
- 自增运算符:
++x
或者x++
- 自减运算符:
--x
或者x--
- 数值运算符:
+x
- 负数值运算符:
-x
减法、乘法、除法运算法比较单纯,就是执行相应的数学运算。下面介绍其他几个算术运算符,重点是加法运算符。
加法运算符
基本规则
加法运算符(+
)是最常见的运算符,用来求两个数值的和。
1 + 1 // 2
JavaScript 允许非数值的相加。
true + true // 2
1 + true // 2
上面代码中,第一行是两个布尔值相加,第二行是数值与布尔值相加。这两种情况,布尔值都会自动转成数值,然后再相加。
比较特殊的是,如果是两个字符串相加,这时加法运算符会变成连接运算符,返回一个新的字符串,将两个原字符串连接在一起。
'a' + 'bc' // "abc"
如果一个运算子是字符串,另一个运算子是非字符串,这时非字符串会转成字符串,再连接在一起。
1 + 'a' // "1a"
false + 'a' // "falsea"
加法运算符是在运行时决定,到底是执行相加,还是执行连接。也就是说,运算子的不同,导致了不同的语法行为,这种现象称为“重载”(overload)。由于加法运算符存在重载,可能执行两种运算,使用的时候必须很小心。
'3' + 4 + 5 // "345"
3 + 4 + '5' // "75"
上面代码中,由于从左到右的运算次序,字符串的位置不同会导致不同的结果。
除了加法运算符,其他算术运算符(比如减法、除法和乘法)都不会发生重载。它们的规则是:所有运算子一律转为数值,再进行相应的数学运算。
1 - '2' // -1
1 * '2' // 2
1 / '2' // 0.5
上面代码中,减法、除法和乘法运算符,都是将字符串自动转为数值,然后再运算。
对象的相加
如果运算子是对象,必须先转成原始类型的值,然后再相加。
var obj = { p: 1 };
obj + 2 // "[object Object]2"
上面代码中,对象obj
转成原始类型的值是[object Object]
,再加2
就得到了上面的结果。
对象转成原始类型的值,规则如下。
首先,自动调用对象的valueOf
方法。
var obj = { p: 1 };
obj.valueOf() // { p: 1 }
一般来说,对象的valueOf
方法总是返回对象自身,这时再自动调用对象的toString
方法,将其转为字符串。
var obj = { p: 1 };
obj.valueOf().toString() // "[object Object]"
对象的toString
方法默认返回[object Object]
,所以就得到了最前面那个例子的结果。
知道了这个规则以后,就可以自己定义valueOf
方法或toString
方法,得到想要的结果。
var obj = {
valueOf: function () {
return 1;
}
};
obj + 2 // 3
上面代码中,我们定义obj
对象的valueOf
方法返回1
,于是obj + 2
就得到了3
。这个例子中,由于valueOf
方法直接返回一个原始类型的值,所以不再调用toString
方法。
下面是自定义toString
方法的例子。
var obj = {
toString: function () {
return 'hello';
}
};
obj + 2 // "hello2"
上面代码中,对象obj
的toString
方法返回字符串hello
。前面说过,只要有一个运算子是字符串,加法运算符就变成连接运算符,返回连接后的字符串。
这里有一个特例,如果运算子是一个Date
对象的实例,那么会优先执行toString
方法。
var obj = new Date();
obj.valueOf = function () { return 1 };
obj.toString = function () { return 'hello' };
obj + 2 // "hello2"
上面代码中,对象obj
是一个Date
对象的实例,并且自定义了valueOf
方法和toString
方法,结果toString
方法优先执行。
余数运算符
余数运算符(%
)返回前一个运算子被后一个运算子除,所得的余数。
12 % 5 // 2
需要注意的是,运算结果的正负号由第一个运算子的正负号决定。
-1 % 2 // -1
1 % -2 // 1
所以,为了得到负数的正确余数值,可以先使用绝对值函数。
// 错误的写法
function isOdd(n) {
return n % 2 === 1;
}
isOdd(-5) // false
isOdd(-4) // false
// 正确的写法
function isOdd(n) {
return Math.abs(n % 2) === 1;
}
isOdd(-5) // true
isOdd(-4) // false
余数运算符还可以用于浮点数的运算。但是,由于浮点数不是精确的值,无法得到完全准确的结果。
6.5 % 2.1
// 0.19999999999999973
自增和自减运算符
自增和自减运算符,是一元运算符,只需要一个运算子。它们的作用是将运算子首先转为数值,然后加上1或者减去1。它们会修改原始变量。
var x = 1;
++x // 2
x // 2
--x // 1
x // 1
上面代码的变量x
自增后,返回2
,再进行自减,返回1
。这两种情况都会使得,原始变量x
的值发生改变。
运算之后,变量的值发生变化,这种效应叫做运算的副作用(side effect)。自增和自减运算符是仅有的两个具有副作用的运算符,其他运算符都不会改变变量的值。
自增和自减运算符有一个需要注意的地方,就是放在变量之后,会先返回变量操作前的值,再进行自增/自减操作;放在变量之前,会先进行自增/自减操作,再返回变量操作后的值。
var x = 1;
var y = 1;
x++ // 1
++y // 2
上面代码中,x
是先返回当前值,然后自增,所以得到1
;y
是先自增,然后返回新的值,所以得到2
。
数值运算符,负数值运算符
数值运算符(+
)同样使用加号,但它是一元运算符(只需要一个操作数),而加法运算符是二元运算符(需要两个操作数)。
数值运算符的作用在于可以将任何值转为数值(与Number
函数的作用相同)。
+true // 1
+[] // 0
+{} // NaN
上面代码表示,非数值经过数值运算符以后,都变成了数值(最后一行NaN
也是数值)。具体的类型转换规则,参见《数据类型转换》一章。
负数值运算符(-
),也同样具有将一个值转为数值的功能,只不过得到的值正负相反。连用两个负数值运算符,等同于数值运算符。
var x = 1;
-x // -1
-(-x) // 1
上面代码最后一行的圆括号不可少,否则会变成自减运算符。
数值运算符号和负数值运算符,都会返回一个新的值,而不会改变原始变量的值。
指数运算符
指数运算符(**
)完成指数运算,前一个运算子是底数,后一个运算子是指数。
2 ** 4 // 16
注意,指数运算符是右结合,而不是左结合。即多个指数运算符连用时,先进行最右边的计算。
// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2
// 512
上面代码中,由于指数运算符是右结合,所以先计算第二个指数运算符,而不是第一个。
赋值运算符
赋值运算符(Assignment Operators)用于给变量赋值。
最常见的赋值运算符,当然就是等号(=
)。
// 将 1 赋值给变量 x
var x = 1;
// 将变量 y 的值赋值给变量 x
var x = y;
赋值运算符还可以与其他运算符结合,形成变体。下面是与算术运算符的结合。
// 等同于 x = x + y
x += y
// 等同于 x = x - y
x -= y
// 等同于 x = x * y
x *= y
// 等同于 x = x / y
x /= y
// 等同于 x = x % y
x %= y
// 等同于 x = x ** y
x **= y
下面是与位运算符的结合(关于位运算符,请见后文的介绍)。
// 等同于 x = x >> y
x >>= y
// 等同于 x = x << y
x <<= y
// 等同于 x = x >>> y
x >>>= y
// 等同于 x = x & y
x &= y
// 等同于 x = x | y
x |= y
// 等同于 x = x ^ y
x ^= y
这些复合的赋值运算符,都是先进行指定运算,然后将得到值返回给左边的变量。
比较运算符
概述
比较运算符用于比较两个值的大小,然后返回一个布尔值,表示是否满足指定的条件。
2 > 1 // true
上面代码比较2
是否大于1
,返回true
。
注意,比较运算符可以比较各种类型的值,不仅仅是数值。
JavaScript 一共提供了8个比较运算符。
>
大于运算符<
小于运算符<=
小于或等于运算符>=
大于或等于运算符==
相等运算符===
严格相等运算符!=
不相等运算符!==
严格不相等运算符
这八个比较运算符分成两类:相等比较和非相等比较。两者的规则是不一样的,对于非相等的比较,算法是先看两个运算子是否都是字符串,如果是的,就按照字典顺序比较(实际上是比较 Unicode 码点);否则,将两个运算子都转成数值,再比较数值的大小。
非相等运算符:字符串的比较
字符串按照字典顺序进行比较。
'cat' > 'dog' // false
'cat' > 'catalog' // false
JavaScript 引擎内部首先比较首字符的 Unicode 码点。如果相等,再比较第二个字符的 Unicode 码点,以此类推。
'cat' > 'Cat' // true'
上面代码中,小写的c
的 Unicode 码点(99
)大于大写的C
的 Unicode 码点(67
),所以返回true
。
由于所有字符都有 Unicode 码点,因此汉字也可以比较。
'大' > '小' // false
上面代码中,“大”的 Unicode 码点是22823,“小”是23567,因此返回false
。
非相等运算符:非字符串的比较
如果两个运算子之中,至少有一个不是字符串,需要分成以下两种情况。
(1)原始类型值
如果两个运算子都是原始类型的值,则是先转成数值再比较。
5 > '4' // true
// 等同于 5 > Number('4')
// 即 5 > 4
true > false // true
// 等同于 Number(true) > Number(false)
// 即 1 > 0
2 > true // true
// 等同于 2 > Number(true)
// 即 2 > 1
上面代码中,字符串和布尔值都会先转成数值,再进行比较。
这里需要注意与NaN
的比较。任何值(包括NaN
本身)与NaN
使用非相等运算符进行比较,返回的都是false
。
1 > NaN // false
1 <= NaN // false
'1' > NaN // false
'1' <= NaN // false
NaN > NaN // false
NaN <= NaN // false
(2)对象
如果运算子是对象,会转为原始类型的值,再进行比较。
对象转换成原始类型的值,算法是先调用valueOf
方法;如果返回的还是对象,再接着调用toString
方法,详细解释参见《数据类型的转换》一章。
var x = [2];
x > '11' // true
// 等同于 [2].valueOf().toString() > '11'
// 即 '2' > '11'
x.valueOf = function () { return '1' };
x > '11' // false
// 等同于 (function () { return '1' }) > '11'
// 即 '1' > '11'
两个对象之间的比较也是如此。
[2] > [1] // true
// 等同于 [2].valueOf().toString() > [1].valueOf().toString()
// 即 '2' > '1'
[2] > [11] // true
// 等同于 [2].valueOf().toString() > [11].valueOf().toString()
// 即 '2' > '11'
{ x: 2 } >= { x: 1 } // true
// 等同于 ({ x: 2 }).valueOf().toString() >= ({ x: 1 }).valueOf().toString()
// 即 '[object Object]' >= '[object Object]'
严格相等运算符
JavaScript 提供两种相等运算符:==
和===
。
简单说,它们的区别是相等运算符(==
)比较两个值是否相等,严格相等运算符(===
)比较它们是否为“同一个值”。如果两个值不是同一类型,严格相等运算符(===
)直接返回false
,而相等运算符(==
)会将它们转换成同一个类型,再用严格相等运算符进行比较。
本节介绍严格相等运算符的算法。
(1)不同类型的值
如果两个值的类型不同,直接返回false
。
1 === "1" // false
true === "true" // false
上面代码比较数值的1
与字符串的“1”、布尔值的true
与字符串"true"
,因为类型不同,结果都是false
。
(2)同一类的原始类型值
同一类型的原始类型的值(数值、字符串、布尔值)比较时,值相同就返回true
,值不同就返回false
。
1 === 0x1 // true
上面代码比较十进制的1
与十六进制的1
,因为类型和值都相同,返回true
。
需要注意的是,NaN
与任何值都不相等(包括自身)。另外,正0
等于负0
。
NaN === NaN // false
+0 === -0 // true
(3)复合类型值
两个复合类型(对象、数组、函数)的数据比较时,不是比较它们的值是否相等,而是比较它们是否指向同一个地址。
{} === {} // false
[] === [] // false
(function () {} === function () {}) // false
上面代码分别比较两个空对象、两个空数组、两个空函数,结果都是不相等。原因是对于复合类型的值,严格相等运算比较的是,它们是否引用同一个内存地址,而运算符两边的空对象、空数组、空函数的值,都存放在不同的内存地址,结果当然是false
。
如果两个变量引用同一个对象,则它们相等。
var v1 = {};
var v2 = v1;
v1 === v2 // true
注意,对于两个对象的比较,严格相等运算符比较的是地址,而大于或小于运算符比较的是值。
var obj1 = {};
var obj2 = {};
obj1 > obj2 // false
obj1 < obj2 // false
obj1 === obj2 // false
上面的三个比较,前两个比较的是值,最后一个比较的是地址,所以都返回false
。
(4)undefined 和 null
undefined
和null
与自身严格相等。
undefined === undefined // true
null === null // true
由于变量声明后默认值是undefined
,因此两个只声明未赋值的变量是相等的。
var v1;
var v2;
v1 === v2 // true
严格不相等运算符
严格相等运算符有一个对应的“严格不相等运算符”(!==
),它的算法就是先求严格相等运算符的结果,然后返回相反值。
1 !== '1' // true
// 等同于
!(1 === '1')
上面代码中,感叹号!
是求出后面表达式的相反值。
相等运算符
相等运算符用来比较相同类型的数据时,与严格相等运算符完全一样。
1 == 1.0
// 等同于
1 === 1.0
比较不同类型的数据时,相等运算符会先将数据进行类型转换,然后再用严格相等运算符比较。下面分成几种情况,讨论不同类型的值互相比较的规则。
(1)原始类型值
原始类型的值会转换成数值再进行比较。
1 == true // true
// 等同于 1 === Number(true)
0 == false // true
// 等同于 0 === Number(false)
2 == true // false
// 等同于 2 === Number(true)
2 == false // false
// 等同于 2 === Number(false)
'true' == true // false
// 等同于 Number('true') === Number(true)
// 等同于 NaN === 1
'' == 0 // true
// 等同于 Number('') === 0
// 等同于 0 === 0
'' == false // true
// 等同于 Number('') === Number(false)
// 等同于 0 === 0
'1' == true // true
// 等同于 Number('1') === Number(true)
// 等同于 1 === 1
'\n 123 \t' == 123 // true
// 因为字符串转为数字时,省略前置和后置的空格
上面代码将字符串和布尔值都转为数值,然后再进行比较。具体的字符串与布尔值的类型转换规则,参见《数据类型转换》一章。
(2)对象与原始类型值比较
对象(这里指广义的对象,包括数组和函数)与原始类型的值比较时,对象转换成原始类型的值,再进行比较。
具体来说,先调用对象的valueOf()
方法,如果得到原始类型的值,就按照上一小节的规则,互相比较;如果得到的还是对象,则再调用toString()
方法,得到字符串形式,再进行比较。
下面是数组与原始类型值比较的例子。
// 数组与数值的比较
[1] == 1 // true
// 数组与字符串的比较
[1] == '1' // true
[1, 2] == '1,2' // true
// 对象与布尔值的比较
[1] == true // true
[2] == true // false
上面例子中,JavaScript 引擎会先对数组[1]
调用数组的valueOf()
方法,由于返回的还是一个数组,所以会接着调用数组的toString()
方法,得到字符串形式,再按照上一小节的规则进行比较。
下面是一个更直接的例子。
const obj = {
valueOf: function () {
console.log('执行 valueOf()');
return obj;
},
toString: function () {
console.log('执行 toString()');
return 'foo';
}
};
obj == 'foo'
// 执行 valueOf()
// 执行 toString()
// true
上面例子中,obj
是一个自定义了valueOf()
和toString()
方法的对象。这个对象与字符串'foo'
进行比较时,会依次调用valueOf()
和toString()
方法,最后返回'foo'
,所以比较结果是true
。
(3)undefined 和 null
undefined
和null
只有与自身比较,或者互相比较时,才会返回true
;与其他类型的值比较时,结果都为false
。
undefined == undefined // true
null == null // true
undefined == null // true
false == null // false
false == undefined // false
0 == null // false
0 == undefined // false
(4)相等运算符的缺点
相等运算符隐藏的类型转换,会带来一些违反直觉的结果。
0 == '' // true
0 == '0' // true
2 == true // false
2 == false // false
false == 'false' // false
false == '0' // true
false == undefined // false
false == null // false
null == undefined // true
' \t\r\n ' == 0 // true
上面这些表达式都不同于直觉,很容易出错。因此建议不要使用相等运算符(==
),最好只使用严格相等运算符(===
)。
不相等运算符
相等运算符有一个对应的“不相等运算符”(!=
),它的算法就是先求相等运算符的结果,然后返回相反值。
1 != '1' // false
// 等同于
!(1 == '1')
布尔运算符
概述
布尔运算符用于将表达式转为布尔值,一共包含四个运算符。
- 取反运算符:
!
- 且运算符:
&&
- 或运算符:
||
- 三元运算符:
?:
取反运算符(!)
取反运算符是一个感叹号,用于将布尔值变为相反值,即true
变成false
,false
变成true
。
!true // false
!false // true
对于非布尔值,取反运算符会将其转为布尔值。可以这样记忆,以下六个值取反后为true
,其他值都为false
。
undefined
null
false
0
NaN
- 空字符串(
''
)
!undefined // true
!null // true
!0 // true
!NaN // true
!"" // true
!54 // false
!'hello' // false
![] // false
!{} // false
上面代码中,不管什么类型的值,经过取反运算后,都变成了布尔值。
如果对一个值连续做两次取反运算,等于将其转为对应的布尔值,与Boolean
函数的作用相同。这是一种常用的类型转换的写法。
!!x
// 等同于
Boolean(x)
上面代码中,不管x
是什么类型的值,经过两次取反运算后,变成了与Boolean
函数结果相同的布尔值。所以,两次取反就是将一个值转为布尔值的简便写法。
且运算符(&&)
且运算符(&&
)往往用于多个表达式的求值。
它的运算规则是:如果第一个运算子的布尔值为true
,则返回第二个运算子的值(注意是值,不是布尔值);如果第一个运算子的布尔值为false
,则直接返回第一个运算子的值,且不再对第二个运算子求值。
't' && '' // ""
't' && 'f' // "f"
't' && (1 + 2) // 3
'' && 'f' // ""
'' && '' // ""
var x = 1;
(1 - 1) && ( x += 1) // 0
x // 1
上面代码的最后一个例子,由于且运算符的第一个运算子的布尔值为false
,则直接返回它的值0
,而不再对第二个运算子求值,所以变量x
的值没变。
这种跳过第二个运算子的机制,被称为“短路”。有些程序员喜欢用它取代if
结构,比如下面是一段if
结构的代码,就可以用且运算符改写。
if (i) {
doSomething();
}
// 等价于
i && doSomething();
上面代码的两种写法是等价的,但是后一种不容易看出目的,也不容易除错,建议谨慎使用。
且运算符可以多个连用,这时返回第一个布尔值为false
的表达式的值。如果所有表达式的布尔值都为true
,则返回最后一个表达式的值。
true && 'foo' && '' && 4 && 'foo' && true
// ''
1 && 2 && 3
// 3
上面代码中,例一里面,第一个布尔值为false
的表达式为第三个表达式,所以得到一个空字符串。例二里面,所有表达式的布尔值都是true
,所以返回最后一个表达式的值3
。
或运算符(||)
或运算符(||
)也用于多个表达式的求值。它的运算规则是:如果第一个运算子的布尔值为true
,则返回第一个运算子的值,且不再对第二个运算子求值;如果第一个运算子的布尔值为false
,则返回第二个运算子的值。
't' || '' // "t"
't' || 'f' // "t"
'' || 'f' // "f"
'' || '' // ""
短路规则对这个运算符也适用。
var x = 1;
true || (x = 2) // true
x // 1
上面代码中,或运算符的第一个运算子为true
,所以直接返回true
,不再运行第二个运算子。所以,x
的值没有改变。这种只通过第一个表达式的值,控制是否运行第二个表达式的机制,就称为“短路”(short-cut)。
或运算符可以多个连用,这时返回第一个布尔值为true
的表达式的值。如果所有表达式都为false
,则返回最后一个表达式的值。
false || 0 || '' || 4 || 'foo' || true
// 4
false || 0 || ''
// ''
上面代码中,例一里面,第一个布尔值为true
的表达式是第四个表达式,所以得到数值4。例二里面,所有表达式的布尔值都为false
,所以返回最后一个表达式的值。
或运算符常用于为一个变量设置默认值。
function saveText(text) {
text = text || '';
// ...
}
// 或者写成
saveText(this.text || '')
上面代码表示,如果函数调用时,没有提供参数,则该参数默认设置为空字符串。
三元条件运算符(?:)
三元条件运算符由问号(?)和冒号(:)组成,分隔三个表达式。它是 JavaScript 语言唯一一个需要三个运算子的运算符。如果第一个表达式的布尔值为true
,则返回第二个表达式的值,否则返回第三个表达式的值。
't' ? 'hello' : 'world' // "hello"
0 ? 'hello' : 'world' // "world"
上面代码的t
和0
的布尔值分别为true
和false
,所以分别返回第二个和第三个表达式的值。
通常来说,三元条件表达式与if...else
语句具有同样表达效果,前者可以表达的,后者也能表达。但是两者具有一个重大差别,if...else
是语句,没有返回值;三元条件表达式是表达式,具有返回值。所以,在需要返回值的场合,只能使用三元条件表达式,而不能使用if..else
。
console.log(true ? 'T' : 'F');
上面代码中,console.log
方法的参数必须是一个表达式,这时就只能使用三元条件表达式。如果要用if...else
语句,就必须改变整个代码写法了。
二进制位运算符
概述
二进制位运算符用于直接对二进制位进行计算,一共有7个。
- 二进制或运算符(or):符号为
|
,表示若两个二进制位都为0
,则结果为0
,否则为1
。 - 二进制与运算符(and):符号为
&
,表示若两个二进制位都为1,则结果为1,否则为0。 - 二进制否运算符(not):符号为
~
,表示对一个二进制位取反。 - 异或运算符(xor):符号为
^
,表示若两个二进制位不相同,则结果为1,否则为0。 - 左移运算符(left shift):符号为
<<
,详见下文解释。 - 右移运算符(right shift):符号为
>>
,详见下文解释。 - 头部补零的右移运算符(zero filled right shift):符号为
>>>
,详见下文解释。
这些位运算符直接处理每一个比特位(bit),所以是非常底层的运算,好处是速度极快,缺点是很不直观,许多场合不能使用它们,否则会使代码难以理解和查错。
有一点需要特别注意,位运算符只对整数起作用,如果一个运算子不是整数,会自动转为整数后再执行。另外,虽然在 JavaScript 内部,数值都是以64位浮点数的形式储存,但是做位运算的时候,是以32位带符号的整数进行运算的,并且返回值也是一个32位带符号的整数。
i = i | 0;
上面这行代码的意思,就是将i
(不管是整数或小数)转为32位整数。
利用这个特性,可以写出一个函数,将任意数值转为32位整数。
function toInt32(x) {
return x | 0;
}
上面这个函数将任意值与0
进行一次或运算,这个位运算会自动将一个值转为32位整数。下面是这个函数的用法。
toInt32(1.001) // 1
toInt32(1.999) // 1
toInt32(1) // 1
toInt32(-1) // -1
toInt32(Math.pow(2, 32) + 1) // 1
toInt32(Math.pow(2, 32) - 1) // -1
上面代码中,toInt32
可以将小数转为整数。对于一般的整数,返回值不会有任何变化。对于大于或等于2的32次方的整数,大于32位的数位都会被舍去。
二进制或运算符
二进制或运算符(|
)逐位比较两个运算子,两个二进制位之中只要有一个为1
,就返回1
,否则返回0
。
0 | 3 // 3
上面代码中,0
和3
的二进制形式分别是00
和11
,所以进行二进制或运算会得到11
(即3
)。
位运算只对整数有效,遇到小数时,会将小数部分舍去,只保留整数部分。所以,将一个小数与0
进行二进制或运算,等同于对该数去除小数部分,即取整数位。
2.9 | 0 // 2
-2.9 | 0 // -2
需要注意的是,这种取整方法不适用超过32位整数最大值2147483647
的数。
2147483649.4 | 0;
// -2147483647
二进制与运算符
二进制与运算符(&
)的规则是逐位比较两个运算子,两个二进制位之中只要有一个位为0
,就返回0
,否则返回1
。
0 & 3 // 0
上面代码中,0(二进制00
)和3(二进制11
)进行二进制与运算会得到00
(即0
)。
二进制否运算符
二进制否运算符(~
)将每个二进制位都变为相反值(0
变为1
,1
变为0
)。它的返回结果有时比较难理解,因为涉及到计算机内部的数值表示机制。
~ 3 // -4
上面表达式对3
进行二进制否运算,得到-4
。之所以会有这样的结果,是因为位运算时,JavaScript 内部将所有的运算子都转为32位的二进制整数再进行运算。
3
的32位整数形式是00000000000000000000000000000011
,二进制否运算以后得到11111111111111111111111111111100
。由于第一位(符号位)是1,所以这个数是一个负数。JavaScript 内部采用补码形式表示负数,即需要将这个数减去1,再取一次反,然后加上负号,才能得到这个负数对应的10进制值。这个数减去1等于11111111111111111111111111111011
,再取一次反得到00000000000000000000000000000100
,再加上负号就是-4
。考虑到这样的过程比较麻烦,可以简单记忆成,一个数与自身的取反值相加,等于-1。
~ -3 // 2
上面表达式可以这样算,-3
的取反值等于-1
减去-3
,结果为2
。
对一个整数连续两次二进制否运算,得到它自身。
~~3 // 3
所有的位运算都只对整数有效。二进制否运算遇到小数时,也会将小数部分舍去,只保留整数部分。所以,对一个小数连续进行两次二进制否运算,能达到取整效果。
~~2.9 // 2
~~47.11 // 47
~~1.9999 // 1
~~3 // 3
使用二进制否运算取整,是所有取整方法中最快的一种。
对字符串进行二进制否运算,JavaScript 引擎会先调用Number
函数,将字符串转为数值。
// 相当于~Number('011')
~'011' // -12
// 相当于~Number('42 cats')
~'42 cats' // -1
// 相当于~Number('0xcafebabe')
~'0xcafebabe' // 889275713
// 相当于~Number('deadbeef')
~'deadbeef' // -1
Number
函数将字符串转为数值的规则,参见《数据的类型转换》一章。
对于其他类型的值,二进制否运算也是先用Number
转为数值,然后再进行处理。
// 相当于 ~Number([])
~[] // -1
// 相当于 ~Number(NaN)
~NaN // -1
// 相当于 ~Number(null)
~null // -1
异或运算符
异或运算(^
)在两个二进制位不同时返回1
,相同时返回0
。
0 ^ 3 // 3
上面表达式中,0
(二进制00
)与3
(二进制11
)进行异或运算,它们每一个二进制位都不同,所以得到11
(即3
)。
“异或运算”有一个特殊运用,连续对两个数a
和b
进行三次异或运算,a^=b; b^=a; a^=b;
,可以互换它们的值。这意味着,使用“异或运算”可以在不引入临时变量的前提下,互换两个变量的值。
var a = 10;
var b = 99;
a ^= b, b ^= a, a ^= b;
a // 99
b // 10
这是互换两个变量的值的最快方法。
异或运算也可以用来取整。
12.9 ^ 0 // 12
左移运算符
左移运算符(<<
)表示将一个数的二进制值向左移动指定的位数,尾部补0
,即乘以2
的指定次方。向左移动的时候,最高位的符号位是一起移动的。
// 4 的二进制形式为100,
// 左移一位为1000(即十进制的8)
// 相当于乘以2的1次方
4 << 1
// 8
-4 << 1
// -8
上面代码中,-4
左移一位得到-8
,是因为-4
的二进制形式是11111111111111111111111111111100
,左移一位后得到11111111111111111111111111111000
,该数转为十进制(减去1后取反,再加上负号)即为-8
。
如果左移0位,就相当于将该数值转为32位整数,等同于取整,对于正数和负数都有效。
13.5 << 0
// 13
-13.5 << 0
// -13
左移运算符用于二进制数值非常方便。
var color = {r: 186, g: 218, b: 85};
// RGB to HEX
// (1 << 24)的作用为保证结果是6位数
var rgb2hex = function(r, g, b) {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16) // 先转成十六进制,然后返回字符串
.substr(1); // 去除字符串的最高位,返回后面六个字符串
}
rgb2hex(color.r, color.g, color.b)
// "#bada55"
上面代码使用左移运算符,将颜色的 RGB 值转为 HEX 值。
右移运算符
右移运算符(>>
)表示将一个数的二进制值向右移动指定的位数。如果是正数,头部全部补0
;如果是负数,头部全部补1
。右移运算符基本上相当于除以2
的指定次方(最高位即符号位参与移动)。
4 >> 1
// 2
/*
// 因为4的二进制形式为 00000000000000000000000000000100,
// 右移一位得到 00000000000000000000000000000010,
// 即为十进制的2
*/
-4 >> 1
// -2
/*
// 因为-4的二进制形式为 11111111111111111111111111111100,
// 右移一位,头部补1,得到 11111111111111111111111111111110,
// 即为十进制的-2
*/
右移运算可以模拟 2 的整除运算。
5 >> 1
// 2
// 相当于 5 / 2 = 2
21 >> 2
// 5
// 相当于 21 / 4 = 5
21 >> 3
// 2
// 相当于 21 / 8 = 2
21 >> 4
// 1
// 相当于 21 / 16 = 1
头部补零的右移运算符
头部补零的右移运算符(>>>
)与右移运算符(>>
)只有一个差别,就是一个数的二进制形式向右移动时,头部一律补零,而不考虑符号位。所以,该运算总是得到正值。对于正数,该运算的结果与右移运算符(>>
)完全一致,区别主要在于负数。
4 >>> 1
// 2
-4 >>> 1
// 2147483646
/*
// 因为-4的二进制形式为11111111111111111111111111111100,
// 带符号位的右移一位,得到01111111111111111111111111111110,
// 即为十进制的2147483646。
*/
这个运算实际上将一个值转为32位无符号整数。
查看一个负整数在计算机内部的储存形式,最快的方法就是使用这个运算符。
-1 >>> 0 // 4294967295
上面代码表示,-1
作为32位整数时,内部的储存形式使用无符号整数格式解读,值为 4294967295(即(2^32)-1
,等于11111111111111111111111111111111
)。
开关作用
位运算符可以用作设置对象属性的开关。
假定某个对象有四个开关,每个开关都是一个变量。那么,可以设置一个四位的二进制数,它的每个位对应一个开关。
var FLAG_A = 1; // 0001
var FLAG_B = 2; // 0010
var FLAG_C = 4; // 0100
var FLAG_D = 8; // 1000
上面代码设置 A、B、C、D 四个开关,每个开关分别占有一个二进制位。
然后,就可以用二进制与运算,检查当前设置是否打开了指定开关。
var flags = 5; // 二进制的0101
if (flags & FLAG_C) {
// ...
}
// 0101 & 0100 => 0100 => true
上面代码检验是否打开了开关C
。如果打开,会返回true
,否则返回false
。
现在假设需要打开A
、B
、D
三个开关,我们可以构造一个掩码变量。
var mask = FLAG_A | FLAG_B | FLAG_D;
// 0001 | 0010 | 1000 => 1011
上面代码对A
、B
、D
三个变量进行二进制或运算,得到掩码值为二进制的1011
。
有了掩码,二进制或运算可以确保打开指定的开关。
flags = flags | mask;
上面代码中,计算后得到的flags
变量,代表三个开关的二进制位都打开了。
二进制与运算可以将当前设置中凡是与开关设置不一样的项,全部关闭。
flags = flags & mask;
异或运算可以切换(toggle)当前设置,即第一次执行可以得到当前设置的相反值,再执行一次又得到原来的值。
flags = flags ^ mask;
二进制否运算可以翻转当前设置,即原设置为0
,运算后变为1
;原设置为1
,运算后变为0
。
flags = ~flags;
参考链接
- Michal Budzynski, JavaScript: The less known parts. Bitwise Operators
- Axel Rauschmayer, Basic JavaScript for the impatient programmer
- Mozilla Developer Network, Bitwise Operators
其他运算符,运算顺序
void 运算符
void
运算符的作用是执行一个表达式,然后不返回任何值,或者说返回undefined
。
void 0 // undefined
void(0) // undefined
上面是void
运算符的两种写法,都正确。建议采用后一种形式,即总是使用圆括号。因为void
运算符的优先性很高,如果不使用括号,容易造成错误的结果。比如,void 4 + 7
实际上等同于(void 4) + 7
。
下面是void
运算符的一个例子。
var x = 3;
void (x = 5) //undefined
x // 5
这个运算符的主要用途是浏览器的书签工具(Bookmarklet),以及在超级链接中插入代码防止网页跳转。
请看下面的代码。
<script>
function f() {
console.log('Hello World');
}
</script>
<a href="http://example.com" onclick="f(); return false;">点击</a>
上面代码中,点击链接后,会先执行onclick
的代码,由于onclick
返回false
,所以浏览器不会跳转到 example.com。
void
运算符可以取代上面的写法。
<a href="javascript: void(f())">文字</a>
下面是一个更实际的例子,用户点击链接提交表单,但是不产生页面跳转。
<a href="javascript: void(document.form.submit())">
提交
</a>
逗号运算符
逗号运算符用于对两个表达式求值,并返回后一个表达式的值。
'a', 'b' // "b"
var x = 0;
var y = (x++, 10);
x // 1
y // 10
上面代码中,逗号运算符返回后一个表达式的值。
逗号运算符的一个用途是,在返回一个值之前,进行一些辅助操作。
var value = (console.log('Hi!'), true);
// Hi!
value // true
上面代码中,先执行逗号之前的操作,然后返回逗号后面的值。
运算顺序
优先级
JavaScript 各种运算符的优先级别(Operator Precedence)是不一样的。优先级高的运算符先执行,优先级低的运算符后执行。
4 + 5 * 6 // 34
上面的代码中,乘法运算符(*
)的优先性高于加法运算符(+
),所以先执行乘法,再执行加法,相当于下面这样。
4 + (5 * 6) // 34
如果多个运算符混写在一起,常常会导致令人困惑的代码。
var x = 1;
var arr = [];
var y = arr.length <= 0 || arr[0] === undefined ? x : arr[0];
上面代码中,变量y
的值就很难看出来,因为这个表达式涉及5个运算符,到底谁的优先级最高,实在不容易记住。
根据语言规格,这五个运算符的优先级从高到低依次为:小于等于(<=
)、严格相等(===
)、或(||
)、三元(?:
)、等号(=
)。因此上面的表达式,实际的运算顺序如下。
var y = ((arr.length <= 0) || (arr[0] === undefined)) ? x : arr[0];
记住所有运算符的优先级,是非常难的,也是没有必要的。
圆括号的作用
圆括号(()
)可以用来提高运算的优先级,因为它的优先级是最高的,即圆括号中的表达式会第一个运算。
(4 + 5) * 6 // 54
上面代码中,由于使用了圆括号,加法会先于乘法执行。
运算符的优先级别十分繁杂,且都是硬性规定,因此建议总是使用圆括号,保证运算顺序清晰可读,这对代码的维护和除错至关重要。
顺便说一下,圆括号不是运算符,而是一种语法结构。它一共有两种用法:一种是把表达式放在圆括号之中,提升运算的优先级;另一种是跟在函数的后面,作用是调用函数。
注意,因为圆括号不是运算符,所以不具有求值作用,只改变运算的优先级。
var x = 1;
(x) = 2;
上面代码的第二行,如果圆括号具有求值作用,那么就会变成1 = 2
,这是会报错了。但是,上面的代码可以运行,这验证了圆括号只改变优先级,不会求值。
这也意味着,如果整个表达式都放在圆括号之中,那么不会有任何效果。
(expression)
// 等同于
expression
函数放在圆括号中,会返回函数本身。如果圆括号紧跟在函数的后面,就表示调用函数。
function f() {
return 1;
}
(f) // function f(){return 1;}
f() // 1
上面代码中,函数放在圆括号之中会返回函数本身,圆括号跟在函数后面则是调用函数。
圆括号之中,只能放置表达式,如果将语句放在圆括号之中,就会报错。
(var a = 1)
// SyntaxError: Unexpected token var
左结合与右结合
对于优先级别相同的运算符,同时出现的时候,就会有计算顺序的问题。
a OP b OP c
上面代码中,OP
表示运算符。它可以有两种解释方式。
// 方式一
(a OP b) OP c
// 方式二
a OP (b OP c)
上面的两种方式,得到的计算结果往往是不一样的。方式一是将左侧两个运算数结合在一起,采用这种解释方式的运算符,称为“左结合”(left-to-right associativity)运算符;方式二是将右侧两个运算数结合在一起,这样的运算符称为“右结合”运算符(right-to-left associativity)。
JavaScript 语言的大多数运算符是“左结合”,请看下面加法运算符的例子。
x + y + z
// 引擎解释如下
(x + y) + z
上面代码中,x
与y
结合在一起,它们的预算结果再与z
进行运算。
少数运算符是“右结合”,其中最主要的是赋值运算符(=
)和三元条件运算符(?:
)。
w = x = y = z;
q = a ? b : c ? d : e ? f : g;
上面代码的解释方式如下。
w = (x = (y = z));
q = a ? b : (c ? d : (e ? f : g));
上面的两行代码,都是右侧的运算数结合在一起。
另外,指数运算符(**
)也是右结合。
2 ** 3 ** 2
// 相当于 2 ** (3 ** 2)
// 512