Web学习 - JavaScript

本文最后更新于 2026年4月15日 晚上

前言

本文主要参考:

SAST技术文档前置知识多,适合有编程基础的人学习

JavaScript 的变量与变量类型

变量声明

JavaScript中用于声明变量的关键字有三个:

  • var
  • let
  • const

前两者都可以声明变量,而const用于声明常量。

建议全部使用let来声明变量以防止混乱。

另外,JavaScript是弱类型语言,重新给变量赋值的时候可以改变类型。

JavaScript的魔法

JavaScript 是一个在两周左右时间就设计完成的语言,即使这门语言的设计者水平很高,但不可否认的是如此紧迫的工期也造成了 JavaScript 之中有许多反直觉的设计缺陷,这些缺陷编者会在文档各对应部分进行讲解。

JavaScript 的基础类型和基础运算符的表现在大多数情况下和 C/C++ 语言类似,但是由于 JavaScript 的语言特性或缺陷,这些基础运算符在另外一些常识之外的运算之中有着相当超凡脱俗的和反直觉的表现。你可以认为这是 JavaScript 的设计缺陷,也可以认为是这门语言的语法特性。

其中,已经被作为语法特性而被广泛运用的用法将会在正文中指出,而其他用法将会在这一节的最后具体介绍。如果你是 JavaScript 语言的初学者,编者并不建议阅读这一部分,因为这可能会造成不良好的编码习惯和编程思维的混乱。就初学而言,阅读本文档的正文就足以写出实用且强大的代码。但如果你想要深入了解这一门语言,编者认为理解这些运算符的具体表现是有必要的。

变量类型

支持7种基本类型:数字、大整数、字符串、布尔值、symbol类型、undefined类型、null类型。

获取类型:使用typeof关键字:

let num = 0;
typeof num; // "number"

布尔

布尔值和cpp没区别。

JavaScript中的判等运算有

  • ===
  • !==

  • ==
  • !=

两套。建议全面使用===!==,因为==在比较的时候可能会发生强制类型转换,导致看似奇怪的行为,而===进行严格的值比较,不发生类型转换。

1 == true; // true
1 === true; // false

数字

JavaScript的数字不区分整数和浮点数,统一用浮点数表示。

仅当在 Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER 之间的整数运算是安全的,否则将会是使用双浮点数的近似值。

除法没有向下取整的性质

5 / 2; // 2.5

数学运算的常数和函数通过内置对象Math调用。

两个保留字:

NaN

NaN:不合法运算的结果,且NaN参与的算术运算只会得到NaN

关于NaN需要注意与比较运算符的搭配使用:因为NaN参与的算术运算只会得到NaN,所以不能使用===,而应使用isNaN()函数。这个函数首先将参数强转为数字类型,仅当转化结果为NaN时返回true

isNaN(Nan); // true
isNaN(1); // false
isNaN("No"); // true,字符串不能被强转为数字

应当合理使用isNaN()函数进行分支判定防止出现bug。

Infinity

运算结果超出了JavaScript能处理的范围,则会得到Infinity;涉及到不定式零乘以无穷大、无穷大减无穷大等)则会得到NaN

1 / 0; // Infinity
-1 / 0; // -Infinity
1e100000; // Infinity
0 / 0; // NaN
Infinity * 0; // NaN
Infinity - Infinity; // NaN

字符串

''""都能表示字符串。

JavaScript中的字符串是原始值,即string不可变,类似Python和Java。

一些常用的字符串方法:

"a" + "b"; // "ab"
"hello".charAt(0); // 'h'
"hello, abc".replace("abc", "def"); // "hello, def"
"hello, abc".indexOf("abc"); // 7
"hello".substring(2, 3); // "l" 左闭右开

模板字符串

不使用'' ""包裹而是使用``作为包裹,内部使用${}包裹代码块。

let i = 1;
`The val of i + 1 is ${i + 1}.`; // "The val of i + 1 is 2."

字符串与其他类型的互转

JavaScript允许任意变量和字符串相加,常见的是字符串在加号左侧,其他变量在加号右侧,这种运算将其他变量转化为字符串之后进行拼接。

"4" + 3; // "43"

一个trick:""加一个变量,可以方便地把这个变量转化为字符串。

"" + 3; // "3"
"" + true // "true"

将字符串转换为数字:parseIntparseFloat

对于parseInt():第一个参数是需要转换的字符串,第二个参数是转换的进制数,不传入的时候默认按照字符串的格式确定,0x开头则为16进制,其余为10进制。一般建议传入第二个参数

parseInt()转换失败时返回NaN,但是这个函数的逻辑是逐个读取字符并实时转换,遇到不能转换的字符的时候返回已经转换好的结果而非 NaN

parseInt("hello", 10); // NaN
parseInt("123abc", 10); // 123

bigint类型

顾名思义,bigint 类型用于存储和计算超过 number 类型限制的大数。

symbol类型

let sym = Symbol('SAST');

是一种非常独特的值。symbol值只能通过Symbol()构造,传入的参数除调试外无意义,函数每次都返回不同的symbol值。

因此,symbol类型的唯一作用:作为独一无二的标识符

值得注意的是 symbol 值是可以哈希的,这也使得 symbol 成为了仅有的三种可以作为对象的键的类型(另外两种是 numberstring,但实际上 number 会被当作 string 处理)。

对象与数组

对象

对象类型是JavaScript中最常用的复合类型,由若干的键值对构成,每个键值对中,值可以是任意类型变量,也允许对象的嵌套。

