一、JavaScript 变量与值

785 阅读17分钟

前言

JavaScript 诞生于1995年,之后的几年时间里,浏览器市场发展迅速,浏览器脚本语言也百花齐放,出现了如 JScript 这样的浏览器脚本语言,各自的发展可谓是五花八门。后来,为了规范化发展,网景公司将 JavaScript提交给了ECMA 进行标准化,并将标准名称定为 ECMA-262,执行这个标准的脚本语言统称为 ECMAScript。

从1997年6月 ECMA 采纳的 ECMA-262 第一个版本开始,ECMA-262 前期经历了如下的版本更迭:

  • 1998年6月发布 ECMAScript 2.0
  • 1999年12月发布 ECMAScript 3.0
  • 2007年10月发布 ECMAScript 4.0 草案
  • 2009年12月发布 ECMAScript 5.0
  • 2011年6月发布 ECMAScript 5.1
  • 2015年6月发布 ECMAScript 6

至此 ECMAScript 的递增编号版本结束,从 ECMAScript6 及以后的版本都以年份定版,所以 ECMAScript 6 可以理解为以年份定版的 ECMAScript 标准的统称。

ECMAScript 简称为 ES,原生 JavaScript 本质上是 ECMAScript 标准的一个实现
有的人会习惯以递增版本号来称呼 ECMAScript,所以你会看到有 ES6/ES7/ES8 这样的称呼,但这不是官方的称呼,只是大家的习惯,官方的称呼是 ES2015/ES2016/.../ESNext

1. 变量定义

ECMAScript 是弱类型语言标准,所以 JavaScript 是一门弱类型语言。

弱类型语言:定义变量时,无需指定变量的数据类型,如 JavaScript、Python
强类型语言:定义变量时,必须指定变量的数据类型,如 C/C++、Java

在了解变量定义之前,首先要理解 作用域 的概念。在 ES6 之前,只有 全局作用域函数作用域 两种 (不考虑不常用的语法);ES6 中新增了 模块作用域块级作用域

  • 全局作用域:即根作用域,在这里定义的所有变量可以在任何地方被访问
  • 函数作用域:即一个函数所限定的作用域范围
  • 模块作用域,即一个 ES6 模块所限定的作用域范围
    • ES6 模块:以 <script type="module"></script> 来标识的 JavaScript 代码块和引入的 JavaScript 代码
  • 块级作用域:即一个 {} 代码块所限定的作用域范围
    • 注:{} 也会被用于对象字面量定义,对象字面量定义时,它不是用来划定作用域的

1.1 普通变量

在 ES6 之前,定义普通变量的关键字是 var;ES6 中,提出了 letconst 两个新的关键字

  • var 关键字定义的变量特征:
    • 作用域:全局作用域/函数作用域
    • 变量提升:在定义变量之前也可以访问变量,只不过取到的值是 undefined
    • 定义的变量可被重新赋值
    • 同一个作用域内,可重复定义同名变量
  • let 关键字定义的变量特征:
    • 作用域:块级作用域
    • 暂时性死区:在定义变量之前访问变量时,将报 ReferenceError
    • 定义的变量可被重新赋值
    • 同一个作用域内,不允许重复定义同名变量
  • const 关键字定义的变量特征:
    • 作用域:块级作用域
    • 暂时性死区:在定义变量之前访问变量时,将报 ReferenceError
    • 定义的变量不可被重新赋值,如果变量是一个对象,它的属性可以被重新赋值
    • 同一个作用域内,不允许重复定义同名变量
console.log("a=", a); // 这里可以访问到 a 变量,但是值为 undefined

// console.log("b=", b); // 这里访问不到 b 变量, 因为这里是暂时性死区
// console.log("c=", c); // 这里访问不到 c 变量, 因为这里是暂时性死区

var a = 0;
var a = 2; // var 定义的变量,允许重复定义同名变量

const b = { attr: 1 };
// const b = { attr: 2 }; // 同一个作用域内, const 不允许重复定义同名变量
// b = { attr: 3 }; // const 定义的变量不允许被重新赋值
b.attr = 4; // const 定义的变量,为对象时,它的属性值可以被修改

let c = 5;
// let c = 6; // 同一个作用域内, let 不允许重复定义同名变量
c = 7; // let 定义的变量可以被重新赋值

1.2 函数定义

使用关键字 function

函数定义有两种方式,一种是函数声明,即使用关键字 function 来声明一个函数;一种是函数表达式,即把一个函数声明赋值给一个变量。

  • 函数声明:通过 functuon fn_name (p1, p2, ...) {} 的声明方式来声明函数

    • 存在变量提升,在可访问的作用域内,可以在函数声明前调用函数。
    • 不可以定义为匿名函数
  • 函数表达式:将函数声明赋值给一个变量,此时的函数声明可以不写函数名,即使用匿名函数

    • 像普通变量一样被定义,只不过变量的值是一个函数
    • 函数可以是匿名函数
    • 调用函数表达式时,只能通过变量名来调用,即使定义时使用的是命名函数

ES6 增加了一种新的函数:箭头函数。只能通过函数表达式的方式声明箭头函数。

fn1(1, 2); // 函数声明的函数可以在声明前访问,不会报错

// fn2(3, 4); // 函数表达式声明的函数,不能在声明前访问,这里是暂时性死区,即使用 var 定义,这里也只是 undefined

// 函数声明
function fn1(p1, p2) {
  console.log("p1=", p1);
  console.log("p2=", p2);
}

// 匿名函数表达式
const fn2 = function(p1, p2) {
  console.log("p1=", p1);
  console.log("p2=", p2);
}

// 具名函数表达式
const fn3 = function f3(p1, p2) {
  console.log("p1=", p1);
  console.log("p2=", p2);
}

// 箭头函数
const fn4 = (p1, p2) => {
  console.log("p1=", p1);
  console.log("p2=", p2);
}

fn3(5, 6); // 调用具名函数表达式时,也应该用变量名,而不是函数名
fn4(7, 8); // 箭头函数表达式和 function 定义的函数表达式使用方式相同

箭头函数和 function 函数的区别:

  • this 指向不同:箭头函数里的 this 值取决于函数调用所在作用域的 this 值,function 函数里的 this 值取决于函数的调用方式
  • 箭头函数不可被实例化,function (函数声明、函数表达式)函数可以被实例化

function 函数内部的 this 指向规则,将在后面补充

1.3 class 定义

使用关键字:class

了解 JavaScript 背景我们知道,JavaScript 有借鉴 Java 的一些特性,其中就包括面向对象,所以才会有 this,但 JavaScript 的面向对象是基于原型链的,所以在 ES6 之前,要想实现继承是一件非常繁琐的事情,如果你对这个感兴趣,推荐你参阅文章 JavaScript常用八种继承方案

ES6 之后,ECMAScript 引入了 class 这个语法糖来封装之前的繁琐实现,这让 JavaScript 的面向对象从编码层面更像 Java、C++ 那样的 OOP 语言。

ECMAScript class 支持 继承、静态属性、静态方法,不支持私有(private)属性、保护(protected)属性、接口。

ECMAScript 不支持的这些 OOP 特性,让遵循 ECMAScript 的编程语言显得没那么真正地 “面向对象”。而 Typescript 支持所有的 OOP 特性,且 Typescript 可以被编译成 JavaScript,所以如果想在前端真正以形似 OOP 的方式去开发,还得用 Typescript。

class Parent {
  static VER = "1.0.0";

  name = "";
  
  constructor(name) {
    this.name = name;
  }
}

class Child extends Parent {
  age = 0;

  constructor(name, age) {
    super(name);
    this.age = age;
  }

  static elderThan(c1, c2) {
    return c1.age - c2.age > 0;
  }
  
  introduction() {
    console.log(`大家好,我叫 ${this.name}, 今年 ${this.age} 岁了。`); // 这里使用的是模板字符串
  }
}

const p1 = new Parent("p1");
const c1 = new Child("c1", 12);
const c2 = new Child("c2", 10);

console.log("Parent version =", Parent.VER); // 静态属性通过 "类名.属性名" 调用
console.log("c1 name =", c1.name); // 实例属性通过 "实例.属性名" 调用
console.log("c1 比 c2 大吗?", Child.elderThan(c1, c2)); // 静态方法通过 "类名.方法名" 调用
c1.introduction(); // 实例方法通过 "实例.方法名" 调用
c2.introduction(); // 实例方法通过 "实例.方法名" 调用

1.4 模块定义

