WebGL Tutorial
and more

JavaScript快速导览

撰写时间:2024-02-11

修订时间:2025-03-21

概述

从TIOBE的排行榜来看,从2004年开始,JavaScript一直排在所有编程语言的前10名。2004至2014年排在第9名,并于2014年被评为当年之最受欢迎的语言(language of the year)。2019年上升至第8名,2024年上升至第6名。

2025年伊始,2024年的年度冠军出来了:Python。

目前,排在前5名的编程语言分别为:Python, C++, Java, C, 以及C#。JavaScript以第6名紧随其后。

与前年相比,C语言从第2位下滑至第4位。当前,C#的占有率 (rating) 为4.45%,升降幅度为-2.71%,而JavaScript的占有率 (rating) 为4.20%,升降幅度为+1.43%。依此趋势,JavaScript将很快与C#易位。

随着更多Web标准的出现,JavaScript的重要性将愈发突显。

C语言高效而强大,但它一直缺乏一个简单而易用的图形库。而有Canvas支持的JavaScript则无此忧。随着ArrayBuffer的加入,不仅WebGL从此可在网页上实现,更被WebAssembly充分利用而实现了桌面应用程序到Web应用的完美移植。而2021年开始的WebGPU技术已让我们感到了不可思议。Web AI?它已经悄悄到来了。

世界变化速度开始比过山车还要更猛了。而所有的这一切,都有JavaScript在背后默默地强劲支撑。更不用说,JavaScript是Web网络的原生土著语言。

过去,JavaScript被认为只不过是一种脚本语言。但现在,越来越多的人认为,JavaScript是一种具备操控网页元素的脚本能力的编程语言。node.js的出现,本地资源管理及服务器端应用均被JavaScript收归囊下。

过去,JavaScript被认为在学习时还在睡觉;而现在,JavaScript被认为在睡觉时仍在学习。

经过较长时间的发展、演化及规范化,JavaScript有许多实用的、独具一格的、令人着迷的编程语言特性。下面开始有趣的JavaScript特性之旅。

数据类型

变量没有固定的数据类型

有固定的数据类型,但声明变量时,变量没有固定的数据类型。

let a = 5; // number a = "Hello"; // string

数据类型自动转换

当数值类型与字符串类型相加时,得到一个字符串的结果。

let a = 5; a = 5 + "Hello"; // string

字符串

可使用Unicode来创建字符串:

let footBall = '\u26BD'; console.log(footBall); // ⚽

可通过本站制作的HTML Unicode工具来获取更多的Unicode符号。

还可转换Unicode的码位,得到相应的十进制数字,然后可在HTML中使用,如:在HTML中显示⚽

要构建一个字符串,不管是否多行,还是需要引用其他变量,都无比轻松:

let num = 15; let str = ` He gave me ${num} apples. And eventually additional 5 more. `;

下面字符串中的控制字符因解析而被吃掉了:

pc.log('%s', `a\rb\rc`);

查看字符串中的控制字符:

pc.log('%s', String.raw`a\rb\rc`);

数值的自由表达与转换

字面符

对于十进制的27,我们可以在字面符中以不同的进制来指定:

二进制。以0b0B为前缀:

var num = 0b00011011; // 27 // or var num = 0B00011011; // 27

八进制:以0o0O为前缀:

var num = 0o33; // 27 // or var num = 0O33; // 27

十六进制。以0x0X为前缀:

var num = 0x1B; // 27 // or var num = 0X1B; // 27

对于数据类型为BigInt的数值,则在上面各种进制的表达式之后加上n

var num = 12345678987654321n; var num = 0B1011100111110011n; var num = 0o123456712345670n; var num = 0XFF33CC8899AA66DDn;

对于较大的数字,可以加入下划线_来分隔,以增加可读性:

var num = 12_345_678_987_654_321n; var num = 9_8765_4321n; // chinese convention,9亿8765万4321

转化为不同进制的字符串

let num = 27; console.log(num.toString(2)); // 11011 console.log(num.toString(8)); // 33 console.log(num.toString(16)); // 1b

字符串转换为数值

parseInt函数只认代表数字的字符[0, 9],这意味着它不支持上面所谈到字面符中的0B等前缀或_字符。

而在用字符串来表示二进制数值时,因太多的01,加入0B等前缀或_字符很有必要。甚至我们可以扩展功能,再加入空格符。

const { getHexAddrStrFromTypedArray, getBinStr, getHexStr } = await import('/js/esm/BinUtils.js'); let str = '0B1011_1101 1100_1110'; str = str.replaceAll(/0B|0b|_| /g, ''); pc.log(str); let num = parseInt(str, 2); pc.log('%d <===> %s <===> %s', num, getHexStr(num), getBinStr(num));

通过调用String.repalceAll方法,先将表示数值的字符串中所有的前缀与分隔符都去掉,变成纯数字字符串后,再调用parseInt函数。

自由的对象创建方式

创建一个对象无比的简单。

let obj = { name: 'mike', age: 25 };

对象在创建后,可以自由地添加或删除属性。

obj.email = '123@263.com'; delete obj.age;

最终结果:

console.log(obj); /* { name: 'mike', email: '123@263.com'; } */

遍历对象属性名值:

let entries = Object.entries(obj); for (const [key, value] of entries) { console.log(key); console.log(value); }

将要创建的对象包含函数属性时,不能使用下面的方式:

let obj = { name: 'Mike', function greet() { /* This is wrong! */ } };

而应使用:

let obj = { name: 'Mike', greet: function() { } };

或者,更简练:

let obj = { name: 'Mike', greet() { } };

可以写:

let number = 2; let msg = 'Hello'; let obj = { number: number, msg: msg };

对于这种属性值为与属性名相同的变量,则可简化为:

let number = 2; let msg = 'Hello'; let obj = { number, msg };

指针或引用

JavaScript也存在指针的操作。当变量引用Object类型时,即为引用关系。

let a = { name: 'Sarkuya', age: 25 }; let b = a; a.name = 'Mike'; pc.log(b);

变量ab均指向同一个对象,当a改变时,b的值也同时改变。

如果希望复制一个对象,则可调用Objectassign方法:

let a = { name: 'Sarkuya', age: 25 }; let b = Object.assign({}, a); a.name = 'Mike'; pc.log(a); pc.log(b);

数组

数组的创建

创建数组很直观。

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

数组是栈

数组是以栈的形式来组织的。

let arr = []; arr.push(1); arr.push(2); console.log(arr); // [1, 2] arr.pop(); console.log(arr); // [1]

栈的特点是先进后出(FILO,first in, last out)。上例中,数值1为栈底,数值2为栈顶。Array类的push方法进栈,pop方法出栈。

可调用shift方法将栈底元素,也即第0个数组元素移出栈。

let arr = [1, 2, 3]; let bottom = arr.shift(); console.log(bottom); // 1 console.log(arr); // [2, 3]

数组的合并

concat方法可将多个数组或元素合并进一个数组中。

let arr1 = [1, 2, 3]; let arr2 = [4, 5]; let num = 6; let arr3 = arr1.concat(arr2, num); console.log(arr3); // [1, 2, 3, 4, 5, 6]

使用展开操作符...,也可以字面符的方式来合并数组元素。

let arr1 = [3, 4, 5]; let arr2 = [1, 2, ...arr1, 6, 7]; console.log(arr2); // [1, 2, 3, 4, 5, 6, 7]

上面如果不使用展开操作符...操作符,则数组元素为数组。

let arr1 = [3, 4, 5]; let arr2 = [1, 2, arr1, 6, 7]; console.log(arr2); // [1, 2, [3, 4, 5], 6, 7]

高效的代码:数组解包

无需借助第三方变量,我们可以一行代码就实现两个变量交换数值。

let a = 10; let b = 20; [b, a] = [a, b]; console.log(a, b); // 20, 10

先将ab置于一个数组,再通过数组解包,直接分别赋值于ba,从而高效地进行数值交换。

数组字面符的使用

数组字面符也是Array的一个实例,因此在数组字面符上面可以直接调用Array的各种方法。

[15, 29, 32, 47, 56] .forEach((num, index) => pc.log(`[${index}]: ${num}`));

switch语句

switch语句中的case会自动下坠,因此需注意调用break来防止下坠。

let num = 6; switch(num) { case 7: pc.log('match'); break; case 6: case 8: pc.log('close'); break; default: pc.log('bingo'); break; }

当值为7时,break语句确保不会执行到下面的代码。而当值为6时,没有break语句,则下坠到执行值为8时的代码。

默认情况下,各个case都处于同一作用域下,即代码相互污染。如果必要,可使用{}为每个case添加闭包。

let x = 15; let y = 25; let operator = 'sum'; switch(operator) { case 'sum': { let result = x + y; pc.log(result); break; } case 'sub': { let result = x - y; pc.log(result); break; } default: pc.log('bingo'); break; }

函数

自调用函数

<script type="module"> (() => { let a = 5; console.log(a); })(); </script>

上面的代码,结合了JavaScript模块、lambda表达式及自调用函数的特点,使相关代码得以在页面内容加载完毕后自动运行。相当于:

<script> function init() { let a = 5; console.log(a); } window.addEventListener('DOMContentLoaded', init); </script>

内嵌函数

内嵌函数是JavaScript的一大特色。

function aFunc() { let a = sum(3, 5); console.log(a); function sum(a, b) { return a + b; } }

如果某函数只在一个地方使用,而又不想与其他局部变量相互影响,则可像上面一样创建一个内嵌函数。它的好处是,对外部不可见,而又与内部的其他变量相互隔离。

因为是内部使用,甚至可以无需函数名而改写为自调用匿名函数:

function aFunc() { let x = ((a, b) => a + b)(3, 5); let m = 5, n = 10; let y = (() => { let temp = m * 2 + n; temp++; return temp; })(); console.log(x); // 8 console.log(y); // 21 }

这种方式,除具备上面两个特点外,还有另外一个特点:它可访问闭包范围外的变量诸如m。利用这种灵活性,我们可节省多少的函数参数传导?

函数指针

JavaScript支持函数指针。

function normalFunc() { console.log('This is a function.'); } function invoker(func) { func(); } invoker(normalFunc);

invoker的参数是一个函数指针,在其方法体中,直接调用了作为参数所传进来的函数。

回调函数

更进一步,编制非常实用的回调机制。其基本形式如下:

function dataConsumer(a, b) { let c = a + b; console.log(c); } function dataProvider(callbackFunc) { let a = 5; let b = 10; callbackFunc(a, b); } dataProvider(dataConsumer);

回调函数的本质,是由一个数据提供者dataProvider,负责调用作为参数所传进的函数dataConsumer,并且将自己所持有的数据作为函数参数传递给后者。意为,我负责提供数据,并负责回调你以传送相应的数据。

ArrayforEach方法就是基于上面的原理而编写的回调函数:

let arr = [5, 10, 15]; arr.forEach((element, index) => { console.log(index + ': ' + element); });

forEach方法就类似于上面的dataProvider函数,以参数elementindex回调了一个匿名函数。然后,在该匿名函数中,我们就可随意使用所传入的参数。

上面的回调函数是单程的,即dataConsumer函数处置完数据后就结束了。

更进一步,我们可以利用回调函数的返回值,实现数据提供者也数据使用者之间的双程沟通。

function dataCustomize(config) { config.fontSize = '20'; return config; } function dataProvider(callbackFunc) { let config = {}; let newConfig = callbackFunc(config); setFontSize(newConfig.fontSize); } function setFontSize() { // ... } dataProvider(dataCustomize);

dataProvider函数先初始化一个config变量,并通过回调机制传给dataCustomize函数由其负责具体设置该对象的值。之后,dataCustomize函数返回设置之后的值,dataProvider接收此返回值后,又进一步根据此值来设置相应的字体大小。这就实现了双程沟通。

这种设计机制有许多好处。dataProvider函数负责config的生命周期,但必须考虑将设置数值的自由还给客户端。而客户端dataCustomize函数只需根据实际需求,专注于设置相应的数值就行了,无需考虑过多的细节。双方合作得都很愉快。

Array类提供了许多这样的双程回调函数。

let arr = [20, -5, 3, -20]; let newArr = arr.filter(element => element < 0); console.log(newArr); // [-5, -20]

filter方法用于筛选出数组中符合特定条件的元素。而筛选条件由回调匿名函数来定义。而对于filter所传进的任一元素,匿名回调函数将条件设定为:如果其值小于0,则筛选出来。filter方法根据各个元素的返回值重新生成了一个新的数组并赋值于变量newArr

这就是回调机制的强大之处,filter方法将重新生成新数组的算法隐藏起来了,但又可让客户端自由地指定各种各样的筛选条件,从而实现了固定算法与灵活配置的完美结合。

除了上面的filter方法之外,Array类的some, every, map, reduce等方法均属于这种情况。

Lambda表达式

Lambda表达式是匿名函数的一种快捷表现方式:

let sum = (a, b) => { return a + b; };

相当于:

function sum(a, b) { return a + b; }

Lambda表达式在函数参数为函数时经常用到:

let arr = [1, 2, 3]; let newArr = arr.map((item, idx) => { return `${idx}: ${item}`; });

如果Lambda表达式的参数只有1个,可省略参数列表的括号()

let newArr = arr.map(item => { return item * 2; });

如果Lambda表达式的函数体内,只有1条返回语句,则可省略大括号{}以及return关键字:

let newArr = arr.map(item => item * 2);

而如果需要将参数直接传至其他方法或函数处理,则可更加简练,直接编写该方法或函数的名称即可:

let arr = [1, 2, 3]; arr.forEach(console.log);

相当于:

let arr = [1, 2, 3]; arr.forEach((item, idx, array) => { console.log(item, idx, array); });

结果输出:

1 - 0 - [1, 2, 3] 2 - 1 - [1, 2, 3] 3 - 2 - [1, 2, 3]

注意,因为consolelog方法的参数为可变长参数,因此forEach的回调函数的3个参数全部都自动传送过去了。

如果我们只需使用其中的某个参数,可编写自定义函数:

let arr = [1, 2, 3]; function show(item) { console.log(item); } arr.forEach(show); // 1, 2, 3

自定义函数show只接受1个参数,因此回调函数的后两个参数被自动忽略了。

因此,当Lambda表达式浓缩为只有方法或函数名称时,需同时考虑回调函数及所调用方法、函数的参数数量、顺序,否则可能出现不易觉察的bug。

构造器

function Rect(x, y, w, h) { this.x = x; this.y = y; this.width = w; this.height = h; this.offset = (xOffset, yOffset) => { this.x += xOffset; this.y += yOffset; }; } let rect = new Rect(10, 10, 50, 50); rect.offset(20, 20);

ECMA 6class出来之前,这是JavaScript最典型的创建类的对象的方式。Rect称为构造函数,也称为对象rect的构造器。我们可以用下面代码查看该构造器:

console.log(rect.constructor); /* * function Rect(x, y, w, h) { * ... * } */

作为对比,也可像下面一样创建实例:

let rect = {}; rect.init = function(x, y, w, h) { this.x = x; this.y = y; this.width = w; this.height = h; }; rect.offset = function(xOffset, yOffset) { this.x += xOffset; this.y += yOffset; }; rect.init(10, 10, 50, 50); rect.offset(20, 20);

注意,作为创建对象的用例,上面在为rect声明方法时,不能使用Lambda的形式。

在一些图形应用中,我很喜欢使用下面便捷的伪构造器代码:

function Rect(x, y, width, height) { return {x:x, y:y, width:width, height:height}; } let rect = Rect(10, 10, 50, 50);

Rect虽不是构造器,却返回一个通过字面符构造器来创建的对象实例。

而根据自由的对象创建方式,则可编写以下更精简代码:

function Rect(x, y, width, height) { return {x, y, width, height}; }

乍看之下,很难看出这里藏有构造器的应用。

自动运行代码

let a = 5; let b = 3; let code = `console.log(${a} + ${b});`; Function(code)(); // 8

Function(code)()效果等同与eval(code),但比后者更安全、高效。具体参见函数类一节。

容易犯晕的地方

aFunc(1, 2, 3); // 3 individual units function aFunc(...args) { // rest parameter pc.log(args); // array pc.log(...args); // spread into 3 individual units }

短短4行代码,共涉及到了可变长参数、数组、展开操作符...等几个方面的内容。

而容易犯晕的地方在于,当...args出现在函数参数的位置,其是可变长参数;当其出现在函数体中,它变成了展开操作符,其结果又变回了3个独立的个体。

解包

参见在WebGL入门教程中的JavaScript解包

Promise

PromiseJavaScript的原生内置对象,与间接支持该特性的其他编程语言相比,JavaScript在处理异步线程上更为简便而强大。

fetch('../promise/examples/demo.txt') .then(response => response.text()) .then(text => pc.log('%s', text));

现在许多Web标准直接绑定了Promise技术。这简直就是为JavaScript量身订制。

具体参见Promise

响应事件

直接在HTML编写事件响应代码:

这种方式,需满足2个条件。

一是onclick的文本内容必须为调用函数的方式showMessage(),而不仅仅是函数名称showMessage

二是在页面中编写响应函数时,响应函数的代码只能放在普通的script中,不能放在类型为modulescript中。

<script> function showMessage() { console.log('Hello'); } </script>

若在类型为modulescript中编写响应函数的代码,可通过调用addEventListener来添加事件监听器。

可对a标签设定自定义的事件响应。首先编写代码:

链接没有默认的URL

将其href属性的值设为javascript: void(0);,表示当用户点击此链接时,浏览器不会自动跳转到特定的URL。接着,为其添加事件响应代码:

document.querySelector('#the-link').addEventListener('click', () => { ... });

这样,我们可通过普通的链接标签来实现复杂的业务逻辑。

Script

当显示一个网页时,涉及到以下几个环节:

  • 下载网页
  • 解析网页的DOM元素
  • DOM元素添加至DOM
  • 下载JavaScript文件
  • 执行JavaScript文件
  • 显示网页

而下载JavaScript文件总会需要一定的时间。此时就有这样一个问题:在脚本下载完毕前、或在脚本下载完毕后但尚未执行脚本前,是否需要暂时中止其他网页元素的解析?

以上问题,由script标签的asyncdefer属性来控制。而这两个属性,又根据脚本是普通脚本或是模块脚本,又有不同的规则及含义。

普通脚本可以带有asyncdefer属性,而模块脚本只有async属性。

asyncdefer
asyncdefer (图像文件摘自whatwg.org)

普通脚本

没有asyncdefer属性时:

  • 中止网页解析,下载脚本
  • 等待脚本下载完毕,立即运行脚本
  • 恢复解析网页其他元素

script标签的defer属性有以下作用:

  • 边下载脚本,边解析网页其他元素
  • 脚本下载完毕后,须等待解析网页其他元素后,再运行脚本
  • 按网页中所声明脚本的顺序来依序运行脚本

script标签的async属性有以下作用:

  • 边下载脚本,边解析网页其他元素
  • 脚本下载完毕后,中止解析网页其他元素,立即运行脚本
  • 脚本运行完毕后,再恢复解析网页其他元素
  • 脚本运行顺序不受其他脚本影响

模块脚本

对于模块脚本,不能使用defer属性。

当没有async属性时:

  • 边下载脚本,边解析网页其他元素
  • 脚本下载完毕,须等待解析网页其他元素后,再运行脚本

当有async属性时:

  • 边下载脚本,边解析网页其他元素
  • 脚本下载完毕后,中止网页解析,立即运行脚本
  • 脚本运行结束后,再继续解析网页其他元素

参考资源

  1. TIOBE Index
  2. ECMA 262
  3. The Script element (MDN)
  4. The Script element (whatwg)