对象字面量使用花括号表示,花括号内部键值对使用逗号分隔,每一个键值对的键和值使用冒号分隔:

let obj = {
    foo: 0,
    bar: "bar",
    foobar: {
        a: 1,
        b: "value",
    }, // 注意最后一个键值对也要有逗号
};

每一个键值对之中的键称为这个对象的属性,访问方式:

  • obj.foo; // 0
  • obj['foo']; // 0

对象的属性名可以是任何有效的JavaScript字符串或symbol值,但如果变量名不是合法的 JavaScript 标识符(例如包含空格等特殊字符、或使用 symbol 等),就只能使用 [] 来访问:

const sym = Symbol();
const obj = {
    [sym]: 1,
    valid: 2,
    'invalid identifier': 3,
};

console.log(obj[sym]);
console.log(obj.valid);
console.log(obj['invalid identifier']);

虽然JavaScript可以接收数字作为属性名,但本质是数字被转换为字符串使用

使用const声明对象常量,指的是这一变量名始终指向该对象,而非该对象的属性不可变。如果需要添加不可修改/不可删除的属性,使用Object.defineProperty方法。

数组

JavaScript中的数组格式:

  • 不要求数组内元素类型相同(因为是弱类型语言啊)
let arr = [1, false, 'test', {foo: 1,}];
arr[1]; // false
arr.length; // 4,内置类型

一些常用的内置方法:

let arr = [1, 2, 3];
arr.indexOf(1); // 0
arr.indexOf(4); // -1
arr.slice(1, 2); // [2]
arr.join("."); // "1.2.3"
arr.push(4);
arr.pop();

使用数组内置方法的时候,需要注意这个方法是否会影响原数组slice获取的切片是一个新的数组,而pop方法就会影响原数组。

其他内置对象

JavaScript还有很多其他内置对象,这里仅具几例:

正则表达式

let reg = /sast/;

日期

let date = new Date();
console.log(date.toLocaleString()); // 现在时间

集合

let docs = new Set();
docs.add('JavaScript');

命名空间对象

并非所有的内置对象都是用来构造对象,一些内置对象只用作存储一类方法或值,称为命名空间对象,比如Math console

展开语法

通过在前面加...来实现:

// 对象
let obj1 = {foo: 'bar', x: 1};
let obj2 = {foo: 'barrr', y: 2};
let clonedObj = {...obj1}; // 与obj1完全一致
let mergedObj = {...obj1, ...obj2}; // 包含两者的内容,相同的键后出现的会覆盖先出现的
let helfClonedObj = {...obj1, x: 2, y: 3}; // 也可以另外覆盖或添加部分内容
// 数组
let arr1 = [1, 2, 3];
let arr2 = [...arr1, 4, 5];
console.log(...arr2); // 1 2 3 4 5

解构赋值

有点像Python的元组解包,不过这里解构的是数组和对象:

let a, b, rest;
[a, b] = [1, 2];
[a, b] = [b, a];
[a, b, ...rest] = [1, 2, 3, 4, 5]; // => a = 1, b = 2, rest = [3, 4, 5]

// 最外层的括号是为了消去歧义,防止大括号被认为是代码块
({a, b} = {a: 1, b: 2}); // => a = 1, b = 2
({ a, b, ...rest } = { a: 10, b: 20, c: 30 }); // => a = 10, b = 20, rest = { c: 30 }

可以忽略一部分值,也可以提供默认值:

let a;
[, a] = [1, 2]; // a = 1
({a}) = {a:1, b:2}; // a = 1
[, a=2] = [1]; // a = 2
({a=1} = {b:2}); // a = 1

解构对象时进行重命名:

let {b: a} = {b: 10}; // 声明变量 a = 10,没有声明变量 b

对象字面量的简记

从 ES 6 开始支持以下简记:

let a = 1; let b = { a };  // 等价于 let b = { a: a }

个人理解就是属性是变量名,值是变量的值。

空类型

  • undefined
  • null

区别可以被理解为“空的程度不一样”。null 是空的,但它是存在的,而 undefined 不存在,它没有被定义。

遇到undefined的情况:

  • 访问未赋值的变量
  • 数组越界
  • 访问对象不存在的属性
  • 获取无返回值的函数的返回值

由于很多时候不想让程序因为访问到不存在的属性而报错退出,可以使用?.?.[]运算符,作用是在这个属性访问出错的时候,中断访问,并返回undefined

举个例子:

response.data?.propList[3].name;

当不确定response是否有data属性时,即不确定response.data是否为undefined,则在后续的访问中使用?.代替.

举一个更极端的例子:

undefined?.anyProp;

语句返回undefined,不报错。

?.[]运算符的使用方法类似:

arr?.[10]; // 可能arr没有10这么长

处理有关null的属性也可以用?.,返回值仍然是undefined

JavaScript的控制语句

if, else, switch, case, while, do, for的用法和C/C++几乎完全一致,这里不赘述。

let i = 0;
if (i > 0) {
    console.log("i is greater than 0");
} else {
    console.log("i is not greater than 0");
}

let arr = [1, 2, 3, 4, 5];
for (let j = 0; j < arr.length; ++j) {
    console.log(`arr[${j}] = ${arr[j]}\n`);
}

在分支控制语句中,条件判断时会发生强制类型转换,逻辑为:

  • undefined, null, 0, NaN, "", false 转化为 false
  • 其余均转化为 true(无论是对象还是基本类型)