在 ECMAScript 中,起初没有 模块 这个概念,随着 JavaScript 承载的能力越来越多、JavaScript 应用程序越来越复杂,开发者们感觉到了 JavaScript 的一些非常突出的问题,比如命名冲突。

在 ES6 之前,开发者社区尝试了非常多的模块化规范,最终脱颖而出的是 CommonJS, AMD, UMD。

  • CommonJS (.cjs):Node.js 采用,没有被浏览器段采纳与实现。该规范约定,每一个文件就是一个模块,模块可以通过 module.exports 这个对象来导出期望导出的内容,模块可以通过 require("other_module_name") 来导入其他模块导出的内容。
  • AMD(Async Module Definition):异步模块定义,浏览器端的一种模块化规范,可通过 RequireJS 来实现。详细的介绍可以参阅文章 Javascript模块化-AMD
  • UMD(Universal Module Definition):通用模块定义,UMD 的目标是让编写的 JavaScript 模块可以在所有的 JavaScript 运行环境中执行。详细介绍和实现可以参阅文章 可能是最详细的UMD模块入门指南

ECMAScript6 中提出了模块的规范,被称为 ECMAScript module,简称 ESM,通常以 .mjs 作为文件扩展名。

在支持 ECMAScript6 规范的浏览器中,可以直接使用 <script type="module"></script> 来标识一个模块:

<script type="module">
// ES6 的模块代码
</script>

<!-- index.js 中可以使用 ES6 的模块语法 -->
<script type="module" src="./index.js"></script>

使用 ES6 模板的时候,通常一个文件就是一个模块,可以通过 import/export 来导入/导出模块数据。

export

导出分为两种:命名导出、默认导出。

export const a = "a"; // 命名导出

const b = "b";
const c = "c";

export { b, c }; // 命名导出

const d = "d";

export default d;  // 默认导出

import

与导出对应,导入也分为两种:命名导入、默认导入。

import { a } from "./a.js"; // 命名导入
import * as aJs from "./a.js"; // 将 a.js 导出的所有内容放到 aJs 这个命名空间下
import d from "./a.js"; 默认导入

console.log("b =", aJs.b); // 通过命名空间使用对应模块的导出属性
console.log("d =", aJs.default); // 通过命名空间使用模块的导出属性时,默认导出只能用 default 这个属性名来获取

ESM 还支持动态导入,格式为 import("./a.js"),动态导入的结果只能通过 import 返回的 Promise 得到。

2. 数据类型

为什么是值的类型?
ECMAScript 规范是针对动态类型脚本语言的规范,变量的数据类型是不确定的,给变量赋不同的值,变量的数据类型就变成了对应值的类型,因此遵循 ECMAScript 规范的编程语言中,我们通常说的是值的数据类型 (简称 值类型 ),而不是变量的数据类型。

ECMAScript 规范定义了 8 种值类型,分别是:undefinednullbooleannumberstringbigintsymbolobject

其中 symbol 是在 ES2015定义的,bigint 是 ES2020 定义的,其他 6 种值类型都是在 ES2015 之前定义的。

这些值类型中,object 为引用值类型,其它 7 种值类型为原始值类型。

原始值类型

原始值类型 可以简单地理解为 “简单的数据”,它们的特性有:

  • 复制:按值复制,复制后的变量与原始变量互不影响
  • 存储访问:大小固定,保存在栈里,方便快速访问

引用值类型

引用值类型 可以简单地理解为 “复杂的数据”,它们的特性有:

  • 复制:按引用复制,复制后的变量与原始变量指向同一个值,对某个变量的属性修改,会反映到另一个变量上
  • 存储访问:大小不固定,引用值的本体保存在堆里,引用值保存在栈里

原始值与引用值.jpg

你可以在浏览器的 devtools 里逐行输入如下的代码,初步感受一下 JavaScript 里的原始值类型和引用值类型。

// 原始值类型
let a = 123;
let b = a;
console("a =", a);
console("b =", b);
console("a等于b吗?", a === b); // a等于b吗? true

b = 1234;
console("a =", a);
console("b =", b);
console("a等于b吗?", a === b); // a等于b吗? false

// 引用值类型
const c = { count: 123 };
const d = c;
console.log("c =", c);
console.log("d =", d);
console.log("c等于d吗?", c === d); // c等于d吗? true

d.count = 1234;
console.log("c =", c);
console.log("d =", d);
console.log("c等于d吗?", c === d); // c等于d吗? true

