JavaScript语法

JavaScript两种源文件(SourceFile)

  • 脚本
  • 模块

JavaScript 引擎执行哪些东西

  • 脚本
  • 模块
  • 函数

JavaScript 有两种源文件,一种叫做脚本,一种叫做模块。

JavaScript 引擎除了执行脚本和模块之外,还可以执行函数。而函数体跟脚本和模块有一定的相似之处。

历史

脚本与模块的区别:

在ES5以前只有一种源文件:脚本
脚本是可以由浏览器或者node环境引入执行的,或者你也可以使用C、C++、Java调用JavaScript引擎,执行脚本
模快只能由JavaScript代码用import引入执行。

从概念上,可以认为,脚本是具有主动性的JavaScript代码段,是控制宿主完成一定任务的代码。
模块是被动性的JavaScript代码段,是等待被调用的库,如果不被其他JavaScript代码段import引入,则不能执行。

对脚本和模块比较不难发现,它们的区别主要在于,代码中是否包含import和export

现代浏览器可以支持用script标签引入模块或者脚本,引入模块要这样使用:

  1. <script type="module" src="xx.js"></script>

引入脚本去掉type=”module”即可
如果我们没有加type=”module”,而引入了模块,则会报错

脚本中可以包含语句,同样模块中可以包含三种内容:
import声明
export声明和语句
普通语句
写给自己的JavaScript语法知识 - 图1

import声明

两种用法:
1.直接import另一个模块
2.带from的import,它能引入模块里的一些信息
示例:

  1. import "module"; // 引入一个模块
  2. import m form "module"; // 把模块默认的导出值放入变量m

import “module”;直接import一个模块,只是保证这个模块代码被执行,引入它的模块,无法获得它的任何信息
import m form “module”;带form的import意思是引入模块中的一部分信息,可以把引入的信息变成本地变量

带from的import细分又有三种用法:
1.i mport x from “./a.js”;引入模块中导出的默认值
2.import {a as x, modify} from “”./a.js;引入模块中的变量
3.import as x from “./a.js”;把模块中所有的变量以类似对象属性的方式引入
其中1与还可以与其他方式组合:
1.1 import d,{a as x, modify} from “./a.js”;
1.2 import d,
as x from “./a.js”;
值得注意的是:
1.0 语法要求,不带as的默认值永远在前面。
2.0 这里的变量实际上仍然可以受到原模块的控制。
这里的变量实际上仍然可以受到原模块的控制,示例:
模块 a:

  1. export var a = 1;
  2. export function modify() {
  3. a = 2;
  4. }

模块b:

  1. import {a, modify} from "a.js";
  2. console.log(a); // 1
  3. modify();
  4. console.log(a); // 2

当我们调用修改变量的函数modify后,b模块的变量也跟着发生了改变。这说明与一般的赋值不同,导入后的变量只是改了名字而已,它仍然与原来的变量是同一个。

export声明

与import相对export声明是负责导出任务。
有两种导出变量方式:
1.0 独立使用export声明
独立使用export声明就是一个export关键字加上变量名列表。

  1. let a = 1;
  2. let b = 2;
  3. let d = 3;
  4. export {a,b,c}

2.0 直接在声明型语句前添加export关键字
在声明语句前加上export关键字,这里export可以加在任何声明性质语句之前,如下:

  • var
  • function(包含async和generate)
  • class
  • let
  • const
    1. export let a = 1;
    另export还有一种特殊的用法,就是跟default联合使用。
    export default表示导出一个默认变量值,它可以用于function和class。
    这里导出的变量是没有名称的,可以使用import x from “./a.js”;这样的语法,在模块中引入。

export default还支持一种语法,后面根一个表达式,如:

  1. var a = 1;
  2. export default a;

注意:这里和导出变量不一致,这里导出的是值,导出的就是普通变量a的值,以后a的变化,和原模块无关。修改变量a,不会使其他模块中引入的default的值发生改变。

在import语句前无法加入export,但是我们可以直接使用export from语法。

  1. export a from "a.js"

