变量提升和块级作用域

开始接触JS的时候,想必大家都听过“变量提升”这个词。由于它的存在,一些代码的执行结果可能会出乎我们的意料,其实,这可以算是JS的一个设计缺陷吧。ES6引入了块级作用域和let、const关键字的概念,通过它们能够较好的规避变量提升。但是作为一门语言来说,向下兼容是必要的,所以变量提升的机制我们还是有必要去理解。今天我们尽量去深入底层来分析变量提升的存在原因,以及ES6又是如何去填补这块缺陷的。
首先我们需要了解作用域的概念,对于它的定义不同的参考资料都各有各的说法。一个我认为比较好理解的是:作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。它是变量和函数的可访问范围,控制着变量和函数的可见性和生命周期。
那么在ES6之前我们知道,只有全局作用域和函数作用域。全局作用域顾名思义就是在代码的任何地方都能访问到里边的对象,而函数作用域呢,就是指变量或者函数是在函数内部定义的,它只能在函数内部被访问。函数执行完后内部定义的变量会被销毁。
我们早期学过的C语言、C佳佳、Java等都是有块级作用域这么一个概念的,它是一对大括号包裹的一段代码,比如函数、if语句、for循环语句等。给一些简单等代码吧(全宇宙语言通用)

1
2
3
if (1) { };
while (1) { };
for (let i = 9; i++; i< 10) { };

代码块内定义的变量在代码块外部是无法被访问到的,而且上面有提到,代码块中的代码执行完毕后其定义的变量会被销毁。让我们梦回大一,看一段C语言的代码。

1
2
3
4
5
6
7
8
9
10
11
12
char* name = "able";
void printName() {
printf("%s \n", name);
if (0) {
char* name = "moriarty";
}
}

int main() {
printName();
return 0;
}

由于C语言有块级作用域,obviously上边的代码打印结果为全局变量的值able。但是如果这段代码是用JS写的呢,结果还会是一样么,我们接着往下看。
在ES6之前,Javascript是不支持块级作用域的。可能这门语言设计的初衷就是简约(而不简单),没有考虑块级作用域,所以作用域内部的变量都被统一提升了。导致函数中的变量无论在何处声明,编译时都会被进入到执行上下文的变量环境中,因此在整个函数体内所有地方都能被访问到。
我们来用JS来实现上面的代码。

1
2
3
4
5
6
7
8
9
var name = "able";
printName = () => {
console.log(name);
if (0) {
var name = "moriarty";
}
console.log(name);
}
printName();

输出结果是什么呢,没想到吧,既不是”able”也不是”moriarty”,是undefined,这是为什么呢?根据之前文章所学,首先创建执行上下文和调用栈,其中printName函数执行上下文的变量环境中name为undefined。执行上下文创建好后,JS引擎执行函数内部代码。函数执行时JS会优先从当前的执行上下文中查找变量,首先执行console打印,那么它会使用函数上下文中的变量值undefined。相信很多人跟为一样,刚开始接触JS的时候会觉得这种结果有些奇怪吧。
还有一种情况就是本应被销毁的变量却没有销毁。

1
2
3
4
5
function S () {
for (var i = 0; i < 6; i++) {
}
console.log(i);
}

为了解决变量提升,ES6引入了let和const关键字,使JS也拥有了块级作用域。let和const的用法大家都很清楚了,我们来看看ES6是如果解决变量提升的吧。

1
2
3
4
5
6
7
8
9
function test() {
let a = 1;
if (1) {
let a = 2;
console.log(a); // 2
}
console.log(a); // 1
}
test();

可以看到输出结果就很符合我们的预期了,这是因为let关键字支持块级作用域,在作用域内声明的变量不会影响外面的变量。JS引擎通过变量环境来实现函数作用域,那么大家肯定会想,ES6即支持变量提升,又如何在函数作用域的基础上来支持块级作用域,它是如何做到的呢?接下来我们结合前面的博客执行上下文相关的知识来一起分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function setBlock () {
var m = 0;
let n = 1;
{
let n = 2;
var o = 3;
let p = 4;
console.log(m);
console.log(n);
}
console.log(n);
console.log(o);
console.log(p);
}
setBlock();

执行上面的代码时,首先编译并创建执行上下文,函数内部var声明的变量在编译阶段存放到了变量环境中,let声明的变量,存放到了词法环境中,但是如果是在函数的块级作用域内部let声明的变量并没有存放到词法环境中。
setBlock函数执行上下文.png
接下来执行代码,此时词法环境中n的值被设置成1。而当进入块级作用域时,作用域块中通过let声明当变量会被存放在词法环境一个单独的区域中,与作用域块外面的变量互不影响,也产生了一个小型栈结构。
setBlock函数执行.png
栈的底部是函数最外层的变量,进入块级作用域后块内let和const声明的变量会进栈,作用域执行完后该作用域的信息从顶部弹出。而console.log(m)时需要在词法环境和变量环境中来找变量a的值了。首先在词法环境的栈中从顶部向底部查找,如果在某个作用域块中找到了则直接返回,没有找到则继续在变量环境中查找。
变量查找过程.png
通过以上我们可以了解到,块级作用域是通过词法环境的小型栈结构来实现的,而JS变量提升的特性是通过变量环境来实现的。想必通过今天的分析,大家对变量提升和块级作用域有了比较清晰的了解啦。

JavaScript调用栈

在我们的日常开发中,相信大家应该会遇到过下边这种报错。从字面意思我们能理解为出现了栈溢出,栈占用的空间比分配给它的空间还大,常见的原因是两个函数之间的互相调用,还有就是没有终止条件的递归。栈大家都知道是一种数据结构,那么在这里的栈又是什么呢。那么我们今天来好好了解一下调用栈,概括来说它是用来管理函数调用关系的数据结构。
首先我们来说说函数调用,它是指我们声明一个函数并使它运行,具体的写法也就是一个函数名称后边加上一对圆括号。举个板栗

1
2
3
4
5
6
var name = 'able';
function sayHello() {
var age = 18;
return name+age;
}
sayHello();

在执行函数之前,我们用上一次的学习可以得知,这段代码会生成对应的全局执行上下文,其中的变量环境里包含了声明的函数sayHello和全局变量name、age。接下来开始执行全局代码,这里的sayHello便是一个函数调用,具体会有以下几个步骤。第一步是从全局执行的上下文中拿到声明的函数代码,然后是对这段函数对代码进行编译,创建函数对应的执行上下文和可执行代码,最后执行代码并输出结果。这里我们可以得知,在执行JS代码的时候可能会有多个执行上下文的存在,比如全局执行上下文和函数执行上下文,那么这里我们就能用栈这种数据结构,来管理这些执行上下文。
说到栈我们能想到最大的特点就是后进先出了,那么在执行上下文创建好以后,它们便会被压入到栈中,我们称之为调用栈,也叫执行上下文栈。为了更好理解它,我们来写一段比开头那段复杂些的代码。

1
2
3
4
5
6
7
8
9
10
11
var a = 6;
function by(b, c) {
return b+c;
}
function byTotal(b, c) {
var d = 5;
result = by(b, c);
return a + result + d;
}
byTotal(1, 2);
}

he上边的情况是在一个函数中调用了另一个函数,我们来详细的分析一下过程。
首先创建全局上下文并入栈,当前生成的全局上下文的变量环境对象中有变量a、函数by和byTotal。当它入栈后,全局代码被执行,首先6被赋给a,此时调用栈中的情况如下图所示。

然后是调用byTotal函数,调用时我们首先需要对其进行编译,也是为其创建一个函数执行上下文,并使其进入栈中,如下图所示。

执行上下文创建好后我们开始执行函数。首先对d进行赋值5,然后我们会执行到by函数,也会创建一个by函数的执行上下文,并使其入账,如下图所示。

那么什么时候开始出栈呢,便是by函数的执行结果返回后,它对应的函数执行上下文便会从栈顶弹出啦,此时byAll中的result得到了返回值3,如下图所示。

然后在byAll执行完最后一行并返回值后,它对应的函数执行上下文也会出栈,此时的调用栈中重新回到了最初的亚子,只剩下一个孤零零的全局上下文了。
通过上边的步骤,我们可以了解到调用栈的作用了,它可以很直观的给我们展示某个函数在被执行,特别是函数调用较多时,能让我们很清楚各个函数之间的调用关系。
那么讲完了原理,它在实践层面上的应用我们一起来看看。在大家用chrome调试的时候,source中debug的位置有一个Call Stack不知道大家还有没有印象呢。我们在source中新建一个代码块(new snippet),把上边那段demo放入js文件中,打上断点来执行一下这段代码。
chrome调试调用栈.png
我们在by函数中打上断点,右键点击run,可以看到图中的代码执行到断点处暂停了,右侧有Call Stack这一栏可以看到当前调用栈的情况,就跟我上图中画的一致,从底部到顶部依次是anonymous,也就是全局的函数入口,往上是byAll函数,最后顶部是by函数。我们能很直观明了的得知函数之间的调用关系。可能demo比较简单啊,但是在分析较为长的复杂的代码时,它是比较有效的,包括在定位问题时也是一样。如果不习惯打断点的童鞋,我们还有一种方式是使用一个方法——console.trace(),它的效果也是一样的,我们可以在控制台看到其结果。
console-trace.png

JavaScript代码的执行顺序

今天我们来研究一下Javascript的执行顺序,了解它是怎么如何运行的。
首先我们来看一段代码。

1
2
3
4
5
6
showName();
console.log('name', name);
var name = 'able';
function showName() {
console.log('执行函数showName');
}

Javascript是按顺序执行的,那么看上去在执行到第一行的时候函数showName并没有定义,那么它是不是会报错呢。想必大家应该都知道,它的结果其实是执行函数showName;name undefined;
那么通过上边的执行结果,我们可以认识到,函数或者变量可以在它被定义之前使用。对于变量而言,如果它在执行过程中未声明,那么js执行会报错。如果在一个变量定义之前使用它,不会报错,但是该变量的值是undefined。如果在一个函数定义之前执行它,不会报错,而且函数能正常执行。
那可能就会产生一些疑问了,为啥子变量和函数能在定义之前使用呢,js不是想象中一行一行执行的么。都是提前使用,为何函数能正常执行,变量值却是undefined。我们来一步一步分析。
首先我们了解一下js中的声明和赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//变量
var name = 'able';
//这段代码可以分为两部分
var name //声明部分
name = 'able'; //执行部分

//函数
function showName() {
console.log('hahhahaa');
}//完整的函数声明,不涉及到赋值

var showName = function() {
console.log('wowowow');
}
var showName //声明部分
showName = function() {
console.log('wowowow');
} //赋值部分

了解声明和赋值之后,,我们先来聊聊变量提升。
变量提升呢,它是指在Javascript代码的执行过程中,JS引擎把变量的声明部分和函数的声明部分提升到代码开头的‘行为’。变量被提升后,会给变量设置默认值‘undefined’。下面我们用代码来模拟一下变量提升。