{}强转是true,因为是对象

for...in循环

for...in语句以任意顺序迭代一个对象除了symbol以外的所有可枚举属性(属性其实就是键)。(关于可枚举,后面会说)

比如这个:

const obj = {
    a: 1,
    b: 2,
};
for (const key in obj) {
    console.log(`${key}: ${obj[key]}`);
}

for...of循环

for...of循环在可迭代对象如 ArrayMapSetString)上创建迭代循环,比如:

let frontend_courses = ['Java/Android', 'JavaScript/TS/React'];
for (const course of frontend_courses) {
    console.log(course);
}

异常处理

类似cpp(虽然cpp的异常处理我也不懂):

try {
    someDangerousFunction();
} catch (err) {
    console.log('error occurred');
}

断句

JavaScript语句末尾分号可加可不加,但是我们总是加。

JavaScript的函数

函数声明与调用

类似Python,我们把JavaScript的函数也认为是一种变量,因为在JavaScript中,函数的行为很大程度上类似变量。

JavaScript中声明一个函数的语法:

function sum(x, y) {
	return x + y;
}

或者这样,这种方式的内涵就是把函数当成一种变量:

const sum = function(x, y) {
    return x + y;
}

这里sum作为一个变量,是一个对象,这个对象的构造函数是Function

JavaScript不检查函数调用时是否符合参数列表,具体体现在:

  • 不检查变量类型(显然)
  • 不检查传入的参数个数

比如上面定义的sum函数,无论是传入1个还是3个参数都会正常工作:

sum(1); // NaN
sum(1, 2, 3); // 3

处理逻辑:

  • 声明了却没有传入的参数是undefined
  • 多余的参数需要手动捕获

捕获方式:

  1. 在参数列表中加入可变长参数...rest,会被赋值为一个数组,而且在没有多余参数的时候是空数组而非undefined

    function sum(x, y, ...rest) {
        let s = 0;
        for (let i = 0; i < rest.length; ++i) s += rest[i];
        return x + y + s;
    }
  2. 在函数中使用arguments变量,这个变量存储函数所有参数并包装成一个数组,理论上不需要声明参数列表

    function sum() {
        let s = 0;
        for (let i = 0; i < arguments.length; ++i) {
            s += arguments[i];
        }
        return s;
    }

消失的返回值

JavaScript 不强制要求语句以分号结尾,而其引擎会在每一行没有以分号结尾的语句后面自动添加分号,但是可能会导致意想不到的结果。

比如cpp里面经常会将返回值换行书写

function foo() {
    return
    	({name: "foo",});
}

但实际上会返回undefined,因为上述代码实际会在return后面加一个;

正确写法应当是:

function foo() {
    return {
        name: "foo",
    };
}

变量提升与函数提升

变量提升是JavaScript的语言缺陷之一,关系到JavaScript的变量作用域问题。

我们知道cpp使用{...}来标记变量的作用域(被称为块作用域)。但是最开始的JavaScript不具有块作用域,而只有函数作用域,也就是说,控制语句块 for, if 等等都不会形成作用域,真正限定变量作用于的是函数:

// i is *not* visible here 
// j is *not* visible here 
function test() {
    // i *is* visible here 
    // j *is* visible here

    var i;
    for (var j = 0; j < 10; ++j) {
        // i *is* visible here 
        // j *is* visible here
    }

    // i *is* visible here 
    // j *is* visible here
}
// i is *not* visible here 
// j is *not* visible here 

这样的本质是因为JavaScript语言会将每一个变量的声明提升到其所属函数最顶部,原先的声明语句变为赋值语句,留在原地,在所有函数外声明的变量就会具有全局作用域。

举一个例子:

代码JAVASCRIPT · 22 行
var name = "Mike";
function foo() {
    console.log(`Hello, ${name}!`);
    if (true) {
        var name = "Helen";
    }
}
// 输出是 "Hello, undefined!"
foo();
/*
实际上的运行方式:
var name;
name = "Mike";
function foo() {
	var name;
	if (true) {
        name = "Helen";
    }
}
foo();
这样就不难理解上面那段代码的结果了。
*/

函数也会提升,但是与变量提升的区别在于,变量提升会将声明赋值拆解开来。但是函数会把函数体一起提升。

greet(); // "Hello!"
function greet() {
    console.log("Hello!");
}
// 解析之后,在这个例子里面就是函数的声明和调用调换位置

如果使用类似声明变量的方式声明函数,其函数体不会跟随着提升,而是会和变量提升的表现形式一样。

如果变量提升和函数提升同时发生,遵循下面这样的规则(下面均区分函数声明变量形式声明的函数):

  1. 变量与函数同时提升,则变量声明排在最前面,函数声明整体紧随其后:

    console.log(a); // 输出 [Function: a]
    function a() {}
    var a = 10;
    console.log(a); // 输出 10
    
    /** * 逻辑拆解(提升后的等价顺序):
     * 1. var a; (变量声明最优先)
     * 2. function a() {} (函数声明紧跟其后,覆盖了前面的同名变量声明)
     * 3. console.log(a); (此时 a 是函数)
     * 4. a = 10; (赋值操作留在原地,此时 a 变成数字)
     * 5. console.log(a); (此时 a 是 10)
     */
  2. 同名符号在多个变量声明中,只记录一次声明,后面保留所有赋值语句:

    var a = 1;
    var a = 2;
    console.log(a); // 2
    
    /** * 逻辑拆解:
     * 1. var a; // 只保留一次
     * 2. a = 1; 
     * 3. a = 2; 
     */
  3. 同名符号在多个函数声明中:后者覆盖前者。

    foo(); // 输出 "second"
    function foo() {
        console.log("first");
    }
    function foo() {
        console.log("second");
    }

隐式全局变量问题

不使用任何关键字编写一个变量赋值语句,且要赋值的变量未定义,则该变量会被声明为全局变量:

function f() {
    foo = 1;
}
f();
console.log(foo); // 1

原因:如果JavaScript引擎在函数内部找不到该变量的声明,就会向外层作用域继续查找,如果直到全局作用域也找不到,就会在全局作用域隐式创造该变量。

在代码开头写一行字符串"use strict"即可禁止隐式全局变量问题。

为什么不使用var

老标准其实只有var,但是由于变量提升规则很反直觉,作用域粒度过大,导致使用不便,所以新标准引入了 let, const 关键字,通过这两个关键字声明的变量遵循块作用域,并且不发生提升

console.log(a); // Uncaught ReferenceError: a is not defined
let a = 1;

console.log(b); // undefined
var b = 1;

箭头函数

更简洁的函数表达式的记号:

const sum = (x, y) => x + y;
const sum = (x, y) => {
    return x + y;
}
function sum(x, y) {
    return x + y;
}

const plusOne = x => x + 1;

即:

  • 只有一个返回语句时,可以省略外层的括号和return
  • 只有一个参数时,可以省略()

由于JavaScript中存在大量匿名函数,这个写法可以少写很多function关键字。

不过返回对象字面量的时候,作为块作用域的{}和作为对象标识的{}可能会冲突,需要用()包裹来消歧义。

const fun = (value) => ({key: value});

回调模式

回调模式可以认为是JavaScript的一大“灵魂”,这门语言的很多功能均基于这一设计模式。

回调模式:将函数作为参数传入另一个函数。

其实CS61A课程对这个的讲解还蛮详细的(Higher-Order Function)。

function calculate(x, y, op) {
    return op(x, y);
}

calculate(1, 2, function (x, y) { return x + y; }); // 3
calculate(1, 2, function (x, y) { return x - y; }); // -1
calculate(1, 2, function (x, y) { return x; }); // 1

JavaScript有关数组的几乎所有操作都使用了回调函数:

  • 数组的map方法:接收一个回调函数,将数组的每一项作为参数传入这个回调函数,返回一个新的数组:

    const newArr = [1, 2, 3, 4].map(function(val) {
        return val * 2;
    }); // [2, 4, 6, 8]

这种回调式的链条在处理数组的时候很方便:

response.data
    .map(function (val, ind) {
        return {
            ...val,
            index: ind,
        };
    })
    .filter(function (val) {
        return val.name !== undefined;
    })
    .sort(function (a, b) {
        return a.subscriberNum - b.subscriberNum;
    });

这里我们使用了 ... 运算符,该运算符如果讲解其运作细节则显得冗余,所以这里讲解其在实际工程中常见的用法:

let obj = { a: 1, b: 2, c: 3 };
let another = { ...obj, c: 2, d: 4 }; // { a: 1, b: 2, c: 2, d: 4 } 

这里 ...obj 的含义就是将 obj 对象展开成键值对,也就是说这里 another 对象就获得了 a: 1b: 2c: 3 这三个键值对。而如果后续的键值对和 obj 之中的发生重合(如这里 another 中重新定义了 c 这个属性),则后者覆盖前者。所以这里新定义的 c: 2 覆盖掉 obj 之中的 c: 3

这个语法的重要用途就是,如果我需要根据一个已有对象 obj 构造一个新对象 another,新对象和已有对象仅有一个键值对不同,就可以用这个语法:

const getNewObjectWithOnePropChanged = (obj, value) => {
    return {
        ...obj;
        keyToBeChanged: value,
    };
};

延迟执行

各个JavaScript运行时中都包含了以下两个函数:

setTimeout(() => {
  // do something
}, 1000);

setInterval(() => {
  // do something
}, 1000);

两个函数传入的第一个参数都是要执行的任务,第二个函数是等待的时间(毫秒数)。不同之处在于 setTimeout 的回调只执行一次setInterval 的回调函数会以传入的时间间隔反复执行

需要注意的是,由于 JavaScript 的事件循环模型,上述的延迟时间并不是精确的,例如当其他复杂任务正在阻塞运行时,任务会按照上述时间添加到队列中,但执行时间会有所延迟。

闭包

用函数返回一个函数:

function getIncreaser(increment) {
    return function (x) {
        return x + increment;
    }
}
getIncreaser(5)(10); // 15

getIncreaserincrement变量包装在其返回的匿名函数中,形成一个闭包

所谓闭包:一个函数和对其周围状态lexical environment,词法环境)的引用捆绑在一起。

所谓词法环境:创建闭包时的函数作用域

闭包会有一定的问题,我们来看这样一个有点难以理解的问题:

其实回忆一下发现CS61A里面讲过类似的知识😭,但是当时没太认真学,打算日后写一篇博客专门回忆一下这一块的知识点。

function getCounters() {
    var arr = [];
    for (var i = 0; i < 3; ++i) {
        arr.push(function () { console.log(i * i); });
    }
    return arr;
}

var counters = getCounters();
counters[0](); // 9
counters[1](); // 9
counters[2](); // 9

预期结果应该是0, 1, 4,为什么呢?

试着简单解释一下:闭包记录的是一个作用域对象的引用。闭包在捕获变量时,对局部作用域的变量会复制,对函数作用域的变量会引用。而var声明的变量都是函数作用域。

可以这样理解:我用var counters记录下了这个函数的闭包,里面的所有临时变量本来在函数结束之后就要被销毁了,但是这个闭包(环境)里面还有着指着这个临时变量的指针,导致在这一个函数帧中,创建的三个子函数的帧,其父帧全部指向getCounters()的帧,而父帧里面就有着变量i

因此,在父帧之外调用三个子函数的时候,会找到这个i,然后由于函数调用完毕之后的i已经是3了,结果就全都是9了。

下面这段Python代码会产生相同(实则类似)的结果:

def get_counters():
    arr = []
    for i in range(3):
        # 定义一个匿名函数(lambda)
        arr.append(lambda: print(i * i))
    return arr

counters = get_counters()
counters[0]()  # 输出 4
counters[1]()  # 输出 4
counters[2]()  # 输出 4

感觉还是说得不清不楚的,之后看看能不能再讲清楚点。

解决这个问题的最好方案是使用let,这样就变成了块作用域而非函数作用域:

function getCounters() {
    let arr = [];
    for (let i = 0; i < 3; ++i) {
        arr.push(function () { console.log(i * i); });
    }
    return arr;
}

这里还需要注意一个问题,这里的本质在于作用域的改变,而不是let带来了什么别的特殊的效果。假如我们这样写:

function getCounters() {
    let arr = [];
    let i = 0;  // 注意在这里声明的 i 是函数作用域
    for (i = 0; i < 3; ++i) {
        arr.push(function () { console.log(i * i); });
    }
    return arr;
}

let counters = getCounters();
counters[0](); // 9
counters[1](); // 9
counters[2](); // 9

结果仍然是错误的。

本质还是,闭包在捕获变量时,对局部作用域的基本变量会复制,对函数作用域的变量会引用。

感觉还是上面这句说得最清楚。

值传递/引用传递

JavaScript在任何情况下都不会帮你复制对象。如果一个闭包捕获的是一个对象的值,无论作用域,都会产生和上面类似的问题。

手动进行深拷贝:

let sharedData = {score: 0};
for (let i = 0; i < 3; i++) {
    let tmp = {...sharedData, score: i};
    arr.push(tmp);
}

JavaScript的面向对象

JavaScript的OOP的实现逻辑不是类封装逻辑,而是原型链逻辑。最初的JavaScript不支持类语法,但是由于原型链太奇异搞笑了,JavaScript后续的标准中将类语法作为语法糖加入了语言标准,但是JavaScript的OOP的底层仍然是基于原型链的。对于初学者,建议只使用类语法,除非遇到不可解决的bug,否则不要花时间钻研原型链。

使用类语法的JavaScript很大程度上类似Java,这里举个例子:

代码JAVASCRIPT · 18 行
// 复数类
class Complex {
    real = 0;
    imaginary = 0; // 默认值
    constructor(_real, _imaginary) {
        this.real = _real;
        this.imaginary = _imaginary;
    }
    print() {
        console.log(`${this.real} ${this.imaginary >= 0 ? "+" : "-"} ${Math.abs(this.imaginary)} i`);
    }
    static printInfo() {
        console.log("This is a complex class.");
    }
}
let com = new Complex(1, 2);
com.print(); // "1 + 2 i"
Complex.printInfo(); // "This is a complex class."
  • 类方法内使用类成员时必须使用this进行调用
  • 在使用class语法时,构造函数的constructor关键字,必须使用
  • 继承使用extends关键字
  • 所有类方法和类成员均为共有,JavaScript不具有访问权限修饰符

this的指向问题

this 的指向问题是 JavaScript 语言设计里可以说是影响最为深远的的一个缺陷,深远到即使要弄明白这个问题需要花费很长时间,但你不得不投入这些精力,否则你可能会在未来的代码实践中遇到很多 this 相关的问题。

this的指向规则:this永远指向最近的调用者

举个例子:

function foo() {
    console.log(this);
}
foo(); // window
let obj = {foo: foo,};
obj.foo(); // {foo: f}
// 为什么写成obj.foo()?因为obj.foo相当于是调用名为foo的成员,而这个成员是一个函数,然后再使用()调用之

两次调用的是同一个foo函数,但是:

  • 前者是全局调用 / 直接调用
  • 后者是将foo作为对象obj的属性后,通过obj调用

而:this的指向在调用函数时确定:

  • 全局调用foo,则this指向全局对象
  • 通过obj调用,则this指向obj

所谓全局对象

JavaScript的全局变量在运行时都是全局对象的属性,每一个JavaScript运行环境都有一个全局对象。比如,浏览器的全局对象就是window,Node.js的全局对象就是global声明的每一个全局变量,都会被绑定为全局对象的属性

var a = 1;
windows.a; // 1
foo();
windows.foo(); // 没区别

再举一个更复杂的例子:

let obj = {
    foo: 1,
    inner: {
        foo: 2,
        fn: function () {
            console.log(this.foo);
        },
    },
};

obj.inner.fn(); // 2

obj.fn = obj.inner.fn;
obj.fn(); // 1

let fn = obj.inner.fn;
fn(); // undefined

三次调用,从上到下的对象分别为obj.inner obj window,从而this也分别指向这三个对象。

所谓this的缺陷,就在于动态指向性。主要是实际写程序的时候一些函数是难以搞明白谁是调用者的,比如事件监听函数、类函数,这种本节也解决不了,得学习原型链逻辑。

有一些补丁,允许开发者硬性指定this的指向:

  • call()apply()方法:调用函数的方法第一个参数指定this是哪个对象,后面的参数:

    • call()的参数是一个一个散着传进去的
    • apply()的参数是打包成一个数组传进去的
    function speak(greeting, punctuation) {
        console.log(`${greeting},我是 ${this.name}${punctuation}`);
    }
    var name = "小王";
    const student = {
        name: "小明"
    };
    
    speak.call(window, "你好", "!"); 
    // 输出:你好,我是 小王!
    
    const params = ["哈喽", "~~~"];
    speak.apply(student, params);
    // 输出:哈喽,我是 小明~~~
  • bind()方法:将this永久绑定于某一个对象上,后续无论怎么调用都维持这个绑定。

现在也常用箭头函数解决:

箭头函数

前面提过箭头函数,但当时只是觉得写法简便。事实上,箭头函数的设计很大程度上是为了解决this问题。

  • 对于一个function,在其内部使用this时,指向的是函数调用时的上下文;
  • 对于箭头函数,实际上是一个闭包,因此,箭头函数保存了定义它时的this,解决了this指向不明确的问题。

举个例子:

const foo = () => console.log(this);

foo(); // window

// Changing caller will not change 'this'
let obj = { foo: foo, };
obj.foo(); // window

// 'call' & 'bind' may fail
foo.call(obj); // window
foo = foo.bind(obj);
foo(); // window

可以看到,在全局环境下定义的箭头函数,this永远指向全局对象。

又给另一个例子(这个例子略绕,好好理解):

let obj = {
    bar: function () {
        const foo = () => console.log(this);
        return foo;
    },
};

const foo = obj.bar();
foo(); // obj

const bar = obj.bar;
bar()(); // window
  • 对于const foo = obj.bar(),是obj调用的bar()返回的foo函数,因此箭头函数的this指向obj
  • 对于const bar = obj.bar,这里的const bar就是一个全局对象,而使用全局对象调用bar(),得到的foo函数的this就指向了全局对象。

JavaScript的异步

为什么要编写异步代码

  • 所谓同步代码:代码的执行顺序从上到下,只有上面的执行完了,下面的才会执行;
  • 但是前端代码中:
    • 我们经常会向后端 / 网络请求数据,这一步的操作必然远慢于别的代码执行的操作,如果使用同步代码,会导致页面卡死在这一步
    • 有些操作可以在前端请求页面的时候完成,比如先渲染页面的模板,等到完全获取数据之后再把数据填入页面
  • 总而言之,前端需要允许多段代码同时执行,即异步代码 (Asynchronous)

事件循环与消息队列机制

这是JavaScript实现异步的方式,因为JavaScript是单线程语言,不支持多线程并发的异步。

为什么禁用多线程

JavaScript的设计初衷是浏览器的脚本语言,作用是修改DOM树,如果允许多线程,可能会导致并发写冲突。

  • 回调函数:这里主要是重新认识一下。当主线程派遣出一个异步过程(如触发网络请求),需要设定一个这个异步过程的回调函数,描述这个异步过程执行完毕需要做的事情。
    • 理由:主线程无从得知这个异步过程何时结束,成功与否,在这样的情况下,主线程完全不能知晓什么时候做这个异步过程的善后工作(比如说从网络获取数据后,主线程应该把这些数据加载到页面上),因此定义一个回调函数,调用时机根据异步过程的执行情况来确定。

JavaScript的主线程上有一个死循环,称为事件循环

  • 当空闲(函数栈空)时,不断检查消息队列是否有消息
  • 若有则执行,没有则等待。

需要异步执行的代码会负载一个回调函数:

  • 当异步过程执行完毕后,会将回调函数放入消息队列末尾;
  • 主线程空闲并且检测到消息队列中有回调函数时,主线程执行回调函数,做好异步过程的善后工作。

宏消息队列和微消息队列

实际上 JavaScript 的消息队列有两种,分为宏消息队列和微消息队列。setTimeout, setInterval 这类引发的回调会放入宏消息队列,而 Promisethen 会放入微消息队列。

JavaScript 的事件循环在查看消息队列的时候,会首先查看微消息队列,如果存在回调则执行每一个回调直到微消息队列清空。之后再去查看宏消息队列,并且只执行队列第一个回调

举一个异步代码的例子:

这里的setTimeout只是用于模拟一个异步过程的耗时操作。

// 这个函数接收两个参数:回调函数,和多长时间之后执行回调函数
setTimeout(() => {
    console.log("1s has passed.")
}, 1000);

这个函数的等待过程异步于主线程执行,第一个参数传入的回调函数在等待完毕后会被放入消息队列。

我们使用这个函数写一个需要耗时的操作来模拟网络请求:

const fetchData = () => {
    setTimeout(() => {
        console.log("got data"); // 可以认为这个是“善后”
    }, 1000);
}
// 主线程:
fetchData(); // 派遣异步任务
// 这里可以做别的任务

可以认为:这个函数的任务是派遣一个异步过程,不负责等待异步任务的结束和善后,善后工作写在回调函数中。

在等待异步的过程中,主线程可以完成其他工作。

回调函数的缺陷

为什么JavaScript中会有大量的回调函数?

往往是因为:我们需要在被调用的函数中,执行一些自定义的操作。

为什么要这样做?一种常见的原因是:

  • 原本我们的方式可以是:被调用的函数提供一个返回值,调用者利用这个返回值去处理;
  • 但实际:这个数据可能不会被立刻获取到;
  • 因此为了不堵塞整个程序:
    • 只能由调用者将后续的操作封装为闭包,交给被调用的函数;
    • 等到数据准备好之后,通过调用闭包来完成后续操作。

举个例子,读文件:

// readFile 是 Node.js 内置的读取文件的方法
import { readFile } from 'fs';

const handler = (err, data) => {
    // 下面的代码只是一个例子,告诉readFile这个异步过程完成之后该做什么
    console.log(data.toString());
}
readFile('~/someFile', handler);

所谓“回调函数的问题”:

  • 虽然做到了「不引入很多别的语法就可以方便地使用到异步中」
  • 但是可读性不好
  • 过分嵌套导致代码难以维护,所谓「回调地狱」。

Promise对象

为了解决回调函数不直观而在JavaScript新版本引入,完全改变了JavaScript异步代码编写的范式

使用构造函数创建一个Promise对象:

  • 其构造函数接受一个回调函数
    • 这个回调函数的参数是另外的两个回调函数
    • resolve代表异步成功的回调函数,reject代表异步失败的回调函数
    • 注意:这里要求这个异步函数的形参名一定是resolvereject
    • 构造函数接收的整个回调函数的函数体,是需要执行的耗时操作
    • resolvereject 指定具体的函数体需要使用 thencatch 方法,这两个方法都会接受回调函数,并且返回 Promise 对象
代码JAVASCRIPT · 18 行
new Promise((resolve, reject) => {
    let asyncSucceeded = Math.random() > 0.2;
    setTimeout(() => {
        if (asyncSucceeded) {
            resolve("Async succeeded!");
        } else {
            reject("Async failed!");
        }
    }, 1000);
})
    .then((res) => {
        console.log("Then");
        console.log(res);
    })
    .catch((err) => {
        console.log("Catch");
        console.log(err);
    });

这样,我们就在Promise构造函数接收的回调函数的函数体中规定了异步过程的具体任务,在thencatch方法中指派了异步过程的回调函数,从而使得主线程完整完成了异步过程的派遣。

感觉上面这段话有点绕,多读读。

为什么Promise比直接写回调模式要优越?我们考虑一个获取多个数据源数据的业务场景,由于:

  • then方法返回值仍是Promise对象,因此我们可以在then后面接着调用then
  • 上一个then的回调函数的返回值,会被传入下一个then的回调函数的第一个参数;
  • 只有前一个Promiseresolve状态之后,下一个Promise才会被触发
  • 每个then接收到的res都是上一个步骤处理完的结果
new Promise((resolve, reject) => {
    resolve("用户登录");
})
	.then((res) => {
   		return requestData("获取用户信息", res);
	})
	.then((res) => {
        return requestData("获取订单列表", res);
    })
	.then((res) => {
   		return "final"; 
	})
	.catch(console.log);

另外,对于多依赖问题(需要多个异步全部结束后才能派遣下一个异步),Promise 提供了 all 方法:

Promise.all(
    [1, 2, 3].map((val) => {
        return new Promise((resolve, reject) => {
            resolve(`Promise #${val}`);
        });
    })
)
    .then((res) => {
        console.log(res); // ["Promise #1", "Promise #2", "Promise #3"]
    })
    .catch((err) => {});

这里 then 之中的回调函数接受的参数是所有 Promise 对象 resolve 回调所接受的参数拼成的数组,catch 同理。

  • Promise一旦创建,内部的异步过程就开始执行,开始之后就不会去掉(atomic)
  • 创建完Promise仅仅只是派遣了异步,不代表异步执行结束

举个例子:

let i = 1;

new Promise((resolve, reject) => {
    resolve(2);
})
    .then((res) => {
        i = 2;
    })
    .catch(console.log);

console.log(i); // 1

由于此时异步还没有执行完毕,所以只能得到1

asyncawait

新标准中引入的两个关键字,目的是让异步代码和同步代码一样自然。

直接使用Promise在使用链式调用时,为了保存中间变量,写法会变得丑陋。因此我们更多使用asyncawait关键字。

async关键字的作用:

  • 将一个函数声明为异步函数,在调用时返回并派遣一个异步
  • 将函数的返回值包装为Promise对象,以使用then的链式调用

举例:

const foo = async() => {
    return "1";
};
foo()
	.then((res) => {
   		 console.log(res);
    	return "2";
	}).
	then(console.log)
	.catch(console.log);

await关键字的作用:在后面跟一个变量

  • 如果不是Promise对象,则无事发生;
  • 如果是Promise对象,则await关键字会阻塞代码运行,直到这个Promise对象代表的异步执行完毕:
    • 如果异步成功,那么此时await语句的返回值是Promiseresolve回调所接受的参数;
    • 如果异步失败:
      • 如果这个Promise有通过catch方法规定reject回调,则返回catch之中回调函数的返回值;
      • 否则,抛出Uncaught Failure错误。

举个例子:

let a = await 1;
a; // 1
let b = await new Promise((resolve, reject) => {
    let succeeded = Math.random() > 0.2;
    if (succeeded) resolve("success");
    else reject("failure");
})
	.catch((err) => {
        return "Error" + err;
    });
b; // "success" or "Error Failure"

通过async await关键字简化之前所说的从多个数据源获取数据:

const fetchData = async () => {
    let data_1 = await fetchData_1(initData);
    let data_2 = await fetchData_2(data_1);
    let data_3 = await fetchData_3(data_2);
    return [data_1, data_2, data_3];
}

和同步代码相差无几了。

同时解决了then链不定长的问题,结合for循环即可解决问题。

sync, await 带来的争议也不小,最大的危险是异步传染。

await 关键字的使用的一个要求:如果在函数中使用,那么这个函数必须是异步的

原因:

  • await 会阻塞代码运行
  • 如果在主线程上阻塞,则会导致页面卡顿,这本来就和异步的初衷矛盾。

因而进一步:

  • 当一个函数内部使用了 await,该函数必须被定义为 async 异步函数
  • 调用方为了防止“变量敏感问题”(也就是Promise问题,创建完Promise仅仅只是派遣了异步,不代表异步执行结束),外层函数不得不也加上 await 关键字来强制等待结果
  • 这种逻辑一直往上蔓延,导致整个代码变成异步,即异步传染

另外,async/await 的同步写法掩盖了异步的本质,容易让人写出低效的代码:

const fetchData = async () => {
    let data_1 = await fetchData_1();
    let data_2 = await fetchData_2();
}

如果 data_1, data_2 本就可以并发获取,这样的写法反而会降低效率,因为这样代码的实际含义是串行地获取数据。实际上,async, await 只是让依次拉取信息的代码变得简洁,对于本就可以多并发的信息拉取,反而会有让我们写出不必要的串行代码的可能。真正的写法应该是:

const fetchData = async () => {
    let data_1_promise = fetchData_1();
    let data_2_promise = fetchData_2();
    let data_1 = await data_1_promise;
    let data_2 = await data_2_promise;
}

只有先获取两个异步的 Promise 对象,同时派遣出两个异步后后分别 await 才能实现并发。

但是 async, await 的目的本来就是用于隐藏掉 Promise 的存在,将异步代码写成同步的格式,这样做其实还不如直接把 Promise 写出来。

此外,对于多依赖问题,async, await 关键字并没有解决,因为 await 一次只能等待一个异步。这个时候还是不可避免地需要使用 Promise 对象。

浏览器中的JavaScript

  • 浏览器可以理解为一个运行时环境

浏览器内置对象

浏览器内置了一系列全局对象,与窗口、屏幕、浏览记录等信息有关。

比如,window对象:是

  • 最常用的内置全局变量之一
  • 一大作用是充当全局作用域,即所有全局变量和函数绑定为其属性
  • innerHeight innerWidth outerHeight outerWidth等属性

还有:

  • screen对象:屏幕的信息
  • navigator对象:浏览器的信息
  • location对象:URL相关的信息
  • document对象:当前页面整个DOM树的根节点

DOM树节点常用方法

查找DOM节点

使用如下的内置方法:

  • document.getElementById:根据HTML节点ID查找,返回具体的DOM节点。在控制台中,显示为一个HTML标签,鼠标悬浮与标签上可以看到网页上对应的元素高亮显示。

    dom.getElementById in Edge
    <h1 id="test-1">
      Hello World
    </h1>
  • getElementsByClassName:对于任何DOM节点均可运用,在以节点为根的子树内查找所有class属性为给定值的节点,返回一个DOM节点的列表。document.getElementById可连用

  • getElementsByTagName:和上面的差不多,唯一区别是查找所有标签名为给定值的节点,返回值仍然是列表。

  • querySelector querySelectorAll:前者返回以该DOM节点为根的子树中满足选择器要求的第一个DOM节点,后者返回子树内所有满足要求的节点构成的列表

    const nodes = node.querySelectorAll("div > p.active");

更新DOM树节点

DOM节点对象有一个很重要的属性:innerHTML,是一个字符串,即一对闭合的HTML标签之间的文本。

比如这么一个DOM节点

<p> The color is <span style="color:red"> RED </span> ! </p>

node.innerHTML == ' The color is <span style="color:red"> RED </span> ! '

这个属性可以随意读写,通过直接修改这个属性可以调整DOM节点。

另一个常用的属性:innerText,与innerHTML的唯一区别是会进行字符转移,比如<会变成&lt;

还可以通过修改节点的style属性来修改其样式:

node.style.color = "red";
node.style.fontSize = "20px"; // "font-size" in CSS

CSS 中允许属性名中包含短划线 -,但是 JavaScript 中不允许,所以遇到这类属性名时,使用驼峰命名法转写。

调整DOM树结构

appendChild insertBefore document.createElement removeChild

全是一些API,使用的时候问LLM吧,这里就是对DOM树的CRUD。

jQuery

一个第三方库,十分流行。

$是jQuery的全局封装对象。

其他是一些CRUD的API,在这里不做赘述。

事件

考虑用户是如何和网页交互的 → 用户是如何触发描述交互行为的JavaScript的 → 引入事件的概念。

  • 用户诸如点击和输入和文档树加载等操作都可以是事件
  • 事件的信息会被包装为一个对象传入JavaScript的事件处理循环
  • JavaScript引擎在接受到事件后调用相应的回调函数,交互行为就定义在这些回调函数中

下面以jQuery为例:

$("#test-button").on("click", () => {
    alert("Hello!");
});

后记

有点沦为人形文档复制机了,之后读别的文档的时候会往里面补充东西的。

TODO:

  • SAST中的OOP进阶(原型链的原理)
  • 通读MDN的部分