JavaScript编译时

JavaScript编译时会执行三个阶段:词法分析、语法分析和代码生成:

  1. 词法分析,主要生成词法单元

  2. 语法分析,生成元素组件嵌套组成的抽象语法树,并生成词法作用域;

  3. 代码生成,将AST语法树转换为可执行代码,在这期间进行内存分配以及生成全局执行环境。

词法分析

词法分析阶段主要生成词法单元,而这种生成方式往往不是简单的切割生成,而是带有状态的,比如保留字、运算符等等,通过有限自动机的方式生成。

词法分析阶段的目标就是通过词法分析器扫描源程序,并输出一系列单词符号构成的词法单元,即token。

token一般有以下五大类:关键字(比如while、if、class等)、标识符、值类型(比如100、true、'text'等)、运算符、界符(比如逗号、分号、大括号,点等)。

比如代码sum=3+2;词法分析后可以得到下表内容:

token 类型
sum 标识符
= 赋值运算符
3
+ 算术运算符
2
; 界符

词法分析的主要过程如下:

预处理

输入字符流后首先会进入输入缓冲区,在词法分析器真实对它进行扫描前,会进行一些预处理的工作。

预处理阶段会对一定长度的字符流进行优化,包括去除注释、合并多个空白符、处理回车符和换行符等,并将完成处理的字符流送入扫描缓冲区,这时词法分析器才正式开始拆分字符流。

词法分析器扫描

词法分析器对扫描缓冲区进行扫描时会用到两个指示器,起点指示器指向当前正在识别单词的开始位置,搜索指示器用于向前搜索以寻找单词的终点。

词法分析器的简单实现

在词法分析中可以使用有限状态机来识别词法单元,例如标识符、运算符、常量等,每个词法单元都可以表示为一个正则表达式,并通过构建有限状态机来进行匹配。

有限状态机可以模拟世界上大部分事物,它有三个特征:有限的状态数、任意时刻只处在同一状态中,某种条件下会从一种状态转变为另一种状态。

有限状态机是一种抽象的计算模型,由一组有限的状态和状态之间的转换规则组成。

可以理解为,有限状态机是一个个单独解决问题的机器,在处理业务的时候,我们只关心本状态需要解决什么问题。

有限状态机分为moore状态机和mealy状态机,前者的每个机器都有确定的下一个状态,后者每个机器根据输入决定下一个状态。

在JavaScript中实现mealy状态机的方式,一般通过函数来表示每个状态,函数参数表示输入,函数的返回值就是下一个状态。

下面是简单的通过有限状态机扫码token的实现:

JavaScript 复制代码
// 定义保留字:

const bao = ['if', 'let']

// 定义运算符

const yun = /[\+\-\*\=/]/

let tokens = []

let currentToken = {}

function emitToken(token) {

  currentToken = { type: '', value: '' }

  tokens.push(token)

}

function start(char) {

  currentToken = { type: '', value: '' }

  if (yun.test(char)) {

    currentToken = { type: 'yun', value: char }

    return yunstate

  } else {

    currentToken = { type: 'string', value: char }

    return stringstate

  }

}

// 进入运算符状态

function yunstate(char) {

  if (char == ' ') {

    emitToken(currentToken)

    return start

  } else {

    currentToken.value += char

    currentToken.type = 'string'

    return stringstate

  }

}

// 进入字符串状态

function stringstate(char) {

  if (char == " ") {

    if (bao.includes(currentToken.value)) {

      currentToken.type = 'bao'

      emitToken(currentToken)

      return start

    } else {

      emitToken(currentToken)

      return start

    }

  } else {

    currentToken.value += char

    return stringstate

  }

}

 

function tostart(string) {

  let state = start

  for (let i of string) {

    console.log(i, tokens, state)

    state = state(i)

  }

  return tokens

}

console.log(tostart('let a = 1 '))

运行结果为:

[

{ type: 'bao', value: 'let' },

{ type: 'string', value: 'a' },

{ type: 'yun', value: '=' },

{ type: 'string', value: '1' }

]