1
2
3
4
5
6
7
8
9
10
//变量提升部分
var name = undefined;
function showName() {
console.log('showName被调用‘);
}

//可执行代码部分
showName();
console.log('name', name);
name = 'able';

可以发现,它的执行结果和我们最开始的代码是一毛一样的。那么我们也就理解了为啥子可以在定义之前使用函数或者变量的原因了——函数和变量在执行之前都提升到了代码开头。
从字面上来看,变量提升意味着变量和函数的声明会移动到代码的开头,就像我们上边模拟的那样,但事实上,这可能不太准确,变量和函数声明在代码中的位置是不会被改变的,而是在编译阶段会被JS引擎放入到内存中。这里又涉及到JS的执行流程了,一段JS代码在执行之前需要被引擎编译,编译完成后才会进入执行阶段。
一段代码在经过编译后,会生成两部分内容:执行上下文和可执行代码。所谓执行上下文呢,它是指JS执行一段代码时的运行环境。比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的this、变量、对象以及函数等。在执行上下文中存在一个变量环境的对象,它里边保存了变量提升的内容,最开始我们研究的那段代码中,变量name和函数showName,都保存在该对象中。我们结合代码来分析一下如何生成变量环境对象。

1
2
3
4
5
6
showName();
console.log('name', name);
var name = 'ableF';
function showName() {
console.log('安排name‘);
}

第一行第二行不是声明操作,所以引擎不会做任何处理。第三行有var声明,因此引擎将在环境对象中创建一个名为name的属性,并使用undefined对其初始化。第四行,引擎发现了一个通过function定义的函数,它将函数存储到堆中,并在环境变量中创建了一个showName的属性,然后将该属性值指向堆中函数的位置,这样就生成了变量环境对象。
我们可以用一张图来形象滴展示Js的执行流程。
Js执行机制.jpg
那么在有了执行上下文和可执行代码后,我们就可以进入执行阶段了。当执行showName函数时,JS引擎便在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以JS引擎便开始执行该函数,并且输出‘执行函数showName‘的结果;接下来打印name信息,JS引擎在变量环境对象中查找该对象,由于变量环境中存在name变量且值为undefined,因此输出undefined。接下来把‘able’赋值给name变量,赋值后变量环境中的name属性值改为‘able’。
那么如果在代码中出现相同的变量或者函数会发生什么呢,我们来看下面一段代码。

1
2
3
4
5
6
7
8
function geName() {
console.log('able');
}
getName();
function getName() {
console.log('moriaty');
}
getName();

我们来分析一下它的完整执行流程。首先是编译阶段,遇到了第一个getName函数,会把该函数放到变量环境中。接下来是第二个getName函数,继续存放到变量环境中,但是变量环境中已经有一个getName了,此时第二个会将第一个覆盖。那么可想而知,在执行阶段的时候,两个getName函数的执行都调用的是第二个函数。
总结
JavaScript的执行机制,先编译,再执行。在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为undefined;在代码执行阶段,JavaScript引擎会从变量环境中去查找自定义的变量和函数。并且如果在编译阶段如果存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,因为它的逻辑是后定义的会覆盖先定义的。

手写Promise

说到异步编程,大家应该会想到Promise。想必对它对典型的认知应该就是——解决了回调地狱的问题,通过链式调用(不停的.then .then)的方式来避免大量嵌套。对于我个人而言,在使用Promise的过程中还是难免出现一些问题,感觉仍停留在稀里糊涂用的这么一个阶段。今天我想通过实现一个Promise来彻底搞懂其原理。
Promise我们可以称之为一个容器,它里边保存了一个异步操作的最终结果。这里有一个Promises/A+规范,我们可以按照其中的描述来一步一步的实现一个符合要求的Promise。
Promise有三个状态,等待、成功和失败,默认是等待状态。并且一旦成功或者失败了状态就不能改变了。
resolve接收一个函数作为参数,我们称之为excutor,它是一个执行器,会立即执行。它有两个参数resolve和reject,分别代表成功和失败。resolve代表的是成功,它会接收一个参数value,状态改变为fulfilled。reject代表的是失败,接收参数reason,状态改变为rejected。这里要注意的是,状态变为成功或者失败后不能再次被更改,如果new Promise的时候throw new Error报错也会变成失败态。
每个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
const PENDING = 'PENDING';
const RESOLVED = 'RESOLVED';
const REJECTED = 'REJECTED';

class Promise{
constructor(executor){
this.status = PENDING;//默认是PENDING状态
this.value = undefined;//成功
this.error = undefined;//失败
let resolve = value => {
if (this.status === PENDING)//状态为PENDING时才能改变 {
this.status = RESOLVED;
this.value = value;
}
};
let reject = error => {
if (this.status === PENDING) {
this.status = REJECTED;
// 储存失败信息
this.error = error;
}
};
try{
executor(resolve, reject);
} catch (e) {
reject(e);//执行器执行时内部可能报错
}
}
}

每个promise实例都有一个then方法,可以传入两个参数,onFulfilled和onRejected。如果当前状态成功我们调用onfFlfilled,失败调用onRejected.

1
2
3
4
5
6
7
8
9
10
then(onFulfilled,onRejected) {
// 状态为fulfilled,执行onFulfilled,传入成功的值
if (this.status === RESOLVED) {
onFulfilled(this.value);
};
// 状态为rejected,执行onRejected,传入失败的原因
if (this.state === REJECTED) {
onRejected(this.error);
};
}

当resolve在setTomeout里运行的时候,上边的写法就不能满足要求啦。我们可以采取发布订阅的思想,现将要执行的方法存到数组里,当状态改变后再执行对应的方法。

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
let resolve = value => {
if (this.state === PENDING) {
this.state = RESOLVED;
this.value = value;
this.onResolvedCallbacks.forEach(fn=>fn());
}
};
let reject = error => {
if (this.status === PENDING) {
this.status = REJECTED;
this.error = reason;
this.onRejectedCallbacks.forEach(fn=>fn());
}
};

then(onFulfilled, onRejected) {
if (this.status === PENDING) {
this.onResolvedCallbacks.push(()=>{
onFulfilled(this.value);
})
this.onRejectedCallbacks.push(()=>{
onRejected(this.error);
})
}
}

我们需要判断then中传递函数的返回结果。当返回结果是promise时,则采用其状态。如果不是promise。将结果传递下去即可。Promise通过链式调用来解决回调地狱,也就是在第一个then里返回里一个Promise。我们可以在then里面返回一个新的promise,称为promise2。

1
2
3
4
5
6
let promise2 = new Promise((resolve, reject)=>{
if (this.status === RESOLVED) {
let x = onFulfilled(this.value);
// 当x为普通值时,通过resolve保存值。如果是promise则要调用then。我们可以通过一个公共方法来解析x的值和promise2的关系。这里要注意promise必须声明完后才能传入resolvePromise方法,我们可以用setTimeout宏任务做延迟,可以保证得到的是声明后的promise2
resolvePromise(promise2, x, resolve, reject);
};

接下来我们来实现resolvePromise这个方法。

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
function resolvePromise(promise2, x, resolve, reject){
//首先它的核心功能是判断x的值是不是resolve。而且它的写法要兼容所有的promise情况
// 如果promise2和x链接的是同一个对象,则报错。比如let promise2 = p.then(() => { return promise2 })
if(x === promise2){
// reject报错
return reject(new TypeError('循环引用啦'));
}
// 防止多次调用
let called;
if (x != null && (typeof x === 'object' || typeof x === 'function')) {
try {
// A+规定,声明then = x的then方法
let then = x.then;
// 如果then是函数, 可以默认认为是promise了
if (typeof then === 'function') {
//y可能还是一个promise,直到解析出的结果是一个普通值
then.call(x, y => {
if (called) return;
called = true;
resolvePromise(promise2, y, resolve, reject);
}, err => {
if (called) return;
called = true;
reject(err);// 采用失败结果向下传递
})
} else {
resolve(x); // 说明x是普通对象,直接成功即可
}
} catch (e) {
//防止多次调用成功和失败
if (called) return;
called = true;
reject(e);
}
} else {
resolve(x);
}
}

onFulfilled和onRejected均为可选参数。
onFulfilled返回一个普通的值,成功时直接等于 data => data
onRejected返回一个普通的值,失败时如果直接等于 data => data,则会跑到下一个then中的onFulfilled中,所以直接扔出一个错误

1
2
3
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : data => data;
// onRejected如果不是函数,就忽略onRejected,直接扔出错误
onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err };

实现了Promise,那么它的all方法和race方法相对来说就比较好实现了。
Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。
只有传入所有实例的状态都变成fulfilled,新的promises的状态才会变成fulfilled,此时每个实例的返回值组成一个数组,传递给promises的回调函数。
只要之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给整个promises的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Promise.all = function (promises) {
//promises是一个promise的数组
return new Promise(function (resolve, reject) {
let arr = []; //arr是最终返回值的结果
let successCount = 0; // 表示成功了多少次
function processData(index, data) {
arr[index] = data;
successCount++;
if (successCount === promises.length) {
resolve(arr);
}
}
for (let i = 0; i < promises.length; i++) {
promises[i].then(function (data) {
processData(i, data)
}, reject)
}
})
}

Promise.race也是将多个Promise实例包装成一个新的实例,只要有其中一个实例改变状态那么整个实例状态改变并停止执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Promise.race = function(promises) {
return new Promise((resolve, reject) => {
let len = promises.length;
if(len === 0) return;
for(let i = 0; i < len; i++) {
Promise.resolve(promise[i]).then(data => {
resolve(data);
return;
}).catch(err => {
reject(err);
return;
})
}
})
}

初探hooks实现与原理

在使用hooks的过程中,大家可能会有一些疑惑。比如为什么useState只能在函数最外层调用,useEffect第二个参数的作用等。今天,我们来实现几个简单的hooks,并从中了解一些其原理。
1.useState
我们首先来实现useState。函数式组件没有实例,每次渲染都会重新执行useState函数。
我们声明一个lastState来保存上一次的状态。第一次渲染的时候执行useState,此时lastState没有值,将useState的参数作为初始值。在useState中定义一个setState的方法,来改变lastState的值。另外每次setState后要重新render。最终useState返回一个数组,里面包含上一个状态和改变状态的方法。以下为useState的简单实现。

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
let lastState;
function useState(beginState) {
lastState = lastState || beginState;
function setState(newState) {
lastState = newState;
render();
}
return [lastState, setState];
}
function Counter() {
let [state, setState] = useState(0);
return (
<div>
<span>{state}</span>
<button onClick={() => setState(state + 1)}>+</button>
</div>
)
}
function render() {
ReactDOM.render(
<Counter />,
document.getElementById('root')
);
}
render();

如果有多个state的时候,那么上面的写法就不适用了。lastState应该声明为一个数组,并且需要相对应有一个索引。要注意的是每次执行useState后需要让index加一,并且每次render后索引需要重置为0。

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
let lastState = [];
let index = 0;
function useState(beginState) {
lastState[index] = lastState[index]|| beginState;
const currentIndex = index;
function setState(newState) {
lastState[currentIndex] = newState;
render();
}
return [lastState[index++], setState];
}
function App() {
let [state, setState] = useState(0);
return (
<div>
<span>{state}</span>
<button onClick={() => setState(state + 1)}>+</button>
</div>
)
}
function render() {
index = 0;
ReactDOM.render(
<App />,
document.getElementById('root')
);
}
render();

可以看出,每次渲染时state跟index是一一对应的,所以这也是不能把useState放到条件语句中的原因。所以在使用 Hook 的时候,我们应该在函数组件最外层使用。

2.useReducer
reducer接收两个参数,原有状态和动作,通过派发动作来改变状态,最后返回一个新状态。useReducer的实现和useState相似。

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
function reducer(state, action) {
if (action.type === 'add') {
return state + 1;
} else {
return state;
}
}
let lastState;
function useReducer(reducer, beginState) {
lastState = lastState || beginState;
function dispatch(action) {
lastState = reducer(lastState, action);
render();
}
return [lastState, dispatch];
}
function Counter() {
let [state, dispatch] = useReducer(reducer, 0);
return (
<div>
<span>{state}</span>
<button onClick={() => dispatch({ type: 'add' })}>+</button>
</div>
)
}

3、useEffect
effect在这里是副作用的意思,我们可以在这个hooks中执行一些有副作用的行为,比如操作dom,通过ajax发送网络请求等。它里边的函数会在组件每次render后执行。而第二个参数我们称之为依赖项,如果其中的元素在每次渲染时和前一次相比没有发生变化,就不会触发这个副作用。下边的实现没有包括销毁副作用的功能。

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
let lastDependencies;
function useEffect(callback, dependencies) {
if (lastDependencies) {
//看看新的依赖数组是不是每一项都跟老的依赖数组中的每一项都相同
let changed = !dependencies.every((item, index) => {
return item == lastDependencies[index];
});
if (changed) {
callback();
lastDependencies = dependencies;
}
} else {//没有渲染过
callback();
lastDependencies = dependencies;
}
}
function App() {
let [number, setNumber] = useState(0);
useEffect(() => {
console.log('看看数字是否变化了呢', number);
}, [number]);
return (
<div>
<span>{number}</span>
<button onClick={() => setNumber(number + 1)}>+</button>
</div>
)
}
function render() {
ReactDOM.render(
<Counter />,
document.getElementById('root')
);
}
render();

当依赖项为空的时候,我们会发现它只会执行一次,此时我们可以用useEffect来模拟componentDidMount生命周期。依赖项的比较是用的‘==’而不是‘===’,因此我们可以得知依赖项的对比是浅比较。
我们对于effect的实现比较简单,还有很多细节没有体现出来。在上面的代码中,每次执行useEffect都会打印最新的number值,那么它是如何读取到最新的state的呢。并不是number的值在“不变”的effect中发生了改变,而是每一次渲染中的effect的count值都来自于它属于的那次渲染。因为effect是在渲染完成后执行的,我们可以把它当作渲染结果的一部分。

4、useCallback和useMemo
在之前的文章中我们有了解过这两个性能优化的hooks。它们的实现和前面的hooks也很相似。

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
let lastCallback;
let lastCallbackDependencies;
function useCallback(callback, dependencies) {
if (lastCallbackDependencies) {
let changed = !dependencies.every((item, index) => {
return item == lastCallbackDependencies[index];//'=='表示浅比较
});
if (changed) {
lastCallback = callback;
lastCallbackDependencies = dependencies;
}
} else {
lastCallback = callback;
lastCallbackDependencies = dependencies;
}
return lastCallback;
}
let lastMemo;
let lastMemoDependencies;
function useMemo(callback, dependencies) {
if (lastMemoDependencies) {
let changed = !dependencies.every((item, index) => {
return item == lastMemoDependencies[index];
});
if (changed) {
lastMemo = callback();
lastMemoDependencies = dependencies;
}
} else {//没有渲染过
lastMemo = callback();
lastMemoDependencies = dependencies;
}
return lastMemo;
}

在本文中我们实现了一个最简单的hooks,主要借助的是数组这个简单的数据结构,一定成程度上了解了hooks的原理。但是在React的源码中,它是通过类似单链表的形式而不是数组。有空应该去读一读源码,才会有更深入的理解。

useCallback、useMemo的使用场景

Hooks是React 16.8版本的新增特性,它的出现让我们可以不再写class组件来维护组件的内部状态。
在Hooks我们常用的基础方法为useState和useEffect,而对于useCallback和useMemo这两个方法,大家看到它的第一眼想到的可能就是性能优化吧。那么这两个方法是不是适用于所有的场景呢,这就是我们今天想要探讨的问题。

useMemo

1
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useMemo缓存计算结果,它接收一个计算的过程(回调函数,它将返回结果)和依赖项数据,返回一个memoized值。当依赖项发生变化的时候,回调函数会重新计算。

useCallback

1
2
3
4
5
6
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);

useCallback缓存一个函数体,它接收回调参数和依赖项数组,返回一个 memoized 回调函数,只有依赖项发生变化的时候才会返回一个新的函数。

那么是不是所有场景下使用useCallback都能达到性能优化的效果呢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Example() {
const [value, setValue] = useState();
const onChange = (e) => {
setValue(e.target.value);
};

return <input value={value} onChange={onChange} />;
}

----------------------------------------------------------------------------------------------
const onChange = useCallback(e=> {
setValue(e.target.value);
}, []);
//等同于
const onChange = (e) => {
setValue(e.target.value);
};
const onChangeMemoized = useCallback(onChange, []);

我们给一个input框传入onChange方法,当我们将它加上useCallback后,我们会发现这个方式除了定义了onChange方法外,还有调用useCallback产生了额外的开销,导致适得其反。
可能会有同学有疑问,我们不是用了useCallback吗,为啥onChange还会重新定义呢。这是因为函数组件每次state一变化,就重新执行,会重复声明。useCallback会缓存之前传入的回调函数,但是一旦依赖项发生变化,将返回新的函数。
实际上,useCallback在很多时候需要和React.memo搭配使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//子组件
const BigData = ({ showNum }) => {
const [num, setNum] = useState(() => showNum());

console.log("子组件重新渲染了喔");
useEffect(() => {
setNum(showNum());
}, [showNum]);
return (
<div className='BigData'>
{'child:'+num}
<br></br>
{'假设子组件渲染大量数据...'}
</div>
);
};
export default React.memo(BigData)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//父组件
import BigData from './Child'

const App = () => {
const [value, setValue] = useState('');
const [num, setNum] = useState(1);
const showNum = useCallback(() => { return num; }, [num]);
// const showNum =() => { return num; };

return (
<div className='app'>
<div>{'parent:' + num}</div>
<div>
<button onClick={() => setNum(num * 2)}>*2</button>
</div>
<input value={value} onChange={event => setValue(event.target.value)} />
<BigData showNum={showNum} />
</div>
);
}

我们假设一个场景,子组件需要展示大量的数据,它从父组件接收一个函数。在很多时候,父组件更新的时候,我们不需要子组件的更新。可能大家会给子组件包装到React.memo中(作用可参考shouldComponentUpdate(),但仅适用于函数组件),来保证props相同的情况下不重复渲染组件。但是函数式组件更新的时候函数会重新声明,引用发生了变化。而React.memo函数只会浅比较props,因此子组件仍然会重新渲染。此时我们给要传入子组件的函数加上useCallback来保证函数引用的相等,从而达到子组件不重复渲染的效果,实现性能优化。
我们来看一下不加useCallback的时候
不使用useCallback.png
可以看到state改变父组件重新渲染的时候,子组件也重新渲染了。

如果加上useCallback
使用useCallback.png
可以看到state改变父组件重新渲染的时候,子组件没有重新渲染,达到了我们想要的效果。

那么useMemo其实也是类似的,当我们需要给子组件传入一个引用类型的对象时,父组件重新渲染会导致值的引用发生变化。如果此时我们不需要重新渲染子组件时,可以用useMemo来记住这个值。

1
2
3
4
5
6
7
const Example = () => {
const value = useMemo(() => {
compute(num)
}, [num]);
//compute方法返回值为数组
return <BigData value={value} />
}

我们假设渲染子组件的开销较大(又是一个渲染大量数据的组件2333),那么value(返回值为引用类型)的引用变化而依赖项num没有变化时,我们可能不想子组件重新渲染。因此可以用useMemo来避免Example组件的渲染导致compute方法重新计算。此时value的引用不会发生变化,子组件不会重新渲染。
我们来看一个具体的例子

1
2
3
4
5
6
7
8
9
10
11
//子组件
const ChildMemo = ({ childData, onClick}) => {
console.log('我是子组件,我渲染了he')
return(
<div>
<span >{'子组件:'+childData.name}</span>
<button onClick={() => onClick('变身2333')}>改变name</button>
</div>
);
}
export default React.memo(ChildMemo);
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
//父组件
const Example = () => {
const [num, setNum] = useState(0);
const [name, setName] = useState('子组件');

return (
<>
<span>number:{num}</span>
<button onClick={(e) => { setNum(num + 1) }}>加1</button>
<ChildMemo
// childData={
// {
// name,
// color: name.indexOf('2333') !== -1 ? 'blue' : 'purple'
// }
// }
childData={
useMemo(() => ({
name,
color: name.indexOf('2333') !== -1 ? 'blue' : 'purple'
}), [name])
}
onClick={useCallback((newName) => setName(newName), [])}
/>
</>
)
}

当我们点击按钮时,如果childData返回值不加上useMemo,由于传入的参数为引用类型,引用变化会导致子组件的重新渲染。这种场景和上一个例子相似,只不过传入的参数不是一个方法,而是一个引用类型的值了。用useMemo可以保证在依赖项不变的时候,传入子组件的是同一个引用。

关于useMemo,还有一种情况我们可以使用。当一个函数的开销很大时(有较复杂的计算过程),我们可以用useMemo来记住它的返回值,这样可以避免性能消耗较高的重复计算。

1
2
3
4
5
6
7
8
9
10
11
const Example = () => {
const result = useMemo(() => expensiveCompute(value), [value]);
function expensiveCompute(value){
//...较复杂的计算过程
}
return (
<div>
{result}
</div>
)
}

加上useMemo后,虽然组件在重新渲染的时候将会重新定义这个开销较大的函数,但是它只会在被需要的时候才会被调用。当依赖项不变时,该方法将返回之前已经计算好的值。

总结
useCallback和useMemo的使用场景可以大致有以下两种。
1、保证传入子组件的引用相等
当子组件或者需要接收父组件传来的函数、对象、数组等引用类型时,或者它们被用在其他hook中的依赖数组中,我们应该使用。
2、开销大的运算
使用useMemo避免重复计算相同的结果。

我真的了解数组嘛

在日常的业务开发中,我们通常需要对后端返回的数据解构进行处理,得到我们想要的格式,而其中比较常见的场景便是对数组的操作。最开始用的时候只是看他人写法或者去菜鸟教程大致看一下demo,对于一些方法之间的区别、是否运用得合理没有明确的概念。还有一些方法(比如reduce)是之前有同学推荐过,但是一直没去用的,这次希望也能一网打尽。因此本次博客希望对数组相关的方法进行一个比较系统的总结,希望通过这次学习我能真正地了解数组,最好也不要再用一次“gugou”一次了QAQ。

1.some 和 find

当我们需要在数组中寻找一个元素时,我们通常会想到Array.find。它需要一个回调函数,并返回符合条件的第一个元素。然而,当我们仅仅需要知道数组中是否存在符合条件的元素时,Array.some或许是一个更好的办法,因为它返回的是一个布尔值。

1
arr.find(callback, thisArg)

callback 是数组每一项都会执行的回调函数,它接受三个参数:
value 当前正在被处理的项
index 当前项的索引
array 数组本身
thisArg 可选,用来当做fn函数内的this对象
说到find,在es6中它有一个兄弟Array.findIndex。它的入参和find基本一致,只不过它返回的是第一个符合条件的项在数组中的位置,如果没有符合条件的则返回-1。

2.find 和 filter

filter的中文意思是过滤,正如其名字所言,它的功能便是通过回调函数过滤数组,并将过滤后的项作为一个新数组返回。
看到上文中我们丢下find使用some的场景,find有些不服气。find曰:看看下面这个场景——我们需要通过一个唯一的ID 为过滤条件去过滤一个数组。如果此时用Array.filter的话,它将会遍历整个数组,当数组项很多时会多次执行回调函数。而明显本场景最终将只有一个符合条件的数据,我们用find在搜寻到第一个符合条件的结果后立即返回。从性能的角度上来说,让我们恭喜find!

3.indexOf 和 includes

Array.indexOf这个方法我们都狠熟悉了,它返回第一个指定元素的下标,如果不存在则返回-1。如果我们不需要知道下标,可以使用直接返回布尔值的 Array.includes。说到返回布尔值,你们有没有想起上面提到的some呢。注意了,注意了,一定要分清楚哦。Array.includes 的第一个入参是一个值, Array.some 的第一个入参是回调函数。includes简单,而some方法可以用来处理较为复杂的数组项哦。

4.reduce来咯

我们什么情况下可以使用reduce呢,有一个比较容易记的方法。当一个场景中我们需要先用Array.filter对数组进行过滤操作,然后对其进行遍历,用map方法返回一个新数组。此时我们可以用Array.reduce方法,一样的味道,更好的配方哦。Array.reduce方法接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值。

1
arr.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])

