这是你了解的Javascript的语句吗?

190 阅读3分钟

书接上文,我们讲了表达式,知道了表达式主要用于求值,其值被称为规范中的‘引用’。以及对这个引用的实战与理论分析。

对于一门语言来说,有了表达式还是不够的,对值的应用我们还需要了解一个基础概念——语句。JavaScript 应用程序是由许多语法正确的语句组成的。就是我们日常开发所写的每一行代码其实都是语句。语句主要用于执行操作或控制流程。简单理解语句是用来执行的,表达式是用来求值的。当然要实现复杂的功能时他们都可以嵌套使用。

JavaScript 语句可以分为基本语句和复合语句两种类型。基本语句分为:表达式语句、声明语句、条件语句、循环语句、函数调用语句等,可以独立存在;而复合语句则在语句体内包含了多个语句块,如 if 语句、switch 语句等。

  • 表达式语句
3 + 4;
  • 声明语句
let a = 1;
  • 条件语句
if (a > b) {
  console.log("a 大于 b");
}
  • 循环语句
for (let i = 0; i < 10; i++) {
  console.log(i);
}
  • 函数调用语句
alert("Hello World!");

表达式语句上篇文章已经讲过,接下来就先说声明语句吧。关于js的声明语句还是有很多小细节的。先来看个简单的栗子。

console.log(x)
let x = y = 100; 
console.log(x); // 100
console.log(y); // 100

这是一段声明语句的代码,存在变量提升、存在隐式创建全局变量y。这些问题你都看出来了吧,但是有木有更进一步的思考过这段声明语句应该叫赋值语句更合适?es6的模块化为什么能做tree shaking?es6的let、const的暂时性死区是怎么做的?全局变量是怎么创建管理隐式变量的?最新的规范对隐式创建有什么优化?这些都时关于声明语句的小细节,如果你都门清那必定是个高手。如果不清楚那不妨看看我对语句的理解。

严格意义上来说,javascript只能声明出两种标识符,常量&变量,但是声明的方式有六种

  • let
  • const
  • var
  • function
  • class
  • import

除了这些声明关键词之外,还有两个语句也是可以声明标识符的

  • for
  • try...catch

以上是所有可能声明变量的方式。 这些声明关键词是在编译阶段处理的,即 JavaScript 引擎在执行 JavaScript 代码之前,会先对代码进行编译,将所有的声明语句进行处理和解析,然后才会执行实际的代码。因此javascript虽然是动态语言却拥有静态语义。 根据语句的定义可知,上述代码的声明关键词在执行前已被处理,执行的时候只剩赋值操作,所以更应该叫赋值语句。 正是因为这种设计能力,让es6的模块化具有了静态分析的能力,可以做到tree shaking。当然静态语义在早期并没有处理好,让Javascript产生了一个典型的问题‘变量提升,对于这个问题大家都已知晓es6之后推荐使用let/const,通过暂时性死区避免变量提升。那规范层面又是如何做的呢?

es6之前var和function声明创建的标识符被称为'变量声明',作为运行时执行上下文的 VariableEnvironment 的变量。Var 变量是在其包含的 Environment Record 被实例化时创建的,并在创建时初始化为未定义的。Let 和 const 声明标识符被称为'词法声明'作为运行时执行上下文的 LexicalEnvironment 的变量。这些变量是在其包含的 Environment Record 被实例化时创建的,这些变量在被LexicalBinding 之前,拒绝访问。具体可以查询ECMA-262第14.3.1~14.3.2章节, 对以上6中声明中,var、function 为‘变量声明’,class按let处理,import按const处理。

那接下来就看看隐式变量是怎么创建的。 当 JavaScript代码在运行环境中被加载和执行时,引擎会为此创建全局执行上下文(Global Execution Context),同时也会创建全局对象。形成一个全局对象的闭包。在创建全局对象时,JavaScript 引擎会添加一些默认属性和方法,包括 Math、Object、Array、String、Date,以及一些浏览器特定的全局对象,例如 window、document 等,这些对象都可以被通过全局对象来访问。开发者也可以向全局对象中添加自定义属性和方法,以满足实际开发需求。这个全局对象几乎等同于一个普通对象。

当向一个不存在的变量赋值的时候,语法有些类似‘with语句’,由于全局对象也是一个普通对象,属性表是可以动态添加的,因此JavaScript将变量名作为属性名添加给全局对象。这种'变量泄漏'在严格模式下是会抛出ReferenceError。在非严格模式下依然是可以执行,但是es6之后还是给出了优化,对这种隐式创建的全局变量,其数据属性中configurable值为true,而通过关键词声明的全局变量该属性则为false,即表明主动声明的全局变量不可删除。

var x = y = 100; // let

var xDes = Object.getOwnPropertyDescriptor(window, 'x');
var yDes = Object.getOwnPropertyDescriptor(window, 'y');



console.log(xDes,yDes)
//{ value: 100, writable: true, enumerable: true, configurable: false }
// { value: 200, writable: true, enumerable: true, configurable: true }

console.log(delete x) // false
console.log(delete y) // true

这个包含全局对象的上下文会在浏览器刷新或关闭的时候销毁。 最后看下赋值语句,你会发现声明语句也并不适合叫赋值语句。 声明关键词在执行前被处理,剩下的并不是通过等号进行赋值的,而是使用规范中称为'初始器'的元素进行赋值的,初始器可描述为: Initializer: = AssignmentExpression

Initializer 是 ECMAScript 2015 规范中的一个元素,用于指定一个变量或参数的默认值。如果变量或参数没有传递值,则使用默认值进行初始化

相对而言我们看下赋值语法的描述 LeftHandSideExpression < = | AssignmentOperator > AssignmentExpression 从规范中的描述可见两者的区别,声明的变量是在运行的时候通过Initializer 进行初始化。赋值表达式的等号左右两边都必须是表达式,声明关键词后不能是表达式,否则会报错,所以准确的来说声明语句也不是赋值语句。

var a.x = ...   // Uncaught SyntaxError: Unexpected token '.'

继续深入赋值语句 赋值语句也是有返回值的,返回等号右侧表达式的结果,关于表达式的结果上篇文章有说过。


// 调用obj.f()时将检测this是不是原始的obj
obj = { f: function() { return this === obj } };


(a = obj.f)();
false

在这个a=obj.f的赋值过程中,对obj.f的引用得到的是一个指针。所以返回的是false。 再看一个连续赋值的栗子

var a = {n:1};
a.x = a = {n:2};
alert(a.x); // --> undefined

这个问题最早来自于JQuery源码,效果类似上面这段代码。 JS引擎肯定是从左往右执行的,等号左侧a.x是一个表达式,根据对表达式的理解“a.x”的计算结果是一个引用,因此通过这个引用保存了一些计算过程中的信息——例如它保存了“a”这个对象,以备后续操作中“可能会”作为this。接着往下执行的时候,又是一个赋值语句,此时通过LHS查询对a进行赋值,此刻查询到的a这个容器,这个容器此时存放的引用正是a.x在计算过程中存储的引用,当对这个容器进行赋值的时候,将覆盖a中原有的引用,而a.x中保存的还是赋值之前的引用,此时的对a.x进行赋值,将使原变量a多一个x属性,但是引用只有引擎才能理解。所以通过容器的新引用是访问不到x属性的,所以返回undefined。

可以通过冻结操作来证明a.x是对原变量a的赋值

'use strict'
let a1 = {n:1};
Object.freeze(a1)

try{
    a1.x = a1 = {n:2};
}catch(err){
    console.error(err)
}

console.log(a1.x,'新值');

在 JavaScript 的非严格模式下,如果尝试给一个不可扩展的对象添加新的属性,不会抛出任何错误,但是该操作会被忽略,即属性并没有被添加到对象上。这是因为非严格模式下,JavaScript 引擎会默默地忽略一些错误,而不是抛出异常。所以这段代码需要在严格模式下才能看到直观的error提示。