标识符
语言中的标识符一般可以分为两类:
- 用于命名语法、符号等抽象概念 => 语法关键字
- 用于命名数据(的存储位置)=> 变量和常量
从标识符的角度来说,绑定分为语法关键字与语义逻辑的绑定,以及变量与它所存储数据和位置性质的绑定。
其中,语法关键字对语义逻辑的绑定结果,是对作用域的限定;变量对位置性质的绑定结果,则是对变量生存周期的限定。
所谓声明,即约定数据的生存周期和逻辑的作用域。
- 纯粹陈述“数据”的过程,被称为变量和类型声明
- 纯粹陈述“逻辑”的过程,被称为语句(含流程控制子句)
- 陈述“数据与(算法的)逻辑”的关系的过程,被称为表达式
从ES6 开始提供了一些新的具有绑定标识符语义的语法,包括赋值模板(assignment pattern)、剩余参数(rest parameters)、默认参数/参数默认值(default parameters/default values)以及展开运算符(spread opreator)等。
从声明语法的角度,JavaScript 有6 种声明标识符的方法,包括变量(var)、常量(const)、块作用域变量(let)、函数(function)、类(class)和模块(import),它们都可以声明出在语法分析阶段就被识别的标识符—其中函数声明包括具名的函数名和形式参数名。
变量声明(var/let/const)
这3 个关键字有两点不同:可变性,与词法环境的关系。
-
变量的可变性:
- const
- var、let
-
与词法环境的关系:
- var:通过var 声明变量,在距离最近的函数内或全局词法环境中定义(忽略块级作用域)
- let、const:直接在最近的词法环境中定义变量
const 变量常用于两种目的:
- 不需要重新赋值的特殊变量
- 指向一个固定的值,如球队人数的最大值MAX_RONIN_COUNT
const vs let
const 声明变量时必须同时初始化变量,且尝试修改 const 声明的变量会导致运行时错误。
const 声明的限制只适用于它指向的变量的引用。如果 const 变量引用的是一个对象,那么修改这个对象内部的属性并不违反 const 的限制。
不能用 const 来声明迭代变量(因为迭代变量会自增)。
let vs var
| let | var | |
|---|---|---|
| 作用域 | 当前的代码块,例如语句块 | 当前函数、模块或全局 |
| 多次声明 | 只能声明一次 | 可以用var 来多次声明变量名,这在语法分析中与声明一次没有区别 |
| 使用 | 必须先声明后使用 | 可以在声明语句之前使用所声明的var 变量,这时变量的值是undefined |
当let 声明发生在全局代码块时,它与var 声明存在细微的差别:
这是因为按照早期JavaScript 的约定,在全局代码块使用var 声明(和具名函数声明语法)时,相当于在全局对象global 上声明了一个属性,进而使所有代码都能将这些声明作为全局变量来访问。而let 声明与其他一些较新的语法元素遵从“块级作用域”规则,因此即使出现在全局代码块中,它们也只是声明为“全局作用域”中的标识符,而不作为对象global 上的属性。
Bable 将let 和const 转换成ES5
let vs var:
- Var 定义的变量有两种作用域:全局和函数作用域
- let 定义的变量是块级作用域
无法直接替换的原因:JS 在ES5 中缺乏块级作用域。
Bable 的解决方式:
- 若let 处于全局作用域 - 直接替换成var
- 若let 处于块级作用域 - 【换个变量名】:let 直接替换成var,let 定义的变量名称前面加__
- 若let 在循环语句中,闭包 - 手动转换成闭包,将循环体写成立即执行函数
// 输出 0 1 2
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i)
}, 1000)
}
// 输出 3 3 3
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i)
}, 1000)
}
// 立即执行函数 - 闭包
for (var i = 0; i < 3; i++) {
(function(i) {
setTimeout(function() {
console.log(i)
}, 1000)
})(i)
}
var _loop = function (i) {
setTimeout(function() {
console.log(i)
}, 1000)
}
// Bable 中标准转换方式
for (var i = 0; i < 3; i++) {
_loop(i)
}
变量的生命周期
学变量声明前,需要了解变量的生命周期:
- Declaration: Registers a variale in the corresponding scope (The variable is registered using a given name within the corresponding scope)
- Initialization: Allocates memory for the variable (When you declare a variable it is automatically initialized, which means memory is allocated for the variable by the JavaScript engine.)
- Assignment: Assigns a specified value to the variable
var x // Declaration and initialization
x = "Hello World" // Assignment
// Or all in one
var y = "Hello World"
let x // Declaration and initialization
x = "Hello World" // Assignment
// Or all in one
let y = "Hello World"
let is the descendant of var in modern JavaScript. Its scope is not only limited to the enclosing function, but also to its enclosing block statement. A block statement is everything inside { and }, (e.g. an if condition or loop).
The particularity of a constant is that you need to assign a value when declaring it and there is no way to reassign it. A const is limited to the scope of the enclosing block, like let.
Accidental Global Creation
If you forget to write var, let or const before an assignment, the variable will automatically be global. To avoid accidentally declaring global variables you can use strict mode.
let and const declarations define variables that are scoped to the running execution context’s LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding(词法绑定) is evaluated.
A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created.
If a LexicalBinding in a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.
JS 代码执行分阶段
一旦创建了新的词法环境,就会执行第一阶段。在第一阶段,没有执行代码,但是JS 引擎会访问并注册在当前词法环境中所声明的变量和函数。
JS 在第一阶段完成之后开始执行第二阶段,具体执行取决于变量的类型(let、var、const 和函数声明)以及环境类型(全局环境、函数环境或块级作用域):
1、如果是创建一个函数环境,那么创建形参及函数参数的默认值。如果是非函数环境,将跳过此步骤。
2、如果是创建全局或函数环境 ,就扫描当前代码进行函数声明(不会扫描其他函数的函数体),但是不会执行函数表达式或箭头函数。对于所找到的函数声明,将创建函数,并绑定到当前环境与函数名相同的标识符上。若该标识符已经存在,那么该标识符的值将被重写。如果是块级作用域,将跳过此步骤。
3、扫描当前代码进行变量声明。在函数或全局环境中,查找所有当前函数以及其他函数之外通过var 声明的变量,并查找所有通过let 或const 定义的变量。在块级环境中,仅查找当前块中通过let 或const 定义的变量。对于所查找到的变量,若该标识符不存在,进行注册并将其初始化为undefined。若该标识符已经存在,将保留其值。
JavaScript 是单线程语言,所以执行肯定是按顺序执行。但是并不是逐行的分析和执行,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。
经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
执行上下文中存在一个变量环境的对象(variable environment),该对象中保存了变量提升的内容。
showName()
console.log(myname)
var myname = '极客'
function showName() {
console.log('函数showName被执行')
}
-
第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
-
第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;
-
第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置。
-
这样就生成了变量环境对象。接下来 JavaScript 引擎会把声明以外的代码编译为字节码。
showName() console.log(myname) myname = '极客时间' -
当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果。
-
接下来打印“myname”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined。
-
接下来执行第 3 行,把“极客”赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“极客”。
出现相同变量或函数的处理
- 如果是同名的函数,JavaScript 编译阶段会选择最后声明的那个。
- 如果变量和函数同名,那么在编译阶段,变量的声明会被忽略。
function showName() {
console.log('极客邦')
}
showName()
function showName() {
console.log('极客时间')
}
showName()
【解析】
- 首先是编译阶段。遇到第一个 showName 函数,会将该函数体存放到变量环境中。接下来是第二个 showName 函数,继续存放至变量环境中,但是变量环境中已经存在一个 showName 函数了,此时,第二个 showName 函数会将第一个 showName 函数覆盖掉。这样变量环境中就只存在第二个 showName 函数了。
- 接下来是执行阶段。先执行第一个 showName 函数,但由于是从变量环境中查找 showName 函数,而变量环境中只保存了第二个 showName 函数,所以最终调用的是第二个函数,打印的内容是“极客时间”。第二次执行 showName 函数也是走同样的流程,所以输出的结果也是“极客时间”。
变量提升所带来的问题
ES5 中var 和function 的声明都存在变量提升。
1、变量容易在不被察觉的情况下被覆盖掉
当全局变量和局部变量同名时,全局变量是不会作用于同名的局部变量的作用域。
var a = 10
function fn() {
a = 100 // 修改全局变量的值
console.log(a, this.a) // 100, 100 this.a => window.a
}
fn()
var a = 10
function fn() {
var a = 100
console.log(a, this.a) // 100, 10
}
fn()
var a = 10
var obj = {
a: 99,
f: test
}
function test() {
console.log(a) // undefined
a = 100
console.log(this.a) // 99
var a
console.log(a) // 100
}
obj.f()
// 美团
var = 10
function f1() {
var b = 2 * a
var a = 20
var c = a + 1
console.log(b)
console.log(c)
}
f1() // NaN 21
var name = 'World'
(function () {
if (typeof name === 'undefined') {
var name = "Jack"
console.info('Goodbye ' + name)
} else {
console.info('Hello ' + name)
}
})()
// Goodbye Jack
var myname = "极客时间"
function showName(){
console.log(myname)
if(0) {
var myname = "极客邦"
}
console.log(myname)
}
showName()
// undefined
// undefined
// 在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量 myname,而值是 undefined,所以获取到的 myname 的值就是 undefined。
2、本应销毁的变量没有被销毁
在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i)
}
foo()
// 7
ES6 是如何解决变量提升带来的缺陷
ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。
JavaScript 如何支持块级作用域
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()
-
第一步是编译并创建执行上下文:
函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。
-
第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2。
当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。
再接下来,当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。
let、const 不可重复声明
Environment Record(抽象类)
-
Declarative Environment Record
- Function Environment Record
- Module Environment Record
-
Object Environment Record
-
Global Environment Record
The abstract operation GlobalDeclarationInstantiation takes arguments script (a Script Parse Node) and env (a Global Environment Record) and returns either a normal completion containing unused or a throw completion. script is the Script for which the execution context is being established. env is the global environment in which bindings are to be created.
At the top level of a Script, function declarations are treated like var declarations rather than like lexical declarations.
The HasVarDeclaration concrete method of a Global Environment Record envRec takes argument N (a String) and returns a Boolean. It determines if the argument identifier has a binding in this record that was created using a VariableStatement or a FunctionDeclaration.
The HasLexicalDeclaration concrete method of a Global Environment Record envRec takes argument N (a String) and returns a Boolean. It determines if the argument identifier has a binding in this record that was created using a lexical declaration such as a LexicalDeclaration or a ClassDeclaration.
VariableStatement : var VariableDeclarationList
LexicalDeclaration : LetOrConst BindingList
函数声明和使用var 声明的变量会添加进入Object Enviroment Record 中。
使用let 声明和使用const 声明的变量会添加入Declarative Enviroment Record 中。
使用var 声明时,V8 引擎只会检查Declarative Enviroment Record 中是否有该变量,如果有就会报错,否则将该变量添加入Object Enviroment Record 中。
使用let 和const 声明时,引擎会同时检查Object Enviroment Record 和Declarative Enviroment Record 是否有该变量,如果有则报错,否则将将变量添加入Declarative Enviroment Record 中。
这就解释了为什么使用var 声明的变量可以重复声明,而是用let 和const 声明的变量不可以重复声明。
let、const 存在暂时性死区
在代码块内,使用let 命令声明变量之前,该变量都是不可用的,在语法上称为“暂时性死区”(temporal dead zone)
const 命令声明的常量也不会提升,同样存在暂时性死区,只能在声明后使用。
const 实际上保证的并不是变量的值不得改动,而是变量指向的那个内存地址不得改动。对于简单类型的数据(数值、字符串、布尔值)而言,值就保存在变量指向的内存地址中,因此等同于常量。但对于复合类型的数据(主要是对象和数组)而言,变量指向的内存地址保存的只是一个指针,const 只能保证这个指针是固定的。
变量或者函数,存在创建、初始化、赋值这三个过程:
Var 的创建和初始化被提升,赋值不会被提升。
let 的创建被提升,初始化和赋值不会被提升。
function 的创建、初始化和赋值均会被提升。
// 当我们让let a 和Object.defineProperty 同时在一个代码块中时,没有报错。
Object.defineProperty(window, 'a', {
value: 'window',
wirtable: false
})
let a = 'let 声明的变量没有提升吗?'
// 当我们先执行Object.defineProperty,回车
Object.defineProperty(window, 'a', {
value: 'window',
wirtable: false
})
//后再用let 声明a 时,浏览器报错了。
let a = 'let 声明的变量没有提升吗?'
// 此时报错:
// Uncaught SyntaxError: Identifier 'a' has already been declared at <anonymous>:1:1
可以看到,当使用Object.defineProperty 在Object Enviroment Record 中添加了一个变量a(使用Object.defineProperty会在Object Enviroment Record 中加入一个变量,而直接使用window.a 这种方式是不会往Object Enviroment Record 添加变量的)。
for 循环
在 let 出现之前, for 循环定义的迭代变量会渗透到循环体外部:
for (var i = 0; i < 5; ++i) {
// 循环逻辑
}
console.log(i) // 5
改成使用 let 之后,迭代变量的作用域仅限于 for 循环块内部:
for (let i = 0; i < 5; ++i) {
// 循环逻辑
}
console.log(i) // ReferenceError: i 没有定义
for (var i = 0; i < 5; ++i) {
setTimeout(() => console.log(i), 0)
}
// 实际输出:5、5、5、5、5
在使用 let 声明迭代变量时,JavaScript 引擎在后台会为每个迭代循环声明一个新的迭代变量。每个 setTimeout 引用的都是不同的变量实例,所以 console.log 输出的是所期望的值,也就是循环执行过程中每个迭代变量的值。
这种每次迭代声明一个独立变量实例的行为适用于所有风格的 for 循环,包括 for-in 和 for-of 循环。
解构赋值
ES6 开始支持更为灵活的解构赋值语法,这个表达式的左侧操作数称为“赋值模板(Assignment Pattern)”:
AssignmentPattern = expression
- 当这个模板使用在var 等变量声明中时,也可以成批地声明变量
- 在赋值模板中可以使用剩余参数语法来声明数组或对象
数组
// 模式匹配
const [a, b, c] = [1, 2, 4]
const [head, ...tail] = [5, 6, 7, 8]
const x = [1, 2, 3, 4, 5]
const [y, z] = x // 1, 2
// 只要某种数据结构具有Iterator 接口,都可以采用数组形式的解构赋值
// Set 结构,也可以使用数组的解构赋值
const [x, y, z] = new Set(['a', 'b', 'c']) // x => 'a'
对象
...扩展对象,只能做到当对象属性是 基本数据类型 才是 深拷贝,如果是 引用数据类型,那就是浅拷贝。
const z = { a: 3, b: 'bb', c: { name: 'ccc' } }
const n = { ...z }
n // { a: 3, b: 'bb', c: { name: 'ccc' } }
n === z // false
n.c === z.c // true
// n.c 跟 z.c 是同一个引用地址
如果一个键的值是复合类型的值(数组、对象、函数),那么解构赋值复制的是这个值的引用,而不是这个值的副本。
const obj = { a: { b: 1 } }
const { ...x } = obj
obj.a.b = 2
x.a.b // 2
-
解构赋值不会复制继承自原型对象的属性
let o1 = { a: 1 } let o2 = { b: 2 } o2.__proto__ = o1 let { ...o3 } = o2 o3 // { b: 2 } o3.a // undefined -
修改现有对象部分的属性
const newVersion = { ...previousVersion, name: 'New Name' // override the same property } -
扩展运算符可以带有表达式
const obj = { ...(x > 1 ? { a: 1 } : {}), b: 2 }
【对象的解构与数组的不同之处】
数组的元素是按次序排列的,变量的取值是由它的位置决定的;
对象的属性没有次序,变量必须与属性同名才能取到正确的值。
const { bar, foo } = { foo: 'aaa', bar: 'bbb' }
=> let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' }
// 对象的解构赋值的内部机制是先找到同名属性,然后再赋值给对应的变量。真正被赋值的是后者,而不是前者。
字符串
const { length: len } = 'hello'
len // 5
函数参数
function move({x = 0, y = 0} = {}) { return [x, y] }
move({x: 3, y: 8}) // [3, 8]
move({x: 3}) // [3, 0]
move({}) // [0, 0]
move() // [0, 0]
// 为函数move 的参数指定默认值,不是为变量x 和y 指定默认值
function move({x, y} = {x: 0, y: 0}) { return [x, y] }
move({x: 3, y: 8}) // [3, 8]
move({x: 3}) // [3, undefined]
move({}) // [undefined, undefined]
move() // [0, 0]
// undefined 会触发函数参数的默认值
[1, undefined, 3].map({x = 'yes'} => x) // [1, 'yes', 3]
解构赋值的用途
// 1 交换变量的值
[x, y] = [y, x]
// 2 从函数返回多个值
function example() {return [1, 2, 4]}
const [a, b, c] = example()
function test() {return {foo: 1, bar: 2}}
const {foo, bar} = test()
// 3 提取JSON 数据
const jsonData = {id: 42, status: 'ok', data: [432, 79]}
const {id, status, data: number} = jsonData
// 4 遍历Map 结构
// 任何部署了Iterator 接口的对象都可以用for...of 循环遍历。Map 结构原生支持Iterator 接口,配合变量的解构赋值获取键名和键值非常方便
var map = new Map()
map.set('first', 'hello')
map.set('second', 'world')
for (let [key, value] of map) {
console.log(key + 'is' + value)
}
// 5 输入模块的指定方法
const {SourceMapConsumer, SourceNode} = require('source-map')
字面量声明
除了定义一个可用的标识符,变量声明通常还具有两方面的功能:声明类型、声明初始值。
但JS 中没有类型声明的概念,因此这是变量声明就只用来说明一个变量的初值。在声明中,等号右边既可以是表达式—这意味着将表达式运算的结果作为该变量的初值,也可以是更为强大和灵活的字面量声明。
var num = 3 + 2 - 5 // 表达式
var str = 'test' // 字面量
字面量类似汇编语言中的立即值—无须声明就可以立即使用的常值,从它的语法形式来说,也被称为直接量。
undefined 并没有被称为字面量,这可能是有着其历史原因的:在早期的JS中,undefined 既不是关键字,也不能直接声明。
字符串字面量、转义符
模板字面量
数值字面量
常量声明
const 关键字用于常量声明。常量声明与变量声明都是用来将一个标识符(变量名/常量名)与其对应的数据存储绑定起来,在本质上一样。但是从语义上来说讲,变量表明相应的数据是可修改的,而常量表明它不可修改(常量的标识符不能再绑定到其他的数据)。
符号声明
符号是从ES6 开始支持的一种数据类型,它可以使用一般形式的变量声明或常量声明,与其他数据类型在声明上无特别的不同。
var aSymbol = Symbol()
const aSymbolConst = Symbol()
函数声明
在JS 中,函数是一种数据类型,所以函数声明是变量声明的一种特殊形式。
【参考资料】
《JavaScript 语言精髓与编程实践》第3版 章节2、章节4( 4.1-4.3)
《JavaScript 高级程序设计》第4 版 章节3
《你不知道的JavaScript》下卷 ES6 及更新版本 章节2
《JS 忍者秘籍》第2版 章节5
极客时间:浏览器工作原理与实践 - 李兵