callback 接受四个参数,分别是:accumulator,累加器累加回调的返回值; currentValue,数组中正在处理的元素;currentIndex(可选),数组中正在处理的当前元素的索引;array(可选),调用 reduce() 的数组。initialValue 为可选参数,作为第一次调用 callback 函数时的第一个参数的值。方法的返回值是函数累计处理的结果。
PS:这里通过查阅资料还得知了mdn的语法,可选参数用括号+逗号 [,表示,而必需的参数只用逗号,
光说不练假把式,让我们来进入实战!
emmm我们来模拟一个后端童鞋返回的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
[{
id: 1,
name: 'A',
number: 6
}, {
id: 2,
name: 'B',
number: 9
}, {
id: 3,
name: 'C‘,
number: 2
},...]
Round 1

先从简单的开始,我们来统计number的总和吧。

1
2
3
arr.reduce((total, { number }) => {
return total + number;
})
Round 2

加大难度,我们来将数组每项转换为字符串,项之间用空格隔开。

1
2
3
arr.reduce((str, { id, name }) => {
return str + `id:${id},type:${name}+ `;
}, '')
Round 3

boss关,我们来将数组转换成 key value 的对象形式。

1
2
3
4
5
6
arr.reduce((obj, { id, ...value }) => {
obj[id] = {
...value,
}
return obj;
}, {})

最终返回的结果为

1
2
3
4
5
{
'1': { name: 'A', number: 6 },
'2': { name: 'B', number: 9 },
'3': { name: 'C', number: 2 }
}

闯关成功,希望我们能学以致用,在今后的工作中很好滴运用这个方法😄

5.forEach 和 map

首先,我们可以明确的是,forEach()方法不会返回执行结果,而是undefined,而map()方法会得到一个新的数组并返回。forEach()允许callback更改原始数组的元素。map()返回新的数组。
项目中有很多地方直接在map方法的回调函数中修改原始数组的值,其实这样做是不建议的。这么做能行得通是因为js中的数组是引用类型,所以可以利用类似指针的特性通过改变另一个变量去修改原始的值。但是!但是!map方法体现的是数据不可变的思想。该思想认为所有的数据都是不能改变的,只能通过生成新的数据来达到修改的目的,因此直接对数组元素或对象属性进行操作的行为都是不可取的。这种思想其实有很多好处,最直接的就是避免了数据的隐式修改。我们来举个栗子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let arr = [{
a:1,
b:2,
}, {
a:3,
b:4,
}];
//让我们来修改a的值为3
arr = arr.map((item) => { // 返回一个新的数组而不应该直接修改原数组
return {
...item,
a:3
}
});

6.展望

emmm可能还有很多数组的方法没有照顾周全,如果以后还遇到一些问题和一些对数组巧妙操作的方法,我们再记录。还有性能上的问题,也值得我们去探讨。

React新版本生命周期及替换方案

React 16.3版本引入了两个新的生命周期函数,getDerivedStateFromProps,getSnapshotBeforeUpdate 。还有三个componentWillMount,componentWillReceiveProps,componentWillUpdate被标记为不安全的生命周期,将在17.0版本被移除。公司的项目逐渐要将不安全的生命周期移除,为以后版本升级React 17.0作准备。因此我们需要来对比一下新旧版本的生命周期,从而得出生命周期的替换方案。
在老版本的生命周期图谱中,被红框圈起来的三个生命周期函数就是在新版本中即将被移除的。
image.png(图中为React 老版本生命周期图谱)
下图中为React 16.4版本的生命周期图谱。我们可以看到,React的生命周期仍分为三种类型。
image.png(图中为React 16.4版本生命周期图谱)

  1. 挂载时,挂载指的是组件实例被创建并插入DOM中。创建时有以下几个阶段,第一个为constructor,它是一个组件的构造函数,一个组件在更新到界面之前需要被创造出来。用于初始化内部状态,它是唯一直接修改state 的地方。第二个方法为getDerivedStateFromProps,它用于从外部的属性来初始化内部的状态,返回的状态可以更新到当前的状态上。第三个方法是render,是用来描述UI的dom结构。创建过程完成后会调用didmount方法,这时候所有的UI都渲染完成了,我们可以安全地操作dom节点以及调用接口来获取外部的资源。这个方法在整个生命周期中只执行一次。
  2. 更新时,它由组件的props或state发生变化时触发。更新时有以下几个阶段,第一个方法为getDerivedStateFromProps,第二个方法为shouldComponentUpdate, 它可以告诉组件我们是否需要render,可以用来性能优化。因为有时候我们在props变化时,界面UI并不需要变化,在方法中返回false告知不需要更新。返回true时react才会继续触发接下来的render。这个方法一般不需要自己去实现,react提供了purecomponent帮助我们判断props、state是否在前后有变化,如果没有变化它可以自己阻止react更新。pre-commit阶段调用getSnapshotBeforeUpdate,这个也是react 16.3新引入的方法。最后会有componentDidUpdate方法,这个方法每次UI发生更新时都会调用,react组件在外部属性或者内部状态变化时都会重新渲染,它始终会整体刷新,可以通过这个方法捕获每一次更新,从而判断是否需要其他操作。举一个我们项目中的栗子,审批的详情页,id通过url参数传入,用户切换到另一个审批,这个id发生变化,在方法中可以获取新的id对应的审批内容显示到界面上。
  3. 卸载时,组件从DOM中移除,通过willUnmount进行资源释放。

新增的生命周期为 getDerivedStateFromProps和getSnapshotBeforeUpdate。

  1. getDerivedStateFromProps
    它是react 16.3新引入的api,给我们提供了一个最佳实践——如何通过属性来初始化内部状态。它的使用场景是当state需要从props初始化来使用。这个方法的名字很长,据说是开发者们不推荐大家使用,因为如果state需要从props获得,一般都可以从props计算动态得到,不需要单独存储这个状态。因为如果一旦要单独存储,这意味着我们要始终维护两者的一致性,会增加很多的复杂度,容易出现bug。它每次更新时都会调用,这个api是用来取代componentwillreceiveprops方法。它的应用场景为我们的表单控件需要获取一个默认值,因为表单除了需要用户输入值之外,开始可能会给我们一个默认值。这个默认值一旦修改之后便没有有了。因此开始的state来源于外部的初始值,但当用户输入后state便来源于用户的输入。几乎在其他场景下我们不需要使用到这个生命周期方法。

  2. getSnapshotBeforeUpdate
    页面render之前会调用,它的典型场景为获取render之前的dom状态。此生命周期的任何返回值将作为参数传递给componentDidUpdate(prevProps, prevState, snapshot)。getSnapshotBeforeUpdate 的使用场景一般是获取组件更新之前的滚动条位置。

接下来我结合项目来说明一下生命周期的替换方案。

  1. componentWillUpdate的替换
    当组件收到新的props或state时,会在render之前调用 componentWillUpdate()。
    componentWillUpdate(nextProps, nextState)
    项目中有两处用到了componentWillUpdate方法,我们来看一段。

    1
    2
    3
    4
    5
    componentWillUpdate({changed, field}, {status}){
    if(status && !this.state.status || changed != this.props.changed){
    this.setState({field});
    }
    }

    这里在 componentWillUpdate 中根据 props 的变化去setState。但是 componentWillUpdate有可能在一次更新中被调用多次,在性能方面来说,多次setState在这里是不可取的。而componentDidUpdate一次更新中只会被调用一次,因此我们可以将 componentWillUpdate 中的内容迁移至 componentDidUpdate,改写后的代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    componentDidUpdate(prevProps, prevState) {
    if (
    (!prevState.status && this.state.status) ||
    prevProps.changed !== this.props.changed
    ) {
    this.setState({ this.props.field });
    }
    }
  2. componentWillReceiveProps的替换
    componentWillReceiveProps() 会在已挂载的组件接收新的 props 之前被调用。

    1
    2
    3
    4
    5
    6
    7
    componentWillReceiveProps(nextProps) {
    const { data, list } = nextProps;
    this.setState({
    data,
    list,
    });
    }

    从项目中找出一段代码,这里其实应该比较props有变化时再去setState,我们用getDerivedStateFromProps方法来替换
    在React16.3中,我们可以这样来改写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //注意,16.4以后版本不能用这种写法, 会产生bug!!!
    static getDerivedStateFromProps(nextProps, prevState) {
    if (nextProps.data !== prevState.data || nextProps.list !== prevState.list) {
    return {
    data: nextProps.data,
    list: nextProps.list,
    };
    }
    return null;
    }

请注意!!!在 React 16.4^ 的版本中 setState 和 forceUpdate 也会触发getDerivedStateFromProps,所以内部 state 变化后,又会走 getDerivedStateFromProps 方法,并把 state 值更新为传入的 prop。而且getDerivedStateFromProps 和 componentWillReceiveProps 只要父级重新渲染时,这两个生命周期函数就会重新调用,不管 props 有没有“变化”。因此,我们要改变一下判断条件,要注意把传入的 prop 值和之前传入的 prop 进行比较。

1
2
3
4
5
6
7
8
static getDerivedStateFromProps (nextProps, prevState) {
if (nextProps.data !== prevState.prevPropData) {
return {
data: nextProps.data,
prevPropData: nextProps.data,
}
}
}

修改完了还有一点需要我们注意,由于componentWillReceiveProps在组件更新时才会调用,而getDerivedStateFromProps在初始挂载及后续更新时都会被调用,因此我们将生命周期替换后,如果didMount里有根据props初始化state的操作可以去掉,可以看到项目中有几处这样的地方。

如果在componentWillReceiveProps中有执行副作用(例如数据获取)以响应 props 中的更改,我们可以改用在componentDidUpdate中触发这些回调。

1
2
3
4
5
componentWillReceiveProps(nextProps) {
if (nextProps.data !== this.props.data) {
this.getData(data.type);
}
}

改为

1
2
3
4
5
componentDidUpdate(prevProps, prevState) {
if (prevState.prevPropData !== this.props.data) {
this.getData(data.type);
}
}

项目中也有很多地方,仅仅是在props更改时重置某些state,没有修改state的业务需求。我们可以从组件里删除state,把它变成一个受控组件。即使仍然有需求要保存临时的值,也可以由父组件像子组件传入一个方法手动执行保存这个动作。亦或者是将组件改为有key的非受控组件,因为当key变化时,React会创建一个新的而不是更新一个既有的组件。

1
<Demo key={item.id} defaultValue={item.value}>

每次id更改,都会重新创建组件,并将其状态重置为最新的value值。这个过程看起来很慢,不过这点性能是可以忽略的。而且如果在组件树的更新上有很重的逻辑,由于子组件的diff过程被省略了,这样反而会更快。

总结
在使用getDerivedStateFromProps方法时,要注意把传入的 prop 值和之前传入的 prop 进行比较,其中之前传入的props通过return存储在state中。在用getDerivedStateFromProps之前可以想一想当前业务场景是否需要用到这个方法,同时保证它是纯函数,不要产生一些副作用。

高阶组件和函数作为子组件

React组件其中一个好处便是可以实现组件的重用。一个组件一旦定义好之后可以用在不同的UI场景之下。在日常的开发过程中,我们可以发现,组件的重用,不一定能满足所有的场景。今天,我来给大家介绍组件的两种复用形式:高阶组件和函数作为子组件。
1.高阶组件

高价组件是对现有组件的封装,形成一个新的组件。新的组件会有自己的应用逻辑,这些逻辑会产生一些新的状态,状态需要传给现有的组件。高阶组件没有自己的UI展现,而只是负责为它封装的组件提供额外的功能和数据。
高阶组件.png

如上图,图中的三个圆圈代表三个组件,UI由组件树的方式来描述。当中间的蓝色组件需要一些属性时,他的父组件需要传递所有的属性给他,同样他也会把他的一些属性传给他的子组件。可以想到一个问题,如果一个组件的所有来自外部的属性都由他的父组件传递给他,那他的父组件需要知道这个组件的所有状态。如果相隔很多层节点,比如这个组件需要获取三层节点之上的节点的内部状态,则需要属性一层一层往下传,中间的两层完全不需要用到这个数据,它们接受属性的唯一目的只是为了把它往下传。我们便可以用高阶组件来解决这个问题。高阶组件可以自己去获取外部资源并对其做一定处理,然后把属性传递给封装前的组件。那么此时组件的数据便有了两个来源,一个是父组件传递给它的属性,另一个便是高阶组件传给它的一些属性。高阶组件产生一般是有个函数,这个函数接收一个组件作为参数,返回一个新的组件,我们把它叫做高阶组件。

1
const EnhancedComponent = HighOrderComponent(wrappedComponent)

我们来看一个高阶组件的应用场景。
现在有一个在界面上显示当前时间的组件,每一秒变化一次。现在我们需要重用这个组件,比如有一个聊天框,我们需要在下面显示当前的时间。我们可以看一下clock组件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
componentDidMount() {
this.timerID = setInterval(() => this.tick(), 1000);
}

componentWillUnmount() {
clearInterval(this.timerID);
}

tick() {
this.setState({
date: new Date()
});
}

它的主要逻辑就是实现一个计时器。在didmount生成一个定时器,在willunmount清除这个计时器,有一个tick方法用来更新当前时间的状态。如果我们要在一个聊天功能的页面中有一个显示时间功能,可以在这个页面加上定时器的逻辑来实现,还一种方法是把clock组件直接放入其中。如果把clock组件引入,有一个问题,clock组件决定了时间显示成啥样,但是如果我们的聊天页面需要显示另外一种时间格式,那么会有一些麻烦。比如聊天功能我们只是需要一个计时器来知道每秒的时候,如何展示是自己来决定,但是又不想重复实现这个逻辑。此时,便可以引入高阶组件的概念。用高阶组件来实现计时器的逻辑,然后把这个时间传递给聊天页面。
我们可以写一个计时器的代码,用来产生高阶组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default function withTimer(WrappedComponent) {
return class extends React.Component {
state = { time: new Date() };
componentDidMount() {
this.timerID = setInterval(() => this.tick(), 1000);
}

componentWillUnmount() {
clearInterval(this.timerID);
}

tick() {
this.setState({
time: new Date()
});
}
render() {
return <WrappedComponent time={this.state.time} {...this.props} />;
}
};
}

这个函数可以给已有的组件注入一个计时器的功能。它接收一个组件作为参数,并且返回一个组件作为返回值。可以看到它内部有正常的生命周期方法,但是它的render函数并没有自己任何新的东西,只是把传入的组件render,但是会增加一些其他的属性。比如这里增加的是time属性。
回到聊天页面,我们想要在其中加入计时器的功能,便可以引入这个withTimer,我们现在导出的是封装后的高阶组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
render() {
return (
<div>
<MessageList messages={this.state.messages} />
<div>
<input value={this.state.inputMsg} />
<button onClick={this.handleSend}>Send</button>
</div>
<h2>{this.props.time.toLocaleString()}</h2>
</div>
);
}
}

