封装的理解

背景

最近在学习 canvas 的使用,并写了个 demo,就是做一个 K 线图,支持提示、拖拽、缩放,也发布为 npm 包:echarts-for-abc
效果如图所示
pic.1708325742371
写代码过程中,封装是必不可少的一部分,所以想以这个为例子来讲一下封装。

目的

封装的目的,是为了减少代码量。

定义

万物皆对象,对象具有属性和行为(方法),对象公共属性和行为的提取就是封装。

理念

封装的前提,必定跟场景相关联的,也就是说,先有场景,再有封装。封装前,先把场景下的属性具象化出来,再考虑提取他们的共性。如果跳过这一步直接思考他们的共性,这很容易出现思考偏差。

举个例子:一个学校里面,有老师、学生、校长等各角色,不同角色有自己的特点和行为,比如学生有学号,老师有职位等级、类型,他们也有共同的特性,比如拥有性别、年龄等特点,用类(class)来表示各个角色。

将学生和老师用代码抽象化表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student {
constructor(studentNumber, sex, age) {
this.studentNumber = studentNumber; // 学号
this.sex = sex;
this.age = age;
}
}

class Teacher {
constructor(rank, sex) {
this.rank = rank; // 等级
this.sex = sex;
this.age = age;
}
}

发现学生和老师都有性别和年龄,那就可以提取出来,所以可以很容易用Person封装起来,

1
2
3
4
5
6
class Person {
constructor(sex, age) {
this.sex = sex;
this.age = age;
}
}

然后就可以使用extends让子类(学生)继承父类(Person)的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Student extends Person {
constructor(studentNumber, sex, age) {
super(sex, age);
// super() 表示执行父类构造函数,相当于Person.prototype.constructor.call(this), 用于继承父类Person的属性
// 在这里,相当于this.sex = undefined; this.age = undefined
// 传入sex,age 相当于 this.sex = sex; this.age = age;

this.studentNumber = studentNumber;
}
}

const student1 = new Student('330311221', '男', 15);
// student1 = { age: 15, sex: "男", studentNumber: "330311221" }

class Teacher extends Person {
constructor(rank, sex, age) {
super(sex, age);
this.rank = rank;
}
}

const teacher1 = new Teacher('高级教师', '女', 35);
// teacher1 = { age: 35, sex: "女", rank: "高级教师" }

封装颗粒度

封装颗粒度表示函数的拆分程度,是否越细越好呢,其实不是。
在项目中,如果组件拆的过于细,可能会导致父组件的参数向子组件一层层传递时,会出现遗漏,传错等问题。如果拆的太粗,会导致难以复用、难以维护等问题,那么怎样的颗粒度大小才算合适呢?封装的颗粒度大小,取决于不同场景下的偏向性考虑。

考虑偏向性

封装偏向性可以分为两种,偏应用还是偏底层。

偏应用:这个封装只适用你这个特定的场景
偏底层:具有独立性,颗粒度更小,可以脱离特定场景,就像工具方法,如 Lodash 工具库

工具方法:是一个纯函数,传入参数,返回结果。也就是说不要在工具方法内部获取外部变量,要作为参数传入。

考虑哪种偏向性,取决于场景,所以进入“写一个 K 线图 demo”的场景,来聊一聊封装。

举例说明

可以看到图中的蜡烛出现了 10 次,那么如何封装渲染蜡烛的这个函数呢?是封装单个蜡烛合适还是一串蜡烛合适?