你也可以将这段代码保存到 JavaScript 文件,或者 HTML 文件的 <script> 标签里,然后运行对应的文件,你就能在浏览器的 devtools 看到对应的输出了。

如何运行 JavaScript 代码?参阅文章 运行 JavaScript 代码的三种方式

2.1 undefinednull

undefinednull 是两个特殊的值类型,这两个类型都只有对应的一个值,分别为 undefinednull

undefined 的含义是 未定义 ,在如下情况下,会获取到 undefined 值:

  • void 表达式

  • 访问未定义的变量 (不是在 暂时性死区 中的变量访问)

  • 访问对象及对象原型链中不存在的属性

  • 使用 varlet 定义变量后,未给变量赋值就访问这些变量

    • 也就是说,变量定义了但未赋值时,变量的值会是 undefined

由于 undefined 不是 ECMAScript 的保留字,因此在某些情况下它会被全局污染,也就是说,全局可能存在一个变量被命名为了 undefined,此时会导致程序获取到不安全的 undefined

要想在所有情况下都获取到安全的 undefined,可以通过 void 表达式来获取,因为 void 任何值得到的都是 undefined,常用的一个获取安全 undefined 的表达式是 void 0

null 的含义是 空对象,它通常的使用场景如下:

  • 定义一个变量,该变量将在后续被赋值为一个对象,此时建议给这个变量附初始值为 null

  • 从 JSON 中获取到的某个属性,其值为 null

    • 通常是后端返回的数据中某些属性值为 null,或者是从 JSON 文件中得到的值为 null 的属性

null 的含义和 undefined 的含义是完全不同的,一定要注意区分。

建议在定义无初值的对象变量时,始终为其赋初始值 null。

console.log("a =", a); // 在定义前就访问,得到的是 undefined
// console.log("b =", b); // 这里是 b 的暂时性死区,访问 b 会报 ReferenceError

var a = 0;

let b;
console.log("b =", b); // 在赋值前就访问,得到的是 undefined
b = 1;

let c = null; 
console.log("c =", c); // 如果一个变量后续会被赋值为对象,建议定义时赋初值为 null

c = { count:0 };
console.log("c =", c);

2.2 boolean, number 和 string

上一节介绍的 nullundefined 是 ECMAScript 中最简单的两种值类型,这一节介绍的这三个值类型是在日常开发中最常用的。

这三个值类型都是原始值类型,在对这三类原始值执行一些操作时,比如对 number 字面量的变量调用 .toFixed 方法,JavaScript 引擎在底层会先把这个字面量用对应的包装类型进行包装,然后再调用包装类型实例对象的这个方法。

字面量:用形如 const str = "abc"; 这样直接把值赋值给变量的形式,这个值就被称为字面量。

原始值类型的值通常都是用字面量的形式写在表达式和语句里的。

包装类型:原始值类型对应的对象类型。

boolean 值类型对应的包装类型是 Boolean

number 值类型对应的包装类型是 Number

string 值类型对应的包装类型是 String

注意:undefinednull 没有包装类型

const a = 123.45678;
const b = a.toFixed(2);
console.log("a =", a);
console.log("b =", b);

这三类原始值类型的包装类型,除了 boolean 外,都提供了非常多的实例方法,参阅 MDN 的文档:

翻阅这些文档我们可以发现,每一个包装类型都提供了对应的方法、释义、使用示例、兼容性等。但是!等等!这么多属性和方法,怎么可能一下子就记住呢?

是的,死记硬背肯定是无法记住的,我们可以通过属性和方法的含义来记忆。

比如字符串用来表示长度的属性,长度 在英语中是 length,所以字符串用来表示长度的属性就是 length

比如拆分字符串,拆分 在英语中是 split,所以字符串用来拆分的方法就是 split,拆分字符串的时候肯定要标明根据什么规则来拆分,所以 split 方法的第一个参数就是用来表明拆分规则的,除了这个参数外,设计师还设计了第二个参数,用来限定返回的分割片段数量,这个参数只需要顺带记忆就可以了。

如果用这种联想方法还是记不住怎么办呢?

优质回答:查 MDN 文档。

这是一个非常细致且全面的前端开发文档,里面包含了 HTML 标签、CSS 属性、JavaScript API 等,只要你想查的,几乎都能查到。所以一定要牢记这个网站:MDN

