Veda

A collection of my tranlation and a few of mind。

0%

半夜学习线代,心血来潮的把当初的 gh_page 捞出来看了一下,真不知道我当初为啥要用 submodule 关联。

不过仔细一想当年没 monorepo 的解决方案,貌似一下就都说的通了。

最新的 hexo-next 的主题发觉真的不喜欢。

先随便整一下,改天要不还是迁移到 jekyll 吧。

不知道为啥 apple m2 下的 ruby 的安装各种报错,不知不觉又熬夜了,昨天回家的路上貌似看到她了。

明天继续线代的学习吧,感谢晓询的“你一直写前端不焦虑吗”。

话说当年忽悠我妹去学数学真的是太好了。

  1. 你觉得有意义事情
  2. 有让你去做的更好的余地
  3. 在一个很好的老大手下做,有权利去让一些事情做得更好

现阶段我所应该去追求的。 – From MIKE

  1. 潜力,很强的学习能力
  2. 目前能力,扎实的基础和不错的知识面
  3. 特质,比如代码洁癖,像素控,产品意识等

这是假如我想进入蚂蚁的团队,我所要做到的。 – From PETER

原文地址:http://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html

(。・∀・)ノ゙嗨,大家好!鉴于我几个星期没有写些什么关于JavaScript的东西了,是时候让我们回到正轨了。

这一次,我们将会来探讨一下 errors 和 stack traces,并且熟练的掌握它们。

有些时候人们的确不太注意这些细节,但是这些细节知识在当你写一个库,并且需要测试和调错时会非常有用。举个例子,这周在 Chai 时,我们有一个很棒的pull-request,关于如何提升我们在堆栈追踪的处理能力上,从而能够使我们的用户能够在 assert 测试失败时,能够获得更多的信息。

熟练的操控堆栈追踪能偶让你清理掉一些不必要的干扰信息,从而能够关注于真正的问题上。此外,当你理解什么是错误及其属性,你会感到更有信心利用它。

这篇博文在开头可能看起来太浅显了,但是当我们开始操作堆栈追踪时,它变得相当复杂,因此在我们进入那个章节之前,请确保您对以前的内容有了很好的理解。

调用堆栈是如何工作的

在我们讨论errors之前,我们必须理解调用堆栈是如何工作的。(的确)这很单调,不过在深入之前理解这些是很有必要的。如果你已经知道了这些,请随意跳过这节。

当一个方法被调用时,它会被push到栈顶。在它执行完成后,它会从栈顶被移除。

这种数据结构有趣的地方在于 最后进来的元素会最先出去。同样这被称作 LIFO (后入先出) 原则。

给你看另一个例子,假设你有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function c() {
console.log('c');
}

function b() {
console.log('b');
c();
}

function a() {
console.log('a');
b();
}

a();

在上面的例子中,当运行方法a时,它被添加到我们栈的顶部。然后,当方法 b 在方法 a 内被调用时,它也被添加到了栈顶。同样的事也发生在方法 c 在方法 b 内被调用时。

当运行方法 c 时,我们的堆栈追踪内顺序包含 a, b, c 三个方法。

一旦方法 c 结束运行,它从栈顶被移除,控制权重新交回给方法 b 。当方法 b 完成时,它也从栈顶被移除,现在控制权被交回到了方法 a 手中。最终,当方法 a 结束运行后,它同样也从栈顶被移除。

为了更好的演示这些行为,我们将会使用console.trace()方法。它能够在控制台种将当前的堆栈信息打印出来。同样,你应该从上到下来阅读这些信息。仔细想想下面每一行代码被调用时都发生了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function c() {
console.log('c');
console.trace();
}

function b() {
console.log('b');
c();
}

function a() {
console.log('a');
b();
}

a();

当代码在 node REPL 运行时,我们得到下面一些信息。

1
2
3
4
5
6
7
8
9
10
11
Trace
at c (repl:3:9)
at b (repl:3:1)
at a (repl:3:1)
at repl:1:1 // <-- 这个指针下面的东西都是Nodejs的内部实现,无视就好
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:313:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)

简单的讲:你调用了一个东西,它被压入栈顶。当它完成了它就被弹出。就是这么简单。

错误对象和错误处理

当错误发生时,通常一个 Error 对象被抛出。Error 对象同样也被当作原型来使用,来拓展或创建自己的错误。

Error.prototype 对象通常包含下面属性:

  • constructor - 构造函数负责这个实例的原型。
  • message - 一条错误信息。
  • name - 错误的名称

上述这些是标准的属性,有些时候不同的环境会有它们自己特定参数。在一些环境下,比如 Node, Firefox, Chrome, Edge, IE 10+, Opera 和 Safari 6+,我们甚至会有 stack 参数,它包含了一个错误的堆栈追踪信息。

一个错误的堆栈追踪信息包含所有到它自身的结构函数为止的栈帧信息

如果你希望了解更多的Error对象的参数,我非常推荐你去看看MDN上的这篇文章.

为了抛出一个错误你必须使用throw 关键词。为了catch 一个被抛出的错误,你必须用try catch将那些可能会抛出错误的代码包裹起来。Catch 同样可以接收一个被抛出的错误作为参数。

如同在 java 种发生的一样, JavaScript 同样允许你在try/catch之后添加一个 finally 区块而不需要去关系 try区块内是否发生了错误。使用 finally 来做好一些善后工作,而不用关心你的操作是否正常工作。

到目前为止的所有东西对于大多数人而言都很基础,所有让我们来看一些不太注意的细节。(译者: indeed 😭)

你可以使用 try区块而不在后面带上 catch区块,但是这时必须带上 finally。这意味着你可以使用三种不同的try表达式结构:

  • try...catch
  • try...finally
  • try...catch...finally

Try表达式能够签到在其他的 try 表达式内,比如:

1
2
3
4
5
6
7
8
9
try {
try {
throw new Error('Nested error.'); // 这里抛出的错误会被他自身的catch子句所捕获
} catch (nestedErr) {
console.log('Nested catch'); // This runs
}
} catch (err) {
console.log('This will not run.');
}

你同样可以将 try嵌入 catchfinally 区块内:

1
2
3
4
5
6
7
8
9
10
try {
throw new Error('First error');
} catch (err) {
console.log('First catch running');
try {
throw new Error('Second error');
} catch (nestedErr) {
console.log('Second catch running.');
}
}
1
2
3
4
5
6
7
8
9
try {
console.log('The try block is running...');
} finally {
try {
throw new Error('Error inside finally.');
} catch (err) {
console.log('Caught an error inside the finally block.');
}
}

同样重要的是,你要知道throw 同样可以抛出非 Error 对象。尽管这看起来很cool,但是实际上真的不好,特别是那些需要在开发时使用其他库的开发者们,他们不得不去处理别人的代码,因为这之前并没有标准,你永远不会知道用户会给你什么东西。你不能信任他们而单纯的只是抛出一个Error 对象,因为他们可能选择不这么做,取而代之,而是抛出一个字符串或者数字。这使得你在处理堆栈追踪和其他一些有价值的元数据时变得困难。

假设你有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function runWithoutThrowing(func) {
try {
func();
} catch (e) {
console.log('There was an error, but I will not throw it.');
console.log('The error\'s message was: ' + e.message)
}
}

function funcThatThrowsError() {
throw new TypeError('I am a TypeError.');
}

runWithoutThrowing(funcThatThrowsError);

当使用者传递一个含有错误抛出的方法到你的 runWithoutThrowing函数时,一切都正常工作。但是如果他们抛了一个 String 给你时,那你就有麻烦了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function runWithoutThrowing(func) {
try {
func();
} catch (e) {
console.log('There was an error, but I will not throw it.');
console.log('The error\'s message was: ' + e.message)
}
}

function funcThatThrowsString() {
throw 'I am a String.';
}

runWithoutThrowing(funcThatThrowsString);

现在你的第二行 console.log 将告诉你 error 的 message 是 undefined 。这看起来在当前似乎不是很重要,不过如果你需要确认Error 对象内存在的一个特定的属性后者需要从用一种方法上处理 Error 特定属性时(比如 Chai’sthrows 断言文档),你需要做更多的工作。

同样的,当抛出值不是 Error 对象时,你不需要去访问其他重要的数据,比如它的stack,一个在一些环境中 Error 对象所包含的字段。

错误同样可以被当作其他(一般)的对象来使用,你并不一定要把他们抛出。这就是为什么它们经常被当初回调函数的第一个参数的原因。比如,在 fs.readdir 方法种:

1
2
3
4
5
6
7
8
9
10
11
12
const fs = require('fs');

fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
if (err instanceof Error) {
// `readdir` 将会抛出一个错误,因为这个文件根本不存在
// 现在我们能够使用回调函数中的错误对象了
console.log('Error Message:' + err.message);
console.log('See? We can use Errors without using try statements.');
} else {
console.log(dirs);
}
})

最后但并非不重要, Error 对象在 promise reject 时被使用。这使得控制promise的rejections变得容易:

1
2
3
4
5
6
7
8
9
10
new Promise(function(resolve, reject) {
reject(new Error('The promise was rejected.'));
}).then(function() {
console.log('I am an error.');
}).catch(function(err) {
if (err instanceof Error) {
console.log('The promise was rejected with an error.');
console.log('Error Message:' + err.message);
}
})

操作堆栈追踪

现在就是你所期待的部分了:如何去操作堆栈追踪信息。

这个章节只针对一些支持 Error.captureStackTrace 的特殊环境,比如 NodeJS。

这个 Error.captureStackTrace 方法将一个 object 作为它的一个参数,一个可选的 function 作为它的第二个参数。这个 captureStackTrace 做的呢就是捕获当前的堆栈信息(废话)并且在一个大的对象中创建一个 stack 参数来保存它。如果提供了第二个参数,这个被传递的方法将会被认为是调用堆栈的重点。因此堆栈跟踪将仅显示在调用此函数之前发生的调用。

让我们给一些例子来让这一切变得更清晰。首先,我们将会捕获当前的堆栈信息,并且将它保存在一个普通的对象中。

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
const myObj = {};

function c() {

}

function b() {
// 这里将会讲当前的堆栈信息储存到 myObj 中
Error.captureStackTrace(myObj);
c();
}

function a() {
b();
}

// 首先我们会调用这些方法
a();

// 现在让我们看看什么堆栈信息被存入了 myObj.stack
console.log(myObj.stack);

// 这将会在控制台中打印出如下信息:
// at b (repl:3:7) <-- 因为它在B内被调用,所以B是堆栈中的最后一个条目
// at a (repl:2:1)
// at repl:1:1 <-- 下面是 node 的内部实现
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)

正如你在上述例子中看到的,我们首先调用了 a (被压入了栈内)然后在 a 内调用了 b (被 push 在 a 上面)。然后,在 b 内,我们捕获到了当前的堆栈信息,并且存入了 myObj。 这就是为什么我们在控制台中只获得了 ab

现在,让我们传递一个方法作为第二个参数给Error.captureStackTrace 方法,来看会发生什么:

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 myObj = {};

function d() {
// 这里我们将会储存当前的堆栈信息到 myObj 中
// 这一次我么将会隐藏 `b` 之后以及它自身的栈帧
Error.captureStackTrace(myObj, b);
}

function c() {
d();
}

function b() {
c();
}

function a() {
b();
}

// 首先我们会调用这些方法
a();

// 现在让我们看看什么堆栈信息被存入了 myObj.stack
console.log(myObj.stack);

// 这将会在控制台中打印出如下信息:
// at a (repl:2:1) <-- 如你所见在这里我们只能获得 `b` 之前的被调用的栈帧
// at repl:1:1 <-- 下面是 node 的内部实现
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)
// at emitOne (events.js:101:20)

当我们传递 bError.captureStackTrace 函数时,它隐藏了 b 本身以及在它之上的所有栈帧。这就是为什么我们在堆栈追踪中只看到了a

现在你或许会问你自己: “为什么这东西有用?”。这个东西在当你试图对非你的用户隐藏内部实现细节时非常有用。在 Chai 内,举个例子, 们使用它来避免向我们的用户显示与我们实现检查和断言自身的方式无关的细节。

真实环境中的堆栈追踪操作

正如我在上一个小节提到的,Chai 使用堆栈操作技术来使得堆栈追踪与我们的用户(的操作)更加关联。下面是我们如何做的。

首先,让我们看一看当断言失败时, AssertionError 构造函数会抛出什么:

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
// `ssfi` 代表 “start stack function”. 它指向堆栈追踪中删除不相关帧的起点
function AssertionError (message, _props, ssf) {
var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
, props = extend(_props || {});

// 默认值
this.message = message || 'Unspecified AssertionError';
this.showDiff = false;

// 从参数中拷贝
for (var key in props) {
this[key] = props[key];
}

// 这里就是与我们相关的部分:
// 如果一个start stack function 被提供了,我们捕获了当前堆栈的追踪信息,并且将其传递给了 `captureStackTrace` 方法,那样我们移除在这个之后的栈帧了。
ssf = ssf || arguments.callee;
if (ssf && Error.captureStackTrace) {
Error.captureStackTrace(this, ssf);
} else {
// 如果没有提供 start stack function 我们就用原来的 stack 属性。
try {
throw new Error();
} catch(e) {
this.stack = e.stack;
}
}
}

如你所见,在上面的代码中我们使用 Error.captureStackTrace 来捕获堆栈信息,并且将其储存在我们所生成的 AssertionError 实例中,(当它存在时)我们传递了一个 start stack function 给它来将不相干的栈帧从栈列内移除。这些仅仅展示了Chai的内部实现细节并且在最后污染了栈列。

现在让我们看看现在由 @meeber这个碉堡的PR内的代码是怎么写的.

在我们看下面的代码之前,我必须告诉你 addChainableMethod 方法做了什么。它将传递给它的可链接方法添加到断言,并且还使用包含断言的方法标记断言本身。它以 ssfi 作为名称保存(代表了起始栈方法指示器)。这基本上意味着当前断言将是堆栈中的最后一帧,因此我们不会在堆栈中显示Chai中的任何进一步的内部方法。我避免添加整个代码,因为它有很多东西,而且有点棘手,但如果你想读它,这里是它的链接.。

在下面的代码中,我们有一个 lengthOf 断言的逻辑,它检查对象是否具有一个明确的 长度。我们希望我们的用户像这么用它:expect(['foo', 'bar']).to.have.lengthOf(2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function assertLength (n, msg) {
if (msg) flag(this, 'message', msg);
var obj = flag(this, 'object')
, ssfi = flag(this, 'ssfi');

// 注意这一行
new Assertion(obj, msg, ssfi, true).to.have.property('length');
var len = obj.length;

// 这一行也同样相关
this.assert(
len == n
, 'expected #{this} to have a length of #{exp} but got #{act}'
, 'expected #{this} to not have a length of #{act}'
, n
, len
);
}

Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);

在上面的代码中,我突出强调了与我们现在相关的代码段。我们先来看看 this.assert 的调用。

下面是 this.assert 方法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Assertion.prototype.assert = function (expr, msg, negateMsg, expected, _actual, showDiff) {
var ok = util.test(this, arguments);
if (false !== showDiff) showDiff = true;
if (undefined === expected && undefined === _actual) showDiff = false;
if (true !== config.showDiff) showDiff = false;

if (!ok) {
msg = util.getMessage(this, arguments);
var actual = util.getActual(this, arguments);

// 这里是我们所要关注的行
throw new AssertionError(msg, {
actual: actual
, expected: expected
, showDiff: showDiff
}, (config.includeStack) ? this.assert : flag(this, 'ssfi'));
}
};

基本上,assert方法负责检查是否通过了布尔表达式的断言。如果没有,我们必须实例化一个AssertionError。请注意,当实例化这个新的AssertionError时,我们也向其传递一个堆栈跟踪功能指示符(ssfi)。如果配置标志includeStack被打开,我们通过将this.assert本身传递给它来显示整个堆栈跟踪,这真的是堆栈中的最后一帧。但是,如果includeStack配置标志被启用,我们必须从堆栈跟踪中隐藏更多的内部实现细节,所以我们使用什么存储到ssfi标志。

现在,我们来谈谈另一个相关的行:

1
new Assertion(obj, msg, ssfi, true).to.have.property('length');

正如你可以看到的,我们在创建我们的嵌套断言时传递了我们从ssfi标志获得的内容。这意味着当创建新的断言时,它将使用此函数作为从堆栈跟踪中删除无用框架的起点。顺便说一下,这是Assertion构造函数:

1
2
3
4
5
6
7
8
9
function Assertion (obj, msg, ssfi, lockSsfi) {
// This is the line that matters to us
flag(this, 'ssfi', ssfi || Assertion);
flag(this, 'lockSsfi', lockSsfi);
flag(this, 'object', obj);
flag(this, 'message', msg);

return util.proxify(this);
}

你可以记住从我对addChainableMethod的说法,它设置ssfi标志与自己的包装方法,这意味着这是堆栈跟踪中最低的内部帧,所以我们可以删除所有上面的帧。

通过将ssfi传递给嵌套断言,它只检查我们的对象是否具有属性长度,我们避免重置我们将用作起点指示符的帧,然后在堆栈中使得之前的addChainableMethod保持可见。

这可能看起来有点复杂,所以让我们回顾一下Chai发生的事情,我们想从堆栈中删除无用的帧:

  1. 当我们运行断言时,我们设置自己的方法作为删除堆栈中的下一个帧的参考
  2. 断言运行,如果它失败,我们删除我们存储的引用后的所有内部帧
  3. 如果我们有嵌套断言,我们仍然必须使用当前的断言包装方法作为删除堆栈中的下一个帧的参考点,所以我们将当前的ssfi(启动堆栈函数指示符)传递给我们正在创建的断言,以便它可以保留它

原文链接: https://github.com/acdlite/react-fiber-architecture

引言

React Fiber 是一个正在进行的的React核心算法的重新实现,它是过去两年react团队的研究的顶峰。

React Fiber 的目标是为了增强react在动画,布局和手势等领域的适应性,它的头号特性就是增量渲染:一种将渲染任务切割成多个小块并分布到复数个帧中。

其他的关键特性包括,在新的更新来临时,暂停,退出和复用任务,为不同类型的更新设置优先级,和新的并发原函数。

关于本文档

Fiber 引入了几个新的概念,它们并不能简单的通过代码来理解。这篇文档最初是我在跟进react项目的Fiber实现时整理的一些笔记引入,随着积累,我意识到它对其他人或许会是有用的资源。

我尝试着用简单易懂的语言来讲述,并通过界定术语来避免行话。如果可能的话,我也会引用大量的外部链接。

请注意我并不在react团队,而且并不能代表官方发言。这并不是一片官方文档,不过我请求了react团队的一些人来验证了文章的准确性。

要记住,这是一个正在进行中的工作。Fiber是一个进行中的项目,在它完成之前可能会颠覆性的重构。同样的,进行中,也是我为这篇文档定义的理念。任何改进或者建议都非常欢迎。

我的目标是在你读完了这篇文档后,你会对Fiber足够认知去理解它的实现,并且最终能够反哺给react。

必要的知识

我强烈建议你先熟悉下面的资源来继续你的阅读:

回顾

请在继续前确认你以对上述知识有所了解。

在我们深入到新的内容前,让我们先回顾一些概念。

什么是协调算法?

协调算法

​ 一个在react中比较一棵树与另一棵树来找出哪些部分需要被改变的算法。

更新

​ 渲染React应用时发生的数据变化。通常是“setState”引发的。最终的结果就是重新渲染。

React API 的核心思想是让更新能够触发整个应用的重新渲染。它允许开发者进行声明式的开发,而不用担心应用在一个状态转移到另一个状态时的性能表现(A到B, B到C, C到A,等等)。

事实上,重新渲染整个应用只适用于一些小型的应用。在现实中的应用,(重新渲染)代价是高昂的。React在这基础上做了一系列优化,使其在对整个页面重新渲染时依然能够保证很好的性能。这些优化都是 协调算法 的一部分。

协调算法是一种隐藏在一个被广泛认知的概念“虚拟DOM”之下的算法。一个高阶的阐述如下:当你渲染一个react应用时,一棵用于描述整个应用的树被生成并保存在内存中。然后这棵树被刷新到正式的渲染环境中 - 举个例子,在浏览器应用中,它被转成一系列的DOM操作。当应用更新时(通常是通过 setState),一棵新的树被生成。这棵新的树与之前的树的差异决定了使用哪些更新操作来重新渲染整个应用。

尽管Fiber是一个协调算法的推倒重写,但是它与React官方文档中描述的高阶算法大致相同。其关键点在于:

  • 不同的组件类型被用以生成不同的树。比起比较两者的区别,react采用了替换掉整个旧树。
  • 通过key来进行diff比较。key必须“稳定,可预测且独一无二”。

协调算法vs渲染

DOM只是React可以采用的一种渲染环境,其他的主要环境还有原生的IOS 和 android 视图,通过React Native.(这既是为什么“虚拟DOM”其实有一点用词不当)。

React支持那么多渲染环境的原因是React在设计时就将协调算法和渲染拆分成了不同的部分。协调算法用以计算一棵树的哪些部分被改变了。渲染器使用这些信息来实际更新应用。

这个分离意味着 React DOM 和 React Native 能使用它们各自的渲染器的前提下共享React核心提供的协调算法,

Fiber重新实现了协调器。渲染并不是Fiber需要考虑的,不过渲染器需要调整以适应新的架构。

调度

调度

​ 决定任务什么时候被执行的过程

任务

​ 任何计算必须被执行。”任务”通常是更新的结果(如 setState

React 的设计原则中对于这部分讲的十分不错,我贴在这里了:

在当前的实现中,react在一个tick中历遍并调用了整棵树的渲染函数。但在未来,它有可能会延迟部分更新来避免丢帧。

这是react设计的一个常见的主题。一些流行的库在实现时采用了一种”push“的方法,当新的数据准备好时触发执行运算。然而,React依然使用了”pull”的方式,计算可以被延迟到必须执行的时候。

React不是一个通用的数据处理库,而是一个构建用户交互界面应用的库。我们认为(在交互界面应用中)知道哪些东西该立即关联,哪些则不必是有着独一无二的地位的。

如果有些东西超出了屏幕,我们可以延迟相关逻辑的执行。如果数据来的比帧绘制快,我们可以合并数据并批量更新。我们可以优先处理来自用户的交互(比如按钮点击出发的动画),而那些不是非常重要的后台任务(比如渲染来自网络的新加载的内容)来避免掉帧。

关键的点如下:

  • 在一个UI界面中,不是每一次更新都有必要立即执行。事实上,这么做很浪费资源,而且会导致丢帧和降低用户体现。
  • 不同类型的更新有着不同的优先级- 一个动画的更新需要在数据源的更新前完成。
  • 一个基于push的方案需要应用(你,敲代码的)来决定如何调度任务。但是一个基于pull的方案允许框架(react)更为智能的来为你做这些决定。

当前React 并没有明显的利用调度: 一个更新会导致整棵树被立即重绘。利用调度来重写React核心算法是Fiber背后的驱动理念。


现在我们已经准备好深入Fiber的实现了。下一章节将比我们讨论到现在所讲的内容更加具有技术性。在继续前,请确认你已经理解上面的材料。

什么是Fiber?

我们将讨论React Fiber架构的核心。纤维(Fibers)是一种比应用开发者想象中还要低阶的抽象。如果你在尝试去理解时出现了困惑,不要灰心。继续下去,最终你会明白的。(当你最终明白了,请提一些意见来优化这个章节)。

现在让我们开始!


利用调度是React Fiber的一个即认目标。具体来说,我们需要有能力做到:

  • 暂停任务,并在之后恢复。
  • 为不同类型的任务指派优先级。
  • 复用之前完成了的任务。
  • 在任务不再需要时放弃任务。

为了做到当中的任意一点,我们首先需要一个方法将任务拆分成单元。从某种第一种,这就是Fiber。一个纤维代表了任务的一个单元。

为了更进一步理解,让我们回到React组件是一个包含数据方法的概念,通常表示为

1
v = f(d)

它遵循以下规则,渲染一个React应用类似于调用一个主体包含其它函数的函数。这个比喻在我们思考fiber时很有用。

计算机通常跟踪程序执行的方式是使用调用堆栈。当一个方法被执行时,一个新的栈帧被添加到栈顶。这个栈帧表示这个任务由这个函数执行。

当处理UI时,这个问题是如果一次性执行了太多的任务,会导致动画掉帧和页面卡顿。更糟的是,当中的一些工作最终会被更靠近的更新所替代,完全不是必须的。这就是菊粉UI组件和普通方法的分界线,因为组件比一般方法有更详细的关注点。

较新的浏览器(和React Native)通过实现一些接口来解决了这个问题。requestIdleCallback 调度了一个更低优先级的方法在空闲时调用,而 requestAnimationFrame 调度了一个更高优先级的方法在下一个动画帧执行。问题在于,为了使用这些接口,你需要一个方法去把任务切分为增量的任务。如果你依赖于调用栈,它将会继续执行执行栈被清空。

如果我们能自定义调用栈的行为来优化UI渲染会不会更好?如果我们可以随意中断调用栈并且可以手动调控栈帧会不会更好?

这就是React Fiber的设计动机。Fiber是栈的重新实现,特针对于React组件。你可以将一个fiber想象成一个虚拟栈帧。

重新实现堆栈的优点是,你可以保持堆栈帧在内存中,并执行它们(和任何时候)你想要的。这对于实现我们的计划目标至关重要。

除了调度,手动处理堆栈帧解锁了诸如并发和错误边界之类的功能的潜力。我们将在以后的章节中讨论这些主题。

在下一节中,我们将更多地了解fiber的结构。

fiber的结构

注意:当我们更具体地了解实现细节时,一些东西可能会已经随着时间被改变了。如果您发现任何错误或过时的信息,请提交公关。

具体来说,fiber是一个JavaScript对象,它包括组件本身以及其输入及其输出的信息。

Fiber对应一个栈帧,但是同样也对应一个组件的实例。

下面是一些属于fiber的重要字段。(这个列表并不完整)。

typekey

fiber的typekey的作用和React元素一样。(实际上,一个fiber从组件创建时,这两个字段会直接复制过来)

fiber的 type字段描述了它对应的组件。对复合组件这个类型就是函数组件或类组件本身。对于原生组件(div, span,等等),这个字段就是一个字符串。

概念上,type是在执行时会被堆栈帧跟踪到的函数(如在v = f(d)中)。

除了type之外,key是在协调算法中用来决定fiber是否可以重用的字段。

childsibling

这些字段指向别的fiber,描述了fiber的历遍树的结构。

子fiber指的是组件render方法的返回值。所以,在下面的例子中

1
2
3
function Parent() {
return <Child />
}

Parent 对应的子Fiber就是Child组件。

sibling字段指代着render方法返回多个子元素的情况(fiber的一个新的特性!):

1
2
3
function Parent() {
return [<Child1 />, <Child2 />]
}

子fiber们组成了一个首元素是第一个子元素的单向链表。所以在例子中,Parent的子元素是Child1,child1的兄弟元素是Child2。

回顾我们之前的函数类比,你可以把子fiber当作一个尾调用函数

return

返回fiber是程序处理完当前fiber时返回的fiber。概念上来说,它和栈帧返回的地址相同。它同样可以被认为是父fiber。

如果一个fiber包含多个子fiber,每一个子fiber的return fiber 都是它的父fiber。所以在我们先前一节的例子中,Child1和Child2的return fiber 就是Parent。

pendingPropsmemoizedProps

概念上来说,props是函数的参数。一个Fiber的 pendingProps在他执行前就被设定好,而memoizedProps则会在尾部被设置。

当传入的pendingPropsmemoizedProps相同时,这传递了一个信号这个fiber的的输出可以被复用,从而避免了不必要的工作。

pendingWorkPriority

一个数字代表了fiber的执行优先级。 React优先级 模块列举了不同的优先级和它们代表的意义。

除了例外的NoWork的值是0,值越大代表优先级越低。比如,你可以使用下述的方法来确认一个fiber的优先级是不是不低于给定的等级:

1
2
3
4
function matchesPriority(fiber, priority) {
return fiber.pendingWorkPriority !== 0 &&
fiber.pendingWorkPriority <= priority
}

这个方法仅供说明用,它并不是React Fiber源码的一部分。

调度器使用优先级字段来搜索要执行的下一个工作单元。这个算法将在以后的章节中讨论。

alternate

flush

​ flush 一个fiber意味着将它打印输出到屏幕上。

work-in-progress

​ 一个fiber如果还没有完成,那么概念上,栈帧就尚未返回。

在任何时候,一个组件的实例,最多有两个fiber关联: 当前的fiber,flush fiber,和work-in-progress fiber。

当前fiber的替代(alternate)就是进行中的fiber,进行中的fiber的替代就是当前fiber。

一个fiber的替代是通过cloneFiber函数懒创建的。cloneFiber会尝试重用fiber的替代(如果存在)来最小化分配空间,而不是总创建新的对象。

你应当把alternate当做是一个实现细节,但它在代码里面出现了很多次,所以值得在这里讨论。

output

host component

​ React应用的叶子节点。它们是跟特定的渲染环境相关的(比如,在浏览器应用中,宿主组件是指divspan等)。在JSX中,它们是用小写字母的tag名称表示的。

从概念上讲,fiber的output是一个函数的返回值。

每个fiber最终都会有output,不过output只在宿主组件的叶子节点上创建。这个输出会向上转移到整棵树。

output最终会递交给渲染器让其根据渲染环境来flush。定义输出结果怎么样创建和更新就是渲染器的职责了。

未来的章节

目前为止就这些了,不过这篇文档远远没有完成,未来的章节将会描述更新周期时使用到的算法。相关的标题包含如下:

  • 调度器如何找到下一个需要被执行的单元
  • 如何在整棵fiber树上最终和传递优先级
  • 调度器怎么知道什么时候去暂停和恢复任务
  • 任务如何被冲洗并标记成完成
  • 副作用(如生命周期函数)是如何工作的
  • 什么是协同程序,以及如何使用它来实现上下文和布局等功能。

相关视频

原址:https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop

JavaScript 有一个基于“事件循环”的并发模型。这个模型和其他语言中(C 和 JAVA)中的近似模型有着很大区别。

运行时的概念

下面的内容描述了一个理论模型。现在 JavaScript 引擎大量的声明和优化了上述语义。

视觉展示

Stack, heap, queue

函数的调用形成有一系列帧组成的栈。

1
2
3
4
5
6
7
8
9
10
11
function foo(b) {
var a = 10;
return a + b + 11;
}

function bar(x) {
var y = 3;
return foo(x * y);
}

console.log(bar(7));

当调用 bar 时,第一帧被产生用以保存 bar 函数的引用参数和局部变量。当 bar 调用 foo 时,第二帧被生成用以保存 foo 的引用参数和局部变量并入栈。当 foo 返回时,栈顶部的元素被抛出。当 bar 执行返回时,栈被清空。

对象被分配在一个用于表示一个泛广的非结构化内存区域的堆中。

队列

JavaScript 在运行时会产生一个由一系列等待被处理的消息组成的消息队列。每个消息都与一个方法相关联。当栈有足够容量时,一条消息会被拿出队列并被处理。这处理的过程包含调用关联方法(因此会生成一个初始化帧栈)。当栈被重新执行清空后,消息处理结束。

事件循环

事件循环之所以得到这么个名字是因为它通常的实现如下:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

queue.waitForMessage 同步的等待消息到来,如果当前还没有的话。

从运行到完成

每一条消息都在另一条消息被处理前完成。这提供了一些不错的特性在对你的程序追责时,它记录了函数运行的状态,函数执行时总会在其他代码捷足先登前完全的被执行,(并可以修改函数操作时的数据)。与 C 不同的是,比如(以下为 C 的情况), 如果一个方法在一个线程上跑的时候,它可以在任何时候被中断,并且在另一个线程上跑一些其它的代码。

这种模式的缺点是,如果一条消息花费了很长的时间去完成,那么web应用将无法处理用户的交互,比如点击和滚动。浏览器通过一个“一个脚本执行了太长时间”的弹窗来缓解问题。 一个好的做法是将消息变短,如果可能的话,将消息切割成一系列短的消息。

添加消息

在web浏览器中,任何时候有事件触发,通过绑定的消息监听器都会触发消息的添加。如果没有监听器的话,事件就会丢失。所以在一个元素上点击会触发一个点击事件并添加消息,相似的还有其他的事件。

调用setTimeout会在参数所给的时间后,将消息添加至队列内。如果此时队列内没有其他的消息了,那么这条消息将会被马上执行。然而,如果有其他消息,这个定时消息则不得不等到其他消息都处理完后才被处理。由于这个原因,函数的第二个参数所指定的时间是最快时间而不是确定的时间。

0延时

0延时并不意味着回调会在0毫秒后被调用。调用setTimeout函数并设置 0 毫秒的延时并不是在指定时间后执行。这个执行取决于当前队列内有多少正在等待的任务。在下方的例子中,“this is just a message”会在回调函数中的那条消息前辈打印出来,因为这个延时是最小时间而不会确定时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(function() {

console.log('this is the start');

setTimeout(function cb() {
console.log('this is a msg from call back');
});

console.log('this is just a message');

setTimeout(function cb1() {
console.log('this is a msg from call back1');
}, 0);

console.log('this is the end');

})();

// "this is the start"
// "this is just a message"
// "this is the end"
// "this is a msg from call back"
// "this is a msg from call back1"

运行时的协作通信

一个 web worker 或一个跨源的帧含有他自己的栈,堆还有消息队列。两个不同线程在运行时可以通过postMessage 方法来进行通信。如果后者监听消息事件,此方法会向前者(队列内)添加消息。

永不阻塞

事件循环模型的一个非常有趣的属性是,JavaScript(的时间循环模型),不像许多其他语言,永远不会阻塞。处理I / O通常通过事件和回调来执行,因此当应用程序正在等待 IndexedDB 查询返回或 XHR 请求返回时,它仍然可以处理其他事情,如用户输入。

一些遗留的异常情况,如使用 alert 或同步 XHR时 ,但它们被认为是避免交互终端的良好做法。注意,异常的异常确实存在(但通常是出现错误,而不是其他任何东西)。

这是 React 的性能优化的第二部分。在第一部分中,我们简单的了解了怎么去使用 React 的 Perf 调试工具,常规的 React 的渲染瓶颈,和一些调试的小技巧。如果您还没准备好(继续深入)的话,那最好先去把它(Part 1) 看了

在第二部分中,我们会继续深入react调试的工作流 - 既然已经给了这些个想法,那又会如何在练习中体现呢?(自问自答)我们将会通过一些切合时机的例子,并且使用chrome的开发工具来分析和解决性能问题。(如果您在看完后有任何建议和新的想法,请让我们知道!)

我们将会参考下面这段简单的代码 - 你可以看出它通过 React 渲染了一个简单的 todo list。在后面的 JS fiddle 代码片段中,你可以点击 “Result” 来看一个带交互的实际例子,complete with performance repros(不知道怎么翻译)。我们将会这过程中提交并更新 JS fiddles。

CASE STUDY #1: TODOLIST

让我们开始上面的 TodoList 。通过尝试在代码未优化的例子中,快速的输入,你会发现它有多慢。

让我们开始用Chrome 开发工具中的 Timeline profiler ,通过一些细节的切面的来看浏览器在做了些什么: 处理用户触发的事件,运行JS,渲染和绘制。在输入框内输入一个字符,然后停止 Timeline profiler 。这个过程中你不会感到明显的缓慢,因为我们只输入了一个字符,这是产生用以分析的少量信息的最快的手段。

img

注意图中 Event(textInput) 进度条在 Scripting(Children) 中总计花费了121.10 ms 。这个时段切面中表明了这个缓慢问题是一个脚本的问题,而不是样式或重复计算导致的性能问题。

所以让我们深入脚本。切换到 Profiles tab - Timeline 不仅给了我们浏览器(和 JS Profile)的一个概况,通过这些个代码运行切面则让我们得以继续深入 JS 的内部实现,而且给了我们各种各样可视化工具。根据另一份Profile, 它中指出运行缓慢的问题并不在于我们的应用代码:

img

将 Profile 的 Heavy(Bottom up)Total 字段降序排列,结果指出消耗时间最多的部分是React batchUpdates方法的调用,这很明确的提示了问题出在 React 上。相反,通过 Self 来测量函数中除去子函数的花费的时间 - 根据 Self 排序来查看是否有明显的昂贵(花费时间多)的函数时,这并没有明显的性能瓶颈在应用层面的函数中,所以让我们换 React 的 Pref 来看看。

为了声称一个针对缓慢操作的的评估切面,在console中,我们调用 React.addons.Perf.start(), 然后通过输入一个字符来重现缓慢的操作,然后输入React.addons.Perf.stop()结束监控。(最后) 通过输入 React.addons.Perf.printWasted()我们可以看到应用在不必要的渲染中花费的时间:

img

这第一项表明TodoItem是由Todos渲染的,而(输入)Perf.printWated()可以看出,如果我们避免render树的反复构建,我们就可以省下100ms。 这看起来像是我们优化中最主要的部分。

为了分析为什么TodoItem会浪费这么多时间,我们创建一个自定义的输入函数,以很明确的 WhyDidYouUpdateMinxin来命名它,它会嵌入组件并且打印一些信息,如触发了哪些更新和为什么更新。下面是代码;按你的需求随意使用它

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
/* eslint-disable no-console */
import _ from 'underscore';

/*
Drop this mixin into a component that wastes time according to Perf.getWastedTime() to find
out what state/props should be preserved. Once it says "Update avoidable!" for {state, props},
you should be able to drop in React.addons.PureRenderMixin
React.createClass {
mixins: [WhyDidYouUpdateMixin]
}
*/
function isRequiredUpdateObject(o) {
return Array.isArray(o) || (o && o.constructor === Object.prototype.constructor);
}

function deepDiff(o1, o2, p) {
const notify = (status) => {
console.warn('Update %s', status);
console.log('%cbefore', 'font-weight: bold', o1);
console.log('%cafter ', 'font-weight: bold', o2);
};
if (!_.isEqual(o1, o2)) {
console.group(p);
if ([o1, o2].every(_.isFunction)) {
notify('avoidable?');
} else if (![o1, o2].every(isRequiredUpdateObject)) {
notify('required.');
} else {
const keys = _.union(_.keys(o1), _.keys(o2));
for (const key of keys) {
deepDiff(o1[key], o2[key], key);
}
}
console.groupEnd();
} else if (o1 !== o2) {
console.group(p);
notify('avoidable!');
if (_.isObject(o1) && _.isObject(o2)) {
const keys = _.union(_.keys(o1), _.keys(o2));
for (const key of keys) {
deepDiff(o1[key], o2[key], key);
}
}
console.groupEnd();
}
}

const WhyDidYouUpdateMixin = {
componentDidUpdate(prevProps, prevState) {
deepDiff({props: prevProps, state: prevState},
{props: this.props, state: this.state},
this.constructor.displayName);
},
};

export default WhyDidYouUpdateMixin;

一旦我们在TodoItem中注入了这个函数,我们可以看到发生了一些什么:

img

Aha! 我们看到tags这个变量在操作前后近似 - 这个注入函数告诉我们当两个状态对象深度相等而不是严格相等时,这时(渲染)是可以避免的。换个角度说,这个问题的难点在于如何判断两个方法是相等的,因为 Fucntion.bind声明了一个新的方法,尽管绑定的参数相同。这是一些有用的线索,既然如此 - 我们回过头来看看我们是如何传入tagsdeleteItem的,看起来没错我们构建一个TodoItem时我们都传递了一些新的值。

假如我们用传递未绑定的进入TodoItem, 同时我们将tags储存成常量,我们将可以避免这些个问题:

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
diff --git i/example.js w/example.js
index ba2427a..2edc85e 100644
--- i/example.js
+++ w/example.js
@@ -11,10 +11,13 @@ const TodoItem = React.createClass({
id: React.PropTypes.number.isRequired,
}).isRequired,
},
+ deleteItem() {
+ this.props.deleteItem(this.props.item.id);
+ },
render() {
return (
<div>
- <button style={{width: 30}} onClick={this.props.deleteItem}>x</button>
+ <button style={{width: 30}} onClick={this.deleteItem}>x</button>
<span>{this.props.item.text}</span>
{this.props.tags.map((tag) => {
return <span key={tag} className="tag"> {tag}</span>;
@@ -26,6 +29,9 @@ const TodoItem = React.createClass({

const Todos = React.createClass({
mixins: [React.addons.LinkedStateMixin],
+ statics: {
+ tags: ['important', 'starred'],
+ },
propTypes: {
initialItems: React.PropTypes.arrayOf(React.PropTypes.shape({
text: React.PropTypes.string.isRequired,
@@ -60,8 +66,8 @@ const Todos = React.createClass({
</form>
{this.state.items.map((item) => {
return (
- <TodoItem key={item.id} item={item} tags={['important', 'starred']}
- deleteItem={this.deleteItem.bind(null, item.id)} />
+ <TodoItem key={item.id} item={item} tags={Todos.tags}
+ deleteItem={this.deleteItem} />
);
})}
</div>

WhyDidYouUpdateMixin 现在表明了 prevProps 和 newProps 浅相等。我们可以使用 PureRenderMixin,来避免当props(和state)浅相等时的组件更新。

img

当我们重新运行Profiler,我们可以看到只花费了35ms(比原来的快了4倍)

img

这比原来的好了,但是仍然不够理想。在输入框内打字不应该导致这么慢。这表明我们任然没有做到0(list的item数量级别)的工作。我们仅仅定义了常量,我们依然需要对每个item进行浅比较。

此时,你可以能会认为1000个items在todolist中已经是极端的情况了,而且30ms的延迟,对你的应用而言并不是问题。如果你希望能够支持几千个子元素,那么,这任然没有达到理想的60fps

(16ms 每帧 - 慢一点点你都会感受的到)。

将组件拆分成多个组件作为下一步工作是有道理的(同样将之视为第一步也是合理的)。我们观察到Todos这个组件由两个没有交集的子组建构成,一个AddTaskForm组件包含了输入框和按钮,一个TodoItems组件包含了Items的列表。

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
let ID = 0; // incrementing counter for todo item ids

const AddTaskForm = React.createClass({
mixins: [React.addons.LinkedStateMixin, React.addons.PureRenderMixin],
getInitialState() {
return {
text: '',
};
},
addTask(e) {
e.preventDefault();
this.props.addTask(this.state.text);
this.setState({text: ''});
},
render() {
return (
<form onSubmit={this.addTask}>
<input valueLink={this.linkState('text')} />
<button>Add Task</button>
</form>
);
}
});

const TodoItems = React.createClass({
mixins: [React.addons.PureRenderMixin],
render() {
return (
<div>
{this.props.items.map((item) => {
return (
<TodoItem key={item.id} item={item} tags={Todos.tags}
deleteItem={this.props.deleteItem} />
);
})}
</div>
);
}
});

const TodoItem = React.createClass({
mixins: [React.addons.PureRenderMixin],
propTypes: {
deleteItem: React.PropTypes.func.isRequired,
tags: React.PropTypes.arrayOf(React.PropTypes.string.isRequired).isRequired,
item: React.PropTypes.shape({
text: React.PropTypes.string.isRequired,
id: React.PropTypes.number.isRequired,
}).isRequired,
},
deleteItem() {
this.props.deleteItem(this.props.item.id);
},
render() {
return (
<div>
<button style={{width: 30}} onClick={this.deleteItem}>x</button>
<span>{this.props.item.text}</span>
{this.props.tags.map((tag) => {
return <span key={tag} className="tag"> {tag}</span>;
})}
</div>
);
},
});

const Todos = React.createClass({
statics: {
tags: ['important', 'starred'],
},
propTypes: {
initialItems: React.PropTypes.arrayOf(React.PropTypes.shape({
text: React.PropTypes.string.isRequired,
id: React.PropTypes.number.isRequired,
}).isRequired).isRequired,
},
getInitialState() {
return {
items: this.props.initialItems,
};
},
addTask(text) {
this.setState({
items: [{id: ID++, text}].concat(this.state.items),
});
},
deleteItem(itemId) {
this.setState({
items: this.state.items.filter((item) => item.id !== itemId),
});
},
render: function() {
return (
<div>
<h1>My TODOs</h1>
<AddTaskForm addTask={this.addTask} />
<TodoItems items={this.state.items} deleteItem={this.deleteItem} />
</div>
);
},
});

// Create a Todos component, initialized with 1000 items.
const items = [];
for (let i = 0; i < 1000; i++) {
items.push({id: ID++, text: 'Todo Item #' + i});
}
React.render(<Todos initialItems={items} />, document.body);

任何一项重构都能提供实质的性能增益:

  • 如果我们通过PureRenderMixin来创建一个TodoItems,因为prevProps.items === this.props.items,我们将能通避免重新渲染每一个item来减少O(n)的时间消耗。
  • 如果我们创建一个AddTaskForm组件时,将文本的state至存在于组件内使,当文本改变时,将不会,Todos组件(列表部分)将不会重新渲染(避免了O(n)的渲染消耗)。

(将以上的工作)合起来,我们每次键盘的按键操作只会消耗10ms

CASE STUDY #2:

场景: 我们想在用户由太多的任务(>3000)时,渲染一个警告,同时我们想给这些todo项添加一个背景样式。

实施:

  • 我们由一个近似的todo列表的案例,用(之前的)TodoItems来实现 - 在这个李子中,我们将会把input的文本内容储存在最顶层的组件状态中。
  • 我们创建了一个TaskWarning的组件,来根据任务的数量来决定消息的渲染。为了封装这层逻辑,我们将会返回null,假如它不该被渲染。
  • 我们用样式给div:nth-child(even)所匹配到的元素添加一个灰色的背景。
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
let ID = 0; // incrementing counter for todo item ids

const TodoWarning = React.createClass({
propTypes: {
itemCount: React.PropTypes.number.isRequired
},
render() {
if (this.props.itemCount > 3000) {
return <div>'YOU HAVE TOO MANY TASKS. SLOW DOWN.'</div>;
}
return null;
}
});

const TodoItems = React.createClass({
mixins: [React.addons.PureRenderMixin],
render() {
return (
<div className="todoItems">
{this.props.items.map((item) => {
return (
<TodoItem key={item.id} item={item} tags={Todos.tags}
deleteItem={this.props.deleteItem} />
);
})}
</div>
);
}
});

const TodoItem = React.createClass({
mixins: [React.addons.PureRenderMixin],
propTypes: {
deleteItem: React.PropTypes.func.isRequired,
tags: React.PropTypes.arrayOf(React.PropTypes.string.isRequired).isRequired,
item: React.PropTypes.shape({
text: React.PropTypes.string.isRequired,
id: React.PropTypes.number.isRequired,
}).isRequired,
},
deleteItem() {
this.props.deleteItem(this.props.item.id);
},
render() {
return (
<div>
<button style={{width: 30}} onClick={this.deleteItem}>x</button>
<span>{this.props.item.text}</span>
{this.props.tags.map((tag) => {
return <span key={tag} className="tag"> {tag}</span>;
})}
</div>
);
},
});

const Todos = React.createClass({
mixins: [React.addons.LinkedStateMixin],
statics: {
tags: ['important', 'starred'],
},
propTypes: {
initialItems: React.PropTypes.arrayOf(React.PropTypes.shape({
text: React.PropTypes.string.isRequired,
id: React.PropTypes.number.isRequired,
}).isRequired).isRequired,
},
getInitialState() {
return {
items: this.props.initialItems,
text: '',
};
},
addTask(e) {
e.preventDefault();
this.setState({
items: [{id: ID++, text: this.state.text}].concat(this.state.items),
text: '',
});
},
deleteItem(itemId) {
this.setState({
items: this.state.items.filter((item) => item.id !== itemId),
});
},
render: function() {
return (
<div>
<TodoWarning itemCount={this.state.items.length} />
<h1>My TODOs</h1>
<form onSubmit={this.addTask}>
<input valueLink={this.linkState('text')} />
<button>Add Task</button>
</form>
<TodoItems items={this.state.items} deleteItem={this.deleteItem} />
</div>
);
},
});

// Create a Todos component, initialized with 1000 items.
const items = [];

观察诊断:在输入框内快速的输入,页面由很明显的延迟(不超过3000个任务)。如果我们继续添加一个任务(> 3000 个任务),延迟随着按钮消失了。令人惊讶的地方,添加更多的任务似乎解决了这个任务!

调试:Timeline Profile 展现了一些很有意思的东西:

img

因为某些原因,输入单个字符时触发了大量的Recalculate Style,超过了30ms(这就是为什么当我们打字的速度大于30ms/每个字符时,我们会观察到Jank【注:应用刷新的速率没有达到设备的刷新速率而产生的卡顿现象】)。

看看图片底部的First invalidated一节内容。它指出Danger.dangerouslyReplaceNodeWithMarkup 导致了页面的布局失效,从而导致了样式的重新计算。react-with-addons.js:2301处:

1
oldChild.parentNode.replaceChild(newChild, oldChild);

因为某些原因,React 用一个完全新的DOM节点替换了原先的DOM节点! 回想起来,生成DOM的操作时非常昂贵的。使用Perf.printDOM(),我们可以看到React在DOM操作时的性能:

img

更新的属性反映了当在输入框输入abc时,TaskWarning是不可见的。然而,replace 项(图中的type)又指出了,此时React正在为TaskWarning组件创建DOM,尽管它看起来不应该有明确可见的实体DOM。

正如上面所展示的,React(<= v0.13)使用了一个noscript标签来渲染“no component”,但是却(在做diff时)错误的将两个noscript的标签视为了不相等:noscript最后被另一个noscript替换。此外,回想起来我们给其他的每个元素添加的一个灰色背景的样式。因为CSS的缘故,这3000个节点中任何一个的单独渲染都依赖与它之前的兄弟节点。每次noscript标签被替换,它随后的DOM节点的样式都会被重新计算。

为了修复这个问题,我们可以:

  • 让TaskWarning 返回一个空的div
  • 将TaskWarning组件用一个div包裹起来,这样它就不会去影响css选择器对随后节点的选择。【意味不明】
  • 升级React :-)

除此以外。这里最关键的一点时,我们能够自己分析出这些,仅仅通过Timeline Profiler!

CONCLUSION

我希望展示react的性能问题在各种开发工具中的表现是有用的(能够帮到大家)- 通过*Timeline *, profiles,和React的Perf工具的配合使用还有很长的道路要走。

在todolisst中包含上千个项,和随意的着色,似乎有些刻意(简单将就是说我上面举得例子看起来有点不切实际),不过它所变面出的问题却和我们在做electronic lab notebook的项目中实际中遇到的渲染大量文档和表格时的问题很近似。

(后面时招聘广告和客套话,省略…)

读后总结:

​ 很老的一篇文章了(最然是今年年初的),原本是奔着文章的题目去的,不过发觉内容里貌似也没怎么深入,Perf的介绍和使用主要还是 Part1 中,不过在文中 timeline 和 profile的配合使用中还是学到了一些东西(思考问题的角度),之后看样子还是要好好的学习如何使用chrome 的devtool。

总结几点吧:

  • 要使用PureComponentMixin,使用es6的class来创建组件时则应该继承PureComponent。
  • 合理的拆分组件(组件解耦)。
  • 常量不要在render时声明,应该抽到外面去。
  • 文中所提到的noscript的问题,在react的https://github.com/facebook/react/issues/2770 中已经解决了,所以返回null也没什么问题了。具体的commit
  • 交互的相应理想的状态下要做到30ms内(难点)。文中提到 Jank 的概念。http://jankfree.org/
  • 看源码!看源码!看源码!重要的事情说三遍。
  • 文中的图片请挂代理…

作者:Dan Abramov
链接:https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0

当我在写react应用时有一种很简单却非常使用的模式。如果你已经写过一段实践的react,那么可能你已经发现了它。这篇文章 将这种模式解释的很好,不过我希望加入一些额外的要点。

你会发现当你把你的组件拆分成两类时,它们会变得更加易于复用和理解。我称呼他们为容器组件和展示组件,不过我同样也听到如下几种说法,胖组件(Fat)和瘦(Shinny)组件,聪明组件(Smart)和呆(Dumb)组件,多状态(Statefull)组件和纯(Pure)组件,放映和组件(Screens and Components)。它们不尽相同,但核心的思想却非常接近。

我所谓的展示组件:

  • 考虑组件长啥样。
  • 可能会同时包含展示组件和容器组件在其中,而且通常会含有写DOM标签和私有样式。
  • 通常用this.props.children来包其他组件。
  • 不依赖于应用内的其他部分,比如Flux的动作和存储。
  • 不会定义数据是如何被加载或改变的。
  • 仅通过props来获取数据和回调函数。
  • 很少包含私有的状态(如果有的话,也只会是UI的状态而不是应用的数据)
  • 一般用方法组件 的写法来写,除非它们需要状态,或者控制生命周期,或者优化性能。
  • 举例: Page, Siderbar, Strory, UserInfo, List

我所谓的容器组件:

  • 考虑组件是如何运作的。
  • 可能会同时包含展示组件和容器组件在其中,出了部分包裹用的div不会拥有任何DOM标签,更绝不会有任何样式。
  • 给展示组件或其他容器组件提供数据和动作。
  • 调用flux动作,并为展示组件提供回调函数
  • 通常会有很多状态,而且通常是应用服务的数据。
  • 通常通过高阶组件 来生成,比如React Redux的connect()组件, Relay的createContainer(),Flux Utils的Container.create(),而不是手工的去写。
  • 举例:UserPage, FollowersSidebar, StoryContainer, FollowedUserList.

我将它们放在不同的文件夹下,从而使它们的作用更加明确。

这种做法的好处

  • 将专注的点分离,通过这种方式,你可以更好的理解你的应用和你的UI。
  • 更好的复用。你可以使用相同的展示组件通过不同的状态源,也可以封装成容器组件,在未来复用它。
  • 展示组件本质上是你的应用的“调色板”。你可以将他们放到一个单独的页面内,并且让设计师随意的来调整它们的样式,而不会碰触来应用的逻辑部分。你可以在哪个页面内进行screenshot regression测试。
  • 这会强迫你去解析“布局组件”,比如Sidebar,Page,ContextMeanu 并且使用this.props.children来传递,而不是粘贴复制那块的jsx。

记住,组件不一定要生成DOM,它们只需要提供UI上的组合关系和界限。

什么时候去进去容器?

我建议你在构建你的应用时,先写展示组件的部分。最后,你会发现,你在中间组件中传递了太多的props。当你注意到部分组件并不需要它所接受的prop,而只是传递给它们的子组件,而当子组件需要更多数据,你不得不如重写所有这些中间组件,这时就是引入容器的最佳时机。这样做,你可以将数据和一些动作的props给余下的组件,而不必去在组件树中负担一些无关的组件。

这是一种循序渐进的不断重构的过程,所以没必要在一开始就做到位。当你不断地实现这种模式时,记得只要像你知道何时去增加新的方法一样来增加新的组件就可以了。我的免费的redux教程系列也许会对你有帮助。

其他的二分性

有很重要的一点你必须知道,容器组件和展示组件的区别并不是技术上的差别,而是两者在目的上的区别。为了比较,我再列举了一些有联系的(但是是不同的!)二分性。

  • 多状体和无状态。一部分组件使用了React.setState()方法,而另一部分则没有。容器组件有很多状态,展示组件状态很少,没错,但这并不是一套铁规则。展示组件也可以有很多状态,同理,容器组件的状态也可以很少。
  • 类和函数。从React0.14版本开始,组件可以用类和函数两种方式来生命了。函数组件更容易声明,不过它们缺少了一些只属于类的特性。这些限制或许再未来会消失,但至少现在是存在的。因为函数组件更易于理解,所以我建议你使用它们,除非你需要状态,再生命周期上添加处理,或是需要优化性能,这种时候只能使用类声明的组件了。
  • 纯的和不纯的。人们说一个组件是纯的,给它相同的props和state时,返回的结果一定时相同的。纯组件可以被定义为类和函数,同时既可以时充满状态的也可以使无状态的。纯组件的另一个重要的部分是,春组件并不会因为所谓的props和state产生一些深层的改变,所以可以通过在shouldComponentUpdate函数内浅比较state和props来优化性能。目前只有类组件可以声明shouldComponentUpdate方法,不过在未来这可能会改变。

展示组件和容器组件都有上述的特性。在我的经验来看,展示组件倾向于无状态的纯函数,而容器组件情况于多状态的纯类。当然了,这只是我的经验之谈,而不是铁则,我也见过一些完全相反的情况。不要将展示组件和容器组件当作教条。有些时候,划清这条线并不是必要,而且有时很难分清。如果你感到不确定一个组件是展示组件还是容器组件时,不要太纠结,可能时候未到。

例子

Michael Chan的这篇梗概中讨论了这个。

衍生阅读

脚注

在这篇文章的早些版本中我们称之为聪明组件和呆组件,不过这对展示组件来说有些讲的太过分了,而且这并不能很好的解释两者的意图。我很喜欢这对新的叫法,希望你也是。

在这篇文章的早些版本中我声称展示组件只能包含其他的展示组件。如今我不太认为这时对的。一个组件时展示组件还是容器组件应该由他的内部细节决定。你应该能够用通过容器组件来替换掉一个展示组件同时不修改任何调用的地方。因此,展示组件和容器组件都可以包含其他展示组件和容器组件,这是没问题的。