JS语法

237 阅读18分钟

1)、脚本和模块(ES6 以前只有脚本)

  • 脚本
    • 可以由浏览器或者 node 环境引入执行的;
    • 具有主动性的 JavaScript 代码段,是控制宿主完成一定任务的代码;
    • 只包含语句;
  • 模块
    • 只能由 JavaScript 代码用 import 引入执行;
    • 被动性的 JavaScript 代码段,是等待被调用的库;
    • 包含:import 声明,export 声明和语句;
  • 模块和脚本之间的区别仅仅在于是否包含 import 和 export。
  • 现代浏览器可以支持用 script 标签引入模块或者脚本,如果要引入模块,必须给 script 标签添加 type=“module”。如果引入脚本,则不需要 type。
    <script type="module" src="xxxxx.js"></script>
    
    js

2)、import 声明

  • 直接 import 一个模块;

    • import "mod"; //引入一个模块
    • 只是保证了这个模块代码被执行,引用它的模块是无法获得它的任何信息的;
  • 带 from 的 import,能引入模块里的一些信息;

    • import v from "mod"; //把模块默认的导出值放入变量 v

    • 引入模块中的一部分信息,可以把它们变成本地的变量

      • import x from "./a.js" 引入模块中导出的默认值。
      • import {a as x, modify} from "./a.js"; 引入模块中的变量。
      • import * as x from "./a.js" 把模块中所有的变量以类似对象属性的方式引入。
    • 第一种方式还可以跟后两种组合使用。

      • import d, {a as x, modify} from "./a.js"
      • import d, * as x from "./a.js"
    • 语法要求不带 as 的默认值永远在最前。注意,这里的变量实际上仍然可以受到原来模块的控制

    • 示例:

      • 模块 a

        export var a = 1;
        
        export function modify() {
          a = 2;
        }
        
      • 模块 b

        import { a, modify } from "./a.js";
        console.log(a);
        modify();
        console.log(a);
        
      • 当我们调用修改变量的函数后,b 模块变量也跟着发生了改变。这说明导入与一般的赋值不同,导入后的变量只是改变了名字,它仍然与原来的变量是同一个。

3)、export 声明

  • 与 import 相对,export 声明承担的是导出的任务;
  • 模块中导出变量的方式有两种,一种是独立使用 export 声明,另一种是直接在声明型语句前添加 export 关键字;
  • export default 表示导出一个默认变量值,它可以用于 function、class。这里导出的变量是没有名称的,可以使用 import x from "./a.js"这样的语法,在模块中引入;
  • export default 还支持一种语法,后面跟一个表达式,例如:
    var a = {};
    export default a;
    
  • 但是,这里的行为跟导出变量是不一致的,这里导出的是值,导出的就是普通变量 a 的值,以后 a 的变化与导出的值就无关了,修改变量 a,不会使得其他模块中引入的 default 值发生改变。
  • 在 import 语句前无法加入 export,但是我们可以直接使用 export from 语法。// export a from "a.js"

4)、函数体

  • JavaScript 引擎除了执行脚本和模块之外,还可以执行函数。而函数体跟脚本和模块有一定的相似之处;
  • 执行函数的行为通常是在 JavaScript 代码执行时,注册宿主环境的某些事件触发的,而执行的过程,就是执行函数体(函数的花括号中间的部分)。
  • 如下代码通过 setTimeout 函数注册了一个函数给宿主,当一定时间之后,宿主就会执行这个函数。宿主会为这样的函数创建宏任务。我们可以认为,宏任务中可能会执行的代码包括“脚本 (script)”“模块(module)”和“函数体(function body)”。
    setTimeout(function () {
      console.log("go go go");
    }, 10000);
    
  • 函数体其实也是一个语句的列表。跟脚本和模块比起来,函数体中的语句列表中多了 return 语句可以用;主要包括下面 4 钟:
    • 普通函数体
      function foo() {
        //Function body
      }
      
    • 异步函数体
      async function foo() {
        //Function body
      }
      
    • 生成器函数体
      function* foo() {
        //Function body
      }
      
    • 异步生成器函数体
      async function* foo() {
        //Function body
      }
      
    • 关于函数体、模块和脚本能使用的语句:
      关于函数体、模块和脚本能使用的语句

5)、预处理

JavaScript 执行前,会对脚本、模块和函数体中的语句进行预处理。预处理过程将会提前处理 var、函数声明、class、const 和 let 这些语句,以确定其中变量的意义。

  • var 声明

    • 永远作用于脚本、模块和函数体这个级别,在预处理阶段,不关心赋值的部分,只管在当前作用域声明这个变量。

    • 下面这段代码声明了一个脚本级别的 a,又声明了 foo 函数体级别的 a,我们注意到,函数体级的 var 出现在 console.log 语句之后。但是预处理过程在执行之前,所以有函数体级的变量 a,就不会去访问外层作用域中的变量 a 了,而函数体级的变量 a 此时还没有赋值,所以是 undefined。我们再看一个情况:

      var a = 1;
      
      function foo() {
        console.log(a);
        var a = 2;
      }
      
      foo();
      
    • 下面这段代码比上一段代码在 var a=2 之外多了一段 if,我们知道 if(false)中的代码永远不会被执行,但是预处理阶段并不管这个,var 的作用能够穿透一切语句结构,它只认脚本、模块和函数体三种语法结构。所以这里结果跟前一段代码完全一样,我们会得到 undefined。

      var a = 1;
      
      function foo() {
        console.log(a);
        if (false) {
          var a = 2;
        }
      }
      
      foo();
      
    • 下上面这段代码中,我们引入了 with 语句,我们用 with(o)创建了一个作用域,并把 o 对象加入词法环境,在其中使用了 var a = 2;语句。

      • 在预处理阶段,只认 var 中声明的变量,所以同样为 foo 的作用域创建了 a 这个变量,但是没有赋值。

      • 在执行阶段,当执行到 var a=2 时,作用域变成了 with 语句内,这时候的 a 被认为访问到了对象 o 的属性 a,所以最终执行的结果,我们得到了 2 和 undefined。

      • 这个行为是 JavaScript 公认的设计失误之一,一个语句中的 a 在预处理阶段和执行阶段被当做两个不同的变量,严重违背了直觉,但是今天,在 JavaScript 设计原则“don’t break the web”之下,已经无法修正了,所以你需要特别注意。

        var a = 1;
        
        function foo() {
          var o = { a: 3 };
          with (o) {
            var a = 2;
          }
          console.log(o.a);
          console.log(a);
        }
        
        foo();
        
    • 因为早年 JavaScript 没有 let 和 const,只能用 var,又因为 var 除了脚本和函数体都会穿透,人民群众发明了“立即执行的函数表达式(IIFE)”这一用法,用来产生作用域,例如:

      for (var i = 0; i < 20; i++) {
        void (function (i) {
          var div = document.createElement("div");
          div.innerHTML = i;
          div.onclick = function () {
            console.log(i);
          };
          document.body.appendChild(div);
        })(i);
      }
      
      • 这段代码非常经典,常常在实际开发中见到,也经常被用作面试题,为文档添加了 20 个 div 元素,并且绑定了点击事件,打印它们的序号。我们通过 IIFE 在循环内构造了作用域,每次循环都产生一个新的环境记录,这样,每个 div 都能访问到环境中的 i。如果我们不用 IIFE:结果将会是点每个 div 都打印 20,因为全局只有一个 i,执行完循环后,i 变成了 20。

        for (var i = 0; i < 20; i++) {
          var div = document.createElement("div");
          div.innerHTML = i;
          div.onclick = function () {
            console.log(i);
          };
          document.body.appendChild(div);
        }
        
  • function 声明

    • function 声明的行为原本跟 var 非常相似,但是在最新的 JavaScript 标准中,对它进行了一定的修改,这让情况变得更加复杂了。
    • 在全局(脚本、模块和函数体),function 声明表现跟 var 相似,不同之处在于,function 声明不但在作用域中加入变量,还会给它赋值。
      • 这里声明了函数 foo,在声明之前,我们用 console.log 打印函数 foo,我们可以发现,已经是函数 foo 的值了。
        console.log(foo);
        function foo() {}
        
      • function 声明出现在 if 等语句中的情况有点复杂,它仍然作用于脚本、模块和函数体级别,在预处理阶段,仍然会产生变量,它不再被提前赋值:
        console.log(foo);
        if (true) {
          function foo() {}
        }
        
      • 这说明 function 在预处理阶段仍然发生了作用,在作用域中产生了变量,没有产生赋值,赋值行为发生在了执行阶段。
  • class 声明

    • class 声明在全局的行为跟 function 和 var 都不一样。在 class 声明之前使用 class 名,会抛错。感觉像是 class 没有预处理,实际不是。
    • 我们把 class 放进了一个函数体中,在外层作用域中有变量 c。然后试图在 class 之前打印 c。执行后,我们看到,仍然抛出了错误,如果去掉 class 声明,则会正常打印出 1,也就是说,出现在后面的 class 声明影响了前面语句的结果。这说明,class 声明也是会被预处理的,它会在作用域中创建变量,并且要求访问它时抛出错误。class 的声明作用不会穿透 if 等语句结构,所以只有写在全局环境才会有声明作用,这部分我们将会在下一节课讲解。这样的 class 设计比 function 和 var 更符合直觉,而且在遇到一些比较奇怪的用法时,倾向于抛出错误。
      var c = 1;
      function foo() {
        console.log(c);
        class c {}
      }
      foo();
      
  • 指令序言机制

    • 脚本和模块都支持一种特别的语法,叫做指令序言(Directive Prologs)。
    • 这段代码展示了严格模式的用法,我这里定义了函数 f,f 中打印 this 值,然后用 call 的方法调用 f,传入 null 作为 this 值,我们可以看到最终结果是 null 原封不动地被当做 this 值打印了出来,这是严格模式的特征。如果我们去掉严格模式的指令需要,打印的结果将会变成 global。"use strict"是 JavaScript 标准中规定的唯一一种指令序言,但是设计指令序言的目的是,留给 JavaScript 的引擎和实现者一些统一的表达方式,在静态扫描时指定 JavaScript 代码的一些特性。
      "use strict";
      function f() {
        console.log(this);
      }
      f.call(null);
      
    • 例如,假设我们要设计一种声明本文件不需要进行 lint 检查的指令,我们可以这样设计:
      "no lint";
      "use strict";
      function doSth() {
        //......
      }
      //......
      
    • JavaScript 的指令序言是只有一个字符串直接量的表达式语句,它只能出现在脚本、模块和函数体的最前面,否则将不会生效。

6)、语句

  • 普通语句

    语句

    • 空语句:就是一个独立的分号,实际上没什么大用
    • if 语句:
      • if 语句的作用是,在满足条件时执行它的内容语句,这个语句可以是一个语句块,这样就可以实现有条件地执行多个语句了。
      • if 语句还有 else 结构,用于不满足条件时执行,一种常见的用法是,利用语句的嵌套能力,把 if 和 else 连写成多分支条件判断
    • switch 语句: switch 语句继承自 Java,Java 中的 switch 语句继承自 C 和 C++,原本 switch 语句是跳转的变形,所以我们如果要用它来实现分支,必须要加上 break。
    • 循环语句:
      • while 循环
      • do while 循环
      • for 循环
      • fot in 循环:for in 循环枚举对象的属性
      • for of 循环:用于数组
        for (let e of [1, 2, 3, 4, 5]) console.log(e);
        
    • return 语句: return 语句用于函数中,它终止函数的执行,并且指定函数的返回值
    • break 语句: break 语句用于跳出循环语句或者 switch 语句
    • continue 语句: continue 语句用于结束本次循环并继续循环
    • with 语句:with 语句是个非常巧妙的设计,但它把 JavaScript 的变量引用关系变得不可分析,所以一般都认为这种语句都属于糟粕。
      let o = { a: 1, b: 2 };
      with (o) {
        console.log(a, b);
      }
      //with 语句把对象的属性在它内部的作用域内变成变量。
      
    • try 语句: 用于捕获异常
    • throw 语句: throw 用于抛出异常
    • debugger 语句: 通知调试器在此断点。在没有调试器挂载时,它不产生任何效果。
  • 声明型语句

    • var 语句:

      • var 声明语句是古典的 JavaScript 中声明变量的方式。而现在,在绝大多数情况下,let 和 const 都是更好的选择;

      • var 声明对全局作用域有影响,它是一种预处理机制;

      • 我们仍然想要使用 var,我的个人建议是,把它当做一种“保障变量是局部”的逻辑,遵循以下三条规则:

        • 声明同时必定初始化;
        • 尽可能在离使用的位置近处声明;
        • 不要在意重复声明。
      • 例如:

        var x = 1,
          y = 2;
        doSth(x, y);
        
        for (var x = 0; x < 10; x++) doSth2(x);
        //这个例子中,两次声明了变量x,完成了两段逻辑,这两个意义上可能不一定相关,这样,不论我们把代码复制粘贴在哪里,都不会出错。
        
        //当然,更好的办法是使用 let 改造,我们看看如何改造:
        {
          let x = 1,
            y = 2;
          doSth(x, y);
        }
        
        for (let x = 0; x < 10; x++) doSth2(x);
        //这里我用代码块限制了第一个 x 的作用域,这样就更难发生变量命名冲突引起的错误了。
        
    • let 和 const 声明

      • let 和 const 是新设计的语法,所以没有什么硬伤,非常地符合直觉。let 和 const 的作用范围是 if、for 等结构型语句。
        const a = 2;
        if (true) {
          const a = 1;
          console.log(a);
        }
        console.log(a);
        //这里的代码先在全局声明了变量 a,接下来又在 if 内声明了 a,if 内构成了一个独立的作用域。
        
      • const 和 let 语句在重复声明时会抛错,这能够有效地避免变量名无意中冲突:
        let a = 2;
        const a = 1;
        
      • let 和 const 声明虽然看上去是执行到了才会生效,但是实际上,它们还是会被预处理。如果当前作用域内有声明,就无法访问到外部的变量。我们来看这段代码:
        const a = 2;
        if (true) {
          console.log(a); //抛错
          const a = 1;
        }
        //这里在 if 的作用域中,变量 a 声明执行到之前,我们访问了变量 a,这时会抛出一个错误,这说明 const 声明仍然是有预处理机制的。
        //在执行到 const 语句前,我们的 JavaScript 引擎就已经知道后面的代码将会声明变量 a,从而不允许我们访问外层作用域中的 a。
        
    • class 声明

      • class 最基本的用法只需要 class 关键字、名称和一对大括号。它的声明特征跟 const 和 let 类似,都是作用于块级作用域,预处理阶段则会屏蔽外部变量。
      • class 内部,可以使用 constructor 关键字来定义构造函数。还能定义 getter/setter 和方法。
        class Rectangle {
          constructor(height, width) {
            this.height = height;
            this.width = width;
          }
          // Getter
          get area() {
            return this.calcArea();
          }
          // Method
          calcArea() {
            return this.height * this.width;
          }
        }
        
      • 需要注意,class 默认内部的函数定义都是 strict 模式的。
    • *函数 声明**

      • 带 * 的函数是 generator,我们在前面的部分已经见过它了。生成器函数可以理解为返回一个序列的函数,它的底层是 iterator 机制。
      • async 函数是可以暂停执行,等待异步操作的函数,它的底层是 Promise 机制。异步生成器函数则是二者的结合。函数的参数,可以只写形参名,现在还可以写默认参数和指定多个参数
  • 语句块:就是一对大括号。

    • 语句块的意义和好处在于:让我们可以把多行语句视为同一行语句,这样,if、for 等语句定义起来就比较简单了。不过,我们需要注意的是,语句块会产生作用域,我们看一个例子:
      {
        let x = 1;
      }
      console.log(x); // 报错
      //这里我们的 let 声明,仅仅对语句块作用域生效,于是我们在语句块外试图访问语句块内的变量 x 就会报错。
      
  • 表达式语句:由运算符连接变量或者直接量构成的
    • 表达式语句要么是函数调用,要么是赋值,要么是自增、自减,否则表达式计算的结果没有任何意义。
    • PrimaryExpression 主要表达式
      • 表达式的原子项,表达式的最小单位,它所涉及的语法结构也是优先级最高的;
      • 包含了各种“直接量”,直接量就是直接用某种语法写出来的具有特定类型的值。我们已经知道,在运行时有各种值,比如数字 123,字符串 Hello world 等;
      • 任何表达式加上圆括号,都被认为是 Primary Expression,这个机制使得圆括号成为改变运算优先顺序的手段。
    • MemberExpression 成员表达式
      • 前面两种用法都很好理解,就是用标识符的属性访问和用字符串的属性访问。而 new.target 是个新加入的语法,用于判断函数是否是被 new 调用,super 则是构造函数中,用于访问父类的属性的语法。
        a.b;
        a["b"];
        new.target;
        super.b;
        
      • 带函数的模板,这个带函数名的模板表示把模板的各个部分算好后传递给一个函数。
        f`a${b}c`;
        
      • 带参数列表的 new 运算,注意,不带参数列表的 new 运算优先级更低,不属于 Member Expression。
        new Cls();
        
    • NewExpression NEW 表达式
      • Member Expression 加上 new 就是 New Expression(当然,不加 new 也可以构成 New Expression,JavaScript 中默认独立的高优先级表达式都可以构成低优先级表达式)
        //注意,这里的 New Expression 特指没有参数列表的表达式。我们看个稍微复杂的例子:
        new new Cls(1)();
        //直观看上去,它可能有两种意思:
        new new Cls(1)();
        new new Cls()(1);
        //实际上,它等价于第一种。我们可以用以下代码来验证:
        class Cls {
          constructor(n) {
            console.log("cls", n);
            return class {
              constructor(n) {
                console.log("returned", n);
              }
            };
          }
        }
        new new Cls(1)();
        // 这段代码最后得到了下面这样的结果。cls 1returned undefined这里就说明了,1 被当做调用 Cls 时的参数传入了。
        
    • CallExpression 函数调用表达式
      • 除了 New Expression,Member Expression 还能构成 Call Expression。它的基本形式是 Member Expression 后加一个括号里的参数列表,或者我们可以用上 super 关键字代替 Member Expression。
        a.b(c);
        super();
        
    • LeftHandSideExpression 左值表达式
      • 左值表达式就是可以放到等号左边的表达式。JavaScript 语法则是下面这样。
        a() = b;
        //这样的用法其实是符合语法的,只是,原生的 JavaScript 函数,返回的值都不能被赋值。因此多数时候,我们看到的赋值将会是 Call Expression 的其它形式,如:
        a().c = b;
        
    • AssignmentExpression 赋值表达式
      • 赋值表达式也有多种形态,最基本的当然是使用等号赋值:
        a = b;
        //这样的用法其实是符合语法的,只是,原生的 JavaScript 函数,返回的值都不能被赋值。因此多数时候,我们看到的赋值将会是 Call Expression 的其它形式,如:
        a().c = b;
        
    • Expression 表达式
      • 赋值表达式可以构成 Expression 表达式的一部分。在 JavaScript 中,表达式就是用逗号运算符连接的赋值表达式。
      • 在 JavaScript 中,比赋值运算优先级更低的就是逗号运算符了。我们可以把逗号可以理解为一种小型的分号。
        (a = b), (b = 1), null;
        
    • UpdateExpression 更新表达式
      • 左值表达式搭配 ++ -- 运算符,可以形成更新表达式。
        --a;
        ++a;
        a--;
        a++;
        
    • UnaryExpression 一元运算表达式
    • 它的特点就是一个更新表达式搭配了一个一元运算符。
      delete a.b;
      void a;
      typeof a;
      -a;
      ~a;
      !a;
      await a;
      
    • ExponentiationExpression 乘方表达式
    • 乘方表达式也是由更新表达式构成的。它使用**号。
      (++i) ** 30;
      2 ** 30 - //正确
        2 ** 30; //报错
      // -2 这样的一元运算表达式,是不可以放入乘方表达式的,如果需要表达类似的逻辑,必须加括号。这里我们需要注意一下结合性,
      // ** 运算是右结合的,这跟其它正常的运算符(也就是左结合运算符)都不一样。
      4 ** (3 ** 2); //事实上,它是这样被运算的:4 ** (3 ** 2)
      
    • MultiplicativeExpression 乘法表达式
    • AdditiveExpression 加法表达式
    • ShiftExpression 移位表达式
    • RelationalExpression 关系表达式
    • EqualityExpression 相等表达式
    • 位运算表达式
    • 逻辑与表达式和逻辑或表达式
    • 条件表达式