2.3 symbolbigint

bigint 类型是一个原始值类型,于 ES2020 中引入,目前它所提供的能力仅用于超过 Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER 的数值计算,且这个数值跟服务端语言 Java 的 Long 类型不一样且不兼容,所以实际开发中没有那么常用。想了解的可以戳 BigInt 这个链接

symbol 类型是一个原始值类型,于 ES2015 中引入,它与前面介绍到的 undefined/null/boolean/number/srting/bigint 这些基本不同的是,它没有包装类型。

symbol 类型的值在日常业务开发中几乎不会被使用到,是 ECMAScript 规范中特有的数据类型,它的存在让 ECMAScript 规范的语言具有 元编程(Metaprogramming) 的能力。

Symbol() 不是一个构造函数,而是用来返回 symbol 类型值的函数,且 Symbol() 的每一个返回值都是不相同的,即使传入了相同的参数。

const s1 = Symbol(1);
const s2 = Symbol(2);
console.log("s1等于s2:", s1 == s2); // s1等于s2: false
console.log("s1严格等于s2:", s1 === s2); // s1严格等于s2: false

2.4 object

object 类型是 ECMAScript 规范中唯一一种引用值类型,其他所有的引用值最终都是指向它的,包括上面几个小节提到的那些原始值类型的包装类型,最终都是继承自 Object 这个对象。

在 JavaScript 中,创建 object 类型的变量有两种方式:

  • 方式一:Object 构造函数
  • 方式二:对象字面量
// Object 构造函数创建对象变量 
const o1 = new Object();
o1.count = 0;

// 对象字面量
const o2 = {
  count: 0,
};

这两种方式创建的变量,它们最终都继承自 Object,都是 Object 类型的实例。

object 类型本身没有多少复杂的内容,只需要记住以下几点:

  • Object 构造函数是所有引用值类型的起始类型,Object 构造函数原型的原型是 null
  • Object 的原型方法,是所有引用值都可以访问到的
  • 所有对象字面量的构造函数都是 Object,这些字面量的 __proto__ 属性都指向了 Object.prototype

3. 类型判定

3.1 typeof

typeof 在 JavaScript 中是一个比较常用的类型判定关键词,其返回值为特定的字符串,针对不同的数据类型,返回值如下:

console.log(typeof null); // "object"
console.log(typeof undefined); // "undefined"
console.log(typeof true); // "boolean"
console.log(typeof 1); // "number"
console.log(typeof "a"); // "string"
console.log(typeof Symbol(2)); // "symbol"
console.log(typeof 1n); // "bigint"
console.log(typeof { count: 0 }); // "object"
console.log(typeof []); // "object"

function f1() {}

console.log(typeof f1); // "function"

为什么 typeof null 会是 "object"

这是一个历史遗留问题。

从上面的结果可以看出来,typeof 可以准确判定除了 null 以外的所有原始值类型和函数类型。

3.2 instanceof

instanceof 用于判断引用类型的关系,语法为 source instance target,返回值类型为 boolean

console.log([] instanceof Array); // true
console.log([] instanceof Object); // true
console.log("" instanceof String); // false

3.3 Object.prototype.toString.call()

这是类型判定最精准的方法,这个方法的原理,可以参阅文章 关于Object.prototype.toString.call()

console.log(Object.prototype.toString.call(undefined)); // '[object Undefined]'
console.log(Object.prototype.toString.call(null)); // '[object Null]'
console.log(Object.prototype.toString.call(false)); // '[object Boolean]'

基于上面的输出,我们可以封装一个函数来专门获取某个变量的值类型:

function getVarType(target) {
  const typeString = Object.prototype.toString.call(target);
  // 关键词的截取规则也可以采用其他的,这只是我的实现方式
  return typeString.substring(7, typeString.length).replace("]", "").toLowerCase();
}

总结

本文较详细地介绍了 ECMAScript 规范和 JavaScript 中的变量、数据类型以及数据类型的判定。本文有的地方用的是 ECMAScript,有的地方用的是 JavaScript,之所以这样描述,是因为 ECMAScript 是规范、纲领,而 JavaScript 是规范的一种实现。实现可以不严格遵守规范,规范也可能被多门编程语言遵循。

本文如有描述错误的地方,欢迎指正~