语法分析

这一阶段将词法单元流转换成由元素逐级嵌套所组成的抽象语法树 ,即AST,并确定变量和函数的作用域范围,建立作用域链

抽象语法树

抽象语法树就是描述语法的关系树,可以类比dom树,知道这个即可,不必去过度分析

词法作用域

在js中去存储、访问和修改变量的规则就是作用域。

JavaScript采用的是词法作用域,词法作用域则是在语法分析阶段就确定了,它只和代码编写位置有关,是静态的。

词法作用域可以分为全局作用域、函数作用域(函数自己的作用域,可以访问外部作用域,但外部无法访问函数内部的作用域)和块作用域(即{}内的代码块,let和const会产生块级作用域)。

语法阶段生成的词法作用域,只是从存储结构和查找规则上来说,词法作用域已经确定了,但并没有开辟内存存储变量。

函数作用域

每声明一个函数都会为自身创建一个作用域,这个作用域就是函数作用域,函数作用域的含义是说,属于这个函数的全部变量都可以在这个函数的执行环境内使用。

函数作用域的作用:

1、隐藏内部实现

由于函数作用域的特性,从所写代码中挑选一个任意片段,使用函数声明对其进行包装,根据函数作用域特性,即相当于把这些代码隐藏起来了。

为什么隐藏内部实现是个有用的技术?

从最小特权原则中引申出来,也叫最小授权或最小暴露原则,在软件设计中,应该最小限度地暴露必要内容,而将其它内容都隐藏起来。

这个原则可以延伸到如何选择作用域来包含变量和函数,如果所有变量和函数都在全局作用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏最小特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确的代码应该是可以阻止对这些变量或函数进行访问的。

JavaScript 复制代码
    function doSomething(a) {

        b = a + doSomethingElse(a * 2);

        console.log(b * 3);

    }

    function doSomethingElse(a) {

        return a - 1;

    }

    var b;

    doSomething(2); // 15

在这个代码片段中,变量 b 和函数 doSomethingElse应该是 doSomething内部具体内容,给予外部作用域对 b 和 doSomethingElse的访问很危险,因为它们可能被有意或无意地以非预期的方式使用,从而导致超出了 doSomething的适用条件,更合理的设计如下:

javascript 复制代码
    function doSomething(a) {

        function doSomethingElse(a) {

            return a - 1;

        }

        var b;

        b = a + doSomethingElse(a * 2);

        console.log(b * 3);

    }

    doSomething(2); // 15

现在,b 和 doSomethingElse都无法从外部被访问,而只能被 doSomething所控制,功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会依此进行实现。

2、 规避冲突

函数作用域中的变量和函数所带来的另一个好处是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突,冲突会导致变量的值被意外覆盖。

变量冲突的典型例子是在全局作用域中,程序加载多个第三方库时,若没有妥善将内部函数或变量隐藏起来,就容易发生冲突。

这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象的属性。

在任何代码片段添加函数对变量进行包装,可以将变量私有化起来,这种技术虽然能够解决很多问题,但不太理想,导致很多问题:

首先必须声明具名函数,污染所在的作用域,且需要显示调用才能执行内部代码,如果函数不需要名字,并自动执行代码将会更加理想。

IIFE正是解决这类问题而产生的:

javascript 复制代码
    var a = 2;

    (function foo() { // <-- 添加这一行

        var a = 3;

        console.log(a); // 3

    })(); // <-- 以及这一行

    console.log(a); // 2

这里的foo会被当作函数表达式而不是函数来处理。

IIFE是常见的函数表达式,代表立即执行函数表达式,相较于传统的(function(){})(),还有另一种改进的方式(function(){}()),两种方式功能一致,选择全凭喜好。

IIFE主要有以下用途:

1、 模块扩展

IIFE可以把模块当作参数传递,实现模块的扩展。

JavaScript 复制代码
    var a = 2;

    (function IIFE(global) {

        var a = 3;

        console.log(a); // 3

        console.log(global.a); // 2

    })(window);

    console.log(a); // 2

2、倒置代码运行顺序

IIFE可以倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当作参数传递进去。

JavaScript 复制代码
    var a = 2;

    (function IIFE(def) {

        def(window);

    })(function def(global) {

        var a = 3;

        console.log(a); // 3

        console.log(global.a); // 2

    });

块级作用域

块级作用域前,函数作用域是最常见的作用域单元,但JavaScript没有块级作用域的概念被诟病。

块级作用域,是对最小授权原则进行扩展的工具,将代码从函数中隐藏信息扩展为在块中隐藏信息,几类块级作用域:

1、try/catch

es3规范中try/catch中catch分局会创建一个块级作用域,声明的变量只在catch内部有效,换个思维理解,catch作为一个函数,充当的是函数作用域。

JavaScript 复制代码
    try { throw 2; } catch (a) {

        console.log(a); // 2

    }

    console.log(a); // ReferenceError

以上代码强制抛出错误值2,然后catch分句接收值,catch具有块级作用域,在ES6前的环境作为块级作用域的解决方案。

2、let

let关键字声明的变量,隐式所在作用域,用来在任意代码块中声明变量。

3、const

除let外,es6引入const,同样创建块级作用域,但值固定,如何试图修改值的操作都会引起错误。

变量的查找规则

变量的查找规则分为左查询(LHS)和右查询(RHS),左查询是对变量进行查找,找不到则创建一个默认值为undefined的变量。右查询则是查找变量的值,若未能查找到则会抛出错误(ReferenceError引用错误)。

JavaScript 复制代码
let a = b; // ReferenceError,因为 b 未被定义

console.log(c) // ReferenceError,因为 c 未被定义

简要分析:在上述代码中,将b的值赋值给a,在查找a这个变量的过程即左查询,而查找b值的过程即为右查询,查不到b值则会抛出错误。

作用域规则:

JavaScript 复制代码
    var a = 'a';

    function b() {

      var b = 'b';

      function c() {

        console.log(b, a); // b a

      }

      c();

    }

    b();

上述代码中,函数b拥有自己的函数作用域和全局作用域,函数c拥有自己的函数作用域外,还拥有函数b作用域和全局作用域,查找变量的规则便是根据这个作用域的嵌套规则一层一层往外查询。

作用域分析

编译时的作用域分析其实前面已经提到了,过程就是前面说的,确定变量和函数的作用域范围,建立作用域链。

预编译

在作用域分析的基础上,预编译阶段会对变量和函数进行提升,将变量和函数的声明提升到作用域顶部。

在预编译中,JavaScript引擎会扫描代码,将变量和函数的声明提升到作用域的顶部。

这意味着,无论在代码中的何处声明变量或函数,在执行阶段之前,它们都会被移动到作用域的顶部。

这使得变量和函数在其声明之前就可以被使用。

引擎会找到所有声明,并通过作用域关联起来,而这就是词法作用域的核心。

表现为var和function声明的变量和函数会在任何代码被执行前首先处理。

比如对于var a =2,JavaScript会将其看作两个声明:var a和a = 2,定义声明是在编译阶段,赋值声明会被留在原地等待执行阶段。

这种好像变量和函数声明在代码中出现的位置被"移动",称之为变量提升。

代码生成

预编译之后,就会进入代码生成阶段。

将AST语法树转换为可执行代码的过程被称为代码生成,在这期间进行内存分配以及生成全局执行环境(上下文)。

执行环境

执行环境又叫执行上下文,通俗来说,就是一段代码执行时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文。

比如js在浏览器中,会对应生成window,在后台或命令行,会生成node的执行环境,这也是解释为什么说node是执行环境而非后台JavaScript编程语言。

每个执行环境都包含一个外部引用,外部引用指向外部的执行环境,当执行一个函数时,引擎会将函数的执行环境推入调用栈中,内部就可以通过外部引用去访问外部变量,这样就生成了变量的作用域链。

并且,每个执行环境的外部引用都是在编译时而非执行时决定的,这和代码编写时的位置有关,即词法作用域。

根据词法作用域的不同,执行环境分为全局执行环境、函数执行环境和块执行环境,并产生AO(活动对象)和VO(变量对象)。

所谓活动对象AO即用于保存this、参数变量(默认arguments)、调用位置和调用方法。

VO变量对象则用于保存定义的变量以及函数。

由于执行环境是动态生成的,所以函数只有在调用时才会生成函数执行环境,而生成执行环境时,引擎会找到执行环境里的所有var和function声明的变量和函数并放置首位,之后将执行环境推入调用栈中,做执行准备,但并未马上执行。

比如var a =2,JavaScript会将其看作两个声明:var aa = 2,定义声明是在编译阶段,赋值声明会被留在原地等待执行阶段。

执行环境在执行时,会创建变量对象的一个作用域链,在作用域链的最前面是当前执行环境的变量对象,当生成的环境是函数执行环境时,则将其活动对象作为其变量对象。

而执行环境栈中的下一个环境的变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境,一直延续到全局执行环境。

当我们进行变量查找时便是遵循的作用域链的规则,从当前环境的变量对象开始查找一直到全局环境。

全局执行环境

全局执行环境可以看作js最外层的代码执行环境,比如在浏览器中全局执行环境是window,在node环境中即是node,它是最外围的执行环境,并且全局环境没有活动对象,当关闭网页和浏览器时会自动销毁环境。

全局执行环境是在编译阶段生成代码时产生的,可以这样理解,全局执行环境的生成相当于调用了最外层那个最大的函数。

函数执行环境

函数调用时,在执行函数内部代码前会生成函数执行环境,然后将该执行环境推入调用栈,做好执行准备,执行完毕后会销毁该函数执行环境,保存在其中的变量和函数也随之销毁。

块执行环境

这个就没什么好说的了,通常就是由let、const加上{}产生的执行环境,同全局执行环境一样,它没有活动对象。

其实这三个执行环境并没有特殊的区分,可以这样理解:全局执行环境和块执行环境是没有AO的执行环境,函数执行环境是同时具有AO和VO的执行环境。

执行环境和作用域的区别

由于执行环境看似保存了变量和函数,所以很多人会误认为执行环境即是作用域,但两者是完全不同的东西。

首先,执行环境是动态生成的,也就是执行代码前产生的,代码执行完环境也就随之销毁,比如调用函数时会生成一个函数执行环境,函数内的代码执行完毕,环境也就销毁了。

而作用域是静态的,它取决于我们代码写在哪里,在编译时的语法分析阶段,就确定并建立了作用域链,当进行变量查找时,会先在自身执行环境的变量对象中查找,如果查找不到则根据变量对象作用域链向外部环境的变量对象中查找,一直查找到全局执行环境的变量对象为止。

相关推荐
道爷我悟了几秒前
Vue入门-指令学习-v-on
javascript·vue.js·学习
27669582923 分钟前
京东e卡滑块 分析
java·javascript·python·node.js·go·滑块·京东
golitter.9 分钟前
Ajax和axios简单用法
前端·ajax·okhttp
PleaSure乐事18 分钟前
【Node.js】内置模块FileSystem的保姆级入门讲解
javascript·node.js·es6·filesystem
雷特IT29 分钟前
Uncaught TypeError: 0 is not a function的解决方法
前端·javascript
长路 ㅤ   1 小时前
vite学习教程02、vite+vue2配置环境变量
前端·vite·环境变量·跨环境配置
亚里士多没有德7751 小时前
强制删除了windows自带的edge浏览器,重装不了怎么办【已解决】
前端·edge
micro2010141 小时前
Microsoft Edge 离线安装包制作或获取方法和下载地址分享
前端·edge
.生产的驴1 小时前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
awonw1 小时前
[前端][easyui]easyui select 默认值
前端·javascript·easyui