JavaScript引擎除了执行脚本和模块之外,还可以执行函数。
函数体跟脚本和模块有一定相似之处,引入函数体。

函数体(setTimeout中的回调等)

执行函数的行为通常是在JavaScript代码执行时,注册宿主环境的某些事件触发的。
通过JavaScript代码,注册到宿主环境的某些事件上(DOM事件,定时器等),当事件触发时,会执行注册的函数。
执行过程,就是执行函数体(函数花括号中的部分)。
示例:

  1. setTimeout(() => {
  2. /*函数体开始*/
  3. console.log("hello")
  4. /*函数体结束*/
  5. }, 1000)

其中,函数体是:

  1. console.log("hello")

这段代码会通过setTimeout在宿主上注册一个函数,当一定时间之后,宿主就会执行这个函数。

其他示例:
比如你在浏览器监听一个DOM的click事件

  1. <button id=btn>click</button>
  1. btn.addEventListener(() => {
  2. console.log("clicked")
  3. })

这段代码会通过addEventListener在宿主上注册一个函数,每次点击这个DOM都会触发这个函数。

你可能想到了,这不就是宏任务嘛

宏任务中,可能会执行的代码包括 脚本(script) 模块(module) 函数体(function body)

函数体其实也是一个语句的列表脚本和模块同样是语句列表(Expression

函数体中的语句列表中多了return语句可以用。

函数体实际上有四种,如下:

1.0 普通函数体,示例:

  1. function foo() {
  2. // Function body
  3. }

2.0 异步函数体

  1. async function foo() {
  2. // Function body
  3. }

3.0 生成器函数体

  1. function* foo() {
  2. // Function body
  3. }

4.0 异步生成器函数体

  1. async function* foo() {
  2. // Function body
  3. }

上面四种函数体区别在于:是否使用await或者yield语句。
关于函数体、脚本、模块能使用的语句,表格
写给自己的JavaScript语法知识 - 图2
三种语法结构已经讲完,下面介绍JavaScript的两个语法的全局机制:
预处理
指令序言

预处理

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

由于ES5之前的历史包袱很重,这一块很复杂,平时写JavaScript时,尽量不要用var,也不要不用关键字声明变量。

var声明

var声明永远作用于脚本、模块、函数体这个级别,在预处理阶段,不关心赋值的部分,只管在当前作用域声明这个变量,即在当前作用域中一定存在这个变量,如果没有赋值则是undefined。

示例:

  1. var a = 1;
  2. function foo() {
  3. console.log(a);
  4. var a = 2;
  5. }
  6. foo();

这段代码声明了一个脚本级别的a,有声明了一个函数体级别的a,我们注意到,函数体级别的var出现在console.log之后。

预处理之前,函数体级别存在变量a,不会去访问外层作用域中的变量a了,而函数体级别的变量a此时未赋值,所以console.log(a)时,a的值时undefined。

等价于

  1. var a = 1;
  2. function foo() {
  3. var a = undefined;
  4. console.log(a);
  5. a = 2;
  6. }
  7. foo();

下面再看一种情况:

  1. var a = 1;
  2. function foo() {
  3. console.log(a);
  4. if(false){
  5. var a = 2;
  6. }
  7. }

这段代码中,var a = 2;外面多了一段if,显而易见,var a = 2;永远不会执行,但是预处理不会管这个,var的作用域会穿透一切语句结构,它认准脚本、模块、函数体三种语法结构。所以这段代码和之前一段代码结果一样,会得到undefined。

等同于:

  1. var a = 1;
  2. function foo() {
  3. var a = undefined
  4. console.log(a);
  5. if(false){
  6. a = 2;
  7. }
  8. }

预处理像疯了(crazy)一样,不会管运行时代码是否会执行,var的作用域会穿透一切语句结构,它认准脚本、模块、函数体三种语法结构,然后停止穿透。所以这段代码会得到undefined。

下面的例子会提到JavaScript的公认的设计失误之一。

  1. var a = 1;
  2. function foo() {
  3. var o = {a: 3};
  4. with(o) {
  5. var a = 2;
  6. }
  7. console.log(o.a);
  8. console.log(a);
  9. }
  10. 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”之下,已经无法修正了,所以你需要特别注意。

  1. var a = 1;
  2. function foo() {
  3. var o = {a: 3};
  4. if(false) {
  5. with(o) {
  6. var a = 2;
  7. }
  8. }
  9. console.log(o.a);
  10. console.log(a);
  11. }
  12. foo();

因为早年 JavaScript 没有 let 和 const,只能用 var,又因为 var 除了脚本和函数体都会穿透,人民群众发明了“立即执行的函数表达式(IIFE)”这一用法,用来产生作用域,例如:

  1. for(var i = 0;i < 20; i++) {
  2. void function(i) {
  3. var div = document.createElement("div")
  4. div.innerHTML = i
  5. div.onclick = function() {
  6. console.log(i)
  7. }
  8. document.body.appendChild(div)
  9. }(i)
  10. }

这段代码非常经典,常常在实际开发中见到,也经常被用作面试题,为文档添加了 20 个 div 元素,并且绑定了点击事件,打印它们的序号。

我们通过 IIFE 在循环内构造了作用域,每次循环都产生一个新的环境记录,这样,每个 div 都能访问到环境中的 i。

如果我们不用 IIFE:

  1. for(var i = 0; i < 20; i ++) {
  2. var div = document.createElement("div");
  3. div.innerHTML = i;
  4. div.onclick = function(){
  5. console.log(i);
  6. }
  7. document.body.appendChild(div);
  8. }

这段代码的结果将会是点每个 div 都打印 20,因为全局只有一个 i,执行完循环后,i 变成了 20。

function声明

function 声明的行为原本跟 var 非常相似,但是在最新的 JavaScript 标准中,对它进行了一定的修改,这让情况变得更加复杂了。

在全局(脚本、模块和函数体),function 声明表现跟 var 相似,不同之处在于,function 声明不但在作用域中加入变量,还会给它赋值。

我们看一下 function 声明的例子:

  1. console.log(foo);
  2. function foo(){
  3. }

这里声明了函数 foo,在声明之前,我们用 console.log 打印函数 foo,我们可以发现,已经是函数 foo 的值了。

function 声明出现在 if 等语句中的情况有点复杂,它仍然作用于脚本、模块和函数体级别,在预处理阶段,仍然会产生变量,它不再被提前赋值

  1. console.log(foo);
  2. if(true) {
  3. function foo(){
  4. }
  5. }
  6. // undefined

这段代码得到 undefined。如果没有函数声明,则会抛出错误。

这说明 function 在预处理阶段仍然发生了作用,在作用域中产生了变量,没有产生赋值,赋值行为发生在了执行阶段

出现在 if 等语句中的 function,在 if 创建的作用域中仍然会被提前,产生赋值效果。

  1. if(true) {
  2. console.log(foo)
  3. function foo(){
  4. }
  5. }
  6. // 打印出函数

块语句及其相关规范的前世今生

这里提及一下,ECMAScript 6 之后的规范中增加了一个补充提案(兼容性扩展规范),包括之前前端最爱的“proto”属性都在这个扩展的规范中。——也就是说,它们并不是 “严格的 ECMAScript 规范” 的一部分,而是用来解释历史问题、兼容问题的扩展部分。然而很不幸的是,几乎所有主要的 js 引擎都是面向浏览器的,都需要解决这些历史问题或兼容问题。
完整文章

  1. {
  2. function a() {
  3. console.log(10)
  4. }
  5. a = 10
  6. a = 20
  7. function a() {
  8. console.log(20)
  9. }
  10. a = 30
  11. }
  12. console.log(a) // 20

那么有没有办法简单而准确地概括这些扩展规范的语法效果呢?当然可以,如下:

  • 在支持Web特性扩展(Web Legacy Compatibility Semantics)的引擎中,具名函数声明语句会被提升到它所在的块级作用域的初始化阶段来完成声明和绑定,并且:
    • 该声明会隐式地向它所在的最外层可执行结构(函数或全局)添加同名变量,缺省值为undefined;然后,
    • 在该函数声明语句执行时,该函数名的当前值还将隐式地“动态提升到(或称为绑定到上述可执行结构的变量环境中的)”该同名变量。

class声明

class 声明在全局的行为跟 function 和 var 都不一样。

在 class 声明之前使用 class 名,会抛错:

  1. console.log(c);
  2. class c{
  3. }

这段代码我们试图在 class 前打印变量 c,我们得到了个错误,这个行为很像是 class 没有预处理,但是实际上并非如此。

我们看个复杂一点的例子:

  1. var c = 1;
  2. function foo(){
  3. console.log(c);
  4. class c {}
  5. }
  6. foo();

这个例子中,我们把 class 放进了一个函数体中,在外层作用域中有变量 c。然后试图在 class 之前打印 c。

执行后,我们看到,仍然抛出了错误,如果去掉 class 声明,则会正常打印出 1,也就是说,出现在后面的 class 声明影响了前面语句的结果。

这说明,class 声明也是会被预处理的,它会在作用域中创建变量,并且要求访问它时抛出错误。

写给自己的JavaScript语法知识 - 图3

class 的声明作用不会穿透 if 等语句结构,所以只有写在全局环境才会有声明作用。
写给自己的JavaScript语法知识 - 图4写给自己的JavaScript语法知识 - 图5
写给自己的JavaScript语法知识 - 图6
将代码中true换成false结果一样

  1. console.log(a)
  2. if(true) {
  3. class a {}
  4. }

阶段总结,var、函数声明、class、const 和 let 这些语句都会预处理,其中var声明、function声明、class声明预处理的结果比较特别,有可能和你的认识有不同。

指令序言机制

脚本和模块都支持一种特别的语法,叫做指令序言(Directive Prologs)。

这里的指令序言最早是为了 use strict 设计的,它规定了一种给 JavaScript 代码添加元信息的方式。

  1. "use strict";
  2. function f(){
  3. console.log(this);
  4. };
  5. f.call(null);

这段代码展示了严格模式的用法,我这里定义了函数 f,f 中打印 this 值,然后用 call 的方法调用 f,传入 null 作为 this 值,我们可以看到最终结果是 null 原封不动地被当做 this 值打印了出来,这是严格模式的特征。

如果我们去掉严格模式的指令需要,打印的结果将会变成 global。
image.png
“use strict”是 JavaScript 标准中规定的唯一一种指令序言,但是设计指令序言的目的是,留给 JavaScript 的引擎和实现者一些统一的表达方式,在静态扫描时指定 JavaScript 代码的一些特性。

例如,假设我们要设计一种声明本文件不需要进行 lint 检查的指令,我们可以这样设计:

  1. "no lint";
  2. "use strict";
  3. function doSth(){
  4. //......
  5. }
  6. //......

两个指令序言有效和失效的例子:

有效:

  1. "no lint";
  2. "use strict";
  3. function doSth(){
  4. //......
  5. }
  6. //......

失效:

  1. function doSth(){
  2. //......
  3. }
  4. "use strict";
  5. var a = 1;
  6. //......

这个例子中,”use strict”没有出现在最前,所以不是指令序言。

  1. 'use strict';
  2. function doSth(){
  3. //......
  4. }
  5. var a = 1;
  6. //......

这个例子中,’use strict’是单引号,这不妨碍它仍然是指令序言。

总结

我们首先介绍了 JavaScript 语法的全局结构,JavaScript 有两种源文件,一种叫做脚本,一种叫做模块。介绍完脚本和模块的基础概念,我们再来把它们往下分,脚本中可以包含语句。模块中可以包含三种内容:import 声明,export 声明和语句。

最后,我介绍了两个 JavaScript 语法的全局机制:预处理和指令序言。

JavaScript 有两种源文件,一种叫做脚本,一种叫做模块。

JavaScript 引擎除了执行脚本和模块之外,还可以执行函数。而函数体跟脚本和模块有一定的相似之处。