如果作为单个蜡烛的出现,我们只需要知道蜡烛横坐标、蜡烛宽度、颜色以及四个点的纵坐标就能绘制出蜡烛,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 绘制蜡烛
* @param {number} abscissa 蜡烛横坐标
* @param {number} topPointY 最高点纵坐标
* @param {number} bottomPointY 最低点纵坐标
* @param {number} secondPointY 第二个点纵坐标
* @param {number} thirdPointY 第三个点纵坐标
* @param {number} candleW 蜡烛宽度
* @param {string} candleColor 蜡烛颜色
*/
function renderCandle(
abscissa,
topPointY,
bottomPointY,
secondPointY,
thirdPointY,
candleW,
candleColor,
) {
const halfCandleW = candleW / 2;

// 绘制蜡烛上影线
ctx.beginPath();
ctx.moveTo(abscissa, topPointY);
ctx.lineTo(abscissa, secondPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛下影线
ctx.beginPath();
ctx.moveTo(abscissa, bottomPointY);
ctx.lineTo(abscissa, thirdPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛实体(中间矩形部分)
ctx.beginPath();
ctx.moveTo(abscissa - halfCandleW, secondPointY);
ctx.rect(
abscissa - halfCandleW,
secondPointY,
candleW,
thirdPointY - secondPointY,
);
ctx.fillStyle = candleColor;
ctx.fill();
}

renderCandle(50, 88, 50, 44, 33, 20, 'red');

pic.1708325756628
这个函数的封装偏向性就属于偏底层封装,它具有独立性,颗粒度更小,复用率更高,就像在 Echarts 图标库里,有多个应用到单个蜡烛的地方,就可以使用这个工具函数,你只需要传入相关参数即可。

但在我这个练习 demo 中,单个蜡烛并没有独立出现的场景,我考虑偏应用封装,而且在渲染蜡烛之前我需要处理数据源、判断涨跌以及蜡烛颜色的情况,所以将这些处理逻辑和绘制蜡烛封装为一个函数,不管在阅读还是使用上都更为方便,我的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 绘制一串蜡烛
* @param {array} data 数据源
* @param {number} candleW 蜡烛宽度
*/
function renderCandles(data, candleW) {
// 将数据源转换为绘制蜡烛所需要的纵坐标集合
const dataYAxisPoint = tranPriceToOrdinate(data);
const halfCandleW = candleW / 2;

for (let i = 0, candleLength = dataYAxisPoint.length; i < candleLength; i++) {
const { heightPrice, lowPrice, openingPrice, closingPice } =
dataYAxisPoint[i];
let abscissa = xAxisTickPointX(i),
topPointY = heightPrice,
bottomPointY = lowPrice,
secondPointY,
thirdPointY,
candleColor;

if (closingPice < openingPrice) {
// 涨
candleColor = 'red';
secondPointY = closingPice;
thirdPointY = openingPrice;
} else {
candleColor = 'green';
secondPointY = openingPrice;
thirdPointY = closingPice;
}

// 绘制蜡烛上影线
ctx.beginPath();
ctx.moveTo(abscissa, topPointY);
ctx.lineTo(abscissa, secondPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛下影线
ctx.beginPath();
ctx.moveTo(abscissa, bottomPointY);
ctx.lineTo(abscissa, thirdPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛实体(中间矩形部分)
ctx.beginPath();
ctx.moveTo(abscissa - halfCandleW, secondPointY);
ctx.rect(
abscissa - halfCandleW,
secondPointY,
candleW,
thirdPointY - secondPointY,
);
ctx.fillStyle = candleColor;
ctx.fill();
}
}

总结

  • 封装的理念为:把场景下的属性和方法具象化,提取共性
  • 封装的颗粒度大小,取决于不同场景下的偏向性考虑

开发环境和线上环境

  • 开发环境:表示本地开发时的环境,便于开发人员调试。代码中可能会包含测试代码,调试工具等,这些东西没必要放到线上环境中
  • 线上环境:追求快速化、最小化。包括测试、生产等非开发环境

在 node.js 中约定了一个环境变量 NODE_ENV,用于环境区分。

未定义任何环境变量时,production表示线上环境,development表示开发环境。

线上环境:线上环境不等于生产环境。在未定义其他环境时,线上环境表示生产环境;定义了其他环境(比如测试环境)时,线上环境表示除开发环境的其他环境。

一般我们还会有一个测试环境,通常情况下都是把线上接口改为测试接口,但是这样容易出错。
为了避免手动修改接口,我们可以定义一个测试环境test

umi 环境变量配置

UMI_ENV

当指定 UMI_ENV 时,会额外加载指定值的配置文件。参考: https://umijs.org/docs/guides/env-variables#umi_env

多了测试环境,这时候的production可能会包含test,所以我们再定义一个 online,用online表示生产。
:::info
注意:umi4.x 版本里,自定义变量时不能用 prod 表示生产环境,所以这里使用 online .
:::

1
2
3
4
5
"scripts": {
"dev": "umi dev",
"build:test": "cross-env UMI_ENV=test umi build",
"build:online": "cross-env UMI_ENV=online umi build"
},

打包时,除了加载.umirc.tc,还会额外加载.umirc.${UMI_ENV}.ts文件。

区分本地开发和线上

1
2
const isDev = process.env.NODE_ENV === 'development';
const notDev = process.env.NODE_ENV === 'production';

异步解决方案

同步与异步

同步是指发起一个请求时,如果未得到请求结果,代码逻辑将会等待,直到结果出来才会继续执行之后的代码。

异步是指当发起一个请求时,不会等待请求结果,直接继续执行后面的代码。请求结果的处理逻辑,会添加一个监听,等到反馈结果出来后,在回调函数中处理对应的逻辑。

使用 Promise 模拟一个发起请求的函数,该函数在 1s 之后返回数值 30。

1
2
3
4
5
6
7
8
9
function fn() {
return new Promise(function (resolve, reject) {
setTimeout(function () {
resolve(30);
}, 1000);
});
}

fn().then((res) => console.log(res)); // 输出 30

在该函数的基础上,我们可以使用 async/await 来模拟同步的效果。

1
2
3
4
5
6
7
8
9
10
var foo = async function () {
var res = await fn();
console.log(res);
console.log('next code');
};
foo();

// 输出结果
// 30
// next code

而异步效果则会有不同的输出

1
2
3
4
5
6
7
8
9
10
var foo = function () {
fn().then((res) => {
console.log(res);
});
console.log('next code');
};

// 输出结果
// next code
// 30

ajax

ajax 是网页与服务端进行数据交互的一种技术。
我们可以通过服务端提供的接口,利用 ajax 向服务端请求需要的数据

整个过程的简单实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var url = 'http: www.demo.com/user/info';

var result;

var XHR = new XMLHttpRequest();
XHR.open('GET', url, true);
XHR.send();

XHR.onreadystatechange = function () {
if (XHR.readyState == 4 && XHR.state == 200) {
result = XHP.reponse;
console.log(result);
}
};

这看起来没什么麻烦的,但是这时候,如果我们还需要做另一个 ajax 请求,这个请求的参数是从上一个 ajax 请求中获取的,那么我们就不得不如下这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var url = "http: www.demo.com/user/info"

var result;

var XHR = new XMLHttpRequest()
XHR.open('GET', url, true)
XHR.send()

XHR.onreadystatechange = function () {
if (XHR.readyState == 4 && XHR.state == 200) {
result = XHP.reponse
console.log(result)

// 伪代码2
var url2 = 'http:xxx.yyy.com/zzz?ddd=' + result.someParams;
var XHR2 = new XMLHttpRequest();
XHR2.open('GET', url, true);
XHR2.send();
XHR2.onreadystatechange = function() {
...
}
}
}

当出现第三个(甚至更多)仍然依赖上一个请求的时候,代码就变成了一场灾难。

我们需要不停的嵌套回调函数。这样的灾难,我们称之为 回调地狱。

**Promise **可以帮助我们解决这个问题。

Promise

我们知道,如果要确保代码在谁之后执行,可以利用函数调用栈,将想要执行的代码放入回调函数中。

1
2
3
4
5
6
7
8
9
10
11
function want() {
console.log('这是你想要执行的代码');
}

function fn(want) {
console.log('这里表示执行了一大堆其他代码');

// 其他代码执行完后,最后执行回调函数
want && want();
}
fn(want);

或者可以利用任务队列

1
2
3
4
5
6
7
8
9
10
11
function want() {
console.log('这是你想要执行的代码');
}

function fn(want) {
// 根据事件循环的机制,我们就不用非得将代码放在最后面了,由你自由选择
want && setTimeout(want, 0);

console.log('这里表示执行了一大堆其他代码');
}
fn(want);

与 setTimeout 类似, Promise 也可以认为是一种任务分发器,它将任务分发到 PromiseJobs 执行队列中。通常的用法是,我们发起一个请求,然后等待并处理请求结果。

简单用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var tag = true;
var p = new Promise(function (resolve, reject) {
if (tag) {
resolve();
} else {
reject();
}
});

p.then(function (result) {
console.log(result);
}).catch(function (err) {
console.log(err);
});

定义

Promise 是一个对象,保存着未来某一时刻才会执行的事件。

我们常使用 Promise 来解决反馈结果需要等待的场景。

例如

  • 前端向服务端发送一个接口请求。请求结果不会马上返回,而是需要等待一段时间。
  • 加载图片,需要等待一段时间
  • 弹窗中,等待用户点击确认或者取消

基础知识

创建 Promise 实例

1
const p = new Promise();

Promise 函数中的第一个参数为一个回调函数,我们可以称之为 executor 。通常情况下,在这个函数中,我们将会执行发起请求操作,并修改结果的状态值。

1
2
3
4
5
6
7
8
const p = new Promise((resolve, reject) => {
if (true) {
resolve();
}
if (false) {
reject();
}
});

状态

状态有三种

  1. pending:等待结果状态
  2. fulfilled:已出结果,结果符合预期完成状态
  3. rejected:已出结果,结果未符合预期完成状态

promise 表达的就是从发起请求开始,从没有结果 padding 到有结果 fulfilled/rejected 的一个过程。

在 executor 函数中,我们可以分别使用 resolve 与 reject 将状态修改为对应的 fulfilled 与 rejected.

resolve/reject 是 executor 函数的两个参数。他们能够将请求结果的具体数据传递出去。

  1. Promise 实例拥有 then 方法,用来处理请求结果变为 fulfilled 状态时的逻辑。then的第一个参数也是一个回调函数,该函数的参数则是 resolve 传递出来的数据。第二个参数用来处理 rejected 状态时的逻辑。
  2. Promise 实例拥有 catch方法,用来处理请求结果变为 rejected 时的逻辑。catch的第一个参数也是一个回调函数,该函数的参数则是 reject 传递出来的数据。

基本使用

写个例子感受一下 Promise 的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fn(num) {
return new Promise(function (resolve, reject) {
// 模拟一个请求,2s 后得到结果
setTimeout(function () {
if (typeof num == 'number') {
resolve(num);
} else {
var err = num + ' is not a number';
reject(err);
}
}, 2000);
});
}

fn('abc')
.then(function (resp) {
console.log(resp);
})
.catch(function (err) {
console.log(err);
});

// 注意观察该语句的执行顺序
console.log('next code');

then方法可以接收两个参数,第一个参数用来接收 fulfilled 状态的逻辑,第二个参数用来处理 rejected 状态的逻辑。

1
2
3
4
5
6
7
8
fn('abc').then(
function (resp) {
console.log(resp);
},
function (err) {
console.log(err);
},
);

因此 catch 方法其实与下面的写法等价。

1
2
3
fn('abc').then(null, function (err) {
console.log(er);
});

then方法返回的仍然是一个 Promise 实例对象,因此 then 方法可以链式调用,通过在内部 return 的方式,能够将数据持续往后传递。

封装 ajax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var url = 'http://www.demo.com/user/info';

// 封装一个get请求的方法
function getJSON(url) {
return new Promise(function (resolve, reject) {
// 利用ajax发送一个请求
var XHR = new XMLHttpRequest();
XHR.open('GET', url, true);
XHR.send();

// 等待结果
XHR.onreadystatechange = function () {
if (XHR.readyState == 4) {
if (XHR.status == 200) {
try {
var response = JSON.parse(XHR.responseText);
// 得到正确的结果修改状态并将数据传递出去
resolve(response);
} catch (e) {
reject(e);
}
} else {
// 得到错误结果并抛出错误
reject(new Error(XHR.statusText));
}
}
};
});
}

// 封装好之后,使用就很简单了
getJSON(url).then(function (resp) {
console.log(resp);
// 之后就是处理数据的具体逻辑
});

Promise.all

当有一个 ajax 请求,它的参数需要另外 2 个甚至更多请求都有了结果之后才能确定,那么这个时候,就需要 Promise.all 来帮助我们应该这个场景。

1
var p = Promise.all([p1, p2, p3]);

Promise.all 接收一个由 Promise 对象组成的数组作为参数,当 Promise 对象状态都变成 fulfilled 的时候,才会去调用 then 方法。

如果其中一个 Promise 对象状态变成 rejected,那么 p 的状态就会变成 rejected,第一个被 reject 的实例的返回值会传递给回调函数。

如果作为参数的 Promise 实例,自己定义了 catch 方法,那么它一旦被 rejected,并不会触发 Promise.all() 的 catch() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const p5 = new Promise(function (resolve) {
resolve('hello');
});

const p6 = new Promise(function (resolve, reject) {
throw new Error('报错了');
}).catch((e) => e);

Promise.all([p5, p6])
.then((res) => console.log(res))
.catch((e) => console.log(e));

// 输出结果
// ["hello", Error: 报错了]

Promise.race

与 Promise.all 相似的是,Promise.race 也是接收一个 Promise 对象组成的数组作为参数,不同的是,只要当数组中的一个 Promise 状态变为 fulfilled 或者 rejected 时,就可以调用 .then 方法了。

1
var p = Promise.race([p1, p2, p3]);

Promise.race 可以理解为 Promise 实例赛跑,哪个实例有了状态就返回哪个,通常用于处理规定时间内请求超时的情况。

如果 5 秒之内请求无法返回结果,变量 p 的状态就会变为 rejected,从而触发 catch 方法指定的回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Promise.race([
new Promise((resolve, reject) => {
// 模拟请求,10秒后返回数据
setTimeout(resolve, 10000, 'my name is a');
}),
new Promise(function (resolve, reject) {
// 处理请求超时
setTimeout(reject, 5000, new Error('超时了'));
}),
])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log('err: ', err);
alert(err);
});

封装

如何封装与使用息息相关。

加载图片

封装一个加载图片的函数,Promise 的使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function imageLoad(url) {
const img = new Image();
img.src = url;

return new Promise(function (resolve, reject) {
img.onload = function () {
resolve('图片加载成功');
};

img.onerror = function () {
reject('图片加载失败');
};
});
}

然后我们就可以使用 imageLoad 来执行图片加载完成之后的逻辑。

1
2
3
4
5
6
7
imageLoad('xxx.png')
.then((res) => {
alert(res);
})
.catch((err) => {
alert(err);
});

封装的核心关键是: Promise 的最终目的是,为了执行 then 中的回调函数,我们称它为 then_cb

所以在封装的时候,我们就应该思考如何在 Promise 内部,让 then_cb执行。

简易版 MyPromise

显而易见,Promise 包含原型方法 then,构造函数需要传递回调函数 executor,该回调函数包含两个参数,resolve 与 reject 。

根据这些特点,我们得出:

1
2
3
4
5
6
7
8
9
10
11
12
class MyPromise {
construcotr (executor) {
executor(this._resolve.bind(this), this._reject.bind(this)
}

_resolve (value) {}

_reject () {}

then (then_cb) {}
}

目的是为了调用 then_cb ,通过封装加载图片可以发现,调用 resolve 时,then_cb 才会执行,所以可以得出结论, then_cb 的执行需要被 resolve 触发

我们可以通过保存 then_cb 引用的方式来解决。所以代码就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyPromise {
constructor(executor) {
this.thenCallback = null;

executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
this.thenCallback(value);
}

_reject(value) {}

then(then_cb) {
this.thenCallback = then_cb;
}
}

同理,再解决 catch 回调函数的执行问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyPromise {
constructor(executor) {
this.thenCallback = null;
this.rejectCallback = null;
executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
this.thenCallback(value);
}

_reject(value) {
this.rejectCallback(value);
}

then(then_cb, onRejected) {
this.thenCallback = then_cb;
this.rejectCallback = onRejected;
}

catch(onRejected) {
this.then(null, onRejected);
}
}

如果不追求别的特性,我们的 Promise 对象就已经封装好了,并且可以使用了。

1
2
3
4
5
6
7
8
9
10
11
12
const p = new MyPromise((resolve, reject) => {
setTimeout(() => {
// resolve('123')
reject('some err');
}, 1000);
});

// p.then(res => {
// console.log(res);
// })

p.catch((err) => [console.log('err', err)]);

再模拟将 then_cb 放入队列中执行,简单调整如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class MyPromise {
constructor(executor) {
this.thenCallback = null;
this.rejectCallback = null;
executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
// this.thenCallback(value)
setTimeout(() => {
this.thenCallback(value);
}, 0);
}

_reject(value) {
// this.rejectCallback(value)
setTimeout(() => {
this.rejectCallback(value);
}, 0);
}

then(then_cb, onRejected) {
this.thenCallback = then_cb;
this.rejectCallback = onRejected;
}

catch(onRejected) {
this.then(null, onRejected);
}
}

加入队列机制后,就可以在 executor 中直接执行 resolve ,否则会报错 this.thenCallback is not a function

1
2
3
4
5
6
7
const p = new MyPromise((resolve, reject) => {
resolve('123');
});

p.then((res) => {
console.log(res);
});

Promise.all

Promise.all 返回的是一个数组,我们要将参数 array 内的每个 Promise 的执行结果放在一个数组 result 里,并且 result 数组成员的顺序要与传入时的 array 成员顺序保持一一对应。

实现 Promise.all 的重点在于对 all 参数内 Promise 实例全部执行完毕时机的判断。

因为 Promise 是异步的,我们不能保证 Promise 实例完成的时机与数组顺序一样。也就是说我们不能使用数组的 length 属性来表达 Promise 全都执行完毕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const delay = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data);
}, Math.random() * 1000);
});
};

Promise._all = (array) => {
return new Promise((resolve, reject) => {
let count = 0;
const result = [];
for (let i = 0, len = array.length; i < len; i++) {
array[i].then((data) => {
result[i] = data;
count++;
// 因为 array[i] 的执行是异步的,所以这种判断是错误的
// 如果 i===2 的 promise 先执行完毕,result[2] 导致 result.length === 3
if (result.length === array.length) {
resolve(result);
}
}, reject);
}
});
};

const p1 = delay(1);
const p2 = delay(2);
const p3 = delay(3);
Promise._all([delay(2), delay(1), delay(3)]).then(
(res) => {
console.log('res: ', res); // [2, 1, 3]
},
(err) => {
console.log('err: ', err);
},
);

上面的代码,如果最后一个 Promise 先执行完毕,赋值时 result[2] = data,那么 result.length等于 3, 满足 result.length === array.length的判断条件,就会提前执行 resolve 。

所以,我们可以参考垃圾回收机制的引用计数法,在内部添加一个计数变量 count 来判断是否所有 Promise 都已执行完毕。当 count 等于传入的 Promise 数组长度时,表示所有 Promise 都有了结果,然后我们再执行 resolve 将结果传递出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Promise._all = (array) => {
return new Promise((resolve, reject) => {
let count = 0;
const result = [];
for (let i = 0, len = array.length; i < len; i++) {
array[i].then((data) => {
result[i] = data;
count++;
// 因为array[i]的执行是异步的,所以这种判断是错误的
// 如果i===2的promise先执行完毕,result[2]导致result.length === 3
// if (result.length === array.length) {
// resolve(result)
// }
if (count === array.length) {
resolve(result);
}
}, reject);
}
});
};

async/await

异步问题除了使用 Promise 来解决之外,还可以使用 ES7 中新增的语法 async/await 来搞定。

在函数声明前面,加上关键字 async,这就是 async 的具体使用了。

1
2
3
4
5
6
7
8
async function fn() {
return 30;
}

// 或者
const fn = async () => {
return 30;
};

然后我们查看一下 fn 的运行结果

1
2
3
4
5
6
7
8
console.log(fn());

// result
Promise = {
__proto__: Promise,
[[PromiseStatus]]: 'resolved',
[[PromiseValue]]: 30,
};

发现 fn 函数运行返回的是一个标准的 Promise 对象。也就是说 async 其实就是 Promise 的一个语法糖,目的是为了让写法更加简单。于是,我们可以使用 Promise 的相关语法来处理后续的逻辑

1
2
3
fn().then((res) => {
console.log(res); // 30
});

await 含义为等待,意思就是需要等待 await 后面函数运行完了,并且有了返回结果,才能继续执行下面的代码。这正是同步的效果。

需要注意的是,await 关键字只能在 async 函数中使用。并且 await 后面的函数运行后必须返回一个 Promise 对象才能实现同步的效果。

当我们使用一个变量去接收 await 的返回值时,该返回值为 Promise 中 resolve 传递出来的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fn() {
return new Promise((resolve, reject) => {
setTimeout(resolve, 1000, 30);
});
}

const foo = async () => {
const t = await fn();
console.log('t', t);
console.log('next code');
};

foo();

// 输出结果
// t: 30
// next code

从例子中我们可以看出,在 async 函数中,遇到 await 时,就会等待 await 后面的函数运行完毕,而不会直接执行 next code。

如果我们直接使用 then 方法,就不得不把后续的逻辑写在 then 方法中。

1
2
3
4
5
6
7
const foo = () => {
return fn().then((t) => {
console.log('t: ', t);
console.log('next doce');
});
};
foo();

很明显,如果使用 async/await 的话,代码会更简洁,逻辑也更清晰。

错误处理

  • 在 Promise 中,我们使用 .catch 方法来捕获错误
  • 而使用 async 时,我们使用 try/catch 来捕获错误
    • 如果有多个 await ,只会捕获到第一个错误
      :::info
      tyr/catch 除了可以捕获使用 async 时 Promise 抛出的错误,还可以捕获 throw 关键字抛出的错误。
      :::

在 Promise 中,我们使用 .catch 方法来捕获错误,而不是使用 .then 的第二个参数。因为:

  1. 更接近同步的语法(try/catch)
  2. then 中的错误也会被 catch 捕获

使用 then 的第二个参数,并不能捕获到第一个参数内部抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const p3 = new Promise(function (resolve, reject) {
setTimeout(() => {
resolve(123);
}, 1000);
});
p3.then(
(result) => {
console.log(result);
throw new Error('is err');
},
(error) => {
console.log('error', error);
},
);

// 输出结果
// 123
// Uncaught (in promise) Error: is err

使用 catch ,则可以捕获到上一个 then 内部抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const p3 = new Promise(function (resolve, reject) {
setTimeout(() => {
resolve(123);
}, 1000);
});
p3.then((result) => {
console.log(result);
throw new Error('is err');
}).catch((error) => {
console.log('error', error);
});

// 输出结果
// 123
// error Error: is err

而使用 async 时,我们使用 try/catch 来捕获错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fn() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error');
}, 1000);
});
}

const foo = async () => {
try {
await fn();
} catch (e) {
console.log('e: ', e);
}
};
foo();

如果有多个 await ,只会捕获到第一个错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fn1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error fn1');
}, 1000);
});
}
function fn2() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error fn2');
}, 1000);
});
}

const foo = async () => {
try {
await fn1();
await fn2();
} catch (e) {
console.log('e: ', e); // some error fn1
}
};
foo();

Generator 函数

Generator 函数可以认为是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。

1
2
3
4
5
6
7
8
function* gen(x) {
var y = yield x + 2;
return y;
}

var g = gen(1);
g.next(); // { value: 3, done: false }
g.next(); // { value: undefined, done: true }

Generator 函数名前要加*号,调用 Generator 函数,会返回一个指针对象(即遍历器)。调用指针对象的 next 方法,会移动内布指针,指向第一个遇到的 yield 语句。

也就是说,next 的作用是分段执行 Generator 函数。每次调用 next 方法,都会返回一个对象,表示当前阶段的信息。

value 属性是 yield 语句后面表达式的值,done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。

错误处理

使用 try catch 捕获函数体外抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14

function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}

var g = gen(1);
g.next();
g.throw'出错了');
// 出错了

async

从 Promise 对象,再到 Generator,异步解决方案每次都有所改进,很多人认为 async 函数是异步操作的终极解决方案。
async 函数就是 Generator 函数的语法糖。
async 函数就是将 Generator 函数的*号替换成 async,将 yield 替换成 await。

1
2
3
4
5
6
7
8
9
var gen = function* () {
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
};

var asyncReadFile = async function () {
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
};

async 函数对 Generator 函数的改进,体现在一下三点:

  1. 内置执行器。Generator 函数的执行必须依靠执行器。
  2. 更好的语义。async 和 await,比起星号和 yield,语义更清楚。
  3. 更广的适用性。await 命令后面可以跟 Promise 对象和原始类型的值(数值、字符串、布尔值),Generator 函数只能是 thunk 函数或 Promise 对象。

微信网页分享「自定义标题、描述和图片」

记录一下通过微信网页分享时,需要自定义标题、描述和图片的功能。参考 JSSDJ 使用步骤

步骤解读

步骤一:JS 接口安全域名,指的是后端 API 接口域名。

步骤二:使用ES MODULE加载微信 JS SDK文件。
pnpm i weixin-js-sdk

步骤三:注入权限验证配置

1
2
3
4
5
6
7
8
wx.config({
debug: true, // 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来,若要查看传入的参数,可以在 pc 端打开,参数信息会通过 log 打出,仅在 pc 端时才会打印。
appId: '', // 必填,公众号的唯一标识
timestamp: , // 必填,生成签名的时间戳
nonceStr: '', // 必填,生成签名的随机串
signature: '',// 必填,签名
jsApiList: [] // 必填,需要使用的 JS 接口列表
});

jsApiList指的是微信 JS 提供的接口,如“分享给朋友”接口updateAppMessageShareData
timestampnonceStrsignature三个签名信息,让后端参考使用签名算法 将其返回给前端即可。
后端需要做的是:

  1. 获取access_token
  2. 通过access_token获取jsapi_ticket,并为jsapi_ticket做缓存处理
  3. 返回签名信息:签名、生产签名的时间戳和随机串

步骤四:
获取签名信息是异步的,并且如果页面加载时就调用微信 JS 提供的接口,需将微信 JS 提供的接口放在wx.ready()中调用。

步骤五:
通过 wx 对象的通用参数来验证微信 JS 提供的接口是否调用成功,如success参数

1
2
3
4
5
6
7
8
9
wx.updateAppMessageShareData({
title: '分享标题',
desc: '分享描述',
imgUrl: '分享图片链接',
link: '分享页面链接',
success: () => {
alert('updateAppMessageShareData接口调用成功');
},
});

封装和使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// hooks/useWXShare.tsx
import wx from 'weixin-js-sdk';
import request from '@/api';
import urlMap from '@/api/url-map';

const { weChat } = urlMap;
const appId = 'xxxxxxxxxxxxx';

interface ShareData {
title: string;
desc?: string;
link: string;
imgUrl: string;
success?: any;
}
// 微信网页分享
// 注意,通过 ready 接口处理成功验证
const useWXShare = () => {
// 自定义“分享给朋友”及“分享到QQ”按钮的分享内容
const updateAppMessageShareData = (props: ShareData) => {
wx.updateAppMessageShareData(props);
};

// 自定义“分享到朋友圈”及“分享到QQ空间”按钮的分享内容
const updateTimelineShareData = (props: ShareData) => {
wx.updateTimelineShareData(props);
};

const getSign = () => {
request
.post(weChat.sign, {
url: location.href,
})
.then((res) => {
const { timestamp, nonceStr, signature } = res.data;
// 签名需要后端返回
wx.config({
// debug: true, // 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来,若要查看传入的参数,可以在 pc 端打开,参数信息会通过 log 打出,仅在 pc 端时才会打印。
appId, // 必填,公众号的唯一标识
timestamp, // 必填,生成签名的时间戳
nonceStr, // 必填,生成签名的随机串
signature, // 必填,签名
jsApiList: ['updateTimelineShareData', 'updateAppMessageShareData'], // 必填,需要使用的 JS 接口列表
});
});
};

return {
wx,
updateAppMessageShareData,
updateTimelineShareData,
getSign,
};
};
export default useWXShare;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useWXShare } from '@/hooks';

export default () => {
const { wx, getSign, updateAppMessageShareData, updateTimelineShareData } = useWXShare();

useEffect(() => {
getSign();

onLoading();
request
.get(home.articlesDetail, { uri })
.then((res) => {
const { title, description } = res.data;
offLoading();

wx.ready(() => {
updateAppMessageShareData({
title,
desc: description,
imgUrl: 'https:xx.jpeg',
link: detailLink,
});
updateTimelineShareData({
title,
imgUrl: 'https:xx.jpeg',
link: detailLink,
});
});
})
.finally(() => offLoading());
}, []);

return (
<>微信网页分享<>
)
}

微信网页分享朋友圈无法显示图片?
pic.1708334234054

文件篇

文件上传和下载是很常见的功能,今天就来梳理一下操作文件过程中常见的概念和 api,加深对文件的理解。

概念

Blob 对象

当用户通过一个input元素选择文件时,浏览器会创建一个Blob对象代表该文件的二进制数据。如果要在将文件数据上传到服务器或者存储到本地之前对其进行操作,你可能需要使用 Blob对象 。

举个例子,你可以使用URL.createObjectURL方法创建一个URL代表这个Blob对象,然后用这个URL<img>video元素中展示这个文件。如:

1
2
const img = document.createElement('img');
img.src = URL.createObjectURL(file);

File 对象

当用户通过一个input元素选择文件时,浏览器会创建一个基于Blob对象的File对象,并添加文件名name、大小size、上次修改日期lastModifiedData等属性。如果要在将文件上传到服务器之前验证文件的属性,则可能需要使用File对象。

:::info
也就是说,File 对象是一种 Blob 对象,他包含了文件的附加信息,如 name、size、lastModifiedDate 等属性。开发人员无法直接访问底层的 Blob 对象,而是通过 api 与 File 对象进行交互。
:::

base 64

base64 编码将二进制数据表示为一串 ASCII 字符「我们常说的字符串」。
有些场合并不能传输或者储存二进制流,这时候就需要使用 base64 编码。

比如,一个传输协议是基于 ASCII 文本的,那么他就不能传输二进制流,想要传输该二进制流就得编码。常用的 http 协议的 url 就是纯文本的,不能直接放二进制流。
大多数现代语言的 String 类型,都不能直接存储二进制流,但可以储存 base64 编码的字符串。

举个例子,你可以使用FileReader.readAsDataURL()方法读取 File 对象的内容并将其转换为 base64 编码的 data:URL 格式的字符串,简称DataURL
这个 DataURL 表示所读取的文件内容,可以将其发送到服务器。然后服务器可以将 DataURL 解码回二进制数据并将其保存为文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* file 转 base64编码
* @param {File} file 文件流
* @param {base64Callback} callback 回调函数 - 处理响应
*/
export const fileByBase64 = (file, callback) => {
var reader = new FileReader();

// onload事件在读取操作完成时触发
reader.onload = function (e) {
// e.target.result/reader.target 该属性表示目标对象的DataURL
console.log(e.target.result);
callback && callback(e.target.result);
};

// 以 data:URL 的形式读取数据
reader.readAsDataURL(file);
};

简单的文件上传

文件上传的传统形式,是使用type='file'input表单元素

1
<input type="file" id="file-input" accept=".jpg, .jpeg, .png" multiple />

可以添加change事件监听读取文件对象列表event.target.files

1
2
3
4
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
});

File 对象:
pic.1708334253215

文件上传前,可以通过File对象,验证文件大小、类型等信息,决定是否进行下一步,比如验证文件大小。

验证文件大小

1
2
<input type="file" id="file-input" />
<button id="upload-btn">Upload File</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fileInput = document.getElementById('file-input');
const uploadBtn = document.getElementById('upload-btn');

uploadBtn.addEventListener('click', () => {
const selectedFile = fileInput.files[0];
const fileSizeLimit = 5 * 1024; // 5KB in bytes

if (selectedFile.size > fileSizeLimit) {
alert(
'Selected file exceeds the size limit of 5 KB. Please select a smaller file.',
);
return;
}

// If file size is within the limit, proceed with file upload
// Your code for uploading the file to the server goes here
});
1
2
3
4
5
6
获取 files 的方式:

- 在 input 元素的 change 事件中,可通过 e.target.files 获取;
- 或者通过 input 元素直接获取,如:document.getElementById('file-input').files;

也就是说`e.target === fileEl`

显示读取进度(下载文件的场景)

FileReader.onprogress
pregress事件,在读取Blob时触发。在下载文件并显示进度这个场景下能够派上用场。

1
2
3
4
5
6
<input type="file" id="file-input" />
<div>
<label id="progress-label" for="progress">Upload File</label>
<progress id="progress" value="0" max="100" value="0">0</progress>
<button id="read-blob">读取Blob</button>
</div>

readAsDataURL能够读取Blob对象,然后监听FileReaderprogress事件,通过ProgressEvent.loadedProgressEvent.total计算读取的进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const fileInput = document.getElementById('file-input');
const readBlobBtn = document.getElementById('read-blob');

// 显示文件读取进度
const reader = new FileReader();

reader.addEventListener('progress', (e) => {
if (e.loaded && e.total) {
// 计算完成百分比
const percent = (e.loaded / e.total) * 100;
// 将值设置为进度组件
progress.value = percent;
}
});

readBlobBtn.addEventListener('click', () => {
const selectedFile = fileInput.files[0];
// 以 URL 格式的字符串的形式读取数据
reader.readAsDataURL(selectedFile);
});

显示上传进度

要显示文件的上传进度,可以使用 JavaScript 中的 XMLHttpRequest (XHR) 对象将文件上传到服务器并使用 XMLHttpRequest.upload.onprogress 事件跟踪进度。这是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const fileInput = document.getElementById('file-input');
const uploadBtn = document.getElementById('upload-btn');
const uploadProgress = document.getElementById('upload-progress');

uploadBtn.addEventListener('click', () => {
const selectedFile = fileInput.files[0];
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');

// Track the upload progress
xhr.upload.onprogress = (event) => {
const progress = (event.loaded / event.total) * 100;
uploadProgress.value = progress;
};

xhr.onload = () => {
console.log('File uploaded successfully');
};

xhr.onerror = () => {
console.log('File upload failed');
};

const formData = new FormData();
formData.append('file', selectedFile);

xhr.send(formData);
});

上传目录

input元素的webkitdirectory属性,表示允许用户选择文件目录,而不是文件。

1
<input type="file" id="file-input" webkitdirectory />

选择目录时,该目录下的文件会全部选中(包括子孙文件)。

拖放上传

设置一个放置文件的目标元素。

1
<div id="drop-zone">Drop files here</div>

调用 event.preventDefault(),这使它能够接收 drop 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const dropZone = document.getElementById('drop-zone');

// dragover 事件在可拖动元素被拖进放置目标时被触发
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('drag-over');
});

// dragleave 事件在可拖动元素离开放置目标时被触发
dropZone.addEventListener('dragleave', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
});

// drop 事件在可拖动元素放置在放置目标时被触发
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
// 获取文件
const files = event.dataTransfer.files;
console.log('files: ', files);

const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');

xhr.onload = () => {
console.log('File uploaded successfully');
};

xhr.onerror = () => {
console.log('File upload failed');
};

const formData = new FormData();
formData.append('file', files[0]);

xhr.send(formData);
});

Content-Type(表单的 enctype 属性)

当 method 属性值为 post 时,enctype 就是将表单的内容提交给服务器的数据编码类型。可能的取值有:

  • application/x-www-form-urlencoded:未指定属性时的默认值。
  • multipart/form-data:当表单包含 type=file input 元素时使用此值
  • text/plain:出现于 HTML5,用于调试。这个值可被 <button><input type="submit"><input type="image"> 元素上的 formenctype 属性覆盖。

application/x-www-form-urlencoded

这种数据编码类型只支持传输文本数据

1
2
3
4
POST http://www.example.com HTTP/1.1
Content-Type: application/x-www-form-urlencoded;charset=utf-8

title=test&sub%5B%5D=1&sub%5B%5D=2&sub%5B%5D=3

首先,Content-Type 被指定为 application/x-www-form-urlencoded;其次,提交的数据按照 key1=val1&key2=val2 的方式进行编码,keyval 都进行了 URL 转码。大部分服务端语言都对这种方式有很好的支持。

此类型不适合用于传输大型二进制数据或者包含非 ASCII 字符的数据。平常我们使用这个类型都是把表单数据使用 url 编码后传送给后端,二进制文件当然没办法一起编码进去了。所以 multipart/form-data 就诞生了。

multipart/form-data

为了支持文件上传,表单数据必须使用multipart/form-data内容类型进行编码。这种编码格式允许二进制数据作为请求主体的一部分发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
POST http://www.example.com HTTP/1.1
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA

------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"

title
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png

PNG ... content of chrome.png ...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--

Content-Type被指定为multipart/form-databoundary----WebKitFormBoundaryrGKCBY7qhFd3TrwAboundary用于分割提交的数据。
Content-Disposition包含文件的基本信息,Content-Type表示文件内容类型。
第一部分是一个名为text的表单字段,该字段的内容是字符串title。第二部分是名为file的字段,文件名为chrome.png,文件内容类型为image/png,内容为PNG ... content of chrome.png ...,然后以boundary为结尾。

FormData()

这是一个传统的最简单的 form 表单上传如下:

1
2
3
4
5
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" value="请选择文件" />

<input type="submit" />
</form>

FormData 的由来:
当使用 Ajax 上传文件时,如果不指定enctype="multipart/form-data",会导致后端在解析 Form 表单的数据格式时与 Ajax 上传的数据格式不一致的问题。为了后端能够使用相同的代码解析这两种提交方式,所以出现了FormData

FormData 接口提供了一种表示表单数据的键值对key/value的构造方式,可以轻松的将数据通过 Ajax 发送出去。

1
2
3
4
5
6
7
8
9
const formData = new FormData();

// 单文件
formData.append('files', file);

// 多文件
_files.forEach((file) => {
formData.append('files', file);
});

使用 FormData 上传文件时,无需手动设置Content-Type='multipart/form-data',FormData 会自动设置正确的 Content-Type 和 数据类型。

实际开发过程中也是如此,通常会使用 FormData 格式保存文件,关于Content-Type需要什么类型,取决于后端是怎么设计的。

下载

window.open

open()方法,用于将指定的资源加载到浏览器新的窗口或者标签页。
当我们指定一个图片链接时,浏览器会自动下载该资源,下载后自动关闭该窗口。

1
2
3
const imgUrl =
'https://nd-news-mangement.oss-cn-hangzhou.aliyuncs.com/2023/04/274a8263e05f37a5d8663193b86e1a0583.png';
window.open(imgUrl);

location.href

location.href表示将当前页面的 URL 设置为一个新的值。它是一个字符串,包含当前页面的完成 URL,包括协议、域名、路径、查询参数和片段标识符。

1
2
3
const imgUrl =
'https://nd-news-mangement.oss-cn-hangzhou.aliyuncs.com/2023/04/274a8263e05f37a5d8663193b86e1a0583.png';
location.href = imgUrl;

window.openlocation.href的区别:

  • window.open会打开一个新窗口或选项卡,而location.href会替换当前页
  • window.open 打开太多新窗口可能会对用户体验产生负面影响
  • location.href可能会刷新整个页面(但如果在输入框中输入了文本,加载新页面时文本内容不会丢失,这是因为浏览器通常将表单数据保存在浏览器的缓存中)

a 标签

a 标签通常用于用户启动的交互,而location.href通常用于响应用户事件。
a 标签的方式属性更多,如downloadtarget,也更为灵活,在实际开发过程中,我们通常会使用 a 标签封装一个下载功能的函数来使用,如下:

1
2
3
4
5
6
7
8
9
10
11
function downloadFile(url, fileName) {
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
// download仅适用于同源 URL
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

总结

  • File 对象是基于 Blob 对象的,只不过多了一些附加信息,如 namesize
  • 想要显示文件读取进度时,我们可以通过 FileReader 的 readAsDataURL(file) 方法,然后监听FileReaderprogress事件
  • 想要显示上传到服务器的进度时,可以通过xhr.upload.onprogress事件
  • 使用 FormData 上传文件时,无需手动设置Content-Type='multipart/form-data',FormData 会自动设置正确的 Content-Type 和 数据类型

实际项目应用中,大多数的表单场景都是手动上传文件到服务器,也就是说在提交到服务器之前,我们是不需要使用后端接口的。但现实中遇到的情况往往是后端提供一个上传接口,再提供一个提交表单数据的接口,前一个接口就显得很多余,而且存在数据库内存被乱用的风险。

参考:

封装的理解

构造函数用于初始化实例对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const p1 = {
name: '张三',
age: 20,
run: function () {
console.log('调用此方法张三就开始奔跑');
},
};

const p2 = {
name: '李四',
age: 22,
run: function () {
console.log('调用此方法李四就开始奔跑');
},
};

const p3 = {
name: '王五',
age: 21,
run: function () {
console.log('调用此方法王五就开始奔跑');
},
};

这种场景很常见,每个对象都有nameage属性和run方法,我们可以用一个函数封装一下。

封装

封装的目的是为了减少重复代码的编写。

提取共性,将不同点作为参数传入。可以如下封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function CreatePerson(name, age) {
this.name = name;
this.age = age;
this.run = function () {
console.log(`调用此方法${this.name}就会开始奔跑`);
};
}

const p1 = new CreatePerson('张三', 20);
const p2 = new CreatePerson('李四', 30);
const p3 = new CreatePerson('王五', 40);
p1.run();
p2.run();
p3.run();

这种方式有个问题,就是每次使用 CreatePerson 函数创建对象时,run 方法被反复创建了多次。同样的内容,占据了多份内存空间。

原型对象
属于所有实例共享的属性和方法,抽离出来放在原型对象中,而每个实例特有的属性和方法,都会留在构造函数中
例如,每一个实例的名字,名字是每个实例特有的,不可能被共享。

将 run 方法挂载到原型对象上,这样就可以实现一次创建,被多个实例对象共同使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function CreatePerson(name, age) {
this.name = name;
this.age = age;
// this.run = function () {
// console.log(`调用此方法${this.name}就会开始奔跑`)
// }
}

CreatePerson.prototype.run = function () {
console.log(`调用此方法${this.name}就会开始奔跑`);
};

const p1 = new CreatePerson('张三', 20);
const p2 = new CreatePerson('李四', 30);
const p3 = new CreatePerson('王五', 40);
p1.run();
p2.run();
p3.run();

new 关键字都干了什么?

  1. 创建一个新对象,该对象为最终返回的实例
  2. 将新对象的原型对象指向构造函数的原型对象
  3. 将构造函数内部的 this 指向这个新对象,即为实例
  4. 如果构造函数明确返回了对象或函数,那么这个返回值取代第一部的新对象

实现一个 new

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function myNew(Fn, ...args) {
// 1.创建一个新对象,该对象为最终返回的实例
let res = {};

// 2.将新对象的原型执行构造函数的原型
if (Fn.prototype !== null) {
// res.__proto__ = Fn.prototype
Object.setPrototypeOf(res, Fn.prototype);
}

// 3.将构造函数的 this 指向新对象
ret = Fn.apply(res, args);

// 4.如果函数返回了对象或者函数,那么这个返回值取代第一部创建的新对象
if ((typeof ret === 'Object' || typeof ret === 'function') && ret !== null) {
return ret;
}

return res;
}

const p1 = myNew(CreatePerson, '张三', 20);
const p2 = myNew(CreatePerson, '李四', 30);
const p3 = myNew(CreatePerson, '王五', 40);
p1.run();
p2.run();
p3.run();

小结:构造函数的返回值为基本类型,其返回值是实例化后的对象。构造函数的返回值为引用类型,其返回值即为 new 之后的返回值。

构造函数和原型对象的访问优先级
如果在构造函数和原型对象中,同时声明了属性/方法,那么会优先访问构造函数中的属性/方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function CreatePerson(name, age) {
this.name = name;
this.age = age;
this.run = function () {
console.log(`构造函数中的:调用此方法${this.name}就会开始奔跑`);
};
}

CreatePerson.prototype.run = function () {
console.log(`调用此方法${this.name}就会开始奔跑`);
};

const p1 = new CreatePerson('张三', 20);
const p2 = new CreatePerson('李四', 30);
const p3 = new CreatePerson('王五', 40);
p1.run(); // 构造函数中的:调用此方法张三就会开始奔跑
p2.run(); // 构造函数中的:调用此方法李四就会开始奔跑
p3.run();

柯里化

定义:柯里化是这么一个函数,他接收一个函数 A 为参数,运行后能够返回一个新函数,这个新函数能够处理函数 A 的剩余参数。

配合例子理解这个定义

假如有个一接收三个参数的函数 A

1
2
3
function A(a, b, c) {
// do something
}

同时还要一个封装好的柯里化通用函数 createCurry 。他接收 A 为参数,能够将 A 转化为柯里化函数,返回结果就是这个转化之后的函数。

1
var _A = createCurry(A);

那么 _A 作为 createCurry 运行的返回函数,他能够处理 A 的剩余参数。因此下面的执行结果是等价的。

1
2
3
4
5
_A(1, 2, 3);
_A(1, 2)(3);
_A(1)(2, 3);
_A(1)(2)(3);
A(1, 2, 3);

_A 能够处理 A 的所有剩余参数。因为柯里化也可以被称为部分求值。

在简单的场景下,可以不借用柯里化通用式得到柯里化函数,比如一个简单的加法函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function add(a, b, c) {
return a + b + c;
}

function _add(a) {
return function (b) {
return function (c) {
return a + b + c;
};
};
}

add(1, 2, 3);
_add(1)(2)(3);

柯里化的实现思路:
柯里化函数的运行过程其实是一个参数的收集过程,每次将传入的参数收集起来,在最里层处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// arity 用于标记剩余参数的个数
// args 用于收集参数
function createCurry(func, arity, args) {
// 第一次执行,剩余参数为函数的参数各位
var arity = arity || func.length;

// 第一次执行,args 为空
var args = args || [];

// 定义一个函数
var wrapper = function () {
// 将wrapper中的参数收集到args中
var _args = [].slice.call(arguments);
[].push.apply(args, _args);

// 如果收集到的参数小于最初的func.length,则递归调用,继续收集参数
if (_args.length < arity) {
arity -= _args.length;
return createCurry(func, arity, args);
}

// 参数收集完毕,则执行func
return func.apply(func, args);
};

// 返回这个函数
return wrapper;
}

柯里化确实把简单的问题复杂化了,但复杂化的同时,我们在使用函数时拥有了更多的自由度。也就是说,柯里化的核心,就是对于函数参数的自由处理。

举一个非常常见的例子

验证一串数字是否是正确的手机号,按照普通的思路,可能如下封装

1
2
3
function checkPhone(phoneNumber) {
return /^1[34578]\d{9}&/.test(phoneNumber);
}

如果想要验证一个邮箱,可能如下封装

1
2
3
function checkEmail(email) {
return /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/.test(email);
}

除此之外,可能还有更多的封装逻辑,因此在实践中,为了统一逻辑,我们会封装一个更为通用的函数

1
2
3
function check(reg, targetString) {
return reg.test(targetString);
}

但是这样封装之后,在使用时又会稍微麻烦一点,因为总是输入一串正则,这样就导致使用效率低下,并且容易出错

1
2
check(/^1[34578]\d{9}$/, '14900000088');
check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@163.com');

那么这个时候,我们可以借助柯里化,在 check 的基础上,再做一层封装,以简化使用

1
2
3
4
var _check = createCurry(check);

var checkPhone = _check(/^1[34578]\d{9}$/);
var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);

最后在使用的时候就会变得更加直观与简洁了

1
2
checkPhone('183888888');
checkEmail('xxxxx@test.com');

通过这个例子可以发现,柯里化能够应付更加复杂的逻辑封装。

虽然柯里化确实在一定程度上将问题复杂化了,也让代码更加不容易理解。但是柯里化在面对复杂情况下的灵活性却让我们不得不爱。

浏览器如何渲染页面

在地址栏输入 url 后发生了什么?

用一张图描述一下:
pic.1708326672310

  1. 输入域名
  2. DNS:域名解析,得到 ip 地址
  3. 建立 TCP 连接
  4. 客户端发起请求
  5. 服务端处理请求,返回数据
  6. 断开 TCP 连接
  7. 浏览器拿到请求结果,解析 HTML,渲染页面

优化方案里,和日常开发息息相关的是最后的流程,浏览器解析 html,渲染页面。本文主要介绍这一块内容。

网页进程:浏览器网页的渲染和 JS 执行在一个单独的进程中执行。这个进程也称为render 进程。每启动一个页面,都会启动一个 render 进程。

说到网页进程,又引入了线程的概念。

如果说进程是一个工厂,那线程就是工厂中的流水线。一个工厂的正常运行,往往需要多个流水线通力合作才能完成。在网页进程中也是一样,想要网页进程能正常渲染运行,也需要多个线程参与合作。

网页的渲染运行,有如下线程参与

GUI 渲染线程

GUI(Graphical User Interface)表示图形用户界面的意思,render tree 的渲染。用一张图看一下渲染路径

渲染路径

pic.1708326684005

后端返回的 html 文件其内容是个字符串,<html>、<style>等标签是语法糖,HTML Parser 或 CSS Parser 表示对应的解析器,解析器的工作就是将这些字符串语法糖转换为对象。

加载:表示『资源文件发起请求->服务器返回结果』这一过程
解析:表示词法分析,先加载,后解析
渲染:将节点信息绘制到页面的过程

html 的解析过程,是自上而下的,解析过程中,如果发现了样式文件(link 标签),就会加载样式文件。样式文件的加载并不会阻塞 html 的解析,阻塞的是 render tree 的渲染。

HTML 会解析出 DOM(Document Object Model)树,样式文件会解析出 CSSOM(CSS Object Model)树,在 Attachment 环节,GUI 线程将 DOM 树与 CSSOM 树合并在一起,生成渲染树(render tree),并将渲染树绘制到页面。

为什么样式文件的加载会阻塞 render tree 的渲染?

如果不会阻塞,页面会先出现没有样式的 DOM 结构,等 CSSOM 树生成后,需要重新计算 Render Tree,这会造成没必要的损耗。

用代码证明 css 的阻塞现象:
我们先把下载速度设置为 20kbit/s
开发者工具-> Network -> No throttling -> 添加一个 20kbit/s,再观察结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html lang="en">
<head>
<title>css阻塞</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
h1 {
color: red !important;
}
</style>
<script>
function h() {
// 打印结果不是空数组(断点看结果),说明h1已解析完成。所以证明css的加载不会阻塞HTML的解析
console.log('h1', document.querySelectorAll('h1'));
}
setTimeout(h, 0);
</script>
<link
href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css"
rel="stylesheet"
/>
</head>
<body>
<!-- 样式文件加载完毕之前,h1并没有显示在页面上。所以证明css的加载会阻塞DOM树的渲染 -->
<h1>这是红色的</h1>
</body>
</html>

怎么解决 css 阻塞的阻塞现象?我们可以提高 css 的加载速度:

  1. 使用 CDN。CDN 会替你挑选最近的节点为你提供资源。
  2. 对 css 进行压缩。webpack,gulp 等打包工具
  3. 将多个 css 文件合并。

思考:在页面中新增一个弹窗节点,加在哪个位置最好?页面是如何重新渲染的?
加在 DOM 结构末尾。无论放在哪个位置,整个 HTML 都会重新解析,加在末尾是为了减少生成 render tree 时的计算过程(整颗新旧树还是会比较的,react diff 也会同理)

JS 与渲染路径

js 与样式文件:
解析 HTML 时,如果在 script 元素之前发现样式文件,样式文件的加载和解析会阻塞 JS 的解析和执行

用代码证明 css 的加载阻塞 js 的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="en">
<head>
<title>css阻塞js的执行</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
console.log('before css');
var startDate = new Date();
</script>
<link
href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css"
rel="stylesheet"
/>
</head>
<body>
<h1>css阻塞js的执行</h1>
<script>
var endDate = new Date();
console.log('after css');
console.log('经过了' + (endDate - startDate) + 'ms');
</script>
</body>
</html>

pic.1708326697875
可以看出,bootstrap 样式文件加载了 111964ms,加载完成后才执行了后面的 JS。

JS 与 HTML,用一张图来表示:
pic.1708326704654
稍微解释一下这张图:
解析 HTML 时,如果发现了 script 元素,JS 的加载和解析都会阻塞 HTML 的解析,等 script 加载以及执行完毕再解析 HTML。这就是我们将 script 放在 DOM 结构后面的原因。

那是不是说 script 标签必须放在 DOM 结构后面?并不是,因为可以给 script 标签添加 async 或 defre 属性。

带 async 属性的 script:

async 表示异步,即用异步的方式加载脚本,render 进程会开启一个新的线程来加载 JS,所以JS 的加载不会阻塞 HTML 的解析,但 JS 的执行仍然会阻塞 HTML 的解析。等 JS 执行完毕后,才继续解析 HTML。

因为 async 加载完成之后会马上执行,所以它是无序的。

带 defer 属性的 script:

defer 表示延迟,即延迟执行,像 async 一样,JS 的加载不会阻塞 HTML 的解析并且 JS 会在 HTML 解析完成后再执行

async 与 defer 的区别:

  • async 脚本是无序的,适合‘完全不依赖它或它不被任何脚本依赖’的脚本
  • async 脚本的执行会阻止 HTML 的解析,defer 不会

async 与 defer 的相同点:

  • 使用 async 或 defer 属性,render 进程会开启一个新的线程来加载 JS 文件,也就是说 script 的加载是和 HTML 的解析同时进行的

HTML 的解析能不能和 JS 的执行同时进行?
不能。GUI 线程负责 HTML 的解析,JS 引擎线程负责 JS 的执行,虽然是两个线程,但他们是互斥的。

补充:
DOMContentLoaded
HTML 解析完成后,DOMContentLoaded 就会触发,解析完成表示我们已经可以访问页面的 DOM 元素了。DOMContentLoaded 类似于 JQuery 中的$(document).ready(function() { // ...代码... })

load
整个页面及所有依赖资源如图片和样式表都已加载完成时触发。
DOMContentLoaded 时间会比 load 小,时间差表示依赖资源加载的时间。

参考:
https://www.growingwiththeweb.com/2014/02/async-vs-defer-attributes.html
https://segmentfault.com/q/1010000000640869
https://www.cnblogs.com/caizhenbo/p/6679478.html

在页面的交互过程中,需要重绘或者由于某些操作引发了回流时,GUI 会重新计算 render tree,然后重新绘制页面元素。

重绘和回流

重绘(repaint):当页面元素的样式发生改变时,GUI 会根据新样式重新绘制该元素,这个过程称为重绘。重绘不影响布局

导致重绘的操作有哪些:

  • 改变元素的外观,可见性(visibility)
  • 回流导致重绘

利用 chrome 中的 Paint flashing 工具,可以观察元素的重绘。
开发者工具 -> more tools -> Rendering,勾选 Paint flashing。重绘的元素会高亮显示。

回流(reflow):当页面元素的尺寸、结构发生改变时,GUI 重新计算的过程,这个过程称为回流。回流完成后,重新绘制受影响的部分到屏幕中,所以说回流必将引起重绘。

导致回流的操作有哪些:

  • 浏览器窗口发生变化
  • 元素的尺寸、位置发生变化
  • 元素内容、字体大小发生变化
  • 激活 css 伪类,例如 hover
  • 添加或者删除元素
  • 滚动

如何减少回流

抓住一个核心原则,对于 DOM 元素的操作,避免影响该元素之外的元素

  • 如果想要改变元素样式,可以通过 class 名,而不是使用 JS 操作
  • 避免使用多层内联样式
  • 首屏服务端渲染,减少页面内容计算次数
  • 避免使用 table 布局

JS 引擎线程

浏览器是不支持直接运行 JS 代码的,所以需要在浏览器中植入一个内核,来支持 JS 的解析和运行。在 chrome 中,这个内核叫 V8。

一个网页只会启动一个 JS 线程来处理 JS 脚本。

JS 线程是单线程。这点不难理解,如果是多线程,一个线程删除 DOM,一个线程新增 DOM,浏览器要如何处理呢?

还有一点,JS 线程和 GUI 线程是互斥的。所以在渲染路径阶段,存在阻塞问题。

定时触发器线程

专门负责 setTimeout/setInterval 的逻辑。应该结合事件循环中的队列来理解定时器线程的执行过程。

事件触发线程

当我们鼠标点击与滑动、键盘的输入等都会触发一些事件,而这些事件的触发逻辑的处理,就是依靠事件触发线程来帮助浏览器完成。

该线程也会把事件的逻辑放入队列中,等待 JS 引擎的处理。在事件循环中,事件触发为宏任务。

http 线程

使用无状态短链接的 http 请求,在应用层基于 http 协议的基础之上,达到与服务端进行通信的目的。

该线程的触发罗家,不是在 JS 引擎线程之中,过程是异步的。

参考:
https://juejin.cn/post/6844903667733118983
https://juejin.cn/post/6844903667733118983

模块化的理解

pic.1708326768564

什么是模块化

模块化,就是把一个大的问题,化解为小的问题在这种思维下提炼出来的工程化解决方案。

核心思想

核心思想就是隔离。要有自己的内部属性,内部方法,以及自己来决定哪些属性与方法能够被其他模块访问。

模块的设计目的是什么?

有了模块,我们可以更方便的使用别人的代码,想要什么功能,就加载什么模块。

什么是 js 模块?

在 es6 之前,JavaScript 没有官方规范,主要采用 CommonJS 和 AMD 这两种规范。

无模块化

函数 -> 命名空间 -> 立即执行函数

CommonJS 规范

nodeJS 采用的就是 CommonJS 规范,运行在 nodeJS 中的规范。

CommonJS 的加载方式称为运行时加载,以下代码的实质是加载整个fs对象,再从fs对象上读取这三个方法。

1
2
3
4
5
6
7
8
// CommonJS模块
let { stat, exists, readfile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

require()

在 node 源码中有一个 module.js 文件,这个文件实现了 node 的整个模块加载系统。

1
2
3
4
5
6
7
// module.js

function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
//...

我们在 nodeJS 中使用的 require()方法调用的就是 module.require 方法。具体可参考 Node.js 模块加载机制 Require()

注意:node 中使用的**_require()_**和 RequireJS 是两个东西。

使用

主要有 module、exports、require。

导出:module

module.exports 由 Module 对象创建。如果我们想导出一个对象

1
2
3
4
5
// a-common.js(导出)
module.exports = { foo: 'bar' };

或者;
module.exports.foo = 'bar';

exports 是 module.exports 的一个引用,所以我们可以使用

1
2
// ✅
exports.foo = 'bar';

错误使用,这相当于重新定义了 exports

1
2
// ❎
exports = {};

导入:require

1
2
3
4
var o = require('./a-common.js');

使用;
console.log(o.foo);

优缺点

优点:
解决了依赖、全局变量污染的问题

缺点:
因为 nodeJS 是运行在服务端,服务端所有的模块是放在本地的,模块的加载速度就是硬盘的读取速度,是同步的。但是浏览器的模块是放在服务器端,如果一个模块过大,浏览器会处于一种假死状态。

因此,浏览器端的模块不能采用”同步加载”,只能采用”异步加载”。所以 AMD 规范诞生了。

AMD 规范

AMD 是”Asynchronous Module Definition”的缩写,意思是”异步模块定义“。模块的加载不影响后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等模块加载完毕,回调函数才会执行。
define 和 require 就是 require.js 在全局注入的函数。

AMD 规范主要由 require.jscurl.js 这两个 JavaScript 库实现。
在网页中嵌入 require.js,就可以进行模块化编程了。

1
<script data-main="scripts/main" src="scripts/require.js"></script>

定义模块

使用 define 方法

1
2
3
4
5
define('A', ['require', 'exports'], function (require, exports) {
exports.fun = function () {
return require('B').fun;
};
});

导入模块

AMD 也是采用 require()语句加载模块,require([module], callback)

1
2
3
require(['math'], function (math) {
math.add(2, 3);
});

注意,不要与 NodeJS 中的 require()混淆。CommonJS 中的 require 是 NodeJS 源代码 module.js 文件中定义的方法,AMD 中的 require()是由 RequireJs 实现的。

CMD

CMD,全称 Common Module Definition,它整合了 CommonJS 和 AMD 规范的特点。
主要区别为一下两点:

  1. CMD 可以同步加载模块,也可以异步加载,而 AMD 需要异步加载模块
  2. CMD 遵循依赖就近原则,而 AMD 遵循依赖前置原则。也就是说 CMD 在使用模块前,引入模块即可,而 AMD 需要把所有的依赖提前放在依赖数组中。

UMD

umd,全称 Universal Module Definition,它允许在环境中同时使用 AMD 规范和 CommonJS 规范。

ES Module

module 可完全取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的解决方案。
ES Module 的设计思想是静态化,也就是说在编译时就确定了模块的依赖关系,而 CommonJS 和 AMD 是在运行时确定,这是 EM 模块和其他模块最显著的差别;第二个差别是 ES6 模块输出的是值的引用,而 CommonJS 输出的是值的拷贝。

ES6 模块的加载方式称为“编译时加载”,在编译时就完成了加载,效率要比 CommonJS 和 AMD 的“运行时加载”效率高。

1
2
// ES6模块
import { stat, exists, readFile } from 'fs';

以上代码只会从fs中加载这 3 个方法,其他方法不会加载。

引入模块

1
2
3
// test.js

console.log('my name is test.js');
1
2
3
4
// index.js
import test from './test';

console.log('test: ', test);
  • import 表示引入一个模块
  • test 可理解为要引入模块的名字
  • from 表示从哪里引入
  • ‘./test’ 为 ‘./test.js’ 的简写,表示将要引入的模块的路径

引入这个模块的同时,'./test'里的代码也执行了。因为 test.js 没有对外暴露接口,所以 test 为空对象。
pic.1708326785878

对外提供接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('my name is test.js');

const name = 1;
const obj = { a: 1 };
const arr = [1, 2, 3];
const fn = () => {
console.log('fn');
};

export default {
name,
obj,
arr,
fn,
};

使用 export default 对外暴露一个对象。当使用import test from './test'时,test 就是这个暴露的对象
pic.1708326805238

我们还可以仅通过 export 暴露几个方法和属性,在 test 中添加以下代码,看看 index.js 中的 test 会发生什么变化

1
2
3
4
5
6
// test.js

export const age = 20;
export const bar = () => {
console.log('bar');
};

结果发现 test 并没有什么变化,因为它仅仅等于export defaule所暴露的对象

如果想要获取 test.js 对外暴露的所有接口,可通过如下模式获取

1
import * as test from './test';

*号表示所有,是比较常用的通配符,as 表示别名。* as test 表示将 test.js 对外暴露的所有接口组成的对象,命名为 test。
pic.1708326814698

1
2
3
4
5
6
7
import test, { age, bar } from './test';

// 等于以下写法

import test from './test';
import * as allTest from './test';
const { age, bar } = allTest;

这种写法非常常见,test 仍表示为export default 所暴露的对象,{ age, bar } 表示用解构的语法从返回的整个对象中取对应的接口。结果显而易见
pic.1708326822951

在 react,这种使用方法很常见,我们能根据导入语法,就知道 react 内部是如何封装接口的

1
2
3
4
5
6
import { lazy } from 'react'

// 对应的导出
expoet default {
lazy: () => {}
}

还有另一种写法:

1
2
3
4
5
6
export { default as RightMain } from './right-main';

// import RightMain from './right-main';
// export {
// RightMain
// }

参考:
https://developer.aliyun.com/article/113478

浏览器的进程与线程

什么是进程

操作系统上有很多 app,app 就是应用程序,应用程序就是一个软件包,一个代码集合。而应用程序是静态的,想要运行应用程序,操作系统就会对应的启动一个进程,负责该程序的运行。

把浏览器看做一个大集团,那进程就是集团下的某一个工厂。

总结进程有以下特点:

  • 动态性:进程的实质是程序的一次执行过程
  • 并发性:集团可以有多个工厂,浏览器也可以同时运行多个进程
  • 独立性:工厂是独立的,进程是一个能独立运行的基本单位
  • 异步性:因为是独立的,因此可以按照自己逻辑不可预知的运行
  • 协同性:应用程序之间能够协作完成一些任务

浏览器进程

与操作系统一样,浏览器也有一个任务管理器,每一个任务,都是独立的进程。
pic.1708326642981

浏览器主要包括如下进程:

一、浏览器主进程

浏览器只会创建一个主进程,它主要负责:

  • 浏览器界面提供的交互,如前进、后退、标签栏、设置等
  • 各页面进程的管理,如创建、关闭等
  • 网络资源的管理,如下载内容、缓存等

二、网页进程

网页进程:浏览器网页的渲染和 JS 执行在一个单独的进程中执行。这个进程也称为render 进程。每启动一个页面,都会启动一个 render 进程。

说到网页进程,又引入了线程的概念。

如果说进程是一个工厂,那线程就是工厂中的流水线。一个工厂的正常运行,往往需要多个流水线通力合作才能完成。在网页进程中也是一样,想要网页进程能正常渲染运行,也需要多个线程参与合作。

网页的渲染运行,有如下线程参

GUI 渲染线程

GUI(Graphical User Interface)表示图形用户界面的意思,render tree 的渲染。用一张图看一下渲染路径
pic.1708326653199
HTML 会解析出 DOM(Document Object Model)树,样式文件会解析出 CSSOM(CSS Object Model)树,在 Attachment 环节,GUI 线程将 DOM 树与 CSSOM 树合并在一起,生成渲染树(render tree),并将渲染树绘制到页面。

JS 引擎线程

浏览器是不支持直接运行 JS 代码的,所以需要在浏览器中植入一个内核,来支持 JS 的解析和运行。在 chrome 中,这个内核叫 V8。

一个网页只会启动一个 JS 线程来处理 JS 脚本。

JS 线程是单线程。这点不难理解,如果是多线程,一个线程删除 DOM,一个线程新增 DOM,浏览器要如何处理呢?

还有一点,JS 线程和 GUI 线程是互斥的。所以在渲染路径阶段,存在阻塞问题。

定时触发器线程

专门负责 setTimeout/setInterval 的逻辑。应该结合事件循环中的队列来理解定时器线程的执行过程。

事件触发线程

当我们鼠标点击与滑动、键盘的输入等都会触发一些事件,而这些事件的触发逻辑的处理,就是依靠事件触发线程来帮助浏览器完成。

该线程也会把事件的逻辑放入队列中,等待 JS 引擎的处理。在事件循环中,事件触发为宏任务。

http 线程

使用无状态短链接的 http 请求,在应用层基于 http 协议的基础之上,达到与服务端进行通信的目的。

该线程的触发罗家,不是在 JS 引擎线程之中,过程是异步的。

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×