export default withTimer(ChatApp);

此时,原组件有了time的属性,我们把它在render方法中展示出来,显示一个当前时间的字符串。我们可以看到时间已经在聊天页面展示了,但是我们并不是重用了clock组件,而是写了一个高阶组件的函数,这个函数实现了一个定时器的逻辑,把时间通过props传入原组件。同样的,我们需要在另一个组件中实现不同格式的时间,也是很容易的,只需要把props传入的时间通过另一种格式展示出来就ok了。
以上便是高阶组件的一个应用场景实例,它可以帮你去实现一些通用的逻辑被不同组件使用,但是它自身并没有任何UI展现。

2.函数作为子组件
函数作为子组件是一个设计模式而不是react一个新的特性。看如下代码,定义了一个myComponent。

1
2
3
4
5
6
7
8
class myComponent extends React.Component {
render( ) {
return (
<div>
{this.props.children('able Feng')}
</div>
);
}

它是直接调用了children这个函数,说明它已经假设传入的children为一个函数。使用的时候需要在mycomponent的子元素中定义一个函数,这个函数接收一个参数返回一个节点。我们不是直接把一个节点作为children,而是把一个函数作为children。这并不是react的一个特性,而是因为mycomponent已经决定了自己是如何使用children,因此它是一个设计模式而不是一种特殊类型组件。
看如下例子,我们用函数作为子组件的方式来实现让外部告知你如何显示你选中的状态。我们选中一个颜色它直接展示颜色,选中小动物直接展示小动物的图片。
函数子组件.jpg
定义一个advancedtabselector组件,它有一个children的属性,我们定义为一个function,即可执行的。除了tab的选中逻辑,现在加入了一个额外的内容区域,this.props.children由外部决定如何render选中的值,如果有value便调用这个children的函数。

1
2
3
4
5
6
7
render(){
return(
<>
{this.props.value&&this.props.children(this.props.value)}
</>
)
}

在使用这个组件时,我们可以定义一个函数作为这个advancedtabselector组件的children。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<AdvancedTabSelector
options={colors}
value={this.state.color}
onChange={c => this.setState({ color: c })}
>
{color => (
<span
style={{
display: "inline-block",
backgroundColor: color,
width: "40px",
height: "40px"
}}
/>
)}
</AdvancedTabSelector>

它的参数是当前颜色,我们来决定它如何显示颜色,例子中用span来展示颜色。同样的,如果选中是一个animal,定义一个函数接收animal作为参数,渲染出来一个图片。可以看到函数作为子组件有一个特点,一个组件如何render它的内容可以由使用者来决定,对于这个tabselector如何render选中的状态,可以由用tabselector的人来决定,而不是在组件内部不停增加自身功能来适配外部的使用场景,这样组件内部可以只有很小的scope,它的灵活性可以由函数作为子组件来决定。

3.总结:
组件复用的两种形式——高阶组件和函数作为子组件均为使用react的组件的新的方式,称为设计模式,而不是代表react有两种新类型的组件。在工作开发中我们可以结合业务场景去使用这两种设计模式。`

Hello World

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment