JavaScript 作为上个世纪末设计的一门动态脚本语言,至今已经发展了 27 年了,但是目前 js 整个语言软件生态来说还是生機勃勃,相比其他类型脚本语言真的生态系统天壤之别。js 最初一个工程师花了 10 天所开发出来的,为了完成上级安排的任务开发出来的,拿现在的流行话说就是 hack 一下就开发出来了。js 最初就是为静态的 html 网页添加点动态效果开发出来了的,这和 Java Applet 小程序类似,为了推广蹭 Java 的热度,取名 JavaScript ,其实和 Java 没有半毛钱关系。

但是目前世界上使用最多编程语言 js 已经占据半壁江山,一个 10 天开发出来的脚本语言为何如此的成功?下图为 js 之父 Brendan Eich ,想想都知道一个花 10 天开发出来的语言有多烂。

js 的发展是跟随着万维网一同发展起来的,占据浏览器市场,就占据桌面电脑的市场,所以 js 的意义流行倒今天,另外还得益于移动互联网的发展,js 天生就是为浏览器所使用的,所以事实证明语言设计的再烂,找到一个好的领域和好的宿主做寄生虫,也是不错选择。js 烂归烂但是对于想要刚刚学习入门编程语言的朋友来说确实是一个不错的选择,因为没有完整的类型系统和静态编程语言那么严格要求程序员编写代码的数据类型和规范,所以导致入门门槛也很低,学习成本也低,不过这样使得写出来的程序肯定是很烂。我个人把 php 和 js 划分成为一类的,都是脚本和动态语言,不过 php 只专注于 web 领域,而 js 已经渗透到除了 web 之外的其他领域,嵌入式、桌面端 electron 技术、移动端的原生开的的 React Native 技术,最主要的还是的得益于 Node.js 运行时的出现,导致 js 可以脱离浏览器,在 Node.js 为宿主环境下运行,并且 Node.js 运行时采用的 V8 可以对写的再烂的代码进行运行时优化。

现在目前 js 运行时也是卷的狠,除了 Node.JS 之外,还有 Bun 、Deno 、和 QuickJS 这些一大堆运行时,不过我还是占 Oracle 最新开发的 GraalVM 这一队,相比普通 js 运行时这些, GraalVM 是一个通用语言运行时,支持很多动态脚本语言和编译型语言 C 和 C++ ,还有 Rust 这些新的赶时髦语言。

还有一些新运行时 wasm 运行时,这种目前还是一个设计阶段,草案阶段很多功能都是玩具,却被一些人吹上了天,怎么怎么牛逼的不得了。开发玩具和 Demo 容易,做一个生产级别还是有挑战,wasm 只是一大堆大厂玩家定制新的类似于二进制文件的编译标准格式,一些小公司却发现了其中红利,想赶上风口自己开发 wasm 运行时来抢占市场,目前市面上做的比较好 wasmer 也不错采用 Rust 编写,一部分采用 C++ 这种估计未来 wasm 生态不断发展,各种新特性往里面估计 C++ 维护成本也在哪里,还有一个 Go 语言实现运行时 wazero ,没有使用 cgo 而是使用纯 Go 语言实现的。相比这些 GraalVM 则采用的是 Java 实现 hotsopt 的 jvmci 接口的 GraalWasm 是我目前最为看好的 wasm 运行时项目,目前 hotsopt 这款虚拟机已经大规模在生产上经过锻炼,而且已经很成熟,并且着很多这方面的专家,做多语言运行时和编译器,还有编程语言设计都需要是经验,而不是某些拿来个 Demo 玩具就跑出来骗投资人的钱的产品。但是目前 wasm 设计内存布局和 Java 编译目标设计内存布局还是有一点差异,例如 wasm gc 还处于草案阶段,但是这不影响 Java 和 wasm 之间操作,毕竟 wasm 是依赖于运行时的,而不是 Java 依赖于 wasm ,wasm 是可以跑在 GraalVM 之上的。我存在一些社区看到有人吹捧过 Go 语言,Go 目前是能编译成为 WASM 文件,但是 WASM 现在 GC 和 多线程的提案还没有落成,如果落成了 Go 会面临 runtime 这一层的问题,因为 Go 默认提供的是 goroutine 协程,那么未来面临一些问题?而且 Java 和 Kotlin 原生设计就是一门 JVM 的语言,前端编译器只需要将源代码编译为字节码,根本不需要负责 Runtime 这一块。


变量作用域

写过程序都会明白一个变量是有默认的作用域,特别是在学习了 Rust 之后,Rust 默认的变量都是使用所有权规则的,例如 Java 语言中内存管理,采用逃逸分析设计,将函数局部变量分配到堆上,然后由 JVM 自行管理。任何何编程语言中变量都有自己作用域规则,只是平时如果开发者不注意这些细节的话,可能认为程序就是要这么去写的话?没有考虑到为什么要这么去设计?JavaScript 也一样,但是在初次版本设计的时候留下的很多 Bug ,早期的 JS 变量不管在哪里定义都是全局的变量,还有变量可以违法前后顺序进行访问内存,这就使得编写出来的代码有各种 Bug ,在 JS 目前使用最多是通过 var 关键字来定义变量,例如下面问题代码:

// 在 js 里面使用 var 变量注意全局作用

arr = [1,2,3,4,5];

function array() {
        arr = "覆盖原理的值";
};

array();

console.log(arr);

var i = 100;

function scope() {
        // 提前被访问到了内存
        console.log(s);
        var s = 12.3;
        console.log(s);

        var i = 0;
        console.log(i);
        if (true) {
                // 覆盖掉外面掉 i
                var i = 200;
                console.log(i);
        }

        console.log(i);

};

scope();

console.log(i);

var 定义变量可以被全局访问,默认还没有类型信息,乃至全局访问这个变量;默认使用 var 关键字定义的变量作用域为函数作用域,而不是块级作用域。这意味着在函数内部定义的变量可以在整个函数内部访问,而不仅仅是在定义的块中。这可能会导致变量被误用或意外地被覆盖,导致某些函数访问到了不该被访问到变量,本应该是块作用域的变量提升到全局;即变量可以在声明之前被访问到,但其值为 undefined,这可能会导致一些错误。


类型系统

由于 JS 对类型的设计比较弱,并且对类型的访问控制权限做也不够,导致可以随意访问修改默认的属性的值,在 JS 中除了基本的 数值 、 字符串 、Boolean 、符号 、null 、 undefined 数据类型之外的类型都是对象,默认数组也是对象类型有自己的属性和方法,例如下面问题代码:

// 在 js 里面还有数组类型
let arr = [1,2,3,4,5];

console.log(arr);

// 打印长度
console.log("arr length:",arr.length);

// 修改下标为 0 的元素的值
arr[0] = 100;

console.log(arr);

// 默认每个对象都方法,包括数组也有方法,预先设计好的
let empty_arr = [];

empty_arr.push(1,2,3,4,5);

console.log("empty_arr:",empty_arr);

empty_arr.reverse();

console.log(empty_arr);


// 通过指定下标的方式创建数组,并且指定一个 lenght 属性
const a = {0: 1, 1: 2, 2: 3, length: 3};

console.log(a); // {0: 1, 1: 2, 2: 3, length: 3}

Array.prototype.reverse.call(a); //same syntax for using apply()

console.log(a); // {0: 3, 1: 2, 2: 1, length: 3}

// 生产环境禁止写这样的代码,因为自定义的 length 会覆盖掉原先的数组长度
console.log("array.length:",a.length);

JS 虽然支持面对对象编程设计,但是没有像 C# 和 Java 那样严格的对对象的方法和属性做访问控制,不过目前最好解决方案是使用 TypeScript 进行编程。JS 的类型系统设计比较粗糙,特别是数值类型,在其他编程语言数值类型被分为多个子类型,例如 Java 中的 Double 和 Float 类型,在 Go 中 float32 和 float64 类型;而在 js 中浮点数和整数都属于一类为数值类型也就是 Number 类型,由于浮点数使用二进制来表示,因此可能存在精度问题,目前所有语言都面临这个精度丢失的问题,Java 中想要高精度则使用 BigDecimal 类型。在 JS 也存在精度丢失问题 0.1 + 0.2 的结果在 JavaScript 中并不是 0.3,而是一个非常接近 0.3 的数,如下的代码:

// 在 js 中数值类型和浮点类型,设计问题最多
let x = .3 - .2;

let y = .2 - .1;


// 肉眼看似相等,但是在计算机底层浮点数存储会出现问题
// 两个的计算结果是近似,而不是相等
console.log(x,y);
console.log("x == y :",x===y);

console.log("x == .1 :",x===.1);

console.log(y);
console.log("y == .1 :",y===.1);

既然是因为 js 底层存储浮点数带来的问题,因为这些数字在计算机内部以二进制浮点数表示,而二进制无法精确表示某些十进制小数,既然存储小数上的误差,那我们可以使用相同的办法来解决这个问题,让减数和被减数的结果误差在合理范围内,我们让在逻辑让它们相等,例如下面的:

function isEqual(a, b) {
  const epsilon = 0.000001; // 定义一个误差范围
  return Math.abs(a - b) < epsilon;
}

console.log(isEqual(0.3 - 0.2, 0.1)); // 输出 true

除了整数和浮点数之外,JavaScript 还有一种特殊的数值类型:NaN(Not a Number),NaN 表示一个无效的数值,例如对一个非数字的值进行数学运算的结果就是 NaN,NaN 不等于任何值包括自身。并且 JS 中整除不会出现异常错误,而是简单返回一个正无穷或者负无穷,0 除以 0 是没有意义的,结果位 NaN 表示,NaN 既不是数值也不是空,如果要判断一个变量是不是为数值需要使用内置的 Number.isNaN(x) 来处理,判断一个变量是否为无穷也是使用内置的 Number.isFinite(x) ,例如下面代码:

// 无穷数值和非数值
let x = 0 / 0;

// NaN
console.log(x);

// is NaN: true
console.log("is NaN:",Number.isNaN(x));

// is Finite: false
console.log("is Finite:",Number.isFinite(x));

// Infinity
console.log(Number.MAX_VALUE * 2);

// is Finite: false
console.log("is Finite:",Number.isFinite(Number.MAX_VALUE * 2));

为了解决大数值存储问题,在新的 ES 标准中添加了 BigInt 类型,通过字面量的的方式创建 1000n 或者使用 BigInt(10000) , 注意在 BigInt 中不能使用浮点数,如果使用了浮点数,将会导致 TypeError 错误,在 BigInt 类型和其他类型之间进行运算时需要进行类型转换,这可以通过调用 Number()String()Boolean() 等函数来实现;BigInt 与 Number 类型在内存使用和性能方面有所不同。BigInt 类型的内存使用量较大,而且执行速度也比较慢,因此当需要处理非常大的整数时,可以考虑使用 BigInt 类型,但对于一般的整数操作,建议使用 Number 类型。

// js 中 bigint 大数值类型
// 可以存储整数,不允许存储浮点数

var n = 10000n;

var x = BigInt('293239129');

// n =  10000n
console.log("n = ",n);

// x =  293239129n
console.log("x = ",x);

// x typeof =  bigint
console.log("x typeof = ",typeof x);

这都是在后面新的 js 标准中添加的类型,事实证明语言设计的再烂,只要后面官方愿意花时间和精力弥补之前设计缺陷,及时修正,还是有很多人使用的,如果是高精度浮点数可以使用第三方库来实现,其中最常用的库是 BigNumber.jsdecimal.js 第三方库实现。


对象类型

JS 处理基本的 Number 、Boolean 、Symbol 、String 、undefined 、null 类型都是原始值类型之前,其他类型都为对象类型,原始值类型是不能进行修改的,即使是字符串每次操作之后都是返回一个新的值类型而不是原来的值类型。但是会带来一个新的问题为如何进行类型之间运算,和类型之间比较?JS 作为一个动态类型的语言,在运行过程中会根据不同类型表达式上下文来自动转换类型进行求值计算,例如 undefined 和 null 都是可以表示无值情况,而且在普通比较情况下结果为 ture ,如下代码:

// 普通比较 true
console.log(undefined == null);
// 严格比较 false
console.log(undefined === null);

// node.js 环境下 两个原始类型都是不一样的
> typeof undefined
'undefined'
> typeof null
'object'

undefined 是一个名为 undefined 的类型,而 null 是一个 object 对象类型的关键字,这也很好解释两个在严格比较情况下为什么不相等,null 在运行过程中可以赋值给任何的定义变量语句使用,暂时改变变量状态所使用的;undefined 更多是指变量没有定义或者内存也没有分配情况下使用,下面代码可以证明:

let o = null;
console.log(o);
console.log(obj);

互联网上经常使用这幅图作为 js 不同零值类型的比较,如下图:

undefined 在 js 执行引擎标准中默认是全家初始化的变量值,而 null 则是在运行过程由程序控制赋值,可以将一个变量从有值状态修改为无值状态,通常在对象引用类型初始化使用比较多。

// 对象类型和 null 值的关系

var people = null;

console.log("people type:",typeof people);  // people type: object

function get() {

    console.log("people bool type:",typeof !people);  // people bool type: boolean
    if (!people) {
        people = {name:"Leon",age:24};
    }
    return people;
}

// { name: 'Leon', age: 24 }
console.log(get());

比较和相等逻辑运算时,js 作为一门动态语言采用的是主动类型转换,例如上面的 if (!people) 语句中 people 会被转换成 Boolean 类型做运算,此时带来一个问题程序中很多时候都需要做这种运算,js 默认的类型转换规则是什么?js 在常规运算的时候都是使用的 松散类型转换隐式类型转换 进行的,当两个不同类型的值进行操作时,JavaScript 会自动进行松散类型转换,例如将字符串与数字相加,JavaScript 会将数字转换为字符串并将其与字符串拼接;当使用运算符或函数时,JavaScript 会自动执行隐式类型转换。例如在执行算术运算时,JavaScript 会将字符串转换为数字;显示类型转换也是支持的,如果在编程的时候明确知道类型和要做的运算预期,可以通过显示类型转换进行,例如下面的显示类型转换操作:

方法描述示例
Number()将值转换为数字类型。如果无法转换,则返回 NaN。Number("42"), Number(true)
String()将值转换为字符串类型。String(42), String(true)
Boolean()将值转换为布尔类型。如果值为 0、空字符串、null、undefined 或 NaN,则返回 false。否则返回 true。Boolean(0), Boolean("hello")
parseInt()将字符串转换为整数。parseInt("42"), parseInt("1010", 2)
parseFloat()将字符串转换为浮点数。parseFloat("3.14")
toString()将数字转换为字符串类型。(42).toString(), (3.14).toString()

显示类型转换大部分是明确上下文和参入计算的表达式值时使用,如果是在运行时让程序自己转动转换需要特别注意被转换后的值,最典型的是 true 转换为数字时为 1 , 而 false 和空字符串都会被转换成为 0 这些都是针对是值类型。如果对象类型就没有这么容易的了,进行类型转换需要使用到显示转换方法,默认对象一般都有 toStringvalueOf 方法进行转换和内置的 toFixedparseInt 函数进行。


表达式和操作符

我们编写的程序都是由源代码一行一行组成的,而这些源代码最后会被编译器或者解释器翻译为更低层次的机器码来运行,而编译器的工作就是要对我们编写的源代码进行解析,最后生成对应 CPU 指令。js 中的程序源代码都是由表达式和操作符组成的语句组成的,js 在设计的时候没有严格程序源代码必须以什么符号来分割,在 js 中多个表达式由多个子表达式和操作符组成,意思多个表达式可以写到一行,不需要注重程序的代码格式化。在 js 中可以在一行上写多个表达式和语句,这种写法通常被称为 压缩缩写。但是这种写法通常会导致代码可读性变差,特别是在代码较长或复杂的情况下,例如下面的代码:

if (x === 10) { console.log("x is 10"); } else { console.log("x is not 10"); }

没有完全可读性,并且依赖于 ; 符号来帮助 js 解释器来区分表达式之间的关系,如果分开来编写源代码,可以省略去表达式或者语句结尾的 ; ,如果没有正确得使用 ; 也会带来各种 bug ,例如:

// js 解释器会为 return 添加 ;  
return 
true;

// 最后解释器得到语句是,错误
return; true;

这也是我认为 js 设计没有严格要求是写 ; 还是不写 ; 带来的问题,像 Java 那样必须严格要求必须写上 ; ,统一一个标准,而不是两套标准都可以混着用。另外在新 es 版本中添加不少新的特性,例如先定义 ?? 符号,因为 js 早期的类型系统导致 0 、空字符串 、 false 都是为假值,在下面这种场景下就不试用了:

let max = maxWidth || perferences.maxWidth || 500;

这个表达式的意思从左往右,如果 maxWidth 值没有意义则,则继续往找有意义的值,以此类推, 如果 perferences.maxWidth 也没有意义那么最好有意的是 500 ,但是我们预期的是 maxWidth 是有意义的,因为它的值是 0 则在默认运算时 js 解释器会认为它是没有意义的,因为如果是 0 则为 false !要改变结果则得使用先定义 ?? 符号,例如有一个 options 变量配置参数,为了保证参数合法性可以使用下面的方式:

// 先定义表达式,在某些时候 js 中的 0 、false 、undefined 是会有副作用

let options = {
    dir: null,
    level: 5,
    verbose: false,
};

// 如果 dir 是没有值,则会使用先定义表达式设置的默认值
options.dir = options.dir ?? "/home/dings/test.txt";

console.log(options);

通过这个例子可以看出来 js 设计并没有 Java 那么严谨但是在后面版本的 es 标准中添加了不少新特性语法糖,更容易解决问题,随心所欲编写代码。


调用表达式

在 js 中默认创建的对象,在运行时都可以继续往对象添加新的属性值,这会带来一个问题在运行过程中会出现随机访问某个属性和方法情况,如果属性本就没有被访问到会触发运行时错误导致程序终止。在 js 中对象类型可以有属性和方法,方法也就是被绑定到对象上的函数,只能通过对方才能放到此函数里面功能;另外对象也有属性字段,也是绑定到特定对象身上的,这些都是通过 . 表达式来访问,对象在左属性和方法在右,这和 Java 是类似的,整体设计图如下:

下面代码中定义一个 p 对象并且把它作为元素存储到名为 arr 数组中,在通过下标进行访问,如果这个元素不存在很有可能触发下标越界异常情况,所有在新的 es 版本中添加了 条件式属性访问 可以帮助在没有对象属性或者方法不存在情况直接返回 undefined ,而不是出现程序运行时错误。

let p = {
    name:"Tom",
    age:23,
};

let arr = [1+2,0===0,p];

console.log(arr);

console.log(arr[2].name);

// 这样如果 arr 中没有第 4 个元素或者第 4 个元素没有 name 属性,程序也不会抛出异常
console.log(arr?.[3]?.name);

// 语法糖,可链接访问
console.log(p?.nil);

function bubble(arr) {
    for (let i = 0;i < arr.length - 1;i++) {
        for (let j = 0; j < arr.length - 1 - i;j++) {
            if (arr[j] > arr[j+1]) {
                [arr[j+1],arr[j]] = [arr[j],arr[j+1]];
            }
        }
    }
    return arr;
}

// fn 参数有值则调用
function sort(arr,fn) {
    return fn?.(arr);
}

let array = [12,345,556,757,132,45,65];

// 正常访问
console.log(sort(array,bubble));

有了条件式属性访问表达式,可以帮助程序员更容易编写程序和减少错误,在对某个对象属性或者方法或者数组访问时,不太确定是否正常访问到可以使用此条件式访问表达式进行操作。


原型链

js 中对象是具备面向对象语言特性,这背后的原理是原型链方式实现的,它是实现继承和属性查找的机制,同时也是 JavaScript 面向对象编程的核心。每个 JavaScript 对象都有一个指向原型对象的内部链接,这个原型对象又有自己的原型对象,形成了一个原型链。当我们访问一个对象的属性时,如果该对象本身没有该属性,则会沿着原型链向上查找,直到找到该属性或者查找到原型链的顶端为止。打个比方我某个人都有父母,我们的父母也有父母,这就和现实生活中的 DNA 一样。当我们定义一个类,这个类对象就可以继承父类对象的属性和方法,从而实现代码的复用;当访问一个对象的属性时,如果该对象本身没有该属性,则会沿着原型链向上查找,直到找到该属性或者查找到原型链的顶端为止。例如下面的代码实现:

// js 中创建对象方式,和对象的原型链

// 空对象
let empty = {};

// 有属性的对象
let point = {x:0,y:0};

// 复杂的对象
let pointer = {
    x: point.x,
    y: point.y,
    name: "pointer",
};

console.log(pointer);

// 通过内置的 Object 类型的方法创建
// 此种方式创建的有原型属性: Object.prototype
let o1 = Object.create({x:1,y:2});

console.log(o1.x + o1.y);

// 创建 null 的对象,不会继承任何东西,连 toString() 也没有用
let o2 = Object.create(null);

console.log(o2);

// 具有 Object.prototype 这样的属性
let o3 = Object.create(Object.prototype);

我们并没有为我们的对象实现 toString() 方法,但是默认创建的对象,通过原型链我们可以访问到原型对象的属性和方法,甚至可以访问到 Object 对象的原型对象,也就是 Object.prototype 。这使得我们可以在任何对象上使用 Object 原型对象中定义的方法,例如 toString()valueOf() 方法。JavaScript 中的继承是基于原型链的。每个对象都有一个原型对象,并且可以通过 prototype 属性访问它。原型对象也是一个对象,它也有自己的原型对象,这样就形成了一个原型链。当你尝试访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript 引擎会在原型链上继续查找,直到找到该属性或者到达原型链的末 null 为止。这样,对象可以通过继承来访问其原型对象上的属性。


函数和方法

js 中的函数对返回值和参数类型是没有明确限制的,这会导致在使用的时候 js 函数传入的参数实参没有类型限制出现运行时错误,因为是动态语言类型系统比较弱,例如下面代码:

// 在 js 中没有类型系统,函数参数没有类型限制
function sum_v2(arr) {
    let total = 0;
    for (let ele of arr) {
        if (typeof ele === "number") {
            total += ele;
        } else {
            throw new TypeError("sum(): elements must be number.");
        }
    }
    return total;
}

// 因为元素不是数组类型,那么就会出现异常
// console.log(sum_v2("11",2,3,4,5,6,7))

console.log(sum_v2([1,2,3,4,5,6,7]))

解决这样情况使用 typeof 或者使用 instanceof 关键字对实参进行操作,来处理符合预期的逻辑。绑定到对象身上的函数叫方法,默认对象方法在上面的文章中已经提到过,对象和字符串做 + 运算的时候会出现方法被隐式调用 toString() 方法。另外一个特殊例子是默认的数组排序算法是安装字符串的 Unicode 进行编码的,在 JavaScript 中,[1, 2, 10].sort() 这个排序问题的解决方案是使用一个自定义的比较函数,默认情况下 sort() 方法将数组元素视为字符串并进行排序,因此它会将数字转换为字符串并按照字典顺序进行排序。

要让这个数组按照数值大小排序必须使用特定的方法进行,重写默认的比较规则,这里主流的操作是采用 减法的方式,下面的代码中,比较函数 function(a, b) { return a - b; } 接收两个参数 a 和 b,并通过返回 a - b 的结果来指定它们之间的相对顺序,如果返回值为负数,则 a 在 b 之前;如果返回值为零,则 a 和 b 的顺序保持不变;如果返回值为正数,则 b 在 a 之前。这样,sort()方法将按照数字的大小对数组进行排序。

[1, 2, 10].sort(function(a, b) {
  return a - b;
});

这个问题也是 js 在设计的时候缺乏函数签名类型参数导致的,将默认的传入的字面量类型视为字符串类型进行运算。


类型和对象

在 js 中对象为多个属性组成集合,这些属性可以是变量字段也可以是对象内置的方法,早期的 js 版本中的对象只能使用 {} 字面量创建,可以满足简单代码需求和实现,但是不能做到和面向对象语言中的封装和多态的属性,在后续的版本更新添加了 Object.create() 来创建对象的方式,此种方式创建的对象带有原型链属性,例如下面的代码:

// js 自有属性和原型链

let o = {};

o.x = 20;

let d = Object.create(o);

d.y = 23;

let z = Object.create(d);

console.log(z.toString());

let n = z.x + z.y;

console.log(n);

如上面代码所示原型链为 JavaScript 中对象之间的连接机制,每个对象都有一个内部链接到另一个对象的引用,这个对象被称为原型 Prototype ,当访问一个对象的属性或方法时,如果对象本身没有该属性或方法,JavaScript 就会沿着原型链向上查找,直到找到匹配的属性或方法或到达链的末尾(即原型链的顶部)。虽然此种方式可以解决对象之间关系,但是不能很好解决对象属性和方法访问限制,在 es5 版本中添加了,构造函数的支持和属性访问限制,如下面的代码:

// js 中如果对象的的 prototype 属性重新设置一个新对象,
// 那么如果没有显示重新指定 constructor 属性,


function Range(from, to) {
    this.from = from;
    this.to = to;
}

// 所有 Rnage 对象都继承这个对象
Range.prototype = {
    // 检查是否包含某个元素
    includes: function (x) { return this.from <= x && x <= this.to; },

    // 只适用数值范围,类似于 Go 语言中的通道传输消息
    [Symbol.iterator]: function* () {
        for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },

    // 返回范围的字符串表示,箭头函数中的 this 是在定义函数时确定的,
    // 它捕获了所在上下文的 this 值,并将其绑定到函数内部,无法通过 call()、apply() 或 bind() 来改变。
    // toString: () => "(" + this.from + "..." + this.to + ")",

    toString: function () {
        return "(" + this.from + "..." + this.to + ")";
    },

    // 自引用属性构造函数
    // constructor: Range 是在 Range.prototype 对象中定义的属性。
    // 它指定了 Range 构造函数作为对象的构造函数。这意味着通过使用 new Range() 创建的对象,
    // 其 constructor 属性将指向 Range 构造函数本身。
    // 这样做的目的是为了确保对象的正确类型信息。
    // 当使用 instanceof 操作符检查对象类型时,它会通过检查对象的 constructor 属性来确定对象是否是由特定构造函数创建的。
    constructor: Range,
}


let r = new Range(1, 10);

console.log(r instanceof Range);

let v = Object.create(r);

v.x = 100, v.y = 200;

v["sum"] = function () {
    console.log(this.x + this.y);
}

v.sum();


for (const v of r) {
    console.log(v);
}

在新的标准中如果一个函数默认为大写字母开头,则可以认为是一个对象构造函数,不需要使用 Object.create() 内置的函数进行创建对象。在最新的 es6 版本中可以设置对象可访问操作属性,并且有更新的方式定义类型的方式,内置了 class 关键字的支持,可以例如下面代码方式创建一个类型和它的属性,并且做访问控制:

// js 新的 es6 标准可以直接在 class 中编写属性,
// 不需要编写 constructor 构造函数。

class Buffer {
    // 带有 # 号表示私有属性
    #size = 0;
    #capacity = 4096;
    #buf = new Uint8Array(this.#capacity);

    // 只提供了 size 的方法进行访问
    get size() {
        return this.#size;
    };

    // 只能通过 add 方法添加元素
    set add(v) {
        this.#buf[this.#size] = v;
        this.#size++;
    };

    // 返回 buf 方法返回元素
    get buf() {
        return this.#buf;  
    };

    toString() {
        return `Buffer {
            size = ${this.#size};
            capacity = ${this.#capacity};
            buf = ${this.#buf};
        }`;
    };
}

// 会有隐私的构造函数进行
let bf = new Buffer();

bf.add = 1;
bf.add = 2;
bf.add = 3;

console.log(bf.size);

// 打印初始化的 bf 对象
console.log(bf.toString());


// 为已经有的 class 扩张添加方法
// 这种方式添加新的属性时必须注意不要把以前已存在属性覆盖掉
// 或者新版本的原型对象新添加了同名的属性,就会覆盖掉这个属性
Buffer.prototype.contains = function(v) {
    for (let i = 0; i < this.size; i++) {
        if (this.buf[i] === v) {
            return true;
        }
    }
    return false;
}

console.log(bf.contains(20));

可以忽略 constructor 关键字进行,使用 # 修饰的字段可以设置为私有属性,方法通过 setget 关键字来控制方法的可操作性。面向对象最为基本的封装问题已经解决了,但是另外一个问题为如何实现对象之间相互依赖关系?如何定义抽象类?在 es6 中可以使用 extends 关键字来实现多个类型继承关系,已经存在了某个类型想对某个类型功能进行扩展,可以使用继承,例如下面代码:

// js 中类的继承关系,原型链

class EZArray extends Array {
    // 获取下标为第一个元素
    get first() {
        return this[0];
    }
    // 获取下标最后一个元素
    get last() {
        return this[this.length - 1];
    }
}

let a = new EZArray();

// 判断当前对象是否为某个类的实例
console.log(a instanceof EZArray);

// 判断当前对象是否为 Array 实例,当前对象原型链上有 Array 所以 true
console.log(a instanceof Array);

a.push(1,2,3,4,5);

console.log(a.pop());

// 我们自定义的方法
console.log(a.first);

// 我们自定义的方法
console.log(a.last);

// true
console.log(Array.isArray(a));

// true
console.log(EZArray.isArray(a));

通过此种方式就可以复用父类的方法,也可以重写父类的方法,上面是为 Array 扩展了方法;另外方式为不实用 extends 关键字,而是之间使用组合的方式实现,弊病是必须要创建一个新的类型并且重写所以的方法:

// js 中的组合,而不是继承
// 有时候我们为了扩展某个类型的功能会为某个类型创建子类来重用父类的功能
// 也可以重写父类的方法,但是某种情况下需要添加更多功能,可以直接考虑创建
// 新的类型,将要重用的类型直接组合进去使用

class Histogram {
    // 初始化构造函数
    constructor() {
        this.map = new Map();
    }

    // 返回某个键出现的次数
    count(key) {
        return this.map.get(key) || 0;
    }

    // 是否包含某个键
    has(key) {
        return this.count(key) > 0;
    }

    // 返回某个键的大小
    get size() {
        return this.map.size;
    }

    // 为某个键加一操作
    add(key) {
        return this.map.set(key,this.count(key) + 1);
    }

    delete(key) {
        let count = this.count(key);
        if (count === 1) {
            // 如果只剩下一个了,则直接删除
            this.map.delete(key);
        }else if (count > 1) {
            // 否则直接将其次数减一
            this.map.set(key,count - 1);
        }
    }

    [Symbol.iterator]() {
        return this.map.keys();
    }

    keys() {
        return this.map.keys();
    }

    values() {
        return this.map.values();
    }

    entries() {
        return this.map.entries();
    }
}

let m = new Histogram();

m.add("Java");
m.add("Java");
m.add("Go");
m.add("😀");
m.add("😀");
m.add("😀");
m.add("😀");
m.add("JavaScript");

console.log(m.count("Java"));
console.log(m.count("😀"));
console.log(m.has("Java"));

// 因为不是 Map 类型的实现,所以 false
// Histogram 不是 Map 子类
console.log(m instanceof Map);

最后只剩下了抽象类如何实现?很简单通过上面的方式可以看出来,直接通过 extends 关键字实现,先定义一个父类作为基类,子类必须继承此抽象类,重写抽象类中的方法,以此来实现 OOP 中多态表现,例如下面的代码:

// js 中的抽象类使用,js 没有原生提供 abstract 的支持
// 但是可以提供 extends 来实现某个预定义的类


// 抽象类可以作为一组子类的父类
// 抽象类型可以定义子类的部分功能实现让类型共享

class AbstractSet {
    // 不允许直接初始化创建
    constructor() {
        if (new.target === AbstractSet) {
            throw new TypeError("AbstractSet not initialized, need to use extends subclasses to implement.");
        }
    }
    // 公共的抽象方法
    has(x) {throw new Error("AbstractSet method.")}
}


// let aset = new AbstractSet();

class MySet extends AbstractSet  {
    // 私有的 set 成员
    #set = null;

    constructor() {
        super();
        this.#set = new Set();
    }

    has(x) {
        return this.#set.has(x);
    }

    add(v) {
        this.#set.add(v);
    }
}

let ms = new MySet();

ms.add(1);
ms.add(2);
ms.add(3);
ms.add(4);

console.log(ms.has(4));
console.log(ms.has(5));

上面的代码中还是使用最新支持特性 new.target 来控制,抽象类不能直接使用 new 进行初始化创建抽象类单个实例,必须要使用子类继成实现抽象类才能通过 new 进行初始化。


元编程

使用 new.target 只能限制默认的构造器的使用和对象原型效果的问题,js 作为一门动态语言默认对象还有一些其他属性可以设置,例如数据属性和可访问属性,这些都可以通过元编程来实现属性配置,对默认的属性可以为可写、可枚举、可配置,例如下面为最基本的属性配置元编程:

// js 中对象数据属性和访问器属性元编程,用代码去操作代码

let obj = {
    x: 10,
}

// 获取对象的某一个属性的描述符
let ds = Object.getOwnPropertyDescriptor(obj, "x");

// { value: 10, writable: true, enumerable: true, configurable: true }
console.log(ds);


// 这个对象有一个只读的访问属性
const random = {
    get octet() {
        return Math.floor(Math.random() * 256);
    }
}

let ds2 = Object.getOwnPropertyDescriptor(random, "octet");

// {
//     get: [Function: get octet],
//     set: undefined,
//     enumerable: true,
//     configurable: true
// }

console.log(ds2);

let ds3 = Object.getOwnPropertyDescriptor({}, "toString");

console.log(ds3 ?? "没有toString可访问属性");

// 为 obj 添加一个属性,并且设置属性参数
Object.defineProperty(obj, "y", {
    value: 100,
    writable: true,
    enumerable: false,
    configurable: true,
});

// 100 
console.log(obj.y);

// [ 'x' ] 没有 y 因为 y 设置为了 不可枚举
console.log(Object.keys(obj));

// 修改 obj 的 y 属性可枚举
Object.defineProperty(obj, "y", {
    enumerable: true,
});

// [ 'x', 'y' ] 可枚举的
console.log(Object.keys(obj));

// 添加一个设置一个访问属性为 octet
Object.defineProperty(random, "octet", { set: function (v) { console.log(v) } });

// 再次获取到 random 属性信息
console.log(Object.getOwnPropertyDescriptor(random, "octet"));

// 通过 random 的 octet 可访问属性设置值
random.octet = 10;


// 批量设置某个对象的属性信息
let obj2 = Object.create(null);

// 对一个空 obj2 对对象添加某个属性和可访问属性
Object.defineProperties(obj2, {
    x: { value: 1, writable: true, enumerable: true, configurable: true },
    y: { value: 2, writable: false, enumerable: true, configurable: true },
    t: {
        get() {
            return this.x * this.y;
        },
        enumerable: true,
        configurable: true,
    }
});

// 2
console.log(obj2.t);

console.log(Object.getOwnPropertyDescriptors(obj2));

所谓的元编程就是通过代码去操作代码逻辑,通过可编程的方式去操作代码,可能很绕口,常规编程方式是通过代码去操作数据,而元编程是通过写代码去操作其他代码。通过元编程并意味着可以不假思索的去使用,例如下面问题代码:

// js 中对象属性的可配置和可写冲突


let obj = Object.defineProperty({}, "x", {
    writable: false,
    configurable: true,
    value: 10,
});

// 10
console.log(obj.x);

// 不可写
obj.x = 100;

// 10
console.log(obj.x);

// 因为初次创建的 obj x 属性配置的是可配置
Object.defineProperty(obj, "x", {
    value: 200,
})

// 此时已经被修改了
console.assert(obj.x === 200);

上面这段代码的问题是一个对象的数据属性设置了不可写只读,但是是可以配置,如果编程高手就可以通过可配置的方式修改掉只读属性。


反射编程

js 作为一门动态编程语言也是支持运行时发射特性的,上面介绍了 js 可以通过基础的元编程的方式操作一个对象的属性,也可以通过反射去操作对象,反射和普通元编程最多区别是,基于 Reflect 对象它提供了一组与对象操作相关的方法,例如Reflect.get()Reflect.set()Reflect.apply() 等,这些方法可以在运行时对对象进行操作,比如获取属性值、设置属性值、调用函数等,反射编程提供了一种以更统一和直观的方式进行元原始对象操作的能力,例如:

// js 中的反射特性使用,Reflect 对象提供一系列的关于反射的 API 函数。


let obj = {
    name: "Leon",
}

function f(params) {
    console.log(this);
    console.log(params);
}

// 将 f 函数绑定到 obj 对象上,并且传入一组参数数组
Reflect.apply(f, obj, ["Leon"]);


class People {

    name = "";
    age = 0;

    constructor(name, age) {
        this.name = name;
        this.age = age;
        // 默认 [class People]
        console.log(new.target);
    }
}

// 通过反射创建一个类型的对象
let p = Reflect.construct(People, ["Leon", 24]);

// People { name: 'Leon', age: 24 }
console.log(p);

// 通过反射创建一个类型的对象,指定 new.target
let p2 = Reflect.construct(People, ["Leon", 24], Object);

// [Function: Object]
// People { name: 'Leon', age: 24 }
console.log(p2);

var obj2 = { x: 1, y: 2 };
let x = Reflect.get(obj2, "x"); // 1

console.log(x);

// Array
let y = Reflect.get(["zero", "one"], 1); // "one"

console.log(y);

反射编程更关注的是在运行时通过检查和修改对象的结构和行为来动态操作代码的能力,而普通元编程针对程序代码本事,针对操作对象本身一些能力控制,常见形式是通过操作对象的属性、方法和原型来改变对象的行为。例如通过修改一个对象的原型,可以为其添加新的方法或覆盖现有的方法,从而改变对象的行为,下面代码:

// js 中的对象原型链

console.log(Object.getPrototypeOf({}));
console.log(Object.getPrototypeOf([]));
console.log(Object.getPrototypeOf(() => { }));

// 判断一个对象是为某一个对象的原型
let p = { x: 1 };

let o = Object.create(p);

// true p 对象是否为 o 的原型
console.log(p.isPrototypeOf(o));


let arr = [1, 2, 3, 4, 5];

// 1,2,3,4,5
console.log(arr.join());

// 把 arr 原型设置为 p ,此时就会失去默认的 join 方法
Object.setPrototypeOf(arr, p);

// undefined 运行时异常
// console.log(arr.join());


let c = { z: 3 };

let d = {
    x: 1,
    y: 2,
    // 通过 __proto__ 字面量的方式设置 d 的原型为 c
    __proto__: c
};

// { x: 1, y: 2, __proto__: { z: 3 } }
console.log(d);

// true
console.log(c.isPrototypeOf(d));

不管是反射还是通过设置对象的属性配置参数来达到某种目的,都属于元编程的一种。


其他资料

便宜 VPS vultr
最后修改:2023 年 11 月 17 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !