Giter Club home page Giter Club logo

blog's People

Contributors

funfish avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

10. 强制转换

前言

类型与文法,在两个月以前其实已经看完了,但看完‘this与对象原型’和‘类型与文法’章节后,却自以为早已经掌握,没有什么可以谈的,于是便束之高阁。直到前一阵子,部门分享python基础的时候,提到python没有变量声明,拿来就用。着不是和JS很像?JS为什么没有变量声明呢?记得C语言都用变量声明,为何JS没有呢?变量声明有什么作用?好不好?

知乎问题为什么像 Java、C、C++ 这样的静态语言会比 Python、Ruby 这样的动态语言流行得多?

再结合另外一个知乎问题弱类型、强类型、动态类型、静态类型语言的区别是什么?里面介绍到:

Program Errors
• trapped errors。导致程序终止执行,如除0,Java中数组越界访问
• untrapped errors。 出错后继续执行,但可能出现任意行为。如C里的缓冲区溢出、Jump到错误地址

Forbidden Behaviours
语言设计时,可以定义一组forbidden behaviors. 它必须包括所有untrapped errors, 但可能包含trapped errors.
Well behaved、ill behaved
• well behaved: 如果程序执行不可能出现forbidden behaviors, 则为well behaved。
• ill behaved: 否则为ill behaved…

强、弱类型
• 强类型strongly typed: 如果一种语言的所有程序都是well behaved——即不可能出现forbidden behaviors,则该语言为strongly typed。
• 弱类型weakly typed: 否则为weakly typed。比如C语言的缓冲区溢出,属于trapped errors,即属于forbidden behaviors..故C是弱类型

动态、静态类型
• 静态类型 statically: 如果在编译时拒绝ill behaved程序,则是statically typed;
• 动态类型dynamiclly: 如果在运行时拒绝ill behaviors, 则是dynamiclly typed。

可以发现像JS这样的动态语言弱类型,没有如JAVA这样明显的编译过程,只是在浏览器或者Node运行时存在解析过程,并且在解析的时候检查有无forbidden behaviors。这样的动态语言有什么好处了,如部分答主所说的动如脱兔,如同草书般洒脱,不像其他语言一笔一划,讲求中正平稳。看看几年前JavaScript文件,程序如果比较大,几百行JavaScript都可以看得人头晕脑胀,也有没有IDE这样跳转工具,虽然现在有webStorm,对于大规模开发自然是不友好的,这种乱的感觉自然让很多人避开它。
到现在ECMAscript已经发展到ES7甚至ES8了,在Vue-router的开发里面,甚至都用上了Facebook的flow,来验证变量类型,提高代码质量,现在的JS已经是越来越旺盛了。
于是对变量声明的更进一步理解,于是又开始看‘类型与文法’这一章,没想到收获很多

开始

在JavaScript中,变量没有类型 -- 值才有类型。变量可以在任何时候,持有任何值

这句话深入我心,JS里面有var的变量声明,后面又有let和const。声明的变量可以是任何值,从基本变量到Object都是木有问题的,甚至在非严格模式下,不声明直接使用变量也不会报错,而且变量还可以随意更改,从number变到Object,完全没有问题,那为何要称变量为类型呢?明明说的就是变量背后的值是什么类型;

强制转换

falsy列表:

  1. undefined
  2. null
  3. false
  4. +0, 0, NaN
  5. ''
    这些在Boolean强制转换的时候都会变成false,值得注意的是String类型里面仅有唯一一个空字符串('')会被转换为false。
    同时,类似的如[], {}以及-1等等的都是true值,这些简单有用的true/false还是值得注意的。

明确的强制转换

一元操作符'+'能将String明确的转换为number,同样能将Date类型变为number如:

var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );
+d; // 1408369986000

但是通常都不会这么做,没有语义,容易误解,不如常用的'getTime()'好使

需要小心的是parseInt传入非string类型的时候产生的bug。

隐含的强制转换

下面的都是是这一章的重点了,明确的强制转换大多好懂,而且不容易犯错。相比之下,隐含的强制转换就深邃的多了。

var a = [1,2];
var b = [3,4];

a + b; // "1,23,4"

上面例子中当操作数不是number的时候,它们都被强制转换为String类型,我们知道'42' + '1' = '420'通过String来实现字符串拼接,但是Array为什么也会这么做?有深度的内容来了:

根据ES5语言规范的11.6.1部分,+的算法是(当一个操作数是object值时),如果两个操作数之一已经是一个string,或者下列步骤产生一个string表达形式,+将会进行连接。所以,当+的两个操作数之一收到一个object(包括array)时,它首先在这个值上调用ToPrimitive抽象操作(9.1部分),而它会带着number的上下文环境提示来调用[[DefaultValue]]算法(8.12.8部分)。

如果你仔细观察,你会发现这个操作现在和ToNumber抽象操作处理object的过程是一样的(参见早先的“ToNumber”一节)。在array上的valueOf()操作将会在产生一个简单基本类型时失败,于是它退回到一个toString()表现形式。两个array因此分别变成了"1,2"和"3,4"。现在,+就如你通常期望的那样连接这两个string:"1,23,4"。

这上面两段话以为着什么?说的是当你用object做加法操作的时候,object会调用其valueOf方法并返回基本类型,如果valueOf返回的基本类型失败(如没有valueOf方法或则返回的是object),那么就退回去用toString()方法,如果object没有toString()方法,那就返回错误;当然对象自然都是有toString方法的。
其他的String到number的常见的转换就是如'42' + 2 = 44通过字符串和数字直接相加。

上面提到的都是'+'加法计算,那减法呢?
字符串相加可以理解为拼接的过程,但是相减就完全不一样了,如下:

[3] + [1] // '31'
[3] - [1] // 2

恐怖吧?减法直接将其强制转为String,再由String转换为Number类型。

||和&&操作符

||和&&操作符其实没有特别的,只是当你去研究它的时候,又会发现它很特别
在判断的时候如if语言或则是三目判断,常会用到||和&&操作符。但是:

引用ES5语言规范的11.11部分:
一个&&或||操作符产生的值不见得是Boolean类型。这个产生的值将总是两个操作数表达式其中之一的值。

||和&&操作符只是意味着选择,而不是其他语言那样的逻辑判断!
想想确实也用过||和&&来做选择,比如以前很常用的:

e = event || window.event

用来做兼容处理。这里用的就是选择,而常见的cb&&cb()也是做选择。那我们所谓的逻辑判断呢?这个时候用选择的思维考虑一下,马上豁然开朗,逻辑的意思也就是返回最后被选择的数,如果这个数是true那就是真咯。

宽松等价与严格等价

提到== 和 ===操作,大多数开发者对前者经常是避而不及,省心的选择就是用后者,做严格的判断,毕竟前者太过花哨了。。。。。。还是先用心了解一下吧:
这里不得不提几条规范:

4.如果Type(x)是Number而Type(y)是String, 返回比较x == ToNumber(y)的结果。
5.如果Type(x)是String而Type(y)是Number, 返回比较ToNumber(x) == y的结果。
6.如果Type(x)是Boolean, 返回比较 ToNumber(x) == y 的结果。
7.如果Type(y)是Boolean, 返回比较 x == ToNumber(y) 的结果。

当String类型和Number比较的时候,String类型会被ToNumber为数字进行对比。同样的Boolean类型会被ToNumber,转换为0或者1。于是就有了如下:

'42' == false // false
'42' == true // false

可以看出'42'既不是0也不1,但是上面表达式却显得42不true也不false。

2.如果x是null而y是undefined,返回true。
3.如果x是undefined而y是null,返回true。
8.如果Type(x)是一个String或者Number而Type(y)是一个Object, 返回比较 x == ToPrimitive(y) 的结果。
9.如果Type(x)是一个Object而Type(y)是String或者Number, 返回比较 ToPrimitive(x) == y 的结果。

对于null和undefined自然是自身不true也不false,就等于null或则undefined,这个还是很容易理解的;那Object呢?
前面在'+'加法操作的时候提到过Object的转换问题,能通过valueOf返回基本类型就返回,不能就通过toString()方法,这里也是适用的。
值得一提的是形如{}这样的对象,其toString()之后是[Object Object]而不是'0',和平时用的Object.prototype.toString.call还是很接近的。
来看看下面例子

Number.prototype.valueOf = function() {
    return 3;
};

new Number( 2 ) == 3;   // true

这种就是业界毒瘤了,希望不要有**这么写。。。。。哈哈哈哈

抽象关系比较

前面提到了加法和双等于判断,接下来怎么可以不提到大于小于判断呢?有了前面的基础,对于强制转换还是很容易理解的。看一下例子

var a = { b: 42 };
var b = { b: 43 };

a < b;  // false
a > b;  // false
a == b;  // false

a <= b; // true
a >= b; // true

这里Object对象自然都是被强制转换为[object Object],那肯定是不是大小于关系的,那==呢?[object Object]难道不等于[object Object]?这个时候就不要陷入思维误区了,就像'' == '0'为false一样,明明转换后都是数字0为何不成立?因为他俩都是String,不用强制转换直接对比。同样的Object和Object用==判断自然也不用强制转换。值得一提的是Object只有和自己做==判断的时候才为true。
那后面的小于等于和大于等于呢?妈呀,看着都要乱了,这个要根据语言规范了,谁叫他是老大呢?

因为语言规范说,对于a <= b,它实际上首先对b < a求值,然后反转那个结果。因为b < a也是false,所以a <= b的结果为true。

原来是反转。。。。。。JS的逻辑还真会玩

文法

语句是有完成值的,比如你打开浏览器的控制台,输入var a = 1回车,这个时候,显示的下一行是undefined,而不是1,但你敲入a会车的时候,才显示1,这里的undefined和1就是语句的完成值。
关于强制转换,一个经常被引用的坑是[] + {}{} + [],这两个表达式的结果分别是[object Object]0,因为后面的{}表示的是缺省';'的语句于是变成{}; + []自然是0了。
另外文中还提到结构解析,操作优先级,自动分号(ASI)和错误的问题,这些都是基础部分,这里就不介绍了。

25. 翻译: RRB-Trees Efficient Immutable Vectors (上)

概论

不可变结构是一种很便利的函数式编程的数据结构,也是现代语言标准库如 Clojure 和 Scala 的一部分。其相同的部分是基于有固定数量的子节点的多阶树,允许快速查询和更新操作。在本文中我们采用了一种新的潜在的 vector 结构 Relaxed Radix Balanced Trees(RRB-Trees)。并展示了这种数据结构在 O(logN) 的时间里进行不可变数据 vector 的串联,插入和分割操作,同时维持着和原始 vector 数据结构的查询、更新和迭代接近的速度。

1 介绍

不可变数据结构可以便利地处理在多核并发的环境中存在的问题。不可变数据像 List 可以在函数式编程中很有作用,但是他们的顺序性使其不适用于并发处理。Guy Steele 在总结其 ICFP 09 的主题时用到的一句很出名的话 “去除弊端”。新的数据结构有有效的渐进行为和良好的常数因子,这种数据结构要能在并行处理时,分解输入的数据,同时可以有效的组合计算结果。

在可变数据里面,数组通常是以列表的形式的,这样访问元素的时间复杂度为常数,而不是线性,同时同一数组的不相交部分也可以并行处理。对常见的可变数据,构建一个高效的不可变数据模型,即一个可索引的有序序列,不是件容易的事。因为幼稚的创建不可变数据行为是在更新独立的元素时,时间复杂度采用不能接受的线性时间。不可变 vector 数据结构由编程语言 Clojure 首创,并在读写性能上有良好的平衡性,有效的支持多种常用的编程模式。在 Clojure,不可变结构有一个至关重要的语言实现设计,Ideal Hash Tries(HAMTs) 用在不可变结构的基本哈希字典上和同样结构的 32 分叉树上。这样设计的使得有效的迭代和单元素添加的时间复杂度都是常数级别,查询的时间复杂度在 log32 N = (1/5)lg N,更新的时间复杂度在(32/5)lg N。使用宽为 32 的数组作为树的节点可以使得数据结构的缓存更加友好。一个索引的更新只会在间接内存的访问里消耗 (1/5)lg N 时间,这意味着,为了实用的目的,程序员可以考虑将所有的操作都当作 “有效的常数时间”。

然而并行操作需要数据结构的高效的 vector 串联,给定序号分割和插入,而这种的结构并不能简单的实现。本文介绍的方式扩展了潜在的 vector 结构来支持 O(logN) 的串联和插入操作,而不是线性时间,同时不会影响现有业务。这种新的数据结构适用于常见理解类型的并行处理。一个 vector 进行拆分,可以认为是等效并行的。对于很多常见的操作例如 filter,拆分大小是能不预先知道。一个子 vector 可以串联并返回一个 vector,并且不是线性拷贝。这种方式下,并行处理在汇总的时候不会遗失数据。

尽管现在的研究是针对在编程语言 Scala 里的,但是该数据结构也适用于其他语言环境,例如 Clojure,C,C++等等。其他用例如:字符串的特别实现等也都促进基于模板的网页生成。

在本文的其他部分,我们会用术语 vector (结构容器) 来指代 Scala 和 Clojure 创建的 32分叉的数据结构。

1.1 相关内容

以前的研究已经阐述了,不可变数据结构对串联问题有所改进,特别是 Ropes、2-3 finger tree、平衡树等结构。然而每个都有限制的地方。Ropes,这个数据结构原本就是创建来支持字符串串联的,其实现方式是,简单的创建一个有两个子字符串的二叉树。通过向节点添加两个字符串的大小,在串联后可以有效的排序。至于分割,可以通过在 Rope 上创建一个分割的节点,并使用上下分割边界值就能实现。然而进行重复的串联和分割时候,性能会下降。索引的时间复杂度,变成 s + lg c,其中 c 是串联的数量,而 s 是沿着 Rope 树的路径的拆分数量。这里需要考虑平衡性,以免导致最坏的情况。如果不拷贝,分割也会导致内存泄漏,因为原始字符串的分割后排除的部分若不被引用,将不会被收集回收。

2-3 finger tree 的查询和更新的时间都是 lg N,同时添加到 vector 的操作保持在平均常数的时间内。串联也是在 lg N 的时间内完成。尽管挺吸引人的,但是使用这种数据结构对于 (1/5) lg N 查询的折中方法,理论中还是要慢5倍。在 Okasaki 的书中,数据结构的不同在于常数因子。

在本文中,我们介绍 Relaxed Radex Balanced Trees(RRB-Trees),一种新的数据结构,扩展了 vector 结构,并保持了其基本特性,同时允许高效的串联,分割和插入操作。

1.2 Vectors

如图所示,我们用 4 分叉树来作为所有 m 阶树的替代。除了特别的说明外,这个方式都适用于所有 m 阶树结构,包括 32 阶--这个有趣的不可变结构。如图1 就是用这种替代的方式来说明基础的 vector 结构。我们可以想象一个 32 阶版本,只要通过将 4 阶替换为32阶。

在为 Clojure 开发不可变哈希字典时,开发组长 Rich Hickey 用了 Hash Array Mapped Tries(HAMT) 做底子。HAMT's 用 32 阶分支结构来实现一个可变的哈希字典。这个由 Clojure 首创的可变版本中,当项目被添加或移除字典的时候,树的路径才会被重写。同样的 32 阶分支结构被用来提供一个不可变数据的 vecter。在这里,对于项目来说 ‘key’ 是其在 vector 中的索引,而不仅仅是键名的哈希值。不可变性的实现是在修改和添加的时候拷贝或升级树的路径,这使得 32 阶分叉的查询时间复杂度是 (1/5) lgN。
选择 32 作为 m 阶分叉的 vector,是从结构的不同使用来权衡确定的。分叉因子的提高会提高查询和迭代的性能,同时趋向于降低更新和扩展的性能。当 m 变化的时候,原则上查询时间等比与 logm N,而更新用时等比喻 m logm N。而实际上,由于高速缓冲行的原因,在64-128位的现代处理器下,使得复制了该大小的小块非常便宜。

正如我们在图2 看到的, m = 32 是在查询和更新耗时中有良好的平衡,尤其是平时使用查询和迭代的次数要远多于更新。

选择 m 为 2 的乘方可以允许位操作,从而在索引中得到分支值序号,而不是昂贵的模运算。尽管这是在过去需要的考虑的重要问题,如今现代计算机使得其额外的有优势。

图 2 演示了使用 m 阶结构要比二叉树或 2-3 finger tree 要有优势。使用 32 阶的数据结构要是他们的 4 倍,而更新时间是相似的。这个原则上五倍的优势,被上层节点的缓存给稀释减少了。

1.3 串联

在图 3 中展示了两个 vector。一个幼稚的方式是将一个 vector 向左移动,然后将所有的右手边的 vector 的节点复制到被串联的 vector 上合适的位置,根据右手边的 vector 的大小,决定处理的线性时间长短。或者可以遍历右手边的 vector 并将其添加到左侧的 vector 中,这也是线性处理。在本文的剩余部分,我们将会提出 RRB-Trees 是如何有效的进行串联。

2. RRB-Trees

RRB-Trees 通过调整固定的分叉数 m 来扩展已有的结构。为了实现该方法,关键是要防止树退化到线性列表,同时要维持树高度为 lg N。

2.1 B-树

B-树维持每一级中的最大分叉数 Mm 和最小分叉数 Ml。相应的最大高度 Hm,和最小高度 Hl,可以通过给定的项数 N 来表示:

平衡性好的有一个高度比:Hr,并且这个值接近 1 的是玩美的平衡。

Ml 越是接近 Mm,则树的平衡性越好。对于B-树,如果 Ml 是 Mm 的一半,则:

当 Mm 变大的时候,B-树那就更平衡,这是总所周知的特性。

2.2 Relaxed Radix Search

在这个结构下,分叉数 m 一直是 32,同时树也完美的平衡。索引的时候可以直接使用基数查询的方式。当两个这样的树串联的时候要放宽约束来避免线性的复制。少于 m 的项或子树可能存储在同一个节点中。这也意味着我们不能再使用任何简单的基数查询了。

基数查询依赖于每个给定的层级的子树上希望的节点数是刚好 m 的 h-1 次方个。因此对于索引 i, 在该节点的子树中被找到的概率是 [i/(m 的 h-1 次方)](ps:索引和节点都是基于 0 偏移)。如果节点少于希望的,我们需要用另外一个方式,我们称之为宽松的基数查询。

在B-树,通过在父节点中存储一定区间 key,使用线性或则二进制查询父节点来找到包含目标 key 的子节点。在 RRB-树,当叉数少于 m 的时候,使用相似的方法,然而存储在父节点的数组中的是索引的区间而不是 keys 的区间。

图 4 展示了RRB树的基本结构。树节点 A 包含了一个指向子树的数组, C, D, 和 E。该数组包含了这些子树的总的累计的子叶的数量。为了方便,我们将一个指向及该数组的相关区间称之为槽,例如,槽位 0 指向节点 C,该数组的区间说明了其包含了三个子树,这些子树同时也是子叶。

假设我们想要获取序号为 3 的节点,也就是节点 D 的第一个项。该序号被 4 整除,将会落在槽位 0。然而我们发现当查找的序号等于或大于该槽位的区间时,需要查找下一个槽位 1。索引少于槽位 1 的区间的话,可以在槽位 1 的子树 D 中继续查询。在这之前,我们需要用查询的序号减掉槽位 0 的 3 个序号,使得后续是从零开始的新索引查询。可以发现想要查询的索引在节点 D 的第 0 个位置。

通常的,如果 Ml 接近 m,父节点上的基数查询将会快速接近希望的槽位。例如,如果 Ml = m -1,树高为 2时,最差的情况下,只会有 (m-1)平方 个项。为了查询第 m 个槽位,希望在选中的槽位中找到相应的子树,然而有时候,下一个槽位也必须被检查到。

在查询的时候,必须要先检查子树的区间来确定进入哪个子树查询(没有必要回溯查询)。区间值是该槽位的子叶的实际项目数量。我们可能需要检查两个可能的区间值。而不是直接查询正确的路径。这个额外的检测在现代计算机中是很简便的。读取第一个值就会引起缓冲行的加载,接着相邻的几个值会和第一个同样速度的进入缓冲。进行一个短的线性查询有较小的开销。接着,如果考虑所有可能的序号,并且槽位上节点数量也是均匀 m 或者 m - 1,我们可以预计这次的基数查询的成功率是 3/4。

槽位的平均节点数是 (m - 1)/2。(接下来这段文字都看懂了,但是不知道什么意思。。就不翻译了。。。)。

2.3 缓存行意识和二进制查询

缓存行加载可以通过短暂的线性查询,对基数查询带来上面的益处。对于二进制查询,这个是很合适的,因为其在串联算法中需要较少的约束。然而,32 阶的二进制查询可能在缓存行加载的时候导致多个缓存丢失,并且不能简单的预取缓存。经测试表明,宽松的基数查询要比二进制或纯线性查询要块三倍。

23. 初识immutable

前言

Immutable.js 出来已经有很长一段时间了,只是日常项目中一直用不上。一个是平时没有怎么接触,不了解,另外一个是团队的开发节奏和习惯已经稳定下来了,要改变也不容易。当然了解一下也不差。

不可变的数据一旦生成,就无法被改变,这就要求数据可持久化。可是日常中的引用类型的数据,一旦改变了,就改变了,谈什么持久化数据结构呢?

接触immutable

感受一下immutable的不同:

// 原本写法:
let a = [1, 2];
let b = a;
b[0] = 0;
console.log(a[0]); // 0
console.log(a === b); // true

// immutable 里面的写法
import { List } from 'immutable';
let a = List([1, 2]);
let b = a.set(0, 0);
console.log(a.get(0)); // 1
console.log(b.equals(a)) // false

可以直观的看到在没有使用 immutable 之前, a 与 b 都是同一引用,一旦其中的数据修改了,另一个也会跟着变化,毕竟 a 是等于 b 的。而到了 immutable,就不一样了,当你设置 a.set(0, 0) 的时候,并不会修改 a ,而是返回一个新的数组赋予到 b,所以 a 还是原来的 a。

**这就是 immutable 致力解决的疼点:持久化数据解构。**平时使用数据的时候,可能一不小心就会把引用类型数据修改了,导致一些隐藏比较深的问题。尤其是对后来的开发者/维护者而言,意义就更重大了。

在 immutable 里面常见的数据类型有:

  1. List: 有序可重复列表,类似于 Javascript 的 Arry;
  2. Map:键值对,类似于 Javascript 的 Object;

其 API 还是很亲民的,基本上字如其名,大致接触一下就能够了解了。常用的用法这里就不做介绍了,需要了解,请移步官方文档

List 类型

Liit 类型可以说是常用的了,尤其是和 Javascript 里面的 Arry 类似。看一下 List 里面的主要部分:

function makeList(origin, capacity, level, root, tail, ownerID, hash) {
  const list = Object.create(ListPrototype);
  list.size = capacity - origin;
  list._origin = origin;
  list._capacity = capacity;
  list._level = level;
  list._root = root;
  list._tail = tail;
  list.__ownerID = ownerID;
  list.__hash = hash;
  list.__altered = false;
  return list;
}

一个空内容的 List,也就是 List(),是 makeList(0, 0, SHIFT)) 生成的,而 SHIFT 的值为 5。传入 List 括号里面的值会被存储在 _root 和 _tail 里面,只是 _root 和 _tail 里面的数据又有以下结构:

// @VNode class
constructor(array, ownerID) {
  this.array = array;
  this.ownerID = ownerID;
}

在 List 列表的生成里,就可以看到,持久化数据的形成,比如看看为何将数据分别保持在 _tail 和 _root 里面,以及又是用何种方式保存;

以设置一个数据为例子,如: List([1]).set(0, 0):

set(index, value) {
  return updateList(this, index, value);
}

function updateList(list, index, value) {
  ...
  let newTail = list._tail;
  let newRoot = list._root;
  const didAlter = MakeRef(DID_ALTER);
  if (index >= getTailOffset(list._capacity)) {
    newTail = updateVNode(newTail, list.__ownerID, 0, index, value, didAlter);
  } else {
    newRoot = updateVNode(
      newRoot,
      list.__ownerID,
      list._level,
      index,
      value,
      didAlter
    );
  }
  ...
  return makeList(list._origin, list._capacity, list._level, newRoot, newTail);
}
// SIZE = 1 << SHIFT,而 SHIFT = 5
function getTailOffset(size) {
  return size < SIZE ? 0 : ((size - 1) >>> SHIFT) << SHIFT;
}

可以看出在 updateList 里面,通过 _capacity 来判断,以 32位 为尺度将 _capacity 切分开来,当 index 大于 ((size - 1) >>> SHIFT) << SHIFT 时候,更新 _trail, 否则更新 _root。例如: 当 _capacity 为 33,index 为 32 及其以下的时候,修改的都是 _root,否之则修改 _tail。这个是很好理解的,当数据量达到一定程度的时候,针对靠后的数据单独存储,而靠前的数据放在 _tail,分类处理。只是特别之处在与 _tail 的设计。

List 里面的 32 阶 RRB-Tree

_tail 里面采用的是 RRB-Tree 的形式存储数据。这个什么树的先不介绍,先看看看怎么形成,形成的是什么,继续看上面的 updateList 方法,里面用到了 updateVNode 方法来生成 _tail:

function updateVNode(node, ownerID, level, index, value, didAlter) {
  // MASK = 31;
  const idx = (index >>> level) & MASK;
  ...
  let newNode;
  if (level > 0) {
    const lowerNode = node && node.array[idx];
    const newLowerNode = updateVNode(
      lowerNode,
      ownerID,
      level - SHIFT,
      index,
      value,
      didAlter
    );
    if (newLowerNode === lowerNode) {
      return node;
    }
    newNode = editableVNode(node, ownerID);
    newNode.array[idx] = newLowerNode;
    return newNode;
  }
  ...
  newNode = editableVNode(node, ownerID);
  if (value === undefined && idx === newNode.array.length - 1) {
    newNode.array.pop();
  } else {
    newNode.array[idx] = value;
  }
  return newNode;
}
function editableVNode(node, ownerID) {
  if (ownerID && node && ownerID === node.ownerID) {
    return node;
  }
  return new VNode(node ? node.array.slice() : [], ownerID);
}
// @VNode class
constructor(array, ownerID) {
  this.array = array;
  this.ownerID = ownerID;
}

在这里可以看到 value/newLowerNode 是赋值给 newNode.array[idx], 而 idx 并不是等于 index。以 _capacity 为 65 的 List 为例子,其 _level 为 5。当 index 为 60 的时候,有以下行为:

  1. 第一次进入 idx = 1,level = 5,将会有 _tail.array[1] = newLowerNode;
  2. 计算 1 里面的 newLowerNode,第二次进入,此时 level = 0,idx = 28,于是有 newLowerNode.array[28] = value;
    可以看出这里生成个二维的数组,其中每个子节点的长度最大为 32,于是这就构成了一个 32阶的树结构。至于为什么阶长是 32,在代码中是这么解释的:

Resulted in best performance after ______?

这不是逗我嘛。。。什么都没有写好吧,而且 github 里面最早的版本也是这么写的。。。。最后还好找到是有测试实验数据证明 32 也就是 SHIFT 为 5 是最佳实践。至于为什么要采用这种结构,不是本文要考虑的,将在下篇中给出来。

最后数据生成的结构如下如所示:

get 方法里面也是差不多的,通过 _capacity 来判断。

Map 类型结构

同样的先看看一个空的 Map() 的结构:

function makeMap(size, root, ownerID, hash) {
  const map = Object.create(MapPrototype);
  map.size = size;
  map._root = root;
  map.__ownerID = ownerID;
  map.__hash = hash;
  map.__altered = false;
  return map;
}

这里就没有 _tail 结构的,所有数据都放在 _root 里面。只是 _root 里面的数据并非总是简单,采用了 Trie Nodes 的方式存储数据。 immutable 里面将对象的键值对转换为数组表示,即 [key, value] 的形式。

存储的数据分为以下级别:

  1. ArrayMapNode,最简单方式,当键值对不超过8个的时候(不含嵌套的键值对),采用这种方式,所有键值对保存在 entries 里面。同时 get/set 方法都较为简单,直接遍历一下获取就好了;
  2. BitmapIndexedNode,当 ArrayMapNode 里面元素超过8个的时候,_root 会转变为 BitmapIndexedNode,BitmapIndexedNode 的子节点是 ValueNode。在 BitmapIndexedNode 里面查/增/改元素,都需要用到 bit-map(位图)算法,BitmapIndexedNode.bitmap 存储的是键名和存储顺序的位图信息。例如 get 方法,通过 BitmapIndexedNode.bitmap,以及 key 名就可以获取该键值对的排列序号,从而获取到相应的 ValueNode;
  3. HashArrayMapNode,ValueNode 个数超过 16 个的时候,_root 会转变为 HashArrayMapNode 对象,其子元素为 ValueNode。而当 ValueNode 个数超过 32 个的情况时,HashArrayMapNode 的亲子元素就会出现 HashArrayMapNode/BitmapIndexedNode,而 BitmapIndexedNode 的亲子元素可以是 BitmapIndexedNode/ValueNode。由此看来巨量的键值对,将有 HashArrayMapNode/BitmapIndexedNode/ValueNode 组合而成,而每个 HashArrayMapNode 最多有32个亲子元素,BitmapIndexedNode 最多有16个亲子元素。 HashArrayMapNode 类对应带的 count,代表其子元素的数量。当需要读取的时候,直接键名的哈希值,就能够实现了。。。。好像有点简单呀;
  4. HashCollisionNode,这种情况相当少,比如当 {null: 1} 和 {undefined: 2} 的键名是完全不同的,但是他们的键名的哈希值却是一样的。这使得后者 {undefined: 2} 创建的时候才会有 HashCollisionNode。HashCollisionNode 包含了这两个相同哈希值的键名下的数据;

// The hash code for a string is computed as
// s[0] * 31 ^ (n - 1) + s[1] * 31 ^ (n - 2) + ... + s[n - 1],
(多键值的整个读写离不开键名哈希计算,而其关键步骤如上,n为键名从左到右字母的 charCodeAt)。

withMutations 操作

每次对 immutable 的数据类型操作的时候,都会返回一个新的数据,这样就存在一个问题,如果需要像原本一样对一个数据不断0操作,如不断的向Arry 里面 push 新值。对于 List,每次都返回一个新 List 岂不是多了很多中间变量,多了很多蹩脚的操作?于是就有了 withMutations 操作。如:

let a = List();
Array.apply(null, { length: 10 }).forEach(item => a = a.push(item));
// 上面可以用withMutations操作
a = a.withMutations(list => 
  Array.apply(null, { length: 10 }).forEach(item => list.push(item))
)

上面为原先不使用 withMutations 的方法,每次 push 之后都需要重新对 a 重新赋值,才能保证正确性。而用了 withMutations 方法之后,不再需要创建中间 List,减少了复杂度,基本上运行时间比前一种快上好几倍。当然 withMutations 不会修改 a,所以需要将中间 List 赋值到 a。

为什么 withMutations 会有这样的效果呢?以 A(List) 为例子,当使用 withMutations 时候,其首先,生成的中间变量 B(List),其属性值就是 A 的属性值,没有任何更改,当然如果 A 没有 __ownerID, B 的 __ownerID 会被设置上。但是对 B 进行 push/pop/set 等等操作的时候,A 其实是没有受到任何影响的。

function editableVNode(node, ownerID) {
  if (ownerID && node && ownerID === node.ownerID) {
    return node;
  }
  return new VNode(node ? node.array.slice() : [], ownerID);
}

如在 withMutations 操作里面 set 数据的时候, 上面 if 语句是无法通过的,因为 A 的数据里面是没后 ownerID 的,相当于给 A 的数据浅拷贝一次到 B 里面,但是在 withMutations 里面再次 set 的时候,这时上面的 if 语句是可以通过的,于是又能节省不少运行时间。类似的 Map 也是相似的,这里就不再提到了。

其他

immutable.js 类型的一层层继承关系,刚开始的看的时候会觉得有点乱,不管哪里都有 extends。细细看下来还是挺不错的。Immutable.js 还有个 Seq 类型,其特点就是懒。懒的意思是,直到获取使用了才开始计算。一开始觉得很神奇,居然有用数据的时候才计算的。。。well,后来一看这得益于一系列的 API,主要就是把调用方法函数数据什么的统统用闭包给存起来。。。。。。。好吧。

ps:immutable.js 最神奇的地方还是在于其数据结构,下篇文章将会好好讲数据结构。

42. sticky 以及 swiper

最近为了新版官网,一直在学习 iphone 11 的效果图,结果越研究发现其复杂度实在远超想象,还要支持各种兼容问题。而我们这次的官网则是要向其学习,其中类似 apple 的翻页的布局是结构的重中之重。

sticky的翻页效果

苹果官网上采用的是 sticky 的效果,就是 position: sticky 这个属性的兼容性比较一般,基本只有现代浏览器都支持。只是 apple 都用了,其在 ie 等浏览器也做了兼容处理,那为什么我们不试一试翻页效果呢?

没有找到合适的第三方库,于是采用自己摸索的方式,按照苹果的方式按葫芦画瓢,具体的结构大致如下

<main>
  <section class="sticky-container">
    <div class="sticky-inner"></div>
  </section>
</main>
.sticky-container {
  margin-top: -100vh;
  height: 200vh;
}
.sticky-inner {
  position: sticky;
  top: 0;
  height: 100vh;
}

每个模块翻页的模块都是一个 sticky-container 模块,并且通过 margin-top 往前上移一个屏幕的高度。内部模块再采用 sticky 的方式,使得上一个模块翻过的时候,下一个模块已经出现了,并且其内部模块牢牢的固定在顶部,达到翻页的效果。

同时这个 sticky 的方案满足长模块的要求,普通模块高度为 100vh,当模块的内容较多,100vh 不够的时候可以扩展开。同时 H5 也可以采用这种方式。

sticky 兼容效果

ie 浏览器毫无意外是不支持的,考虑到2019年11月份 ie 浏览器的**份额已经接近 0.8% 的水平,于是采用简单的兼容方式,将 sticky 统统改为普通布局。只是在移动端,本来以为兼容效果是最好的,caniuse 里面基本移动端都是没问题,没有想到现实中有各种问题:

oppo 浏览器最新版不支持 sticky,uc 浏览器对嵌套 sticky 支持效果非常差,会出现大块的白屏情况,chrome 浏览器是最好的。可以通过简单的判断 $('.sticky-inner').css('position') === 'stikcy' 或者是 -webkit-stikcy 来判断是否支持。而 uc 的嵌套问题只能通过修改代码结构来实现。只是 UC 浏览器对 sticky 的滑动效果不太好,底部边缘会出现颤抖的情况,通过 GPU 加速的方式也无法消除问题,最后考虑还是将 UC 浏览器同样降级为非支持 sticky 的模式。

为了兼容非支持 sticky 模式的机型,特意查询了一下主流的代替方案,采用 fixedabsolute 来代替 stikcy,其中效果最好的要数 stickybitsstickyfill 这两个 Polyfill 方案了,但是其对长模块内容的 sticky 支持却不好。最后还是自己调试生成兼容版本。

sticky 翻页

sticky 已经可以很好的解决翻页问题了,奈何领导提出这样的翻页效果不符合要求,滚动或者滑动过程存在可以看到其他页的效果(难道苹果不是也有同样的问题?),于是在上线前两天临时改了方案,经过评估最好的方案是用 swiper,只是由于整个功能页改为 swiper 需要时间较多,于是折中使用 sticky 翻页时,自动整体往上滑动的效果。

主要技术难点为页面定位问题,这个需要维护一个锚点位置的列表,在初始化和 resize 时候更新,而长模块内容的自动上划以及其自然翻滚要做区分处理,这个区分处理就很麻烦,需要耐心调试。而更加麻烦的是在 mac 下面表现很差,到处乱飞,以及 H5 的滑动也是乱飞的情况,需要一个个适配,于是在上线前一天理所当然的放弃了 H5 以及 mac 的效果,也好给领导交差。

swiper 版本的翻页效果

对于垂直翻页的效果,如果长模块内容复杂,可以下个定论,是不适合用 swiper 的,只能用 sticky 的方案,swiper 在长短屏切换时需要处理各种逻辑兼容问题,如果长模块内容复杂,则会增加复杂度。

相比较于 stikcy 的翻页模式,swiper 的翻页需要自己搭建,其本身自带的切换效果只有 fade cube 这些模式。pc 端用到的是 wheel 滚动,在事件 transitionStart 触发的时候修改 swiper 动画就可以了。需要注意的是由于是翻页效果,所以每个页面都要绝对定位,并设置 z-index;由于长模块内容在 wheel 触发的时候,不能直接翻页,需要判断是不是长模块内容本身的滚动,于是要动态设置 mousewheel.enabled

移动端则比 pc 端复杂不少,由于其翻页是触摸式翻页,需要在 progress 里面同步修改翻页的 transform,同时由于长模块问题,需要动态设置 allowTouchMove,类似 pc 端的 mousewheel.enabled

由于长模块的内容,存在 fixed 元素,而 swiper 的翻页效果为了达到顺滑,采用的 transform 动画,这样将导致长模块内的 fixed 失效。为此有两个方案可以实现:

  1. 将长模块内容高度限制在 100vh,支持 overflow-y: scroll;长模块内分为 fixed 元素和长高度的空元素,长高度元素起高度撑开作用;由于 fixed 元素存在交互,需要将长高度元素的 z-index 放在最底层,所以无法自然滚动该元素,故采用控制滚动。fixed 元素在上下翻动的时候改为 absolute 布局。
  2. 将 swiper 内的长模块的内容完全移出来,在 swiper 翻页的时候,单独控制其 transform,原本的 swiper 模块只做高度撑开作用,来配合控制滚动。

这两个方案分别用在了 pc 端和移动端,最后效果看来是第二种好,absolutefixed 布局还是有差异,会导致页面抖动,需要不断调试。第二个方式这是需要自己修改 swiper 翻页模式,将 fixed 的元素和对应 swiper 页面的翻动结合在一起。

swiper 切换模式

初步尝试切换,采用在 fade 模式和 transform 修改,但在移动端的 progress 事件里修改 transform 时候,页面的动画无法生效,一直是 translate3d(0px, 0px, 0px),除非采用 !important 增加 transform 的权重,并且要写在 css 样式中,无法做到动态修改,于是一开始移动端使用的切换效果是基于 top 的,调试的时候效果符合要求,但是用真机调试,发现 top 的效果还是差强人意。

后面立刻研究 swiper 的源码,从模式入手,发现其切换模式的添加,是采用 swiper.use 方法,该方法没有开放出来,有点类似 Vue 的模式。研究 effect-fade.js 文件可以发现下面代码:

// ... 省略部分代码
const Fade = {
  setTranslate() {
    $slideEl
      .css({
        opacity: slideOpacity,
      })
      .transform(`translate3d(${tx}px, ${ty}px, 0px)`);
  }
}

export default {
  name: 'effect-fade',
  on: {
    setTranslate() {
      const swiper = this;
      if (swiper.params.effect !== 'fade') return;
      swiper.fadeEffect.setTranslate();
    },
  }
}

移动端每次滑动的时候,其 transform 值都会被重新修改,导致 progress 里面的修改无效。于是我就自定义一种模式 slide-page,其在滑动的时候,读取当前 progress 来修改 transform,只是需要注意的是不能仅仅对当前页面修改,需要对全体页面都重新设置,避免切换的时带来的问题,并且需要同步到 fixed 的元素。这样的模式也适用于 pc 端。

总结

sticky 的优势是最明显的,功能完备,pc 端兼容良好。而 swiper 需要在长短模块之间切换,mac 下由于高度是不变,没有正常布局的格式,所以会触发橡皮胶效果,体验整体没有 sticky 好。另外上面的方案还有改进点:对于长模块内容,内部可以去掉长高度元素,每次长模块滑动滑动都触发一次状态修改就可以了,没有必要采用 z-index 为负的滚动。

31. react 开启异步渲染与优先级

在研究时间调度时就有 React 的异步渲染的迹象了,只是在实际应用中却不知道如何开启,如何跟踪,后来看了 react-fiber-resources 上面介绍的 Try React Fiber asynchronous rendering!。其实在 React 的博客 React v16.0 发布之际就已经说明了:

We think async rendering is a big deal, and represents the future of React. To make migration to v16.0 as smooth as possible, we’re not enabling any async features yet, but we’re excited to start rolling them out in the coming months. Stay tuned!

直到现在本文研究的版本 16.4.1,异步渲染 async rendering 仍然没有正式推出,其中日常开发中可以看到 ReactDOM.render(element, container) 模式采用的就是 legacyCreateRootFromDOMContainer 这类方式,在生成 root 的时候直接将 root.current 的 mode 赋值为 0,也就是常见的同步的方式。fiber 的模式 mode 有以下类型:

// ReactTypeOfMode.js
const NoContext = 0b000;
const AsyncMode = 0b001;
const StrictMode = 0b010;
const ProfileMode = 0b100;

StrictMode/ProfileMode 在源码中似乎是用于开发中的,于是看这里的 AsyncMode 模式,也就是异步模式。只是通过官网教程介绍的方式是无法开启异步模式,还没有大规模的推广,现在日常开发用的 React 基本上还是同步模式 sync,也就是上面的 NoContext。

那如何开始异步模式呢?在上文链接中有介绍到。可以通过以下方式:

// 较于之前的方式,将 container 与 element 分开。通过 unstable_createRoot 
// 创建异步的 fiber。
const root = ReactDOM.unstable_createRoot(container);
root.render(<App />);

unstable_createRoot 的方式和先前一样,创建根部 root,即是 ReactRoot 对象,当前 current fiber 为异步模式,mode 为 3 即 AsyncMode|StrictMode。再进行 rendering 渲染。

expirationTime

在早期的 React 版本里面采用了优先级的 Priority 模式,如下:

module.exports = {
  NoWork: 0,              // No work is pending.
  SynchronousPriority: 1, // For controlled text inputs. Synchronous side-effects.
  AnimationPriority: 2,   // Needs to complete before the next frame.
  HighPriority: 3,        // Interaction that needs to complete pretty soon to feel responsive.
  LowPriority: 4,         // Data fetching, or result from updating stores.
  OffscreenPriority: 5,   // Won't be visible but do the work in case it becomes visible.
};

到了该版本 React 16.4.1,采用的则是 fiber.mode 以及 expirationTime 相继结合来实现异步以及优先级的。已经没有上面这些标志的存在了。通过统一的参数 expirationTime 来表示优先级。分为两种情况:

  1. expirationTime 为 1,就是同步模式,也就类似于 React 16 之前的 stack 情况,程序会按照顺序执行下去,直到结束;
  2. expirationTime 不为 1 的时候,则其值越低,优先级越高。rootExpirationTime 不为 1 的时候会启动异步模式。
function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
  let expirationTime;
  if (expirationContext !== NoWork) {
    expirationTime = expirationContext;
  } else if (isWorking) {
    if (isCommitting) {
      // 在 commit 阶段更新任务,需要给予同步优先级
      expirationTime = Sync;
    } else {
      // 渲染阶段则设置为渲染结束后的下次时间
      expirationTime = nextRenderExpirationTime;
    }
  } else {
    // 非执行阶段,根据 fiber.mode来设置 expirationTime。
    if (fiber.mode & AsyncMode) {
      // 异步模式下
      if (isBatchingInteractiveUpdates) {
        expirationTime = computeInteractiveExpiration(currentTime);
      } else {
        expirationTime = computeAsyncExpiration(currentTime);
      }
    } else {
      // 同步模式下
      expirationTime = Sync;
    }
  }
  // ...省略交互相关部分
  return expirationTime;
}

如果 fiber.mode === AsyncMode,则 expirationTime 为大于 1 的值,同步则为 1,当然同步是优先级最高的,异步具有 LowPriority。前面博客都是同步模式下研究的。对于异步时间的计算,按照 当前时间为基准,250ms 为一个单元。两个异步 fiber 计算 expirationTime 的时候,如果当前时间 currentTime/25 为相同整数,则这两个异步 fiber 具有相同的优先级。

render/reconciliation 阶段的从深调用栈返回处理交互事件,也和时间的计算有关系。在上文中提到,在 workLoop 中通过计算 deadline.timeRemaining() 来判断是否还剩余时间,这个也就是 requestIdleCallback 的用法。但是有一点不同,在 performWork 里面,也就是即将进入 performWorkOnRoot 之前有:

function performWork(minExpirationTime, dl) {
  //...省略部分
  if(dl !== null) {
    while (
      nextFlushedRoot !== null &&
      nextFlushedExpirationTime !== NoWork &&
      (minExpirationTime === NoWork ||
        minExpirationTime >= nextFlushedExpirationTime) &&
      (!deadlineDidExpire ||
        recalculateCurrentTime() >= nextFlushedExpirationTime)
    ) {
      recalculateCurrentTime();
      performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, true);
      findHighestPriorityRoot();
    }
  }
  // 省略部分
}

由前文可知当没有剩余时间的时候会终结 workLoop 循环,同时令 deadlineDidExpire = true,只是如果这样还是不足以使得 performWork 里面的循环为 false,必须要 recalculateCurrentTime() >= nextFlushedExpirationTime。recalculateCurrentTime 计算的是当前时间,nextFlushedExpirationTime 则是 root.expirationTime,初看的时候会自然的认为后者肯定是更小的,但是恰恰相反。异步的 expirationTime 是以 5000ms 为基数, 250ms 为单位计算的。所以只有当超出时间过长,比如五六秒,才会变为立刻执行的情况。在之前的小版本里面,基本是只要是剩余时间不足就立刻中断,没有和 nextFlushedExpirationTime 比较的过程。

前面的 expirationContext 涉及到 ReactDOM 的另一个异步更新的 API,如下:

ReactDOM.unstable_deferredUpdates(() => {
  ReactDOM.render(<App />. container);
  // or
  instance.setState(() => newState);
}); 

与 unstable_createRoot 一开始就创建异步模式的 fiber 不同,unstable_deferredUpdates 会修改 expirationContext 生成异步的 expirationTime 达到延迟更新的目的,

优先级

expirationTime !== SYNC 时候,fiber 就具有不同的优先级,有优先级的前提是异步。异步模式是通过时间调度方式实现的,具体看前文 react 时间调度。那优先级是定义好了,但是如何保证高优先级的先执行呢?

在 renderRoot 的时侯,会有 nextRenderExpirationTime = root.nextExpirationTimeToWorkOn,所以当初次构建 workInProgress Tree 的时候所有的 fiber 也就是 expirationTime 都是一致,哪怕是异步组件 React.unstable_AsyncMode。生产的 expriationTime 是一样的,但是若中途出现优先级更高的事件,导致基准的 expriationTime 改变又如何?

function beginWork(current, workInProgress, renderExpirationTime) {
  if (
    workInProgress.expirationTime === NoWork ||
    workInProgress.expirationTime > renderExpirationTime
  ) {
    return bailoutOnLowPriority(current, workInProgress);
  }
  // ...省略后面 switch (workInProgress.tag) 的情况
}

在 beginWork 就比较当前 fiber.expirationTime 和 renderExpirationTime 关系。renderExpirationTime 即是 nextRenderExpirationTime,是下次要渲染的时长,高于它,则该 fiber 将不会在该阶段中渲染。这种情况会发生在异步渲染里面,还在渲染阶段的时候,插入低优先级的的事件,这个时候将会产生更低优先级的 fiber,本轮 commit 结束后,会在下一次渲染阶段再做安排。

update 的优先级

在 fiber 里面有任务队列 updateQueue,该队列维护的是传参 element,是个链表结构,节点为 update。通过 update.nextEffect 来指向下一个节点。而这里的每个节点,则是需要更新的 element。update 结构如下:

export function createUpdate(expirationTime: ExpirationTime): Update<*> {
  return {
    expirationTime: expirationTime,
    tag: UpdateState,
    payload: null,
    callback: null,
    next: null,
    nextEffect: null,
  };
}

expirationTime 是对应该节点的优先级。fiber 有优先级,这个 update 也有优先级,这个在 ReactUpdateQueue.js 开头的注释有介绍到:

  1. updates 的排序不是基于优先级的,而是基于插入顺序。
  2. 渲染阶段的时候,只有高优先级的 update 才会被被用到。

以前版本里面,对 update 的处理是采用 insert 方式的,即是高优先级的会插入到低优先级 update 前面。而今则是通过 expirationTime 区分。

function processUpdateQueue(workInProgress, queue, props, instance, renderExpirationTime){
  // ...省略部分
  let update = queue.firstUpdate;
  let resultState = newBaseState;
  while (update !== null) {
    const updateExpirationTime = update.expirationTime;
    if (updateExpirationTime > renderExpirationTime) {
      // This update does not have sufficient priority. Skip it.
      if (newFirstUpdate === null) {
        newFirstUpdate = update;
        newBaseState = resultState;
      }
    } else {
      // This update does have sufficient priority. Process it and compute
      // a new result.
      // ..,省略
    }
  }
  // ..,省略
}

可以看出只有小于或者等于 renderExpirationTime 的才会被采用,你没有看错是小于或者等于,而不仅仅是小于。因为同优先级的 update 是会被采用的,这个 bug 已经在 Always batch updates of like priority within the same event 被修复了,well,同时也修改和很多地方,最大的是 requestCurrentTime 取代了 recalculateCurrentTime。
没有被采用的 update 最怎么样呢?通过 newFirstUpdate 保留到 updateQueue 里的 firstUpdate 了。下次渲染的时候,才会被用到。

总结

异步渲染里面,除了 ReactDOM.unstable_createRoot/ReactDOM.unstable_deferredUpdates 以外,还有 React.unstable_AsyncMode 的方式创建异步组件,React.Timeout 这样的 TimeoutComponent 组件。只是目前关于异步,优先级,React 本身还在一直更新中,指不定有不少的坑,当然都冠以 unstable 的名号了,谁用谁负责,就像 UNSAFE_componentWillReceiveProps 之类的。期待未来 React 异步渲染正式推出那天。期待下图:

参考

  1. [翻譯] React Fiber 現狀確認
  2. react-fiber-resources 里面的资源都灰常好!如这篇 Build your own React Fiber,还有视频系列。
  3. coordinating-async-react

24. immutable.js 数据结构

前言

前文《初识immutable》介绍 immutable 的一些基本操作和特色,但是其重点部分结构共享,即数据结构留在了这里。这不是平时接触到的简单的数组、对象亦或则字典的形式,而是 tree !日常开发基本就遇不到树结构,再复杂一个字典表就可以搞定了,于是乎 immutable.js 提供了一次很好的学习数据结构,学习算法的体验。

共享结构

共享结构还是比较简单的。对于已有的数据结构,若需要更新其中的某个节点(中间节点或则子叶),并不会把整个数据结构都拷贝一份,再修改该节点并返回新的数据结构。immutable.js 里面会采用路径修改的方式来实现更新。

简单的来讲,对于数组或则对象的形式,更新的时候,仅仅会修改该节点,浅拷贝该层级所有节点,修改所有父节点,以及浅拷贝该父节点的所有同父级节点。这么说可能有点绕口,来看看大神 camsong 做的图:

如图所示,当更新黄色节点的时候,黄色节点所在的同一父节点的元素都会被浅拷贝,同时黄色节点的所有父节点也会被更新,以及浅拷贝父节点的同一级节点。于是就有了路径修改这一说,相当于其他节点也就是树结构左下角蓝色的四个节点,对于新旧结构就是处于共享状态的。所以这应该就很清晰了,通过这样的树结构减少拷贝数量,而对于其他节点而言,由于都是浅拷贝,是不影响旧结构指向的。只是在新的父节点处修改对子节点的指向,也就是上图中右边分支。

同样的对于 List 结构,也是树结构,也会浅拷贝,所以这里就不多谈了。

数据结构

看了上篇博客的,基本可以发现,列表 List 的数据结构就是一个 32 阶树。查找和更新都是根据 5 bit 为标准的,一个分支最多有 32 个子分支。所以通过对索引序号 index 进行位移操作,就是 index >>> level,再取 31 的模。从 index 的高位到低位来读取树结构从上到下的数据。而这里用到的是什么技巧呢?

Bitmapped Vector Trie

总所周知,对于普通的数组结构,其读取的时间复杂度为 O(1),只是插入更新的时间复杂度为 O(n),对于海量数据是不可取的。而二叉树 BST 呢,读取的速度为 1.39 O(lg n),插入平均速度也为 1.39 O(lg n)。相较于线性的读写,还是要优秀不少的。**所以是不可能采用线性结构来存储数据的。**List 结构采用 32 阶的树结构,其读写的时间复杂度更加优秀,是 O(lg n),其底数是 32,也就是说时间复杂度是二叉树的 1/5。

Bitmapped Vector Trie,位图数列前缀树,名字有点长,但是意思很明显。先看看维基中的 trie:

trie 前缀树,是树的一种,只是 trie 的键是由位置决定的。比如数字 3 ,其就是有 t 节点, e 节点和子叶 a 节点决定的,是由其路径决定的。通过 tea 这个键,就可以找到值 3,而对于键 inn,则可以找到 9 这个数值。这个就是 trie 的功能,多用由于字典查询~~

Bitmap 算法则是,将数据通过位移运算映射到位上,从而实现内存开销的减少。如果能用一个 bit 位来识别这样一个数字,就会很方便了。如数字 3,通过位运算 1 << 3 得到映射的二进制 1 000,从而当的映射数据的第四位为 1 的时候,则说明 3 这个数存在!为 0,则不存在,通过位图算法可以大大减少内存使用。对于 java,int 类型的长度为 32 位,所以也就是为原来 1/32 的样子。

在普通数组中要如何结合 trie/Bitmap 呢。trie 和 Bitmap 的结合可以加快树结构里面的查询。具体操作可以看下图,

在该图中,对于数组 zoo,当要取索引为 5 的数,要如何获取呢?可以看到存储的数据结构是 trie 的形式,只是每个节点上面的键名都不是之前介绍的字符串,而是 0/1 的形式。将索引 5 二进制化为:101。通过二进制数 101,从高位到低位依次读取 1 bit,来读取现有的 trie 结构上节点的数组的值,就能够确定路径,从而现实 Bitmap 位图的查询,都不用什么复杂的判断,只要每次取索引 5 的映射值,也就是二进制 101,就能够轻松读到数据。

对于 32 阶的数结构,则是每次都读取最高位的 5 bit,来实现 Trie 查询。对于长度为 65 的数组,如索引值为 60,则其第一次 60 >> 5 & 31 为 1,第二次为 28,所以第 60 个数据,在第一个 32 阶分支的第二个分支下面的第 28 个元素就是目标值。是不是很快,飞一般的速度。

32 阶的选择

选择 32 阶树结构是要远远快于二叉树的,理论上是二叉树时间复杂度的 1/5。使用 32 底数的时候,若层级超过 7,就已经有超过数十亿个数了,这个时候内存可能都不够了吧。所以在实际开发中可以把这个 32阶的 Bitmapped Vector Trie 的时间复杂度当作常数。

那为何不继续使用更大的底数呢,底数更大,其时间复杂度应该是更加优秀的吧?

事实上,选择 32 作为 m 阶的分叉,是有其特殊性的。可以看到下图:

可以看到上面是更新操作,下面接近 x 轴的是查询操作。而当底数为 32 的时候,在更新和查询操作上都有良好的平衡性。当然这么一看不是最好的选择,可能看客会觉得似乎 8 要远远优于 32。只是这个考虑是不周全,在日常使用持久化操作的时候,查询操作的比例是要远远高于更新操作的。所以很显然会选择 32 而不是 8,前者的查询耗时是后者的 66% 左右。

只是为什么选择 32,而不是 33/31/29 之类的呢?原因很简单,因为选择 32 的时候,对索引的取模操作,可以被优化为位运算,也就是上文中出现的 index >>> level。在现代计算机下,位运算可以大大的减少计算时间。

另外由于计算机的高速缓存行的问题,阶数少的时候,其缓存效果更加,导致了底数为 2 的查询速度只是 32 的 1/4 而已,而不是 1/5。

HAMT

上面介绍 Bitmapped Vector Trie 的时候,讲的是 List 结构。那对于 Map 结构又要如何处理,毕竟没有 List 结构的索引,好像就无从下手了。

Map 是没有索引,没有错,但是我们可以把键名的哈希值变为索引呀~~

HAMT:Hash Arrey Mapped Trie,就是这样一个结构,将 key 的哈希值存储在 Trie 节点上。如同索引一样,需要对哈希值取模,也就是 32 bit 的位操作。只是不同的是 List 结构上面是从高位往低位的索引读取的,而 Map 是从 key 哈希值的低位往高位顺序读取的。为什么这么设计呢?List 一开始就能够知道数组的容量 capacity 是多少,通过 capacity 和 index 索引可以知道数据的位置,而容量肯定是大于等于索引的,排在索引前面的是必然有数据内容的。而 Map 结构,如果从哈希值的高位往低位读取的话,那就有点恐怖的,小于该哈希值的字段,却不一定有呀,这样就导致在没有什么数据下,树的层级就可以很深了。这样就达到了压缩层级的效果。

为了更好的解释 HAMT 结构,这里举个例子,对于 key = Aa,其计算出来的哈希值为 2112,取模 2112 & 31 为 0,所以他将会在数组的下角标为 0 的对象里面,至于该对象是不就是这个 key 生成的对象呢?取决于还有没有其他低 5 位也是 0 的元素,如果有那还要取下一个高 5 位来查询,直到找到数组。这样就很明朗了,通过每次取 31 的模,从低位到高位,这样的 Bitmapped 的方式就可以确定元素的位置了。

每次取 31 的模,这样每个层最多都有 32 个元素。于是就形成了 32 分叉的结构。但是并不是每次都是 32 个数据的,这样看数量量的大小。当字段比较少,少于 8 个的时候,Map 结构只是个简单的数组结构,每次读取 key,通过循环判断就好了。

如果字段介于 8 和 16 个之间(普通情况下),则是采用 BitmapIndexedNode 的对象,通过 bitmap 方式:计算出每个键 key 的哈希值,再将哈希值做基于 1 的位移操作,如哈希值为 A,将会得到新的 bit = 1 << A,如下所示:

class BitmapIndexedNode {
  constructor(ownerID, bitmap, nodes) {
    this.ownerID = ownerID;
    this.bitmap = bitmap;
    this.nodes = nodes;
  }
  // 省略部分内容
}
// 省略部分内容
const newBitmap =  bitmap | bit;

上面的 BitmapIndexedNode 对象的 bitmap 则是通过将所有字段的 bit 做或操作得到的,从而实现 bitmap 算法。再加上 popCount 操作,毕竟数据是以数组的形式存储在 BitmapIndexedNode 里面,而 BitmapIndexedNode 里面数组的长度为 16,所以不是字段 key 的 bit 是第几位就在第几位数组上,而是要同通过 popCount 来获得当前 bit 前面有多少个 1,也就是有多少个字段,从而得到对应的位置。

BitmapIndexedNode 里面最大数是 16,也就是在 32 个哈希值中选取 16 个不同的哈希值,为什么是 16 不是 17/18 个之类的呢?BitmapIndexedNode 这里其实起到一个压缩的作用,因为哈希分布不可能是从小开始出现,而是随机的,若是给随机的哈希,配置到同样的位置,就会造成很多内存的浪费,比如,对于 key = '0',其计算出来的 bit 因该是 28,若前面没有什么数据,不可能就让数据的第 28 位存储 key = 0 的数据的,这只是浪费,它理所应当排在第一个位置,这样就有效的压缩了树内存大小。

如果这个时候字段数持续增多,如果得到的新的 Bit 和存在的值是重复的,比如字段 Aa 和 11 的哈希值 31 (哈希函数在下方)的模都是 0,但是却有完全不同的哈希值,前者是 2112 后者是 1568,于是下角标为 0 的元素就升级为 BitmapIndexedNode 对象,这个 BitmapIndexedNode 对象的 bitmap 则由 Aa 和 11 的下一个高 5 位的哈希值,再位移,做或操作来得到。

当字段持续增加,而对应的低位哈希量已经超过 16 个的时候,就会升级为 HashArrayMapNode 对象,可以没有 16 位的限制,根据取模 31,则最多有 32 的长度。通过每次读 5 bit 的方式,来实现读写。

哈希计算如下:

function hashString(string) {
  let hashed = 0;
  for (let ii = 0; ii < string.length; ii++) {
    hashed = (31 * hashed + string.charCodeAt(ii)) | 0;
  }
  return smi(hashed);
}
function smi(i32) {
  return ((i32 >>> 1) & 0x40000000) | (i32 & 0xbfffffff);
}

上面的 hashString 和 jvm 里面的 string 类型的 hashCode 计算是一致的。都是乘以 31,看这里又有 31,这个必然是 31 可以被计算机转换为位移操作啦,当然也有统计结果下性能好的原因。

但是这样的哈希值,以及取 31 的模的方式会出现另外一个问题。hashString 确实可以计算出一个独立的哈希值,只是独立的哈希值,却不意味着不会发生重复碰撞的情况,比如 key = 'Aa' key = 'BB',其哈希值是完全一样的,这个时候就会启动 HashCollisionNode 对象,将相同的哈希值的对象都放在同一个 HashCollisionNode 里面,而这里面就是简单的线性读写数组了,没有之前的 Bitmapped 操作,毕竟一次性不可能有太多相同哈希值的键名出现。

另外 immutable.js 里面认为 splice 操作是昂贵,基本上都写了自己的 api 来替换掉 splice

总结

难得有一次学习数据结构的机会,大学好像就没有学过相关知识吧,这个月才开始看《算法》一书,很是感动,第一次如此接触到二叉树,红黑树,觉得前端,原来不止有三大框架,不止有三大框架的 API,生态圈里的的组件,nodejs,更有数据结构的存在,希望还是能更多的接触。

参考

  1. Anjana Vakil: Immutable data structures for functional JS | JSConf EU 2017,非常好的视频,通俗易懂;
  2. RRB-Trees: Efficient Immutable Vectors,很无聊的论文,语法怪怪的,但是键 RRB-Trees 的都会说到这篇文章,翻译了一半不到。
  3. Striving to Make Things Simple and Fast - Phil Bagwell,Clojure 的一个讲座,说的挺好的,就是没有怎么听懂,和上面的论文比较搭。
  4. Immutable 结构共享是如何实现的,很优秀的讨论。
  5. Use RRB-Tree in Vector,immutable.js 里面对 RRB-Tree 的讨论。

1. Vue 0.6.0 早期源码研究

前言

相信大家都或多或少接触过Vue,先前就有人介绍学习Vue的源码,提到旧版本的源码行数只不过一千多行,可以一个一个commit学习下去。前段时间为了找下家,一直在用Vue做作品,效率也较以前原生JavaScript要快上许多,后来工作上手了,不禁想看看Vue源码长什么样子的,只是从第一个commit开始读起来较为费时,而Github上面Vue项目能够找到的最早branch是Vue 0.10,然而在发布版本里面,可以发现最早一版本是Vue 0.6.0版本。本文介绍也是从该本版开始,该版本较0.10的要上少40%左右代码。

目标

本文目的在于最短时间内,介绍Vue0.6.0的核心模块源码,数据双向绑定,computed计算属性等。
看完本文之后,能明白Vue框架到底做了些什么,并自行构建一个早期Vue框架的简化版本。

准备

在介绍Vue 源码之前,需要了解Object.defineProperty,通过这个方式,在获取对象字段值时,调用getter方法,而设置对象字段值时候,调用setter方法,来实现数据劫。Object.defineProperty是Vue的基础内容,至今不熟悉的请一定要看Object.defineProperty MDN

思路

对于

data: {
    a: 1,
    c: 2
}

如果你要设置data.a的值,并更新渲染DOM,会怎么做呢?
了解Object.defineProperty神奇的setter和getter数据劫持功能后,自然而然是有以下步骤:

  1. data.a的访问器属性setter里,通知所有和data.a有关的instances;
  2. instances在源码中指的是Directive构造函数生成的实例集合,其中每个实例的this.el是一个指向data.a有关联的node节点,如
    {{a}}
    里面的textNode节点。如果有多个这样的节点,data.a就对应多个directive实例。在进行data.a重新赋值的时候,setter被调用,执行data.a下每个directive实例的update函数,实现局部DOM更新;
  3. directive实例和data.a数据的对应关系可以用Binding构造函数说明,每个Binding都含有instances数组,该数组存放的就是directive实例集合,而data.a只有一个Binding

实现

看了源码不难发现,创建的实例new Vue({}),指的是ViewModel构造函数,而ViewModel函数如下:

function ViewModel (options) {
    // just compile. options are passed directly to compiler
    new Compiler(this, options)
}

所以comilper.js才是生成Vue的核心所在,下面是简化了compiler构造函数

function Compiler(vm, options) {
    let compiler = this;

    options = compiler.options = options || makeHash()

    var el = compiler.setupElement(options)

    var scope = options.scope;
    if (scope) utils.extend(vm, scope, true)

    compiler.vm = vm

    var observables = compiler.observables = [],
        computed    = compiler.computed    = [];

    compiler.bindings = makeHash()

    compiler.setupObserver()

    let keyPrefix;
    for(let key in vm) {
        keyPrefix = key.charAt(0)
        if (keyPrefix !== '$' && keyPrefix !== '_') {
            compiler.createBinding(key)
        }
    }

    compiler.compile(el, true)

    while (i--) {
        binding = observables[i]
        Observer.observe(binding.value, binding.key, compiler.observer)
    }

    if (computed.length) DepsParser.parse(computed)
}

上面过程分为三个阶段:

准备设置

  1. compiler.setupElement(options)通过querySelector返回Vue的el节点
  2. 我们熟悉的data,computed和metheds,在该版本中都是options.scope里面,也就是说米有data,computed和methods之分,统一在scope里面,utils.extend(vm, scope, true)则是将scope的所有字段扩展到vm下面,方便后面处理;
  3. compiler.bindings = makeHash()则是给compiler的bindings设置为{};

observer实现

这里实现每个key的数据劫持和事件监听
4. compiler.setupObserver() 创建compiler.observer监听'get', 'set', 'mutate'动作
5. compiler.createBinding(key),将所有的scope里面的字段key都创建Binding实例,而compiler.bindings[key]则指向创建的binding,同时若字段不含有'.'则会进行define方法,该方法先是将刚刚创建的Binding{key].value设置为vm[key],实现每个key的Binding实例都保存下value值。如果该value是对象或则数组,如scope.a为对象,则push到compiler.observables里面;最后现实数据劫持Object.defineProperty
在define里面的Object.defineProperty,setter方法: 若新值不等于旧值,则重新对bindings[key].value设置为新值,同时进行Observer.unobserve旧值和Observer.observe新值,只有value是对象或则数组的时候,才会进入Oberver.oberver/unobserve方法,那对于普普通通的data.a等于字符串这种呢?前文说好的设置data.a的时候通知所有和data.a有关的instances,又发生在那里呢?
答案在在setter里面还通过compiler.observer触发了set事件,set事件里面就有bindings[key].update,而正如上文所说的bindings[key].instances就是directive实例,而directive实例又是什么时候添加进去的呢,且看下面

compiler.compile的实现

  1. compiler.compile(el, true)解析DOM节点,通过遍历所有的节点,对不同的属性名字如v-text采用不同指令,如果是有效的字段就返回Directive实例,这个实例会通过CompilerProto.bindDirective方法处理Directive和Binding的关系。如
    {{a}}
    , 会先生成一个Directive实例,该实例包含textNode节点,v-text的更新指令,在bindDirective方法中,则会判断vm是否有bindings['a'],如果有的话,直接使用compiler.bindings['a'],并将directive实例push到bindings['a'].instances,接着执行directive.update(value),而这个value就是前文提到的binding['a'].value,至此完成DOM节点和scope的关系

那对于scope.a为对象,如scope.a = {b: 1},这里的scope.a.b是无法在步骤5中实现的,只有scope.a会出现在步骤5,那scope.a.b要如何创建binding呢?
在步骤5中,进入define方法后,若检测到scope.a是对象,则将binding['a'] push到compiler.observables里面,到了步骤6,遍历到{{a.b}}的时候,会创建bindings['a.b'],但是不会进入define方法里面,无法对bindings['a.b']赋值,和Object.defineProperty数据劫持,没有赋值没有数据劫持,那又要如何实现{{a.b}}以及后面的重新设置a.b的值呢?

  1. 对observables遍历,通过Observer.observe方法对scope.a下的所有字段遍历,并在observer.js里面的bind方法,对所有的字段建立Object.defineProperty数据劫持,同时触发'set'事件,最后会传递到步骤4里面的事件监听'set'事件,执行bingdings['a.b'].update,现实DOM里面的数据首次展现

通过对于普通的socpe.a = 12,而言打印创建的Vue实例有如下图

compiler下创建了a:Binding构造函数,而这个实例的instances包含了一个Directive,里面存放的value 12,

computed原理

类似于observables,在compiler里面也会创建compiler.computed数组,在define方法里除了在compiler.observables.push(binding)外,若是对象,并且有$get方法,则是computed,这个版本里面,computed是有$get方法的,well...当然还有$set;

scope: {
    a: 1,
    c: {
        $get: function(){
            return this.a
        }
    }
}
  1. DepsParser.parse(computed),若computed存在元素,则调用DepsParser.parse,这个时候DOM里面的计算属性还没有赋值!只是创建了对应的binding,和observables的遭遇是一样的。。。。在 DepsParser.parse里面,当执行computed的$get函数的时候,如遇到依赖scope.a,则会调用步骤5里面为scope.a创建的数据劫持getter,这个方法里面触发'get'事件,在步骤4里面监听到'get'事件后,触发DepsParser.observer的'get'事件,而这个'get'事件作用则是将bindings['a'],bindings['c'].deps里面,同时bindings['a'].subs里面放入bindings['c'],为何要这样做呢?在初始化的时候是不必的毕竟$get已经可以获得正确的数值了,但是当重新设置scope.a的时候,就需要通知scope.c同步更新了,这个在bindings['a'].update里面实现

自此Compiler构造函数也就大体如此,具体细节还是要多看源码

监听数组

在compiler.observables里面,在Observer.observe里面,可以看到当处理对象的typeOf值是对象或则数组都会进行特别处理,若是对象上文已经提过处理方法,若是数组的话又会如何呢?
这里简要概述一下:通过ExpParser.parse方法,将bindings['a[0]'].value设{$get: newFunction('this.a; return this.a[0]')}的形式,后面就和computed类似了,这里有一点需要注意,在observer.js里面,重写了['push','pop','shift','unshift','splice','sort','reverse']等数组方法,当a.push(1)的时候,会触发mutate动作,执行bindings['a'].pub(),从而通知bindings['a[0]']的instances更新,具体内容可以自己去看源码

参考资料

  1. 剖析Vue原理&实现双向绑定MVVM

27. HTTP 2 笔记

前言

续网络请求后,学习 HTTP/2。HTTP/2 的主要协议 RFC 7540 出来已经三年多了,HTTP/2 的应用也越来越多。几年前还在为 HTTPS 响应速度慢,能不上 HTTPS 就不上,结果现在基本成为标配了。对于大型系统,HTTP/2 也会是这样,毕竟知乎都是 HTTP 2 了。

HTTP/1.1

从 HTTP/1.1 诞生至今,互联网已经走过了十几个年头,当初的 HTTP/1.1 协议出现了不少缺陷,其中管线化,就是其中一个突出问题。

上图中的绿色是数据量的增加,红色是资源数的增加。流行的网站其首页加载的资源越来越多,在 2015 年的时候已经达到 2.1M 的数据量了,同时更严重的是完成渲染需要 100 多个资源,与 HTTP/1.1 出来时候的互联网环境与其相比,非常吓人,。

在 HTTP 请求时候,一个 TCP 连接上只能同时有一个请求/响应,如果你一次要发送多个请求,那可以建立多个 TCP 连接来实现,但是 主流浏览器只能允许同域下 6~8 个连接。这意味着如果你想同时访问多个连接怎么办,只能等,等最快的那个 HTTP 响应返回,通过 Keep-Alive 的方式继续使用该 TCP socket,而不是再次进行断开,握手的操作。在日益复杂的资源数下,管线化问题就日益突出了。

管线化是什么?看看维基中的图:

这张图很清晰的指出了,管线化可以避免在一个 TCP 连接下需要等待服务端响应,才能继续发送请求的问题。这个技术 HTTP pipelining 将多个请求批量提交,其实在 HTTP/1.1 中已经实现了,但是由于线头阻塞(Head of line blocking)的问题,服务器对浏览器的响应是按照其请求顺序来的,如果前一个响应出现阻塞,后一个请求将不会被处理。这样一来 HTTP pipelining 并没有解决 Head of line blocking 的问题。大部分浏览器也关闭了这项管线化功能。

下图比较全面的概况 HTTP/1.1 中的缺陷:

对于多请求的问题,前端可以采用雪碧图、js 文件合并、图片资源内联 base64,接口合并等等的方式。而后端可以采用分片的形式,简而言之即使既然限制了 6~8 个连接,那就用更多的主机,散列域名,比如微博的:wx1.sinaimg.cn,wx2.sinaimg.cn,wx3.sinaimg.cnwx4.sinaimg.cn。这样简单暴力,可以提升载入速度。

只是对于下面提到的服务端问题,请求/响应头的问题,已经不在 HTTP/1.1 能够处理范围了。

HTTP/2

HTTP/2 相比与 HTTP/1.1 并不会破坏现有的工作,服务器和客户端都必须确定自己是否完整兼容http2或者彻底不兼容。HTTP/2标准于2015年5月以 RFC 7540 正式发表,主要基于 Google 的 SPDY 协议。

先总结一下 HTTP/2 的新特性

  1. 二进制分帧
  2. 多路复用的流
  3. 优先级/依赖性
  4. 服务器推送
  5. 头部压缩

这样上面就是其主要特性,下面着重介绍一下

二进制分帧

HTTP/2 是二进制协议,而 HTTP/1.1 是文本/ascii的协议,这也是其根本性的不同,是 HTTP/2 性能增强的核心。

该图就很清晰说明了一切,对 HTTP 和 TCP 都没有改动,只是在中间添加了一个层 二进制分帧层。HTTP/1.1 采用换行符作为纯文本的分隔符,而在 HTTP/2 里面则是将所有信息分割为更小单元的消息和帧的形式。用二进制的方式传输,这也是 HTTP/1.1 和 HTTP/2 的根本区别。

在这个二进制分帧层下,我们看看数据的传输是什么样子的:

上面就是数据传输的过程,可以看到数据是由 流(stream) 形成的,在 stream 里面有一个个 消息(message),而 message 里面还有 帧(frame)。在第一条流里面,有两个消息,请求消息和响应消息。响应消息里面又有两帧,分别是 HEADERS 和 DATA 帧,是 HTTP/2 里面最常见的帧。

上文图里面的消息,由与逻辑请求或响应消息对应的完整的一系列帧组成的,但是这些帧却可以不用整整齐齐排在一起,可以和其他的消息的帧交叉分散在一个流里面,从而实现交错分散,交错的帧也不会产生相互的干扰影响。再在服务器/客户端里面组装好。二进制帧的交错发送实现了请求响应的并行,解决了线头阻塞的问题

这是一个官方的示例,well,在相同的网速下,可以看到左侧 HTTP/1.1 的图片是接近于一排一排的刷出来的,而右侧 HTTP/2 则是 duang 一下就好了。HTTP/2 真是强无敌,还有什么理由不用 HTTP/2 呢。

二进制的帧

下图是 HTTP/2 帧的通用结构:

+-----------------------------------------------+
|                Length (24)                    |
+---------------+---------------+---------------+
|  Type (8)     |  Flags (8)    |
+-+-------------+---------------+-------------------------------+
|R|                Stream Identifier (31)                       |
+=+=============================================================+
|                  Frame Payload (0...)                       ...
+---------------------------------------------------------------+

可以看到一共有 9 个字节,变化的部分是帧负载(payload),其余的 9 个字节是固定的。从上到下分别是

  1. 帧长度 Length:24位表示帧负债的长度,默认最大长度是 2^14,当超过此值的时候,将不允许发送,除非收到接收方的长度修改同通知。
  2. 帧类型 Type:8位表示帧的类型,HTTP/2 规范定义了 10 种帧,包括:HEADER(0x1),DATA(0x0),SETTINGS(0x4),CONTINUATION(0x9),PUSH_PROMISE(0x5),PING(0x8),PRIORITY(0x2),WINDOW_UPDATE(0x8),RST_STREAWM(0x3),GOAWAY(0x7)。这里有最常见的 HEADER 和 DATA 帧。
  3. 帧标识 Flags:8位表示帧的标识,一个帧可以同时是有多个帧标识。也和发送的帧类型数据有关系。
  4. 帧保留位 R:在 HTTP/2 下作为保留字段。
  5. 流标识符 Stream Identifier

HTTP/2 允许帧扩展的,毕竟有 8 位长度。

双方可以在逐跳原则(hop-by-hop basis) 基础上协商使用新的帧,但这些帧的状态无法被改变,也不受流控制.

常见的包括 ALTSVC(Alternative Services):替换服务,服务端建议客户端连接到另外一台服务器,实现负债均衡;BLOCKED:阻塞,用于通知接收方,因为流量控制无法发送数据。

多路复用的流

提到多路复用,有通信领域常见的时分复用,空分复用亦或则是频复用等等,HTTP/2 里面的多路复用,更像是时分复用。

一个 TCP 连接里面可以允许多个流的存在,而流本身是由自己的 31 位的标识符的。客户端创建的流是奇数,服务端创建的流是偶数,

流的多路复用意味着在同一连接中来自各个流的数据包会被混合在一起。就好像两个(或者更多) 独立的“数据列车”被拼凑到了一辆列车上,但它们最终会在终点站被分开。

多路复用的实现是二进制上的。

优先性与依赖性

流的优先级在于告诉对方哪个流更重要,分配多少资源。优先级取决于数据流的权重以及依赖关系

流与流之间是存在依赖关系的,所有流默认依赖 “根数据流” 0x0,其权重值在 1 至 256 之间的整数。同父级流的同样权重的流具有相同的优先级,而不是由顺序决定。可以看看下图:

HTTP/2 内的数据流依赖关系通过将另一个数据流的唯一标识符作为父项引用进行声明;如果忽略标识符,相应数据流将依赖于“根数据流”

声明的数据流依赖下,首先向父节点分配资源,在向依赖项分配资源,比如先处理 D 的响应,在处理 C 的。而对于上图中右侧的两种情况,当具有相同父级的时候,自然是先处理父级,再处理依赖级,而依赖的的资源分配取决于其权重,如 C 下面的 A/B,其权重分别是 12 : 4,所以 A 将占有 3/4 的资源,而 B 占有剩下的 1/4 的资源。

通过优先级的处理,可以提高浏览器的性能。当然客户端也可以自己随时更新优先级,其可以 通过 HEADERS 帧里面的优先级 priority 属性,也可以通过 PRIORITY 帧来专门设立优先级

优先级的设立不一定能保证对端的遵守,非强制性需求,well,这也是必要行为,不能阻止服务端处理优先级低的资源。

流控制

流控制可以阻止发送方向接收方发送大量的数据,以免超过后者的需求或者处理能力,而同样的发送方忙碌的时候,可能仅仅能分配出少量资源给到高优先级的请求。

多路复用引入了资源的竞争,流控制可以保证流之间不会严重影响到彼此。流控制通过使用WINDOW_UPDATE帧实现,可作用于单个流以及整个的连接。

流的控制也有自己的原则,这就不阐述了。流使用 WINDOW_UPDATE 帧来做流量控制。接收方有流控制窗口,当流控制窗口允许的时候,可以恢复之前的传输。

流量控制是为解决线头阻塞问题,同时在资源约束情况下保护一些操作顺利进行,针对单个连接,某个流可能被阻塞或处理缓慢,但同时不会影响到其它流上正在传输的数据。

服务器推送

HTTP/2 让服务器可以将响应主动得 “推送” 到客户端,而不是以前简单的 “请求-响应” 模式。一个典型的场景就是当许多个资源的时候,客户端要逐个逐个的检查文档,遇到了才请求,而这个过程是由时间差的,为什么不一开始就让服务端发送所有的数据呢?尤其是一些内联的元素,提前让服务器推送是极好的。

服务器推送可以提前发送常规的资源,当然客户端也可以选择拒绝了,

所有服务器推送数据流都由 PUSH_PROMISE 帧发起,表明了服务器向客户端推送所述资源的意图,并且需要先于请求推送资源的响应数据传输。
使用 HTTP/2,客户端仍然完全掌控服务器推送的使用方式。客户端可以限制并行推送的数据流数量;调整初始的流控制窗口以控制在数据流首次打开时推送的数据量;或完全停用服务器推送。这些优先级在 HTTP/2 连接开始时通过 SETTINGS 帧传输,可能随时更新。

标头压缩

在 HTTP 请求中,当你不断发送类似的请求,比如都是 GET 请求,只有请求地址是不一样的,但是其他的信息,包括 cookie 什么的都是一样的,这个时候客户端还是会重复发送类似的请求头,尤其是一些图片加载。当有数个资源的时候还好,如果数量很多,将会很占用开销,尤其是对 cookie 的反复传输,冗余数据浪费了非常多的宽带。

HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据。其采用了哈夫曼编码表,同时要求请求方和响应方维护同一张见过的标头的索引表

HPACK 压缩上下文包含一个静态表和一个动态表。静态表指的是常见的 HTTP 标头的列表,包括 method、status 等等,而动态表初始是空的,根据值动态更新。通过采用哈夫曼编码将请求中的部分替换为静态表或动态表中已经存在的索引,从而实现标头压缩。

注:在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异:所有标头字段名称均为小写,请求行现在拆分成各个 :method、:scheme、:authority 和 :path 伪标头字段。

协商部署

服务器和客户端如何识别 HTTP/2 ,如何让协议从之前的 HTTP/1.1 升级到 HTTP/2 呢, 难道要通过握手来通知客户端和服务端采用 HTTP/2 的方式?HTTP/2 的前身 SPDY 为了实现协议升级,在 TLS 上添加了 NPN(Next Protocol Negotiation),就是一个在 TLS 上面的扩展,服务器会通知客户端其所支持的协议,让客户端来选择。

后来在标准化制定时候,NPN 就演变成为 ALPN(Application Layer Protocal Negotiation)。ALPN 中是让客户端提供其所支持的协议类型,让服务端来选择。

这就要求 HTTP/2 是基于 HTTPS 的,也就是 HTTP 2 over HTTPS。那有没有不建立在 HTTPS 的 HTTP 2 呢?有的,但是主流浏览器都不支持的,这里就不做介绍了,看下图就好了。

左图中基于 HTTPS 的 HTTP/2,客户端在和服务端请求之前会在 TLS 中的通信中协商具体用什么协议。看看 wireshark 捕获到的数据:

上图中可以看出本机在与 183.61.14.105 通信前的 TLS 里面,有个 APLN 的插件,插件里面提供了两种协议,第一个是 h2,也就是 HTTP/2 的版本标识符,后面一个是 http/1.1 也就是之前的版本,如果服务端不支持 h2,就采用 http/1.1。在服务度的 Server Hello 里面返回的 ALPN 就只有 h2,所以当前协议就采用 HTTP/2 了。

参考

  1. HTTP2 is here, let's optimize! 大神的文章
  2. HTTP/2:新的机遇与挑战 大神的总结
  3. High Performance Browser Networking 书中自有黄金屋

39. 页面搭建工具盘点

中后台近几年的发展,已经不满足于简单的手脚架,比如 vue-cli 和 create-react-app,从 ant-design-pro 到 vue-element-admin,提供了各种开箱即用的体验。这些方案可以直接套用,自己需要做的只是写业务代码就可以了,其他的整体架构,包括菜单和权限都帮你搞定了,甚至还有很多很多可以选择的业务模板,一顿微调,一个中后台系统就有模有样了。全套下来比自己从头开始搭质量要高很多,而且至少可以节省一两天开发成本,后期的扩展维护也很方便。

到如今这些开箱即用的解决方案已经显得有些不够用了,中后台的前端,大部分都不需要和移动端一样有严格的 GUI,布局,更多的是日常业务,而这些业务写多了以后,就变成一个个表单,一个个列表,一个个图表,写来写去,给人一种搬砖的感觉,重复劳动,成就感低。

WYSIWYG 为 所见即所得,是 github 上面的一个分类。可以追溯到 dreamweaver,只是后面随着前端的发展没落了。一个可视化的页面搭建工具,毫无疑问是可以解放生产力的,只是为什么以前没有大热门的开源产品出来,实在原因实在太多,可以看看侯老师的 前端服务化——页面搭建工具的死与生

只是现在不同于以前,前端越来越繁华,虽然还要持续的不断学习新的内容方向,但是前端的三大框架基本定下来,更多的是新特性新版本。于是在中后台基础设施完善下,做了一个中后台系统,再做下个,再做一个,这种重复的问题,不在是大厂才会面临,小厂的前端员工自然也会有同样的困扰,需要解决效率问题。well,大厂一般都有这样的工具,只是看大厂开不开源罢了,比如支付宝的 金蝉/凤蝶,代替传统前端开发模式的一站式研发系统,都可以让非前端人员开发了,只是是要收费的好像,虽然金禅说是今年会开源,只是没有排期都是骗人的。

自己在写业务的时候也有这样的困扰,重复的组件,能提用公共的逻辑都抽离出来了,但是组件上实在是每个组件都需要定制,需要自由度非常高。于是开始探索有没有可以提高生产力的工具。

飞冰 ice

飞冰从去年就开始开源了,结合 umi.js,ant-design 和 ant-design-pro,基本上 react 的中后台开发有这一套就很棒了,加上自己的设计,按照同事的说法,是一套闭环的生态。

飞冰最大的特点,在于其海量的物料,以及杀手锏般的桌面工具,开发体验是非常友好的,没有什么复杂的操作,想要什么物料,在 iceworks 上面点一点就能安装好了,自己只要修改一些字段,再增加对应的交互和接口调试,就差不多了。飞冰提供的物料是以区块来的,而不是组件级别,而且基本布局是从上到下,按照选择的区块顺序生成页面。对比传统的开发模式,可以归纳为飞冰给你提供了更多可以复制粘贴的代码,而且质量还不低。

使用一阵之后,觉得其离高度自由化,还是差了点,解决了部分开发效率问题,但是区块级别依旧有点粗糙,缺少组件以及拖拽布局,美中不足。可视化的编辑区域至今也是规划。

iceworks 这样客户端的体验还是非常友好的。

百度 AMis

百度的 AMis 其实很早前就有踪迹了,在 吴多益 的描述里面 前端服务化-通向零成本开发之路,就提到 AMis 作为中后台解决方案。然而时隔多年,终于开源了。大厂的好处还是显而易见的,至少这种基础建设的水平要领先外面一到两年。这次 QCON 大会上 AMis 又再次露脸。

AMis 采用的是特定格式的 JSON 配置,这个特定的格式,指的是其自身 baidu 的一套 JSON Schema,这一套 JSON Schema 用的不同于 社区 的 JSON Schema,估计是结合自身的业务定制的,毕竟 16年就有 AMis,那会社会可能都还没有成熟。其自身的 schema 为: "$schema": "https://houtai.baidu.com/v2/schemas/page.json#" 的格式;

看 QCON 上面的介绍的 ppt 是挺好的,只是一看仓库文档,好像资料有点少,关于这个特定的 JSON 配置,在 github 里面是有明细的文档的,只是感觉构建的文档站点有点不好用,示例看不了代码,下面的教程文档倒是挺充足,只是更多的是 api 和一些字段的定义,没有教人怎么去用,而且整体的组件风格偏 bootstrap,和 antd 的设计相比,觉得差了不少。

关键是这个 AMis 只是开源了其 JSON 配置部分,渲染器的部分,其他的可视化编辑器没有开源出来,这样前端写 JSON 开发真的好吗?诚然 AMis 设计理念是很棒的,抽象出 UI 组件的逻辑,形成 JSON 的形式,减少前端的冗余开发,提高生产力,只是前端居然要写 JSON,路走歪了。所以其 可视化编辑生成页面 开源就好了,可惜还没有排期的消息,而且开源的 AMis 总觉得有点赶工的嫌疑,还是先不用,免得进坑。

react page

这个所见即所得工具使用起来体验是很好的,只是其更多的是面向用户的产品,而不是面向工程师的产品,其支持的组件需要自己提供,也就是可以用 antd 的那一套加一个中间层就可以用上了。生成的 editorState 对象也可以实现根部注入的方式,实现后端数据动态渲染。只能给用户画画原型什么的而已,其对前端的生产帮助少之又少。

阿里 UForm

看了一圈其他开源产品页面搭建工具优秀的少之又少,最后看到这个 UForm 是表单解决方案,似乎和前面提到的都不是一个东西,前面是通过页面编辑可视化,来提高效率,而这只是个 react 的表单解决方案。

这个 UForm 和百度的 AMis 其实是有点类似的,都可以通过特定的 JSON 规范来配置生成代码,只是 UForm 是专门针对表单的,而 AMis 是通用。 UForm 采用的 JSON Schema 是和社区保持一致,同时和 Mozilla 的 react-jsonschema-form 有点类型,在社会的 JSON Schema 之上增加了 UI 组件相关的信息。另外其字段状态分布式管理和 React EVA 的方式很大程度的提高 form 表单的性能和可维护性。对于 JSON Schema,UForm 采用了自己的 JSchema 描述语言,不再是单纯的写 JSON,可以通过 Field 等组件来描述,避免前端写 JSON 的尴尬情况。可以看出其整体的设计理念很赞。

并且其可视化编辑工具,说是要在端午后开源,表示万分期待。如果真是如此,那就可以进一步实现动态化了,用可视化编辑工具生成想要的 JSON 数据,最后通过后端返回数据注入到根组件,直接回显就可以了。前端要专注的只是 form 表单的交互和接口的调试,以及测试部分。感兴趣可以看看其介绍 UForm表单解决方案

百度 RCRE

这个是最近出现的中台后系统的解决方案,结合了状态(mobox和reduce)、表单和接口请求,解决复杂的组件联动带来的问题。给的 文档里面,看起来挺香的,只是对应的使用说明和知乎上面写的实在有点看不出所以然,最后还是有 demo 什么的吧。或许这就是百度的风格?

总结

期待百度系列 AMis 和 RCRE 接下来的表现,尤其是前者,在百度内部应该是非常成熟的方案了,可惜不够开源。目前能看得着,能使用的上的,就是阿里的 UForm 了,期待其可视化编辑器。然而最后基于业务的原因还是用上了 react-page 先,等 UForm 完善了再看。

9. vuex源码分析

前言

前文分析了Vue-router,感觉后劲十足,于是开始分析Vuex。在项目上,Vuex也是常客。它可以很好的管理状态,尤其是跨组件的时候,Vue的单向数据流使得子组件无法修改prop,经常用$emit和$on的话组件是要多难看就多难看。当组件切换,数据需要缓存总不能一直依赖于向上级组件emit传递数据吧?如果要更好的管理状态,Vuex是个很好的选择。Vuex代码量较Vue-router少了很多,而且也没有flow的校验机制,看起来更加习惯了。这里介绍的Vuex版本号为2.4.1。

从示例开始

Vue.use(Vuex)
const state = {
  count: 0
}

const mutations = {
  increment (state) {
    state.count++
  },
  decrement (state) {
    state.count--
  }
}

const actions = {
  increment: ({ commit }) => commit('increment'),
  decrement: ({ commit }) => commit('decrement'),
}
// getters are functions
const getters = {
  evenOrOdd: state => state.count % 2 === 0 ? 'even' : 'odd'
}
export default new Vuex.Store({
  state,
  getters,
  actions,
  mutations,
})

上面示例基本上包含了最常用的mutations,getters和actions了。可以发现这一切从Vue.use(Vuex)开始的,对Vue.use不熟悉的可以看上一篇的Vuex-router中对Vue.use的介绍。
Vuex中用到了install方法来提供Vuex的使用环境。和Vue-router不同,Vuex的主要代码功能都在store.js文件里面(这对查阅代码友好度明显提到了不少)。install过程里面用到了Vue.mixin,并用到了beforeCreate钩子,使得Vue实例化和组件加载的时候都可以调用到钩子。设计如下:

const version = Number(Vue.version.split('.')[0])
if (version >= 2) {
  Vue.mixin({ beforeCreate: vuexInit })
} else {
  const _init = Vue.prototype._init
  Vue.prototype._init = function (options = {}) {
    options.init = options.init
      ? [vuexInit].concat(options.init)
      : vuexInit
    _init.call(this, options)
  }
}

function vuexInit () {
  const options = this.$options
  // store injection
  if (options.store) {
    this.$store = typeof options.store === 'function'
      ? options.store()
      : options.store
  } else if (options.parent && options.parent.$store) {
    this.$store = options.parent.$store
  }
}

可以看到这里对Vue的版本分别做了处理,本版是2.0.0及以上的都会采用Vue.mixin的方法,而低版本的,则将修改Vue的内部_init方法,来添加$store至根。高级别的版本则采用mixin的方法,同样也是添加this.$store。在Vue-router里面是采用数据劫持的方法,来通知更新,顺便提供this.$router,对于状态管理而言,数据劫持显然是不需要的,仅仅提供入口this.$store就够了,这样为全局提供了访问store对象的方法,可以轻松得使用this.$store.commit, this.$store.state之类的方法。

Store

Store.js里面最主要的就是Store类,这个也是之前提到的this.$store对象。先看看constructor方法:
在构造里面先判断有无使用install方法,没有则intall一下,接着是断言有无安装Vue,是否支持Promise和是否是通过new创建Store的实例。另外在install过程里面还有是否重复安装Vuex的断言,这个场景会发生在已经先使用Vuex了,但是没有用Vue.use(Vuex)来显式安装Vuex,如果再加上Vue.use(Vuex)就会有这样的提示,尤其是在开发环境和生产环境配置中。
Store初始化过程,有this._modules = new ModuleCollection(options),这个_modules就是Store集合的意思了。Vuex有modules的概念,允许对store进行分割形成不同的模块,每个模块都可以有自己的state,getter,mutation和action,甚至还可以嵌套子模块。于是将这些模块包括根模块一起放入modules里面。this._modules的一个重要api就是注册添加一个模块:

register (path, rawModule, runtime = true) {
  if (process.env.NODE_ENV !== 'production') {
    assertRawModule(path, rawModule)
  }

  const newModule = new Module(rawModule, runtime)
  if (path.length === 0) {
    this.root = newModule
  } else {
    const parent = this.get(path.slice(0, -1))
    parent.addChild(path[path.length - 1], newModule)
  }

  // register nested modules
  if (rawModule.modules) {
    forEachValue(rawModule.modules, (rawChildModule, key) => {
      this.register(path.concat(key), rawChildModule, runtime)
    })
  }
}

这里还可以看到this._modules.root就是根模块,并且对于子模块的,还会被添加到父模块parent的_children对象里面;到这里可以发现this.modules.root和原先的store很像,只是单独分离出state,并且将子模块改为了_children关系,并将_rawMoudule赋值为整个传过来模块,同时为this._modules和每个module都添加不少方法,这些方法自然是为后面做准备的。
在谈commit和dispatch方法之前,先看看后面的模块安装和StoreVM的设置

installModule(this, state, [], this._modules.root)
resetStoreVM(this, state)

function installModule (store, rootState, path, module, hot) {
  const isRoot = !path.length
  const namespace = store._modules.getNamespace(path)
  // 命名空间字典的添加
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module
  }
  // 设置state
  if (!isRoot && !hot) {
    const parentState = getNestedState(rootState, path.slice(0, -1))
    const moduleName = path[path.length - 1]
    store._withCommit(() => {
      Vue.set(parentState, moduleName, module.state)
    })
  }
  const local = module.context = makeLocalContext(store, namespace, path)
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key
    registerMutation(store, namespacedType, mutation, local)
  })
  // 下面省略部分是通过module提供的方法分别对action和getter进行registe
  // 以及对子模块modules的遍历式得注册mutation/action/getter

  // ...
}

对于modules而言,官方文档有介绍到,模块内部的 action,mutation和getter是注册在全局命名空间的,如果想要独立的空间,比如有命名重复的情况下,可以使用namespaced: true来注册单独的空间;同时访问的时候也也要加上模块的名字,否则否则无法定位到。
接着看state的设置,对于if条件语句,若是子模块并且非hot,会获取子模块的亲父级模块,并通过Vue.set方法将该子模块的state添加到亲父模块state里面,这是响应式的,会被Vue劫持到。后面部分就是对action/getter/mutation的注册添加了,这部分后面在讲。

后面是resetStoreVM:

function resetStoreVM (store, state, hot) {
  const oldVm = store._vm
  // bind store public getters
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  forEachValue(wrappedGetters, (fn, key) => {
    // use computed to leverage its lazy-caching mechanism
    computed[key] = () => fn(store)
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key],
      enumerable: true // for local getters
    })
  })
  const silent = Vue.config.silent
  Vue.config.silent = true
  store._vm = new Vue({
    data: {
      $$state: state
    },
    computed
  })
  Vue.config.silent = silent
  // enable strict mode for new vm
  if (store.strict) {
    enableStrictMode(store)
  }
  // 如果存在oldVM对其进行销毁
  // ... 
}

刚看到这里时候可能会惊奇何时来的_vm?事实上这个_vm正是这里的核心,_vm是个Vue实例,并将_vm.data.$$state指向的option中的state。细心的话还可以发现在Store类中,其中的Store.state:如下

get state () {
  return this._vm._data.$$state
}

state返回的正是_vm.data.$$state,这个也就是平时所用的this.$store.state。观察resetStoreVM还可以发现通过遍历wrappedGetters,来将wrappedGetters中的方法通过_vm.computed的形式添加到store.getters里面,这么复杂的办法有什么好处呢?而且为什么只是专门处理getter,没有对mutation和action进行这样的处理?getter的方法是对state进行处理提取过滤,而computed是依赖于data的,当data更新的时候computed就自动计算,同样这里也是的,当state更新的时候,通过computed的方法,getter不就自动计算更新了吗?只是这样就有点麻烦。。。。。要新建一个Vue实例,关于_vm,更多的可以点这里

commit和dispatch

在介绍之前先看看前面忽略的,在installModule方法里面对mutation/getter/action等方法的添加机制。
对于registerMutation:

function registerMutation (store, type, handler, local) {
  // 内部的_mutations[type]保存对应的mutattion方法
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    // 在mutation方法里面传入local.state和payload,
    // wrappedMutationHandler只需要payload,符合commit时,仅需传入type和payload
    handler.call(store, local.state, payload)
  })
}

上面方法添加了store._mutations[type],而handler传参里面的local.state又是什么呢?回头看可以发现这里调用了makeLocalContext,生产local变量,makeLocalContext代码这里就不贴出来了。local.state就是对应path的state变量,只是是通过数据劫持的方法获得的,代码中说明getters和state对象都必须要懒加载,因为可能被vm更新影响到,这里是不是指_vm重新创建的时候造成的影响呢?由于namespaced的问题,local里面的dispatch和commit都做了特别处理,但是还是使用store的dispatch和commit的方法,只是传参做了修改。
对于registerAction,类似与mutation,采用了store._actions[type]来保存handler数组,但由于action有用于异步的情况,所以若返回的action不是Promise类型,则进行Promise包装。同时action的传参不是local.state,而是传入local的本身的所有字段和store的getters以及state,这也符合action的基本应用。
对于registerGetter,这里比较简单直接采用store._wrappedGetters[type] = handler的形式,而registerMutation是采用数组的形式。所以对于重复名字的getter就会有告警``[vuex] duplicate getter key: ${type}`。

回到commit方法和dispatch,在Store类构造的时候,有如下:

this.dispatch = function boundDispatch (type, payload) {
  return dispatch.call(store, type, payload)
}
this.commit = function boundCommit (type, payload, options) {
  return commit.call(store, type, payload, options)
}

这里面定义commit方法和dispatch方法,这两个就是$store.commit$store.dispatch,而commit这个方法处理起来也是比较简单,就是将_mutations里面对应方法名都执行一遍,并传递payload进去。同时还将_subscribers里面的函数都遍历执行。_subscribers是通过subscribe这个api添加进来:

subscribe (fn) {
  const subs = this._subscribers
  if (subs.indexOf(fn) < 0) {
    subs.push(fn)
  }
  return () => {
    const i = subs.indexOf(fn)
    if (i > -1) {
      subs.splice(i, 1)
    }
  }
}

该方法可以添加订阅函数,每当mutation执行的时候,所有订阅函数都会执行,值得一提的时候在devtool.js文件里面用到了:

store.subscribe((mutation, state) => {
  devtoolHook.emit('vuex:mutation', mutation, state)
})

当使用devtoolHook的时候(这个也涉及到Vue官方推荐的浏览器插件工具Vue devtools)能在每个mutation动作结束后,触发vuex:mutation事件,并在devtools插件内打印动作
还可以看出这个subscribe设计很巧妙,subscribe直接运行是添加订阅函数,而其返回函数就是disSubscribe,就是将订阅函数去除掉,由于不常用,所以就没有直接给出api了,厉害的很。

dispatch该动作类似的,也是调用之前存在_actions里的handlers,只是由于handles可能有多个,并且是异步的原因,若是多个的话需要用Promise.all来执行;

其他Api

日常用的比较多的是registerModule/unregisterModule,两个过程是类似的,注册新模块的时候需要重新installModule和resetStoreVM,这个时候就会将老的_vm delete掉,重新实例化Vue给到_vm。
mapState/mapMutations/mapGetters/mapActions等api结构类似。以mapState为例子:

export const mapState = normalizeNamespace((namespace, states) => {
  const res = {}
  normalizeMap(states).forEach(({ key, val }) => {
    res[key] = function mappedState () {
      let state = this.$store.state
      let getters = this.$store.getters
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapState', namespace)
        if (!module) {
          return
        }
        state = module.context.state
        getters = module.context.getters
      }
      return typeof val === 'function'
        ? val.call(this, state, getters)
        : state[val]
    }
    // mark vuex getter for devtools
    res[key].vuex = true
  })
  return res
})

normalizeNamespace来调整参数,再通过normalizeMap将传入的state调整为{ key, val: key }结构,并根据情况返回。这几个api还是很容易懂的。

结束

一周下来写了两篇源码分析,Vuex的代码和Vue-router相比还是很良心的,没有Vue-router里面那么多弯弯绕绕,Vuex简单明了多了。

3. void 0 以及let const var 的理解

void 0

最近在backbone的源码(两年前就研究过underscore的源码,现在才看backbone是不是有点那个),发现里面用到了void 0,这是什么鬼?void居然出现在JavaScript里面,敢情以前用的ES是假的....这么基础的东西,瞟一眼MDN文档,还果然有:

void: 运算符 对给定的表达式进行求值,然后返回 undefined。

So, void 0就是返回undefined了,这不是多此一举?直接用undefined来判断,不就完事了,何必用上void 0,这种云里雾里的操作?
原来在早期的ECMAscript里面,undefined不是关键字,也就是说undefined是可以被修改的,如

var undefined = 1;

这样再用undefined判断某个变量,是不是就懵逼了?而void却是关键字,从这个角度出发void 0很有使用的必要,幸好从ES5开始(IE8+)就只把undefined当作一个全局只读的值,不能进行修改,修改了当然就报错咯。
在backbone的issue里面也有人提到了void 0的问题,里面提到了void 0的运算速度比undefined更优,只是我在IE9+以上还有chrome上面测得的值都是十分接近的,所以性能不是需要关注,后面还提到void 0的字符要比undefined少,这好处之一,也是为什么我们不用void 1000, void 'hellow world'而是用void 0 的原因,这对主流大型框架还是很有意义的。只是普通的工程就木有必要了

let,const和var

上周在chrome的控制台里面无意写了let a = sd,sd是没有声明的变量,所以回车后马上报错,而后面我再次声明let a的时候,又报错显示a已经声明了,于是我再直接在控制台敲 a 回车,结果报错显示a没有声明,这是在赤裸裸的耍我吗?问同事也是一脸懵逼+神奇的样子,后来也就没有管了,毕竟用没有声明过带的变量赋值给其他变量本来就是有问题的,更不要提后面的情况了。
故事应该到这里就截止了,可是昨天,同事给我发了个链接,这是何其相似,这个实用性不大的问题上,居然还有人花了了两个月的时间,蛋疼的很。于是打开方应杭的介绍(链接在上面自己找),发现里面的重点在于 Rick Waldron的一段话:

In JavaScript, all binding declarations are instantiated when control flow enters the scope in which they appear. Legacy var and function declarations allow access to those bindings before the actual declaration, with a "value" of undefined. That legacy behavior is known as "hoisting". let and const binding declarations are also instantiated when control flow enters the scope in which they appear, with access prevented until the actual declaration is reached; this is called the Temporal Dead Zone. The TDZ exists to prevent the sort of bugs that legacy hoisting can create.

var的使用存在变量提升,而let/const是不存在变量提升的,控制语句到let/const时,声明会被实例化(instantiated ),但是在执行前禁止访问,不像var和function会先是被赋予undefined,后者称为变量提升,而前者变量提升却是不一样的,也由此生成了死区,就是变量必须在let声明语句后使用;这点其实在阮一峰老师的ECMAscript 6已经介绍到了,只是没有提及死区形成原因;
回顾前文提到的问题就很简单了,对于let a = sd a变量会在语句执行前实例化,而执行let a = sd的时候,自然报错,同时由于a已经实例化了,不能再次 let a(还得看是什么浏览器。。。。。。),并且由于a 初始化失败,而在实例化后到初始化这段过程里面,a处于死区中,是无法访问的(acess prevented),所以不能对a有其他操作。
下文在引用ECMAscript的一段话:

The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated.
A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer's AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created

7. 了解Node.js中的网络通信

前言

一直想知道Node.js是如何作为一个用js语言写的后端平台。这个设定很是奇怪,前端开始涉足后端了?刚开始用api实现通信的时候,蛮简单的,框架都不用到,简单几句就能实现通信,于是借此机会研究一下Node.js的通信。

从net模块出发

看一个简单的例子

require('http').createServer((req, res) => {
    res.end('hello world');
}).listen(8181);

这里用的是http模块通信,也就是我们最常用的部分,看官方文档可以发知道其返回一个http.Server实例,而该实例又继承与net.Server,net.Server的描述是用于创建TCP或者本地服务器,这不正是我们想要的吗?
通过上面例子不难发现,我们主要用到的api的接口是createServer和listen,而首先的是net.Server实例。

// net.js中
function createServer(options, connectionListener) {
  return new Server(options, connectionListener);
}
function Server(options, connectionListener) {
  ...
  EventEmitter.call(this);
  ...
    if (typeof connectionListener === 'function') {
      this.on('connection', connectionListener);
    }
  this._connections = 0;
  ...
  this[async_id_symbol] = -1;
  this._handle = null;
  this._usingWorkers = false;
  this._workers = [];
  this._unref = false;

  this.allowHalfOpen = options.allowHalfOpen || false;
  this.pauseOnConnect = !!options.pauseOnConnect;
}
util.inherits(Server, EventEmitter);

这里可以发现Server实例继承EventEmitter,就像Backbone里面的model等继承Events一样,监听connection事件,并初始化this,net.createServer也是返回Server实例,看来Server很重要。再看看listen

Server.prototype.listen = function(...args) {
  ...
  if (hasCallback) {
    this.once('listening', cb);
  }
  ...
  var backlog;
  if (typeof options.port === 'number' || typeof options.port === 'string') {
    ...
      // 对于listen(port, cb)的情况
      listenInCluster(this, null, options.port | 0, 4,
                      backlog, undefined, options.exclusive);
    return this;
  }
  ...
};
function listenInCluster(server, address, port, addressType,
                         backlog, fd, exclusive) {
  ...
  if (cluster.isMaster || exclusive) {
    server._listen2(address, port, addressType, backlog, fd);
    return; 
  }

  const serverQuery = {
  ...
  };
  ...
  cluster._getServer(server, serverQuery, listenOnMasterHandle);
  function listenOnMasterHandle(err, handle) {
    ...
    server._handle = handle;
    server._listen2(address, port, addressType, backlog, fd);
  }
}
Server.prototype._listen2 = setupListenHandle;  // legacy alias

上面listen方法中有不同的场景判断,上面代码仅仅列出开发中常用的listen(port, cb)的方法。 结合Node.js的document里面的server.listen方法和这里的源码,发现document里面的介绍不就是源码里面的实现吗。。。。listenInCluster方法中通过cluster.isMaster来判断是否是主线程,如果是直接server._listen2,不是的话,还要进行cluster._getServer
而setupListenHandle方法

function setupListenHandle(address, port, addressType, backlog, fd) {
  ...
    var rval = null;
  ...
      rval = createServerHandle(address, port, addressType, fd);
  ...
    this._handle = rval;
  }
  this[async_id_symbol] = getNewAsyncId(this._handle);
  this._handle.onconnection = onconnection;
  this._handle.owner = this;
  var err = this._handle.listen(backlog || 511);
  ...
  nextTick(this[async_id_symbol], emitListeningNT, this);
}
function createServerHandle(address, port, addressType, fd) {
  ...
    handle = new TCP();
    isTCP = true;

  if (address || port || isTCP) {
    ...
    if (!address) {
      err = handle.bind6('::', port);
      ...
    } else if (addressType === 6) {
      err = handle.bind6(address, port);
    } else {
      err = handle.bind(address, port);
    }
  }
  ...
  return handle;
}

上面的setupListenHandle经过简化,不难发现最后落脚点在this._handle = new TCP(),而TCP:const TCP = process.binding('tcp_wrap').TCP,this._handle 正是server._handle,在初始化Server实例时候所建立的,process.binding是用来连接Node.js的内建模块,这里要看tcp_wrap.cc文件

tcp_wrap.cc文件开始的C++和C语言

tcp_wrap.cc文件里面有这里几个方法,

TCPWrap::TCPWrap(Environment* env, Local<Object> object)
    : ConnectionWrap(env,
                     object,
                     AsyncWrap::PROVIDER_TCPWRAP) {
  int r = uv_tcp_init(env->event_loop(), &handle_);
  CHECK_EQ(r, 0);  // How do we proxy this error up to javascript?
                   // Suggestion: uv_tcp_init() returns void.
  UpdateWriteQueueSize();
}
void TCPWrap::Listen(const FunctionCallbackInfo<Value>& args) {
  TCPWrap* wrap;
  ASSIGN_OR_RETURN_UNWRAP(&wrap,
                          args.Holder(),
                          args.GetReturnValue().Set(UV_EBADF));
  int backlog = args[0]->Int32Value();
  int err = uv_listen(reinterpret_cast<uv_stream_t*>(&wrap->handle_),
                      backlog,
                      OnConnection);
  args.GetReturnValue().Set(err);
}

TCPWrap方法里面调用了tcp.c里面的方法,建立TCP的句柄,函数形参包括uv_loop_t,uv_tcp_t和flags标识,接着对uv_tcp_t结构体中的各项进行初始设置,并调用uv__handle_init循环;
为什么要提及listen方法?在net.js的setupListenHandle方法里面明确用到了this._handle.listen(backlog || 511),在stream.c里面的uv_listen方法来监听,根据uv_stream_t结构体的type来判断是否是TCP类型,在tcp.c,uv_tcp_listen中通过uv_tcp_t结构体的flags来判断执行;

在uv.h里面定义了上面的结构体,每个结构体都有自己的成员。刚开始接触的时候,看到这里么就有种越看越乱,越看越多的感觉,这个时候开始知道有libuv;

libuv为何物

简单来讲就是跨平台io库,整合了window下的iocp和Linux的epoll,官网上有下图

在node_maic.cc里面调用了start方法,加载bootstrap_node.js文件,并同时while循环调用uv_run(),uv_run就是libuv事件循环的入口,这个方法的执行如下图

其中每一个模块和uv_run中的语句是对应,其中在window里面用(*poll)(loop, timeout),而unix采用uv__io_poll(loop, timeout)
上文提到的结构体uv_xx_s/t正是libuv的观察者,其中对应的类型uv_TYPE_t中的type指定了handle的使用目的。 至于具体的机理还是看官方文档好
看过了文档以及api之后,再去看Node.js里面代码,well, 还是一脸懵逼。。。。

只有硬啃下来,发现就是观察体太过长了,一遍一遍的嵌套宏定义,只是到最后还是没有完全读懂libuv如何实现通信,或许以后有下一篇来阐述libuv吧

参考资料

  1. node源码详解(六) —— 从server.listen 到事件循环
  2. uvbook 中文教程
  3. 初步研究node中的网络通信模块
  4. 《深入理解Node.js:核心**与源码分析》

2. webpack-dev-middleware主要源码理解

前言

每次用Vue-cli的时候,都会觉得配置的nodejs服务器很是让人省心,几个项目下来,都只用关心工程前端部分,用久了便想探其究竟。后来才发现原理用了express框架,代码也挺简单的,但是里面用的一个中间件webpack-dev-middleware,刚开始看的时候却不知有何用处?既然是express框架,又用的是SPA,路由不需要express来分发,那webpack-dev-middleware有何用处?抱着这样的疑问,看源码去吧

思路

一般express中间件的结构如下:

App.use((req, res, next) => {
  if (nextNeeded) {
    // 根据不同的请求处理,并传递到下一个next(),形成处理流
    next();
  } else { 
    // 根据不同的请求处理
  }
});

而webpackDevMiddleware也大体如此,只是返回一个Promise对象
在webpackDevMiddleware中的返回Promise前,有这两句:

var filename = getFilenameFromUrl(context.options.publicPath, context.compiler, req.url);
if(filename === false) return goNext();

getFilenameFromUrl这个名字就可以知道这是个返回匹配url路径文件名字,若不存在这个filename则goNext,而goNext里面若在webpackDevMiddleware中没有配置serverSideRender这个选项,则next进入下一个中间件。getFilenameFromUrl这个看源码就知道了,那问题的关键在于返回的Promise如何才能满足webpage开发和express框架?
Promisel里面的try出现了下面一句:

res.statusCode = res.statusCode || 200;
if(res.send) res.send(content);
else res.end(content);
resolve();

咦?这不就是最普通不过的res.send/end方法吗?那为何要大费周章的写个Promise呢?
用的确实是send和end方法,但是里面的content,却大有文章,一般用response返回的时候,直接返回本地文件就好了,用webpack的时候,部分文件却是自动生成的内存文件如index.html, app.js之类的,这些文件是不能直接本地读取并获得的。在processRequest里面的try里面有如下:

var stat = context.fs.statSync(filename);

这个context.fs是在Share.js里面的生成的

fs = compiler.outputFileSystem = new MemoryFileSystem();

MemoryFileSystem用的是memory-fs npm模块,这个模块就是一个内存文件模块,而这里用到的statSync方法,则是返回一个是否是文件/目录的对象,若都不是就抛出MemoryFileSystemError,所以思路就清楚了,当不存在内存文件的时候,如直接在html里面引用到的文件js/css/picture,就直接抛出Error,进入goNext,从而进入下一个中间件。若不抛出Error,则在try外部通过 context.fs.readFileSync(filename) 读取内存文件,如webpack生成的内存文件app.js等,从而选择性发送content。
至于剩下的响应头设置和webpackDevMiddleware的其他方法,聪明的你,自己挖掘吧

疑问

在返回的Promise里面,先是进行了shared.handleRequest,这里面会有若filename是文件才进行processRequest,而在processRequest的try里面,若filename不是文件,是路径才进行里面的改写filename,这样的逻辑设计是为何?是文件,又不是文件的,如何进入里面呢?

33. Decorator

前言

ESnext 里面提到的修饰器,在 redux 的推广下,几乎每个工程师都有或多或少的用过,最常见的就是 @connect 的形式,而在 Java 领域同样也存在 @ 这种符号的存在,被称之为注解,而巧的是修饰器同样能实现注解的功能。在看 Java 的注解的时候必然会看到反射这个概念。在阅读下面之前请先看阮老师的文档修饰器,再看下文。

Babel 开发环境须知

修饰器 Decorator 是 ES7 里面提出的,在 babel 6 里面需要引入 preset-stage-2,并在 .babelrc 中配置 "presets": ["env", "stage-2"]。到了 babel 7.0.0-beta.54 之前,则是需要 npm 包 @babel/preset-stage-2,配置 .babelrc 为 ["@babel/preset-stage-2", { "decoratorsLegacy": true }],默认是关闭的。而 babel 7.0.0-beta.54 之后的版本里面,已经 弃用 Stage Preset ,所以后面需要安装的版本是 @babel/plugin-proposal-decorators 配置为 ["@babel/plugin-proposal-decorators", { "legacy": true }],这样可以达到以前的效果,具体看官方介绍,以及@babel/plugin-proposal-decorators。目前 babel 7.1.0 已经发布了TC39 Standards Track Decorators in Babel

java 中的注解和反射

在 Java 里面注解是元数据,相当于是提供元素的配置信息,也就是额外的数据,比如最常见的 @Override @RequestMapping 这些。而在 ES 里面修饰器则更强大的多,是对类进行处理封装的改变,也可以不使用 @ 符号进行描述,但是这样就失去其便捷性、直观性了。ESnext 中关于 Decorator 的提案中提到修饰器不仅仅是可以用来修饰类,更包括了字段、getter、setter 和 方法。

先来看看 Java 中的注解是什么用法:

public class MyClass {
  ...
  @Test public void getName()
}

通过 Test 注解,来添加额外的信息,本身是没有什么作用的,需要工具支持才可以用,而这个工具就是反射了。反射有什么作用呢?反射可以在运行时获得程序或程序集中每一个类型的成员和成员的信息 。如果结合上注解,那就是 可以在运行时通过反射的方式取出方法的注解 ,从而实现额外的功能。反射里面主要用到下面四个类:

  1. java.lang.Class 类对象;
  2. java.lang.reflect.Constructor 构造器对象 有 public Constructor[] getConstructors() 方式;
  3. java.lang.reflect.Method 方法对象,有 public Method[] getMethods() 等;
  4. java.lang.reflect.Field 属性对象,有 public Field[] getFields() 等;

可以看看下面示例了解一下反射:

for (Method m : obj.getClass().getMethods()) {
  Test t = m.getAnnotation(Test.class);
  if (t != null) {
    // 对符合要求的 m 做处理
  }
}

反射的存在使得注解变得灵动起来。注解除了是 JDK 或者 Spring 里面提供的外,还可以自己定义注解,形如下面的 @Override 注解:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

ES 中的修饰器

ES 中对类的修饰器,更像是给类加个包装,对这个类进行操作。这里可以看看 react-redux 里面的 connect 的实现:

function connectAdvanced() {
  return function wrapWithConnect(WrappedComponent) {
    // ...
    class Connect extends Component {
      // ...
      render() {
        if (this.state.error) {
          throw this.state.error
        } else {
          return createElement(WrappedComponent, this.addExtraProps(this.state.props))
        }  
      }
    }
    // ...
    return hoistStatics(Connect, WrappedComponent)
  }
}

// 使用方式
@connect(state => ({
  global: state.global
}))
class WrappedComponent extends PureComponent {}

如上所示的,通过 @connect,对 WrappedComponent 修饰,在 Connect 中实现一套更新 store 的逻辑,最后通过 setState 来触发,使得传入 WrappedComponent 的 props 发生更新,并注入 dispath 等等方法,最后达到传递状态的作用。
而上面的这一切的功能只要用一个 @connect 就可以办到,和 Java 的注解不同,这里不需要反射来实现其功能。

对类/方法修饰还可以看看方正大神的(egg-blueprint)[https://github.com/Foveluy/egg-blueprint/blob/master/lib/index.js],是一个 egg.js 为插件,采用修饰器的方式,实现了类似于 Spring 中 @RequestMapping 的方式。

对于一些 java 中常见的注解,如 @override @readonly 等等,在 ES 里面也有同样的实现!比如常用的 core-decoratorslodash-decorators 库。

比如 @readonly 的实现:

function readonly(target, name, descriptor){
  descriptor.writable = false;
  return descriptor;
}

如果说对类的修饰是外在包装一层,那么对方法的修饰,由于 descriptor 的存在,则更像是修饰器。方法的修饰里面传入的参数中,包含了 descriptor 描述对象。这个在 Object.defineProperty 中应该是再熟悉不过的了。通过 descriptor 可以实现非常多的功能。

对于类的属性同样也能采用修饰器的方式,类似于方法,但是其 descriptor 属性描述符里面只能用 initializer 来替代之前的 value。

另外 ESnext 里面也可以通过 # 来定义私有变量,就如同 java 的修饰符 private。

只是对于私有方法和私有变量而言,其 descriptor 属性描述符里面 writable enumerable configurable 均为 false,同样的私有变量的描述对象只能用 initializer 来替代的 value。其他静态方法/变量等可以看看文档

总结

由于对 java 的理解尚浅,本文也是做个简单的对比。ES 中的修饰器随着新特性的出现,也会被更多人所使用。

28. react 源码开始的那一步

前言

本来想着学习路径是从 react 周围生态开始的,比如之前的 preact,react-router,再到后面 immutable.js,读懂这些源码,接着可以看 dva,rematch,亦或则是 redux,甚至是 ant-design 都可以看看的,到了最后再吃大餐,react 源码。只是不知道为什么想要挑战一下自己,不想这么循循渐进。想要试试自己的实力。于是便有了这次的 react 源码的阅读。

刚开始读的时候觉得看 react 源码是一种享受,就像在读一本小说一样,惊险刺激,停不下来。只是后面到了 fiber 的阶段,就有点懵逼了,这绝对是烧脑侦探片,而我是里面的路人甲,看几行代码都觉得费劲。这个时候遇到了如何阅读大型前端开源项目的源码,文章写得极好,根据上面的内容开始去看 react 文档里面的 Contribution Guide 里面的指导,随后又读了读 Blog 部分,简直是 amazing,尤其是Beyond React 16 by Dan Abramov,以及A Cartoon Intro to Fiber 。看得内心澎湃,觉得为何自己不能早点入坑呢?随后有看了正妹,以及方大神的介绍,顿时有了不少底气。

学习方式

想要完整的学习,于是最简单的从 ReactDom.render 开始一步一步往下走,后面遇到看不懂的地方,则开始用 debug 的方式,打断点看代码。按照 Contribution 里面的意思,先安装包,然后构建项目,生成对应的 core、dome 文件。这里需要注意的是,构建 React 项目居然要安装 Java,而且只能用 yarn,嗯,还是自己家的东西好是吧。构建好文件后,复制并打开fixtures/packaging/babel-standalone/dev.htm 文件,就可以是愉快的调试了(后期看代码看的心烦都是靠 debug 走下来的)。由于还有很多地方没有去读或者没有读懂,这里只是作为学习的记录,记录的是上面 dev.htm 里面这个例子的加载而已。

ReactDOM.render(
  <h1>Hello World!</h1>,
  document.getElementById('container')
);

就是从上面的例子,一步一步走下去,直到没有下一步。就是本文介绍的内容。由于涉及到的步骤内容较多,所以采用思维导图的方式来介绍。

前部分

这里是初始化以及 Reconciler 和 Scheduler 的前部分。

从 ReactDom.render 里面过来时,会先创建 new ReactRoot(),该对象也就是下图左侧的 root。同时 root.current 为 fiber 对象。而 fiber.stateNode 指回 root。在 updateContainer 函数里面会计算出超时时间 expirationTime,这个时间常数在后面经常用上。

前面这部分主要功能是以建立 root 对象,并创建第一个 fiber,HostRoot,也就是 tag 为 3 的情况。这个 HostRoot 有点类似于上文中的 container,将会包含的子 fiber,并且以后的 dom 节点的操作都少不了 HostRoot。

fiber 就是一个普通的对象,对于这个对象而言,最重要的字段是下面这几个:

const fiber = {
  stateNode: root,
  return: null,
  child: null,
  sibling: null,
  tag: HostRoot,
  effectTag: Callback,
  expirationTime: 1,
  updateQueue: updateQueue,
  alternate: workInProgress,
}

stateNode 在本文中就是 dom 节点,或者是 root;return 和 child 分别指向 父 Fiber 以及子 fiber,sibling 则是兄弟 fiber 了,这四个字段构成了 fiber 间最直接的关系;

tag 代表当前 fiber 类型,目前有 17 个值;effectTag 有 14 个类型,按照 bitmap 的结构,表示的是 dom 操作类型。

上图中 performWork 之前会构建一个 updateQueue,正如其名一个更新队列。在图中可以看到,root.containerInfo 为 container 这个 dom 元素,而传入 ReactDOM.render 的第一个元素,则在 update 对象的 payload 上。这里有两个重要函数 scheduleWork 和 performWork,可以说是开始 react 工作的第一步,其简化大致如下:

function scheduleWork(fiber, expirationTime) {
  let node = fiber;
  while(node !== null) {
    if(node.return === null) {
      const root = node.stateNode;
      const rootExpirationTime = root.expirationTime;
      requestWork(root, rootExpirationTime);
    }
    node = node.return;
  }
}
function requestWork(root, expirationTime) {
  if (expirationTime === Sync) {
    performWork(Sync, null);
  }
}
function performWork(minExpirationTime, dl) {
  findHighestPriorityRoot();
  while(nextFlushedRoot !== null) {
    performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false);
    findHighestPriorityRoot();
  }
}

代码中的 Sync = 1,也就是本文中 expirationTime 的值。这里面 scheduleWork 是更新的开始,通过循环找到,也就是 return 字段,沿着父 fiber 的路径一直到根节点。最后在根 root 开始工作啦。而 performWork ,这名字取得真好,先是 findHighestPriorityRoot,获取当前的最高优先的 root,再 nextFlushedRoot = root,执行 performWorkOnRoot 函数,再循环 findHighestPriorityRoot 函数会更新 nextFlushedRoot,但是本文中,只会发生一次循环,只有一个 root 呀。。至于其他情况,还不晓得,当然好像不重要。performWorkOnRoot 函数如下:

function performWorkOnRoot(root, expirationTime, isYieldy) {
  if (!isYieldy) {
    let finishedWork = root.finishedWork;
    if(finishedWork === null) {
      root.finishedWork = null;
      renderRoot(root, false);
      finishedWork = root.finishedWork;
      if (finishedWork !== null) {
        completeRoot(root, finishedWork, expirationTime);
      }
    }
  }
}

performWorkOnRoot 函数主要是通过是否异步 isYield,有的话就进入 commit 阶段,上面的代码自然是没有的部分,没有就进入 renderRoot,等 renderRoot 结束了再判断有没有完成,完成就进入 completeRoot 函数,也就是 commit 阶段咯。下文中的大部分步骤都是在 renderRoot 里面执行的。

workInProgress tree

上面的过程更多只是准备以及刚进入更新的过程,下面则是 reconciler 的核心部分。
fiber 是有两个阶段,Phase 1 render/reconcilation,在这个阶段是生成更新 fiber,更新虚拟 DOM 的过程,这个过程是可以被打断的。第二个阶段是 commit 阶段,这个阶段里面会把元素插入更新删除到 dom 树里面,无法被打断。本段落以及前面段落都是在 Phase 1 里面。

该阶段最重要的一个特征是会创建一个 workInProgress tree。在之前已经创建了一个父子兄关联的 fiber tree 了,而本次过程里面会再次创建一个类似的 tree。如下图所示:

可以看出来在上图的 root 下面,current fiber 下面有两个 fiber 构成父子关系。先看看进入 renderRoot 的大致写法:

function renderRoot(root, isYieldy) {
  nextUnitOfWork = createWorkInProgress(
    nextRoot.current,
    null,
    nextRenderExpirationTime,
  );
  do {
    workLoop(isYieldy);
    break;
  } while(true)
}
function workLoop(isYieldy) {
  if (!isYieldy) {
    while (nextUnitOfWork !== null) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  }
}
function performUnitOfWork(workInProgress) {
  const current = workInProgress.alternate;
  let next;
  next = beginWork(current, workInProgress, nextRenderExpirationTime);
  if(next == null) {
    next = completeUnitOfWork(workInProgress);
  }
  return next;
}

上面可以看到是由两个循环组成的,而在进入循环之前,会 创建一个 fiber,也就是 workInProgress tree 的 root fiber,即 HostRoot。这个 HostRoot 与 root.current 的关系更像是一个浅复制的关系,共享一个 stateNode,tag 都为 HostRoot。 workLoop 里面的 nextUnitOfWork 全局变量指的是下次要处理的 fiber 单元,自然首次是 workInProgress tree 的根元素,而下次则是该根 fiber 的子 fiber,也就是 child,不断下来从而实现 tree 的迭代。在工作单元 performUnitOfWork 函数里面,有个至关重要的函数 beginWork,顾名思义要开始工作了,前面函数更多的只是一个展开迭代,beginWork 才是阶段一里面最为重要的部分。

beginWork

function beginWork(current, workInProgress, renderExpirationTime) {
  switch (workInProgress.tag) {
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderExpirationTime);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderExpirationTime);
  }
}
function updateHostRoot(current, workInProgress, renderExpirationTime) {
  let updateQueue = workInProgress.updateQueue;
  processUpdateQueue(
    workInProgress,
    updateQueue,
    nextProps,
    null,
    renderExpirationTime,
  );
  reconcileChildren(current, workInProgress, nextChildren);
  return workInProgress.child;
}
function reconcileChildren(current, workInProgress, nextChildren, renderExpirationTime) {
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    current.child,
    nextChildren,
    renderExpirationTime,
  );
}

beginWork 里面根据不同的 fiber 类型来选定更新函数。updateHostRoot 则是更新 HostRoot,其先是浅复制了 current.updateQueue,再修改当前 workInProgress.effectTag = 32。更新队列的时候会把之前的 firstUpdate/lastUpdate 置为 null,同时 firstEffect/lastEffect 指向 update。

这里的 nextChildren 是就是 update 里面的 payload 的 element,也就是传入 ReactDom.render 的 element。通过 reconcileChildren,会直接的创建一个子 fiber,并返回到 workInProgress.child。由于 element 的 type 为 'h1',所以该 child 的 tag 为 HostComponent,child.effectTag = Placement是一个需要插入元素的 fiber。这个 effectTag 在后面也会用到。

生成 child 返回给到 workInProgress.child,也就是下一轮的 nextUnitOfWork。child 也就是下一个工作单元 fiber 了。updateHostComponent 函数同样也会进入 reconcileChildren 里面,只是并不会生成一个 fiber 传给 child.child,因为该 child fiber 没有任何的子元素,所以直接结束,

completeWork

上面结束后,会继续在 performUnitOfWork 执行 completeUnitOfWork,上一段的工作主要是建立 workInProgress tree,而这一段的工作将是生成 DOM。先看看 completeUnitOfWork:

function completeUnitOfWork(workInProgress) {
  while (true) {
    const returnFiber = workInProgress.return;
    const siblingFiber = workInProgress.sibling;
    if ((workInProgress.effectTag & Incomplete) === NoEffect) {
      // 这个fiber 已经完成
      let next = completeWork(
        current,
        workInProgress,
        nextRenderExpirationTime,
      );
      // 修改 firstEffect/lastEffect 为当前 workInProgress 也就是 child fiber
      if (siblingFiber !== null) {
        return siblingFiber;
      } else if (returnFiber !== null) {
        workInProgress = returnFiber;
        continue;
      } else {
        // 到达根部
        return null;
      }
    }
  }
}

这里 completeUnitOfWork 的传参是 child fiber,先是执行 completeWork 函数,随后开始循环,如果有兄弟 siblingFiber 则用,否之则为父 fiber,如果都没有则 return null。可想而知结束该循环的方式就是循环执行到 workInProgress tree 的根部。值得注意的是这里的 子 fiber 的 effects 会被添加到 父 fiber 的 effects list 上面

if (returnFiber.firstEffect === null) {
  returnFiber.firstEffect = workInProgress.firstEffect;
}
if (workInProgress.lastEffect !== null) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = workInProgress.firstEffect;
  }
  returnFiber.lastEffect = workInProgress.lastEffect;
}

传递方式如上面所示,returnFiber 就是父 fiber,如果有 returnFiber.lastEffect 以及 workInProgress.lastEffect,则通过 nextEffect 这个字段来传递,当然啦这里的 lastEffect/nextEffect 都是 fiber 元素哦。

接下来看看重点 completeWork

function completeWork(current, workInProgress, renderExpirationTime) {
  const newProps = workInProgress.pendingProps;
  const type = workInProgress.type;
  switch (workInProgress.tag) {
    case HostComponent: {
      if(current === null || workInProgress.stateNode === null) {
        let instance = createInstance(
          type,
          newProps,
          // div#container 元素
          rootContainerInstance,
          currentHostContext,
          workInProgress,
        );
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
        return null;
      }
    }
  }
}

在 completeWork 里会通过 createInstance 创建 dom 元素,这里的 instance 就是 <h1></h1> 随后会进一步添加 Dom 属性,最后成为 <h1>Hello World!<h1>。传递到 workInProgress 也就是 child fiber 的 stateNode。如上图所示。返回 null, completeUnitOfWork 里面会继续 completeWork 循环,此时 workInProgress.tag = HostRoot,进入不一样的 case,但是好像没有什么执行的。。。继续返回 null,同时和第一轮一样也会清空 fiber.expirationTime。

到了这一步基本就完成 completeUnitOfWork 的工作,接着会退出 performUnitOfWork 循环回到上文的第一个图的 performWorkOnRoot 里面,执行后面 performWorkOnRoot 函数。也就是 commit 阶段。

commit

commit 阶段主要有三大循环,每个循环都有不同的作用,其简化如下所示

function commitRoot(root, finishedWork) {
  let firstEffect;
  finishedWork.lastEffect.nextEffect = finishedWork;
  firstEffect = finishedWork.firstEffect;

  nextEffect = firstEffect;
  while(nextEffect !== null) {
    commitBeforeMutationLifecycles()
  }

  nextEffect = firstEffect;
  while(nextEffect !== null) {
    commitAllHostEffects()
  }

  nextEffect = firstEffect;
  while(nextEffect !== null) {
    commitAllLifeCycles(root, currentTime, committedExpirationTime);
  }
}

第一个 commitBeforeMutationLifecycles 函数,主要是执行组件的 getSnapshotBeforeUpdate 方法,这也是 react 新增加的一个生命钩子,该函数的返回值 snapshot,将是 componentDidUpdate 的第三个传参,commitBeforeMutationLifecycles 的主要作用也就在于此。

第二个 commitAllHostEffects 函数。这里面会将之前的插入,更新,删除和 ref 卸载的操作都执行到真实 DOM 上面。

function commitAllHostEffects() {
  const effectTag = nextEffect.effectTag;
  if (effectTag & ContentReset) {
    // ...
  }
  if (effectTag & Ref) {
    // ...
  }
  let primaryEffectTag = effectTag & (Placement | Update | Deletion);
  switch (primaryEffectTag) {
      case Placement: {
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        break;
      }
  }
  nextEffect = nextEffect.nextEffect;
}
function commitPlacement(finishedWork) {
  const parentFiber = getHostParentFiber(finishedWork);
  switch (parentFiber.tag) {
    case HostRoot:
      parent = parentFiber.stateNode.containerInfo;
      isContainer = true;
      break;
  }
  let node = finishedWork;
  while(true) {
    if (node.tag === HostComponent || node.tag === HostText) {
      if (isContainer) {
        appendChildToContainer(parent, node.stateNode);
      }
    }
    if (node === finishedWork) {
      return;
    }
  }
}

可知当前的 nextEffect 是 child, 在 commitAllHostEffects 里面根据不同的场景处理,由于都是 bitmap,所以流程下来很简单。最终由于 child 的 effectTag 为 Placement,从而 找到 root 的 containerInfo,将 child 的 stateNode 添加到 containerInfo 里面,并随后清楚掉 child.effectTag 的 Placement 位置。这样就从肉眼上可以看到的真实的 DOM 结构被改变了。第二轮 commitAllHostEffects 循环的时候,由于父 fiber 的 effectTag 为 Callback,不存在任何进一步的操作,最后会退出本次循环。

第三个循环 commitAllLifeCycles 函数,如下

function commitAllLifeCycles(finishedRoot, currentTime, committedExpirationTime) {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    if (effectTag & (Update | Callback)) {
      const current = nextEffect.alternate;
      commitLifeCycles(
        finishedRoot,
        current,
        nextEffect,
        currentTime,
        committedExpirationTime,
      );
    }
    if (effectTag & Ref) {
      // .. 处理 ref 相关
    }
    const next = nextEffect.nextEffect;
    nextEffect.nextEffect = null;
    nextEffect = next;
  }
}
function commitLifeCycles(
  finishedRoot, 
  current, 
  finishedWork, 
  currentTime, 
  committedExpirationTime
) {
  switch (finishedWork.tag) {
    case ClassComponent: {
      // 执行生命钩子,componentDidMount与componentDidUpdate
    }
    case HostRoot: {

    }
    case HostComponent: {

    }
  }
}

在上面的 commitAllLifeCycles 函数中,通过 commitLifeCycles 方法,执行生命钩子 componentDidMount 与 componentDidUpdate 的调用,同时会处理 updateQueue。这两点应该就是该循环的主要作用了。

通过上面的三个循环,而不是递归的方式实现了 commit 阶段。最后执行回归到 performWorkOnRoot,并结束前面两个循环。到此结束了。

总结

本文更多的只是从一个非常非常简单的例子来摸索 react 的首次渲染,能够清晰的看到其生成的 workInProgress tree,以及 reconciliation 与 commit 两个阶段的存在。一切的真实的 DOM 操作都发生在 commit 阶段,同时也会执行相关的生命钩子。但是对于 react 而言以上的探索是远远不够的。后面还会继续其他研究如:

  1. 后台调度机制 requestIdleCallback 的 ployfill 实现,以及现场保护等等运用。
  2. diff 机制,原则。
  3. 组件更新过程。
  4. 其他生命周期过程。

参考

开头文章列出的部分

  1. 如何阅读大型前端开源项目的源码
  2. Beyond React 16 by Dan Abramov
  3. A Cartoon Intro to Fiber
  4. React Fiber架构
  5. 为 Luy 实现 React Fiber 架构

49. eslint 多语言规则

盛夏到初秋,这次没有坐在窗边看不到磅礴的雨,只是这个夏天也少了烈日的暴晒,就这么过去了。去年台风天的黄昏,很好看,还拍了不少照片,到了今年,左等右等的,结果台风一刮,夏天就走了。2021好像也快要过去了。今年没有专门学习新的技术,中途换工作的,从2月拖到了6月,中间又忙了工作,梳理业务的,难得现在又空总结一下自己做的多语言。

多语言与 eslint

多语言为什么和 eslint 相关?通过 eslint 规则来限制业务开发,凡是涉及到中文的,都应该有多语言的约束,否则提示 error。这样就很强约束了,在多人协作的时候尤其重要,保持团队的统一风格。

这里说一下这个功能: 凡是涉及到中文的,都应该有多语言的约束。在 eslint 里面,这样的规则称之为 rule,比如我们经常看到的 no-debugger 禁止使用 debugger,就是这样的。最后实现效果是这样的:当输入中文变量包括 jsx 里面存在非法中文的时候,都会提示报红:

加上代码提交之前的 lint-staged 检查就可以统一规范了。

通用规则实现

在看多语言的 eslint 的规则实现之前可以看一下普通规则是如何的,我们就按上面的 no-debugger 来看看,这个是 eslint 内部的实现,用户只需要配置就好了,代码如下:

module.exports = {
    meta: {
        type: "problem",

        docs: {
            description: "disallow the use of `debugger`",
            recommended: true,
            url: "https://eslint.org/docs/rules/no-debugger"
        },

        fixable: null,
        schema: [],

        messages: {
            unexpected: "Unexpected 'debugger' statement."
        }
    },

    create(context) {

        return {
            DebuggerStatement(node) {
                context.report({
                    node,
                    messageId: "unexpected"
                });
            }
        };

    }
};

eslint/lib/rules/no-debugger.js 这个文件蛮简单的,可以看出下面有个 metacreate,前者不难猜出是配置信息,包括提示信息以及是否推荐这些;后面的则是出现 debugger 语句的时候就提示报错 context.report。是不是很简单?

回过头来可以看一下 eslint rule 官方教程,可以看到 recommended 就是是否在 eslint:recommended 扩展里面启用。最重要的 create 方法,eslint 会去遍历代码的抽象语法树 AST,调用 rule 里面注册的方法检测节点,上面则是 degugger 节点。

函数名是自己随便定义的?不是的,需要是对应的节点,可以通过 esprima 来解析来获得需要的函数名,比如下图,先有变量声明 VariableDeclaration 后有 DebuggerStatement

如果你把 no-debugger 中的 DebuggerStatement 换成 VariableDeclaration,那每次声明变量都会异常。提到 AST 解析还是要说一下,espreeeslint 的官方解析器,基于 esprima,并且输出结构相似。

钩子实现上面的 no-debugger 比较简单,遇到 DebuggerStatement 直接 context.report,就完成了整个上报过程了。

其他需要主要的是规则代码的位置需要在 lib/rules/ 下面,同时要有 tests/lib/rules 以及对应的文档。

多语言规则实现

通过上文 create 提示的实现,先是梳理常见的中文都是哪些节点,比如最常见的 let a = '中文',在 esprima 里面查询到的节点类型为 Literal;其他的还有两大块一个是 jsx 里面的文字,比如 <div>中文</div> 这样的,还有一个是字符串模版变量,比如 let a = 中${name}文。前者可以通 JSXText 获取,后则稍微麻烦点,下面重点介绍一下:

模版字符串

模版字符串解析的下图:

可以看到存在 quasisexpressions 两个数组,前者是字符串信息,后面是表达式。i18next 本身是支持变量传入的,比如 i18next.t('中{{value}}文', { value: name }),变量从第二个参数里面传入,生成文案替换。由于表达式不一定是变量,可以采用 value 名字自动生成名称。

字段名称是生成了,后续还要获取到字段名称对应的 value 也就是 name 这个字符串。存在变量名的可以直接获取,其他的比如 i18next.t('中{{value}}文', { value: 1 + 1 }),要如何获取到后面的 1 + 1 呢?可以有如下写法

const value = context.getSourceCode().getText().slice(expression.range[0], expression.range[1]);

eslint 规则里面会传入 context 上下文,通过上下文可以获取到非常多的信息,比如上面的获取全文字符串,再通过 range,定位到具体的内容。

忽略与修复

正常代码里面中文也是会有需要的地方,比如上报日志,埋点信息这些,甚至更加根本的如何识别 i18next.t('中文') 这样的表达式不报错呢?这样的表达式已经是正常写法了。所以这里需要 ignore 正常表达式。

进入节点通过匹配白名单的形式可以过滤掉,只是就上文的 i18next.t('中文') 匹配了函数,但是文案又是另外一个节点,如何做到两个关联到一起呢?这里就需要额外数组来记录当前关系,比如进入 i18next.t 函数的时候,标记为 true,后面进入函数形参的时候,检查标记,为 true 就忽略了。只是什么时候退出呢?这里有另外一个钩子 CallExpression:exit。由于存在多个嵌套的可能,所以用的是一个数组来维护,当 exit 的时候退出。

上面的例子,是遇到 debugger 的时候报错,那如何 fix 呢,eslint 有修复功能。context.report 里面的传参 fixer 函数,提供了不少方法。

context.report({
    node,
    message,
    fix(fixer) {
        return fixer.replaceText(
            node,
            `${useCallee}('${textReplacer(node.value)}')`
        );
    }
});

还用到了 fixer.replaceTextRange,来精准替换。

存量代码处理

对于老项目,直接用多语言的主要问题是,现有文案如何处理,要一个个替换显然不合理。最先的 i18next 提供了 i18next-scanner 工具,只是起更多的是将现有的 i18n._('Loading...') 这样的实现提出文案到 json 文件里面。还有其他思路比如通过 babel 的形式来解析,还有通过正则的形式来处理,两个方案都比较麻烦,容易出问题,于是我们这边有新的方案,本来也是通过 eslint 的规则来处理,那为什么不用 eslint 的解析器来提取词条呢?

require('eslint').CLIEngine(options).executeOnFiles 获取到 eslint 的分析结果,当然需要提前配置好规则,比如 i18next/no-literal-string 这个。后续获取到结果后,将其输出为转换为 json 文件输出就可以了,可以说这个规则一举两得。

单测

用的是 require("eslint").RuleTester 比如下面:

var ruleTester = new RuleTester({
  parser: require.resolve("babel-eslint"),
  parserOptions: {
    sourceType: "module",
    ecmaFeatures: {
      jsx: true
    }
  }
});
ruleTester.run("多语言规则测试", rule, {
    // 写 case valid 和 invalid
})

debug 的时候采用 create javascript debug terminal 的方式就可以了。

总结

这次算是深入的研究 eslint 了,从单个规则到引擎扫描,还有测试,打开了自己了新天地,尤其是 eslint 对团队规范的统一还是有很大帮助,后续的还可以添加 typescript 帮助,将 json 文件改为 ts,在文案里面缺少该词条就报错的,建立更加完整的机制。

在三亚的酒店的窗边,眺望着远处的海岸线,觉得写写博客,旅游,生活可好了,只是心中想的还是想要进击,想要不断的提升自己,偶得半天安宁,还是好好偷闲。

惭愧这边文章是 2021.10 就写好了,结果拖到今天才发表,不过说来也奇怪感觉时间好像没有过多少一样,好像去三亚还是昨天的事情。

16. webpack构建优化

前言

项目中经常用 Jenkins 构建项目,只要点击构建,服务端就会按照指令,重新拉取数据构建,这是很好的,只是久而久之发现一个问题:项目的构建时间从之前的飞快,到现在龟速。等待构建开发时间长是一个问题,更重要的是如果项目继续发展壮大呢?现在的 antd-pro 项目也就十来个页面,一点也不多,但是测试服构建起来,时间将近4分钟,特别吃内存。如果以后页面多很多呢?五六十个页面呢?那岂不是要二十来分钟的构建时间?内存呢,难道最后要溢出?这是难以置信的。

初探问题

春节期间前有空,上网查了一下方法,webpack.optimize.UglifyJsPlugin 几乎是千夫所指的,自带的代码丑化基本就是鸡肋,用上其他丑化插件,打包时间可以节省上30%,甚至更多,只是 antd-pro 用的是 roadhog.js,是一款接近于 create-react-app 的基础工具,能自己编写的配置参数少之又少,更不要提随意运用 webpack 的插件了。只是想要试探性的玩一下,于是在本地的 node_modules 里面修改了 roadhog 关于 webpack 的 UglifyJsPlugin 插件,结果一试,速度 duang 的一下就上来了。后来由于工作忙就没有怎么 care 构建问题。

春节期间看了几位大佬的博客,有感珠玉在前,webpack 构建问题,是要好好处理的了。

roadhog.js问题

roadhog.js 是类似可配置的 react-create-app,只是这个可配置,也只是部分可配置的,木有办法,只能从源码开始看 webpack 配置。

在进行 npm run build 的时候发现终端有提示 Creating an optimized production build... 的字样,而且出现的时间也挺晚的,以前其他项目上面从未见过,难道是 roadhog 自己的?这个时候 webpack 居然还没有开始构建?抱着疑惑,从 roadhog 的bin/roadhog.js就开始打印当前时间,再在到开始webpack构建的时候再打印一次时间。

结果这个过程要花上2931ms,还是可以接受的,只是明明第一次的时候记得等了很久的,为什么这次只要3s不到?后面又试了几次,耗时均3s左右,后来想起了Webpack 构建性能优化探索里面提到的初次构建和再次构建的问题,一般再次构建耗时都要比初次构建的要少。会不会第一次比较慢是初次构建,后面都是再次构建呢?初次构建和再次构建有什么区别?百度和谷歌都没有查询到答案,只有该博客提到比较多。为了再现问题,well,重启电脑,再次 npm run build 不就是初次构建吗?结果还正如此。

初次构建从进入 roadhog.js 到开始执行 webpack,居然花了30268ms,没有看错居然要30s的时间,这还没有算是 webpack 运行的时间,我的天呀。

时间都去哪儿了-roadhog.js

多次尝试初次加载,大概都在30s时间左右,而再次加载平均3s不到,十倍的差距,初次加载如此龟速,时间都去哪儿了?

粗看代码,并没有什么特别耗时间的计算,于是到处用console.log()打印时间,发现原来是几个requre的地方特别耗时间,分别如下:

var _webpack = require('webpack');

var _getConfig = require('./utils/getConfig');

return (0, _applyWebpackConfig2.default)(require('./config/webpack.config.prod')(argv, appBuild, c, paths), process.env.NODE_ENV);

这三行代码基本占用80%以上的时间,尤其是最后一句读取webpack配置,耗时将近14s,**原来时间都花在读取这些文件上面了。**这下瞬间明白了,roadhog.js 在初次加载上面表现很差劲,就是这个原因了。对比一下 Vue 的脚手架 Vue-cli,Vue-cli 里面从开始到进入 webpack 初次构建只要13s左右。这 roadhog.js 简直是慢慢慢。

再次加载由于node的缓存机制,之前require的内容都可以缓存下来,所以只是读取一个缓存的过程,时间可以缩短到3s不到。而正式服上面,一周构建一次基本就算频繁了,每次构建前期工作都要花30s,有必要这么复杂吗?看看新出的 parcel,秒杀。

数据报备

本机硬盘环境:

2.3 GHz Intel Core i5 / 4 核
4 GB DDR3

项目数据:

1526 个 node_modules 文件,package.json 中项目依赖 58 个

运行数据:

[email protected] npm5.5 下整个webpack初次构建耗时达 92229ms,再次构建 86422ms

走进webpack优化

按照Webpack 构建性能优化探索里面给出的思路,对于webpack的优化,可以从四个维度考量:

  • 从环境着手,提升下载依赖速度;
  • 从项目自身着手,代码组织是否合理,依赖使用是否合理,反面提升效率;
  • 从 webpack 自身优化手段着手,优化配置,提升 webpack 效率;
  • 从 webpack 可能存在的不足着手,优化不足,进一步提升效率。

从环境出发这一点,是因为不同的nodejs版本和npm版本,有着显著的性能差异来的。可以这么认为最新版本的nodejs/npm自然有更优秀的性能。由于项目本身用的就是最新版本的环境,所以这里也不加以分析了。

从项目中出发

首先用比较常规的方法,通过 webpack-bundle-analyzer 来查看 webpack 体积过大问题,结果如下图所示:

图挺好看的,乍一看没有什么特别的地方,好像每个打包文件都是由诸多细文件组成的。并从文件大小来看压缩过后都在1M以下,无可厚非。但是细心对比下,还是有不少发现。

案例1:为了实现小功能而引用大型 lib

这里用 webpack-bundle-analyzer 来查看打包过大问题,但是在引用的时候,却发现roadhog原本自身就用了 webpack-visualizer-plugin 插件,只是在analyze指令下才能进入分析,整理之后webpack配置如下:

var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
var Visualizer = require('webpack-visualizer-plugin');

plugins: [
  new Visualizer({
    filename: './statistics.html'
  })],
  new BundleAnalyzerPlugin()
]

这里 webpack-bundle-analyzer 可以给予直观的整体感受,而 webpack-visualizer-plugin则细化到每个文件中,每个模块的百分比。

首先看到的是:最大的文件打包压缩后是815.9kb,相对于其他较大的文件大出了整整400kb,这里肯定是有什么问题,细看之后,发现用了支付宝的 G2,在代码中的体现是:

import G2 from 'g2';
// 将整个G2都引入进来了,导致文件过大

遗憾的是目前G2没有实现按需加载的功能,在issue里面也只是表示正在讨论而已(庆幸这里只是用了G2,没有用到Data-set)。

仔细看了每个js文件打包构造后,发现有个文件也用了 moment 模块,在印象中是基本没有用到的。moment 模块大小为53.2kb,而在总的打包文件中占 131.6kb。正同 Webpack 构建性能优化探索所说的,如果不想简单实现,就采用 fecha 库来代替 moment,fecha 要比 moment 小很多。只是替换后,发现 moment 体积并没有降低多少,由于出处是在 index.js 文件里面,可能的地方只有 dva 了。只是 dva 怎么可能用到moment?完全不可能的,他的package.json里面也同样没有用到。通过排除法最后定位到如下:

// @src/index.js
import 'moment/locale/zh-cn';

// @src/router.js
import { LocaleProvider } from 'antd';

第一个行代码是直接使用了 moment 模块,该代码看着作用不大,而且查阅Ant-Design-Pro的历史版本,均没有发现在index.js里面使用 moment/locale/zh-cn。细心观察,发现在 index.js里面使用了 moment/locale/zh-cn 之后,其他几处用到moment的地方,生成文件都没有明显 moment 包,这些文件的体积基本上要减少一个 moment 的大小。这个moment/locale/zh-cn,还能降低其他文件体积。
第二行代码,是在 router.js 文件里面,由于使用了 LocaleProvider 组件,这个组件通过源码可以发现直接引用了 moment 模块 import * as moment from 'moment'。当然同样也起到了 moment/locale/zh-cn 的效果,能降低其他原本含 moment 文件的体积。

案例2:废弃依赖没有及时删除

项目中用的是 Ant Design,import 的时候,组件是按需加载的,并不会整个引入 Ant Design,但是由于敏捷开发周期较短,新建页面不会从零开始写,基本都是移植相似的页面,由此导致了Ant Design组件的乱引用。

由于 G2 的不可按需加载,以及 moment 在 Ant-Design 中的作用,工程的打包体积和打包时间没有较大的减少。

从 webpack 自身优化点出发

webpack 本身也提供了许多优化的插件,但是由于经常接触不多,许久后容易遗漏,导致再次学习的成本高。一个好的脚手架,是相当重要的。

webpack 自带的优化

webpack 就有不少内置的插件。

  • CommonsChunkPlugin

CommonsChunkPlugin 可以从 module 提取公共 chunk,实现降低模块大小,有利于整体工程打包后的瘦身。
CommonsChunkPlugin 这个插件在Vue-cli中也有用到,如下:

// split vendor js into its own file
new webpack.optimize.CommonsChunkPlugin({
  name: 'vendor',
  minChunks: function (module, count) {
    // any required modules inside node_modules are extracted to vendor
    return (
      module.resource &&
      /\.js$/.test(module.resource) &&
      module.resource.indexOf(
        path.join(__dirname, '../node_modules')
      ) === 0
    )
  }
}),
new webpack.optimize.CommonsChunkPlugin({
  name: 'manifest',
  chunks: ['vendor']
})

把相同的 chunk 提取出来,命名 vendor 与 manifest,前者是常说的公共 chunk 部分,后者是由于代码变动导致 chunk 的 hash 值变化,导致公共部分在每次打包时都会有不一样的 hash 值,使得客户端无法缓存 vendor。**由于代码变动导致 hash 变化,而生成的代码,自然而然的会落在最后配置的 commonschunk 上面,**所以这部分可以单独提取,命名为 manifest。

在roadhog里面,刚开始看以后没有CommonsChunkPlugin的配置,想着赶紧提个issue,但是后面发现,是通过common.js引入,只有在roadhog里面配置了multipage选项为true的时候,才执行CommonsChunkPlugin插件。其代码如下:

var name = config.hash ? 'common.[hash]' : 'common';
ret.push(new _webpack2.default.optimize.CommonsChunkPlugin({
  name: 'common',
  filename: name + '.js'
}));

通过 CommonsChunkPlugin 插件,node.js 在打包的时候,峰值内存增加了40M,就是约5.4%的内存,打包时间延长了大约6s,而构建后项目体积基本不变,what?有点震惊。只有负面效果。。。。看构建文件,只提取了一个公共文件,大小1kb,而且内容为一句普通的错误打印。为什么人与人之间没有相互的chunk可以提取呢?

通过反复查 roadhog/ant-design/ant-design-pro 的 issue 都没有类似的问题,似乎用了 babel-plugin-antd 对 antd 进行按需加载,没有办法将其提取到 vendor 里面了。如若不想不想按需加载,直接用 cdn 不就好了。但是现在想的是只要单独的提取antd里面几个涉及CRUD的重要组件:表格,form,日历这几个组件能否实现单独打包到vendor?难道是我打开方式不对吗?大神里面少 7s,我还多了 6s。。。。

在这个issue里面看到了这种写法,顿时觉得没错,就是她了。entry 里面设置多入口,CommonsChunkPlugin里面再提取。

entry: {
  //...
  antd: [ //build the mostly used components into a independent chunk,avoid of total package over size.
      'antd/lib/button',
      'antd/lib/icon',
      'antd/lib/breadcrumb',
      'antd/lib/form',
      'antd/lib/menu',
      'antd/lib/input',
      'antd/lib/input-number',
      'antd/lib/dropdown',
      'antd/lib/table',
      'antd/lib/tabs',
      'antd/lib/modal',
      'antd/lib/row',
      'antd/lib/col'
  ]
},
//...
new webpack.optimize.CommonsChunkPlugin({
    names: ['antd'],
    minChunks: Infinity
}),

咦?见证奇迹的时候到了,构建后的项目大小居然小了,整整3M,少了36.64%,厉害了。更惊讶的是,峰值内存减少了180M,减少了24.3%,打包时间减少了26s,直接下降到59196ms,减少25%;这牛逼了。

仔细对比一下,发现原来减少的部分并不是我以为的 antd 组件,antd 组件反而在每个打包文件里面的体积都要更大了,大概多了几kb,而减少的部分却是一些 _rc 开头的组件,这 CommonsChunkPlugin 也是厉害,按需加载部分没有单独打包起来,反而打包了这些组件背后的引用,如 rc-table。为什么这些组件最后还是没有完整的打包在antd里面呢?难道每次用的都不同?

import { DatePicker } from 'antd';
// / babel-plugin-import 会帮助你加载 JS 和 CSS 转变成下面内容
import DatePicker from 'antd/lib/date-picker';  // 加载 JS
import 'antd/lib/date-picker/style/css';        // 加载 CSS

这没看来,只是典型的引入组件,以及引入css模块而已。这是必然会被打包到公共模块的呀。看了未丑化的代码,发现用同一个组件的话,生成的不同文件 antd 的组件内容是一样的,不存在组件内部不一样导致没有打包在一起的情况。折腾许久后尚未解决,不晓得有没有大神知道。

而且 roadhog.js 的方式不允许添加新的入口,只能直接改源代码。。。这项目要怎么上线呢?难道每次都要自己改一遍?这就是约定和可配置的问题所在了,后面大神的博客也有讨论到,最后的**还是约定为若干模块,可自选配置,来适合不同的场景。

  • DedupePlugin/OccurrenceOrderPlugin

这两个功能在webpack里面很常见,以至于已经被移除了,默认加载包含在 webpack 2 里面了。

CommonsChunkPlugin对项目的优化还是很实在的,能减少不必要的打包,不仅是体积,更多的是从内存和时间上。

webpack外引入的优化

前面提到的 webpack-bundle-analyzerwebpack-visualizer-plugin 插件就是从 webapck 外部引入的,可以很直观的看。

externals 的设置在 Vue 项目里面用的比较多,其中主要 externals 的是 axios, Vue, Vonic, Vue-router 这些。本身体积也不大,而且作为单页面应用还是很需要的。

但是到了 Ant Design Pro 项目,由于 Ant Desgin 项目本身 CSS + JS 就要1.5M,对于首屏的影响是显著的。虽然可以通过浏览器缓存/cdn缓存的方式来自然优化,但是首次体验还是不行,还是按照官网上的介绍来吧。

  • DllPlugin 和 DLLReferencePlugin

按照官网上的介绍:DLLPlugin 和 DLLReferencePlugin 用某种方法实现了拆分 bundles,同时还大大提升了构建的速度。具体原理则是将特定的第三方 NPM 包模块提前构建再引入就好了。通过在 webpack 外进行配置,DllPlugin 负责配置输出引用文件 manifest.json,而 DLLReferencePlugin 在webpack的正常配置里面用 manifest.json 就好了。可以避免每次都对 npm 包打包,明明它们就不会改动,直接引用不是更好吗。

在 roadhog.js 里面实现就有点那个了,按照 sorrycc 作者的意思,在生产环境使用 DllPlugin 是不合适,打包大量的 npm 包后,会延长首屏时间,与按需加载矛盾。这点就和 CommonsChunkPlugin 是相同,都是提取第三方库,而且 DllPlugin 是一次打包即可,以后重复用引用,而 CommonsChunkPlugin 是每次打包都要重复提取公共部分,那这两个又有什么区别?

一般 DllPlugin 打的包会包含很多 npm 包,导致体积很大,首次加载自然不好,而且若以后更新某个包,会导致客户端重新下载整个 DllPlugin 的生成文件,对于产品迭代是不友善的。反观 CommonsChunkPlugin,一般提取的公共部分体积较小,例如antd主要组件提取,不到500kb,除非大版本升级,否则客户端是不会重新请求 vendor.js 文件的。

基于上面的观点 DllPlugin 一般用于提升本地开发的编译速度,就是启动项目开发的时候能够快点。只是一天能够启动多少次项目呢,基本都是热更新为主吧。。。。。这么看好像意义不大,就是开发人员的自 hight 而已。

发现原来roadhog自己也有 DllPlugin 的配置,只要在 config 里面添加 dllPlugin: true 就可以了,当然也是仅仅限于开发环境,肯定不是生产环境。很是方便,这里就不详细介绍了,感兴趣的可以自行看看这个issue

从 webpack 不足出发

  • HappyPack

使用 HappyPack,可以利用 node.js 的多进程能力,来提升构建速度。在 webpack 打包的时候,经常可以看到 CPU 和内存飚的非常高,内存可以理解,但是 CPU 为何会如此之高呢?只能说明有大量计算,而 node.js 的单进程在大量计算面前是单薄的。可以在 webpack 中定义 loader 规则来转换文件,让HappyPack来处理这些文件,实现多进程处理转换

设置如下:

new HappyPack({
    threads: 4,
    loaders: [{
      loader: 'babel-loader',
      options: babelOptions
    }],
})
{
  test: /\.(js|jsx)$/,
  include: paths.appSrc,
  // loader: 'babel',
  loader: 'happypack/loader',
  options: babelOptions
}

只是运行结果却不让人满意,打包时间/内存什么都和原先的数据几乎相当。难道和 CommonsChunkPlugin 的时候一样,又是打开方式不正确?于是按照官网说的加个 id 试试,结果立马报错,提示AssertionError: HappyPack: plugin for the loader '1' could not be found! Did you forget to add it to the plugin list?,看到有 issue 提出将 loader 里面的 options 改为 query 就可以了,只是官方提示 webpack 2+ 需要使用 options 来代替query ,最后试了一下也是报错,报错的根由是 happyloader 没有获取到查询的识别 id。回头看了下源码,query = loaderUtils.getOptions(this) || {}这句话不就是获取 loader 的 option 配置吗,里面怎么可能有 id 呢?里面就是 babelOptions,不可能有 id 的。接着看 loader-utils 的源码,这个就是简单的获取查询到的 query,没有毛病,难道是 HappyPack 用错了?

折腾好久后,差不多都要放弃了,我定了定神,重新理一遍,看到了 rules 里面的配置:

loaders: [{
  loader: 'happypack/loader?id=js',
  options: babelOptions
}],

options 选项是 roadhog 原先就有的,而 laoder 原先是 babel,后面改为了 happypack 的设置。这个时候眼睛一亮 loader 设置里面有个问号 ?,这个不就是 query 吗?那 options 呢?loader-utils 里面获取的是这个 query 还是 option?注释掉试一试?完美成功了。。。。原来如此简单。

用了 happypack 之后,不能在 rules 里面的相关 loader 中配置 options,相反只能在 happypack 插件中配置 options!

well, 然而什么都没有变呀,设置了缓存也没有用,速度/内存什么的都和之前一摸一样。这个时候看到了(在 roadhog 中尝试支持happypack)[https://github.com/sorrycc/roadhog/issues/122]里面大神说了社区版本有问题。。。。。。虽然不知道具体的原因,但是实际效果是对 js 文件用 HappyPack 的配置,是没有起到想象中的多进程计算的优点的,原因或许出在 babel/HappyPack 身上了,最后还是落到了单线程计算上。具体就不分析了,有空可以在研究一下。

  • uglifyPlugin

uglifyPlugin 是生产环境中必备的,毕竟压缩丑化代码,不仅可以降低客户端加载项目体积,降低打开时间,而且可以防止反向编译工程的可能性。在本文的开头就提到过,首次优化就是针对 uglifyPlugin 的,而且效果显著。

使用 webpack.optimize.UglifyJsPlugin 的时候,平均下来 webpack 的构建时间要达到 86s 左右。当不进行代码压缩丑化的话,构建时间下降了 68s 左右,并且构建时候,node.js 占用内存峰值下降了 380M 多,可以说不压缩丑化的话,效果是非常好的。但是项目体积却基本是原本的三倍之大,这是难以容忍的。webpack自带的uglifyPlugin,如此笨拙,要如何处理呢?

webpack.optimize.UglifyJsPlugin 在里面添加 cache: true 的配置也是没有什么效果,看了下官网介绍的另外一个 UglifyJsPlugin 插件,上面写着 webpack =< v3.0.0 已经包含 UglifyjsWebpackPlugin 的 0.4.6 版本了,如果想要安装最新版本才按照下面介绍的来。发现本地安装的 webpack 版本是 3.11.0,自然是内置 0.4.6 版本。1.0.0 版本是会在 webpack 4.0.0 里面安排的。那如果直接用 uglifyjs-webpack-plugin 最新版本呢?

安装 uglifyjs-webpack-plugin 1.2.2,设置配置如下:

new UglifyJsPlugin({
  cache: true,
  uglifyOptions: {
    compress: {
      warnings: false
    },
    output: {
      comments: false,
      ascii_only: true
    },
    ie8: true,
  }
})

初次构建的时候,构建时间较之前多40s,也就是多了46.5%,有点夸张的多,内存还好,峰值基本和用 0.4.6 版本的一样。但是 再次构建呢?构建时间居然下降了68s,而且内存峰值和未用代码压缩丑化的时候相似,也就是减少了 380M,实在厉害,牛逼哄哄。

还可以开启并行,也就是多进程工作,设置 parallel: true,设置之后测试,初次构建时间居然比普通的再次构建时间要少10s,但是问题也很明显 CPU 在平时的时候峰值基本在 45% 左右,而多进程后,CPU 的峰值居然很长一段时间都在 100%,内存也是达到了 1300+M,实在恐怖,如果正式服这么用不晓得会不会爆炸呢?hahaha。parallel 除了可以设置为 true 以外,还能设置成进程数,于是试了等于 2 的时候,CPU 运行峰值接近 95%,而内存峰值在 1100+M,也算是相对较好的数据,只是 CPU 还是接近于爆表。

对于再次构建 parallel 自然是起不到作用的,这里有不得不提另外一个插件 webpack-parallel-uglify-plugin (下载量比另外一款 webpack-uglify-parallel 多上一倍,肯定使用这个嘛)。试了一下,初次构建基本和 uglifyjs-webpack-plugin 1.2.2 一致,只有构建时间快 7s。

综上所诉,对于服务器 CPU 豪华的可以考虑平行压缩丑化,一般时候用 uglifyjs-webpack-plugin 1.2.2 多进程就不用设置,使能 cache 就好了,初次构建会慢点,再次构建的话,速度就上天了。

  • UglifyJsPlugin 与 CommonsChunkPlugin

最后自然也是要让两者合并试一试,效果如何呢?和为优化之前相比,初次构建,内存减少 120+M,构建时间基本一样,构建项目大小自然还是少了 3M。咋一看好像不怎么样,但是要知道这是用上了UglifyJsPlugin,有缓存的!结果再次构建数据如所想的一样,速度和内存数据,和没有用代码压缩丑化基本一致!

这样 uglifyjs-webpack-plugin 与 CommonsChunkPlugin 在生产环境自然是很好的选择。

** 总结
本文主要是按照(Webpack 构建性能优化探索)[https://github.com/pigcan/blog/issues/1]介绍到的方法实践一遍,可谓收获颇多。但是还是遗留下两个问题:

  1. CommonsChunkPlugin 对 Ant Design 提取问题;
  2. HappyPack 没有效果的问题;

另外前几天 webpack 4 已经正式发布了,大佬们都迫不及待的想介绍一波。初看一下,从之前的配置化**更多转化为约定俗成,这确实是前端发展的趋势。尤其是这类工具,每次都学习一下,成本过高,看看create-react-app,基本都封装好webpack,上手用就可以了。而2017年的明星项目 parcel 更是夸张,直接跑,没有什么配置的。还是期待 webpack 4 的传说中的提升98%的速度吧。Twitter上面的数据基本在提升60%多。Twitter数据

对 webpack 4 感兴趣的可以看这篇(翻译)[http://www.zcfy.cc/article/x1f3bc-webpack-4-released-today-webpack-medium](国外大佬们都迫不及待要介绍了,国内还在过春节元宵节,哈哈哈哈)

参考

  1. Webpack 构建性能优化探索

14. 文件的故事

前言

项目中遇到的文件下载,上传基本上最常见的事情了。大概半年前,需要实现某表单的查询下载功能,查询还好,只要后端返回数据,我负责展示就好了,但是下载要如何实现呢?用axios的GET请求返回的数据,不忍直视,根本就下载不了。一顿百度谷歌之后,哦,原来这么简单,只要一个window.location.href=url就搞定了,是不是很简单~

文件下载

后来的文件下载我都统统用这种方式,只是下载提示不够明显,后来改为window.open打开个新的tag页,然后自动关闭,明显点下载。好像到这里就已经很完美,一切交给浏览器解决。

直到开始node.js中间层搭建。中间层的功能负责连接前后端,接口还是由后端提供的,具体可以参考淘宝前后端分离实践,以及天猪大神的egg - JSConf China 2016。node.js端用的是egg.js,小公司用它还是很顺心的。平常的API接口还好,只用转发到对应的后端接口就好了,但是文件下载怎么办?难不成我也要在node.js里面写个window.open?滑稽可笑。

这个时候又回到了请求上面,客户端自然还是用window.open,而nodejs端获得文件流再返回给客户端,这样window.open才能用。于是在本地测试了一下,用fs.readFile以及fs.createReadStream的方式都可以返回给客户端,但是下载文件类型却是没有的,几经波折后才发现没有设置Content-dispositionContent-Disposition消息头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地,用上koa2的attachment(fileName)方法更是简单,那这个不就迎刃而解了?
这里测试的只是本地的文件流,那后端接口上的文件流呢?这里还是用ctx.curl方法来请求获取数据,只是需要注意的是curl的设置响应数据格式,不是之前的RESTful的API接口-json交流方式了,而是文件流,所以默认不设置dataType就好了。代码如下:

async download() {
  const { ctx, app } = this;
  let data = {};
  try {
    data = await ctx.curl(url, {
      method: 'GET',
      headers: {},
      timeout: 8000,
    });
  } catch(e) {
    data = {
      status: 404,
      data: {msg:'服务访问出错'}
    }
  }    
  ctx.status = data.status || 404;
  ctx.set(data.headers || {});
  ctx.body = data.data;
}

客户端的文件下载

之前在客户端表格导出不是用window.location.href=url就是用window.open(url),这个方法感觉很土,当然还有更土的就是用a标签,动态修改里面的href,<a href="url" target="_blank">导出</a>一般这种才是最常见的吧,可惜url需要一直修改。那有没有正常的用请求接口的方式来下载文件呢?
答案是有的,

fetch('/download').then((response) => { 
  return response.blob().then((blob) => {
    var a = document.createElement('a');
    var url = window.URL.createObjectURL(blob);
    var filename = response.headers.get('Content-Disposition');
    a.href = url;
    a.download = filename;
    a.click();
    window.URL.revokeObjectURL(url);       
  })
});

这个好像那里看到过,不就是创建了个a标签,再点击下载嘛。。。。还不如直接用a标签方便的多!!而且这里还用到了HTML5的download属性,还有blob对象,实在是麻烦。另外如果接口返回的不是文本流,而是json的话,就不用blob,直接用返回的url作为href,来click就好了。
这么看来用接口的形式来下载文件似乎很笨吧,当然从另外一个角度讲,请求/download接口后可以用js控制很多东西,比如客户端权限认证,而不是一股脑丢给浏览器。

文件上传

文件上传也是软肋,毕竟多年来一直没有接触过。。。。以前知道的范围领域也就是input标签可以设置type属性为file,这样就能上传文件了。后来在项目中还真的遇到文件上传的,但是这个时候已经有各种组件了,上来直接是饿了吗element的组件库,又或则是Ant Design的组件,样式又漂亮,根本不需要自己去开发嘛。。。。但是这样真的好吗,之前忙一直没有时间去看,直到了用上了node.js中间层,需要自己来做中转维护。

客户端实现

想想只是用<input type="file" />要如何实现上传呢,明明这都没有和后端联系上。。。。于是乎只能从饿了吗的代码里面找起来,其实饿了吗和ant design的实现大同小异,只是语言不同罢了。代码如下:

let upLoad = (ev) => {
  let files = Array.prototype.slice.call(ev.target.files);
  let rawFile = files[0];
  const formData = new FormData();
  const xhr = new XMLHttpRequest();

  formData.append('file', rawFile);
  if (xhr.upload) {
    xhr.upload.onprogress = function progress(e) {
      if (e.total > 0) {
        e.percent = e.loaded / e.total * 100;
      }
    };
  }
  xhr.onload = function onload() {
    if (xhr.status < 200 || xhr.status >= 300) {
      return console.log('wrong');
    }

    // onSuccess(getBody(xhr));
  }
  xhr.open('post', '/action?_csrf=VNPzCPKhQRs4eYhoCjFQgwQh', true);  
  xhr.send(formData);
}

上传的文件到哪里了?ev.target.files里面就是上传的文件数组,获取到上传文件的对象,再添加到FormData里面。FormData又是何物?带着一脸懵逼又去一顿百度谷歌,FormData是用XMLHttpRequest发送请求的键/值对,当然这也意味着是表单传输multipart/form-data的形式。如果你想要传入参数,只需要formData.append(key, value)就可以了。上面代码中自然是用formData.append('file', rawFile),紧接着用了xhr.openxhr.send方法,开眼界了,原来xhr.send里面可以带参数的。。。。文件的键名是file
可以看出上面的处理方式直接用的是XMLHttpRequest 2.0,那为什么不用fetch呢?fetch不应该是未来趋势吗?想来这里有兼容问题,另外一点fetch上传文件好像没有进度条一说,只是Response.body有getReader方法用于读取原始字节流,如此来解决进度条问题Fetch进阶指南,以及XMLHttpRequestabort方法取消对象,也是fetch不能媲美的。

node.js端实现

node.js端的实现就曲折多了,为了获得上传的文件,用了官方的示例里面的方法ctx.getFileStream(),获得了文件流之后,再在官网介绍的httpclient里面有示例,用到了苏大神的formstream模块,生成可以被httpclient消费的stream对象,如下:

const fileStream = await ctx.getFileStream();
const form = new FormStream();
form.stream('file', fileStream, fileStream.filename);
data = await ctx.curl(url, {
  method: 'POST',
  headers: form.headers({
      Cookie: 'cookieHere',
  }),
  // contentType: 'multipart/form-data',
  stream: form,
  dataType: 'json',
}) 

看着简单,但是刚开始弄的时候却一脸懵逼,不知道如何是好,尤其是添加cookie的时候,由于没有用到form.headers,文件上传一直有问题,没有依据rfc1867, multipart/form-data是必须的,同时最重要的是分隔符!!boundary=这个在headers中是一定要加上的。
看了苏大神的FormStream里面,才发现原来这是模拟浏览器文件上传的动作,添加leading再添加stream/buffer,是个不错的npm模块,值得学习。
知道FormStream了,那ctx.getFileStream()又是如何获得stream对象呢,一开始以为是egg中ctx自带的方法,后来查了api指南才知道是egg-multipart模块引入的。但是egg-multipart核心部分其实是基于Busboy模块的。Busboy是个好东西,其安装量也是杠杠的。Busboy是用来解析node.js里接受到的form-data请求,这里egg-multipart用到的代码大致如下;

busboy.on('file', onFile)
function onFile(fieldname, file, filename, encoding, mimetype) {
  if (checkFile) {
    var err = checkFile(fieldname, file, filename, encoding, mimetype)
    if (err) {
      // make sure request stream's data has been read
      var blackHoleStream = new BlackHoleStream()
      file.pipe(blackHoleStream)
      return onError(err)
    }
  }

  // opinionated, but 5 arguments is ridiculous
  file.fieldname = fieldname
  file.filename = filename
  file.transferEncoding = file.encoding = encoding
  file.mimeType = file.mime = mimetype
  ch(file)
}
request.pipe(busboy)

通过pipe,busboy会触发file事件,同时传入file参数,也就是一个可读流ReadableStream.call(this, opts)。对于这个可读流,可以直接file.pipe(fs.createWriteStream(saveTo))将文件保存到本地磁盘,也可以再度转手如ctx.getFileStream()。关于busboy模块还是自己多玩玩比较好。

总结

文件下载上传对于大多数JSer可能都不陌生,但是于我却是刚刚开始,犹如打开了新技能,同时也知道了postman里面的文件上传key值是file,所以想梳理api,总结一下文件相关部分。

29. react 源码下一步

前言

前文提到了一个简单的 react 例子,结构如下所示

ReactDOM.render(
  <h1>Hello World!</h1>,
  document.getElementById('container')
);

只是实在简单呀,缺少 state,缺少状态的变化,于是用另外一个例子来继续研究:

class Hello extends React.Component {
  state = {
    hasWorld: true,
  }

  changeWorld = (e) => {
    console.log('isChanging:', this.state.hasWorld)
    this.setState({hasWorld: !this.state.hasWorld});
  }

  render() {
    return (
      <div onClick={this.changeWorld}>
        {this.state.hasWorld ? 'Hello World!' : 'Hello'}
      </div>
    )
  }
}
ReactDOM.render(
  <Hello />,
  document.getElementById('container')
);

本文将围绕这个简单的 class 展开研究。

Hello 的初始化

这里先介绍 Hello 组件的初始化过程,包括前面提到的 render/reconcilation 阶段,以及 commit 阶段。

render/reconcilation 阶段

这个阶段前面部分和之前博客介绍的很类似,创建 root,创建 current,创建 workInProgress 的 root。只是到了 beginWork 就开始不一样了。这个阶段可以看看下面的图片。

可以看到在 Step 11 也就是 updateHostRoot 之前都是一样的,只是在该函数的时候,由于 element 的 type 为函数,不是前一篇博客中的 'h1',所以会在 reconcileChildFibers 函数阶段创建 tag 为 ClassComponent 的 fiber。可以从上图中看到,其 type 为 element 的 hello 函数。

第二轮 beginWordk 的时候,对象为上面生成的 child,执行的 case 对应的函数如下:

function updateClassComponent(current, workInProgress, renderExpirationTime) {
  if (current === null) {
    if (workInProgress.stateNode === null) {
      // 构建实例
      constructClassInstance(
        workInProgress,
        workInProgress.pendingProps,
        renderExpirationTime,
      );
      mountClassInstance(workInProgress, renderExpirationTime);

      shouldUpdate = true;
    }
  }
  return finishClassComponent(
    current,
    workInProgress,
    shouldUpdate,
    hasContext,
    renderExpirationTime,
  );
}
function constructClassInstance(workInProgress, props, renderExpirationTime){
  const ctor = workInProgress.type;
  const context = emptyObject;
  const instance = new ctor(props, context);
  const state = workInProgress.memoizedState = instance.state || null;
  instance.updater = classComponentUpdater;
  workInProgress.stateNode = instance;
  instance._reactInternalFiber = workInProgress;
  return instance;
}

在 constructClassInstance 时候会 new 一个 Hello class,将 child.stateNode 指向该实例。这里有三个特别操作

  1. 同时给实例的 instance.updater = classComponentUpdater。这个方法在 setState 里面会用到。
  2. 给 child.memoizedState 添加上 instance 的 state,也就是 { hasWorld: true }。为以后更新提供前值。
  3. 给 instance 添加对应的 fiber

这上面的三条都在 setState 里面起到非常关键的作用。updateClassComponent 里面还有 shouldComponentUpdate 的判断,但是这里是首次加载,必然是 true 了。

mountClassInstance 里面则是从 child 里面往 instance 添加 props,ref,context 等属性,同时判断 instance 有无 componentDidMount 函数,有的话 child.effectTag |= Update;。从而在后面阶段可以执行 componentDidMount。

后面的 finishClassComponent,如下

function finishClassComponent(current, workInProgress, shouldUpdate, hasContext, renderExpirationTime) {
  const ctor = workInProgress.type;
  const instance = workInProgress.stateNode;
  const nextChildren = instance.render();
  workInProgress.effectTag |= PerformedWork;
  reconcileChildren(current, workInProgress, nextChildren);
  return workInProgress.child;
}

reconcileChildren 的功能在前一篇博客里面已经提到过,就是如果存在 nextChildren,会生成一个新的 fiber。给到按当前 workInProgress.child。如上图所示。这里我们称其为 render fiber.

第三轮 beginWordk 的时候,对于 render fiber 执行的 case 为 HostComponent,和上篇博客介绍的类似。最后其会结束 beginWork 的工作,开始 completeUnitOfWork。

由于有三个父子关系的 fiber。所以这里也会有三轮 completeUnitOfWork 循环。首先是底层的 render fiber。对于该 fiber,和前一篇博客类似,进入 completeWork,通过 createInstance 创建 DOM。第二轮 completeUnitOfWork 里面 tag 为 ClassComponent,主要修改 root fiber 的 firstEffect/lastEffect。

第三轮同样也是,至此进入 commit 阶段。

commit 阶段

commit 阶段也简单,如同前一篇博客提到的。同样也有三大循环,同样的在 commitPlacement 里面插入元素到真实 DOM 里面,只是都了一次循环查找的过程。

可以发现对于 Component 组件的渲染和上一篇博客的介绍到的有大同小异,无非就是 Component 形成一个 tag 为 ClassComponent 的 fiber,而且本身的 render 结果又会是一个 fiber。EffectTag 以及 firstEffect 和 lastEffect 的关系还是一样的。

setState 之后的变化

本例子的对 setState 的触发,是通过定时器来实现的,而不是上文中的例子,这也是为了减少分析复杂度。

class Hello extends React.Component {
  state = {
    hasWorld: true,
  }
  
  componentDidMount() {
    setTimeout(() =>{
      this.setState({hasWorld: false})
    }, 5000)
  }

  changeWorld = (e) => {
    console.log(333, this.state.hasWorld)
    this.setState({hasWorld: !this.state.hasWorld});
  }

  render() {
    return (
      <div onClick={this.changeWorld}>
        {this.state.hasWorld ? 'Hello World!' : 'Hello'}
      </div>
    )
  }
}
ReactDOM.render(
  <Hello />,
  document.getElementById('container')
)

先看看 setState

Component.prototype.setState = function(partialState, callback) {
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
enqueueSetState: function(inst, payload, callback) {
  // 获取对应的 fiber
  const fiber = inst._reactInternalFiber;
  // 依旧还是 Sync 也就是 1;
  const expirationTime = computeExpirationForFiber(currentTime, fiber);
  const update = createUpdate(expirationTime);
  update.payload = payload;
  enqueueUpdate(fiber, update, expirationTime);
  scheduleWork(fiber, expirationTime);
}

可以看出来正是调用了实例的 updater 属性,也就是上文中提到的创建实例时候添加上的属性。而 enqueueSetState 里面获取到的 fiber,其实也就是生成 instance 的fibier,下图中的 oldChild。

在 enqueueSetState 中和我们前文提到的 scheduleRootUpdate 是很像的,都会创建新的队列,并且都会用到 enqueueUpdate 与 scheduleWork。于是后面看到的过程很多都是之前提到过的。只是由于这一次是更新所以有了很多不同。

比如在构建新的 workInProgress tree 的时候,并不会创建新的 newWorkInProgresRoot,而是修改之前的 oldWorkInProgresRoot.alternate 也就是最上面的 current 这个 fiber。当然同时也会将 newWorkInProgresRoot 的部分属性改为 oldWorkInProgresRoot,或者是清为 null,可以在上图看到,为了方便其见,新画了一个 workInProgress tree,而不是沿用之前的 current,当然这两个是一个 fiber 哦

这里简述一下后面的过程,在第一轮 performSyncWork 里面会通过 cloneChildFibers 方式给 newWorkInProgresRoot 创建 newChild fiber,tag 同样为 ClassComponent,child 也是一样的。

在第二轮 performSyncWork 的时候,会在 beginWork 进入 updateClassComponent,需要 updateClassInstance,

function updateClassComponent(current, workInProgress, renderExpirationTime) {
  const shouldUpdate = updateClassInstance(current, workInProgress, renderExpirationTime);
  return finishClassComponent(current, workInProgress, shouldUpdate, hasContext, renderExpirationTime);
}
function updateClassInstance(current, workInProgress, renderExpirationTime) {
  const ctor = workInProgress.type;
  const instance = workInProgress.stateNode;
  const oldProps = workInProgress.memoizedProps;
  const newProps = workInProgress.pendingProps;
  const oldState = workInProgress.memoizedState;
  let updateQueue = workInProgress.updateQueue;
  if (updateQueue !== null) {
    processUpdateQueue(
      workInProgress,
      updateQueue,
      newProps,
      instance,
      renderExpirationTime,
    );
    newState = workInProgress.memoizedState;
  }
  const shouldUpdate =
    checkHasForceUpdateAfterProcessing() ||
    checkShouldComponentUpdate(
      workInProgress,
      oldProps,
      newProps,
      oldState,
      newState,
      newContext,
    );
  if(shouldUpdate) {
    if (typeof instance.componentWillUpdate === 'function') {
      instance.componentWillUpdate(newProps, newState, newContext);
    }
    if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
      instance.UNSAFE_componentWillUpdate(newProps, newState, newContext);
    }
    if (typeof instance.componentDidUpdate === 'function') {
      workInProgress.effectTag |= Update;
    }
    if (typeof instance.getSnapshotBeforeUpdate === 'function') {
      workInProgress.effectTag |= Snapshot;
    }
  }
  workInProgress.memoizedProps = newProps;
  workInProgress.memoizedState = newState;
}

updateClassInstance 函数中可以明显的看到 fiber 的新老 state 和 props 的更新,当然也包括 instance 的 state/props 更新。以及 component 独有的生命钩子的执行,包括 ShouldComponentUpdate/componentWillUpdate/UNSAFE_componentWillUpdate,甚至通过修改 effectTag 为以后 commit 阶段执行其他生命钩子做好铺垫
由于 newChild 的 upDateQueue 在 enqueueSetState 时候被添加更新了,有了更新 updateQueue 的过程,同时 newChild 的 memoizedState 也会被更新为最新的 state;

updateClassComponent 后面的 finishClassComponent,先是通过 instance.render() 生成 children 也就是虚拟 DOM,再通过 reconcileChildren 同样的创建新的 newRender 这个fiber。只是这一次不再是简单的生成新的 fiber。这里存在一个新老对比的过程。

function reconcileSingleElement(returnFiber, currentFirstChild, element, expirationTime) {
  const key = element.key;
  let child = currentFirstChild;
  while (child !== null) {
    if (child.key === key) {
      deleteRemainingChildren(returnFiber, child.sibling);
      const existing = useFiber(
        child,
        element.type === REACT_FRAGMENT_TYPE
        ? element.props.children
        : element.props,
        expirationTime,
      );
      existing.return = returnFiber;
      return existing;
    }
  }
}

因为 oldChild 存在 oldRender,所以就会在给 newChild 生成 newRender 这个 fiber 的时候有一个对比 key 的过程。以前 reconcileChildren 时候由于 oldChild 并不存在 alternate,所以不进入上面比较的过程。如果 key 相同则通过 useFiber 生成新的 fiber。

后一轮的 beginWork 和初始化的时候差不多,这里就不提了。

diff 属性

在结束 beginWork 之后,进入 completeWork 过程。这里同样的由于 newRender 存在 alternate,所以存在一个比较过程。这里也是 diff 的重点。

function completeWork(current, workInProgress, renderExpirationTime) {
  const newProps = workInProgress.pendingProps;
  const type = workInProgress.type;
  switch (workInProgress.tag) {
    case HostComponent: {
      if (current !== null && workInProgress.stateNode != null) {
        const oldProps = current.memoizedProps;
        const instance = workInProgress.stateNode;
        const updatePayload = diffProperties(
          instance,
          type,
          oldProps,
          newProps,
          rootContainerInstance,
        );
        workInProgress.updateQueue = updatePayload;
        workInProgress.effectTag |= Update;
      }
      return null;
  }
}

存在 alternate,就意味着这是个更新,而 这里的 diffProperties 就是对比新老 DOM 的不同。其做法很是粗暴,通过 for in 的形式对比出两个新老 Props 的不同,当然我们这里重点是 children 这个属性。最后会得出一个 updatePayload 数组,其奇数为 key,偶数为新的值。最后再将其赋予给到 workInProgress.updateQueue,更重要的是修改 effectTag,这样可以在 commit 阶段被识别到这是一个更新。同时也修改 newChild 的 firstEffect/lastEffect 为 child。第二轮 completeWork 里面,会把 newWorkInProgresRoot 的 firstEffect/lastEffect 指向 newRender 这个 fiber。子 fiber 的 effects 会通过链表的形式被添加到父 fiber 的 effects 上面。如果有原先的存在的则通过 fiber 的 nextEffct 来传递,实现链表。

commit 阶段

在 commitRoot 里面由于 finishedWork.effectTag 为 0,所以三大循环前的 nextEffect = finishedWork.firstEffect。也就是前面的提到的 newRender。这样下来在 commitPlacement 里面,由于 effectTag 为 4,即 Update case,会进入 commitWork 更新元素。

function commitWork(current, finishedWork) {
  switch (finishedWork.tag) {
    case HostComponent:
      const instance = finishedWork.stateNode;
      const newProps = finishedWork.memoizedProps;
      const oldProps = current !== null ? current.memoizedProps : newProps;
      const type = finishedWork.type;
      const updatePayload = finishedWork.updateQueue;
      updateFiberProps(instance, newProps);
      updateProperties(instance, updatePayload, type, oldProps, newProps);
  }
}
function updateProperties(domElement, updatePayload) {
  for (let i = 0; i < updatePayload.length; i += 2) {
    const propKey = updatePayload[i];
    const propValue = updatePayload[i + 1];
    if (propKey === CHILDREN) {
      setTextContent(domElement, propValue);
    }
  }
}

commitWork 里面的 updateFiberProps 是负责更新该 dom 指向的 props,改为 newProps;后面则是直接修改 nodeValue,实现 DOM 的更新。

setState 与 初始化的不同

从 setState 开始就不一样了。

  1. 先是给老的 oldChild 添加 updateQueue,里面维护着一个 update,包含了新状态。
  2. 由于创建的 newChild 存在 alternate,在 beginWork 时候会更新队列以及相关的生命钩子。
  3. newRender 的创建充分考虑到 key 的复用性,key 相同则根据老的 fiber 来创建新的 fiber 就可以了。
  4. completeWork 阶段对于 updatePayload 数组的收集,为后面的 commit 阶段做了很好的铺垫。
  5. commit 阶段 commitAllHostEffects 函数里面由于 effectTag 为 Update,会进入 commitWork 导致直接的更新操作。

上面只是介绍了通过 componentDidMount 定时器方式触发 setState 的,实际上常用的是通过点击的方式触发 interactiveUpdates 更新,也就是文中开头的方式,在 click 事件之后发生,click 的时候会触发监听的事件 dispatchInteractiveEvent,最后触发 setState 函数来更新队列,但是最后并没有在 setState 阶段执行 beginWork。而是在监听函数里面触发另外执行的。

4. backbone之Events实现

前言

记得某次在大神的博客里面讲技术选型,提到团队对Backbone的框架很熟悉,在一次开发的时候选用Backbone源码的一部分,再搭配其他的使用。虽然Backbone现在已经不流行了,但从几年前我就开始听说它的存在了,一直觉得这么神奇的框架肯定很有必要学习,看到大神提到选取Backbone源码的一部分,顿时觉得大神就是大神,对源码运用与此精通。最近有空看Backbone源码,细读时,如啃老牛肉,又硬又难吃,常常看了一部分忘记另外一部分,疼苦不堪,后来结合Backbone的api文档和里面的Todos例子顿时觉得,神清气爽,仿佛任督二脉都打通了,只是看过之后愈发觉得,Backbone框架已经不是前端的弄潮儿,只是接近2000+的源码,里面的MVC**,值得去研究,而不是天天研究如何运用新框架的api

目的

刚开始读Backbone就被开头的Events弄得云里雾里的,应该很多人有这样的经历,于是想介绍Events的实现思路以及其中的疑难;

思路

var object = {};
_.extend(object, Backbone.Events);
object.on('expand', function(){ alert('expanded'); });
object.trigger('expand');

这是源码在介绍Events用到的例子,Events模块可以被扩展到任意的对象上面,而在其他的Bakbone模块如Model/Collection/View等,同样在其内部扩展了Events模块;
事件模块,无非就是实现注册动作,监听动作,对应的在Events里面主要是on,listenTo,off, stopListening和trigger这几个;
刚开始看的时候比较晕,后来一想从注册事件,到触发事件,看不懂究竟在做什么,那可不可以从后往前看,先看如何触发事件,在看如何注册事件,这么一想就事半功倍了。

从trigger函数开始,落着点在triggerEvents函数,而在传递的events,一层层回溯,正是this._events,于是triggerEvents主体表达式就是

this._events[name].callback.call(this._events[name].ctx)

这就完事了,也就是说on事件负责把事件对应的回调函数/上下文注册到this._events里面,然后需要trigger的时候,直接call就好,如此的简单明了。再看on事件的时候发现却实是如此;
on事件通过onApi将事件需要的回调和上下文写入this.__events[name]里面;形成事件名字和回调映射的关系;这一切都是如此的顺畅,直到回头看on事件,发现写入this._events[name]的混杂着listening: _this.listening。

奇形怪状的各种listening _listening listeningTo _listeners

在on下面还有:

if (_listening) {
  var listeners = this._listeners || (this._listeners = {});
  listeners[_listening.id] = _listening;
  _listening.interop = false;
}

更不要提listenTo函数,一开始看直接懵逼了,但是主要流程都懂了还怕这些listenXX干嘛?
于是整理了一下和listenXX有关的所有地方,发现有如下关系:

this._listeners[_listening.id] = _listening
_listening = this._listeningTo[id] = new Listening(this, obj)

理清关系后发现都是纸老虎,就是在this._listeners里面添加个id和new Listening(this, obj)键值嘛,每当有个新的对象要监听的时候,就在this._listener里面添加构造函数Listening(this, obj),而 _listeners则是个全局变量,用来传递这个构造函数。那细心的观众不难发现,this._listeningTo又是用来干嘛的?this._listeningTo不是和this._listener一摸一样的吗?
开始我也是很疑惑,明明就是两个一样的对象,到后台打印也是一摸一样的呀?那为何作者要写两个呢?直到有一天,看着this._listeningTo和this._listener发呆的时候,发现,咦?前者有个To,后面没有呀,listeningTo不就意味着某个对象监听另外一个对象,而listener更像是监听者监听某个动作,再看看源码,哦,this._listeningTo出现在listenTo事件里面,而this._listener出现在On事件里面,So两个是完全不一样的呀。。。只有在纯粹的listenTo里面才会一样,如果单独监听某个动作,那创建的构造函数Listening不会出现在this._listeningTo里面。。。。。。尽然如此简单。。。。。。

interop库互相操作理解

前面提到的on和listenTo,接下来可以看stopListening和off函数,本质上和之前提到的on和listenTo很相似,但是这里面反复提到listening.interop到底是什么东西呢?如果是真的话,则会执行listening.off/on函数,咦?前面不是已经执行了obj.off/on了吗?这又是有何作用,带着疑问翻看了backbone的issue,发现下面这条issue原来是当obj本身有on/off函数,而obj又不扩展Events,则会进入listening构造函数中保存的off/on事件。
另外值得一提的是在on事件里面,若listening.interop=true,在进行listening.on之前,已经将全局变量_listening设置为void 0,后面再进行on函数的时候,其option中保存的listening将不是Listening实例而是undefined,所以在off事件中进行listening.off事件,若this.interop=true则会再次进入off事件,而在offApi中,listening.off之前会判断之前存放在this._events[name]中对应的option是否保存Listening实例,而前文提到这个时候listening为undefined,故不会重复循环进入listening.off里面;

还剩下once相关的事件,看过underscore源码的你,或者是没有看过的你,肯定可以理解,这里就不解释了
由于水平有限,若有水平有限,有错误的地方还请提出来

37. shader 习题与笔记

从元旦就开始学习 WebGL,只是网上的资料很少,没有相关的课程,three.js 还好一点,更多的是关于 OpenGL 的书。最后看了看有:WebGL 技术储备指南 这篇介绍入门,网上首推资料 Learn WebGL,还有同人翻译的书 WebGL编程指南。

一个月的时间看完了上述内容,Learn WebGL 后面几章觉得意义不大就没有继续学习了。只是学了,做了 demo 之后很是困惑,学的 WebGL 和工作关系有点难联系上,最低也要用 three.js,难道这就要上手 three.js 吗?直到后面发现了 thebookofshaders。之前学的基本都是以顶点着色器为主,对片元着色器的进一步介绍很少,而片元着色器和我的日常工作更加有关系。thebookofshaders 刚好有中文版,只是原文残缺,没有对 纹理/模拟/3D图形 的继续介绍。

前面部分比较基础,也好理解,加上其他的 webgl 基础内容这里就不介绍了。下面介绍 生成设计 里面的部分内容及课后习题:

技术

沿着 thebookofshaders 里面的内容学习,会用到两个基本的库,同样的都是出自作者 Patricio Gonzalez Vivo:glslCanvas(加载 webgl 的通用库),glslEditor(实时渲染的辅助调试工具)。这些作者已经在电子资料里面用到了,平时只要在线修改代码调试就好了。

随机 random

这里介绍的随机,不是真正的随机,包括文中接下来的部分都是 伪随机 ,意味着同样的输入,会有同样的输出,并且在 x 值附近具有 连续性。这是和以前用的 Math.random 的随机是不一样的。

// 一维随机 
float random (float x) {
  return fract(sin(x) * 1e4);
}

// 2D 随机
float random (vec2 st) {
  return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}

由上面代码就可以看出来,这是确定性随机。这样的随机要如何利用?关键是同样的输入会有同样的输出,于是,可以传入 x 的整数部分来随机分块。具体可以看以下联系:

  1. 做按行随机移动的单元(以相反方向)。只显示亮一些的单元。让各行的速度随时间变化
#define TIMEPERIOD .2

void main() {
  vec2 st = gl_FragCoord.xy / u_resolution.xy;
    
  float speed = 0.;
  float xRate = 0.;
  float floorTimeRate = random(floor(u_time * TIMEPERIOD));
  float fractTimeRate = fract(u_time * TIMEPERIOD);

  if (st.y > 0.5) {
    xRate = floorTimeRate * 50. + 10.;
    speed = floorTimeRate * 2.+ 2.; 
  } else {
    xRate = -floorTimeRate * 50. + 60.;
    speed = -floorTimeRate * 2. - 2.; 
  }
  st.x = st.x + fractTimeRate * speed;
  
  float floorX = random(floor(st.x * xRate)) * 4.;
  float randomX = step(0.20, floorX);
  vec3 color = vec3(randomX);

  gl_FragColor = vec4(color,1.0);
}

这里 xRate 代表黑线放大的倍数,speed 则是黑线的移动速度,其中速度和放大倍数要随着时间变化而变化,图中上下两部分要分开处理。

  1. 同样地,让某几行以不同的速度和方向。用鼠标位置关联显示单元的阀值。
void main() {
  vec2 st = gl_FragCoord.xy / u_resolution.xy;
  st.x *= u_resolution.x / u_resolution.y;
  float mouseX = u_mouse.x / u_resolution.x;
  float thresholdX = 0.5 + mouseX / 2.;
  
  vec2 grid = vec2(100.0, 50.);
  st *= grid;
  
  float positionX = (st.x - u_time * 60. * random(floor(st.y))) * random   (floor(st.y)) / 1.5;
  float randomX = step(thresholdX, random(floor(positionX))) ;
  vec2 fpos = fract(st);
  
  vec3 color = vec3(randomX);
  
  // Y轴的每一行产生白边
  color *= step(0.2, fpos.y);
  gl_FragColor = vec4(1.0 - color, 1.0);
}

x 轴的黑色部分宽度思路和之前不一样,这里是通过 grid 放大整体的倍数,包括 y 轴的。最后以鼠标的 x 轴位置来改变 step 函数的阈值,实现交互。

  1. 创造其他有趣的效果:
vec2 ipos = floor(st); 
color *= step(st.x +  (50. - ipos.y) * 100., fract(u_time/50.) * 5100.);

这个图和上面 2 的图是很相似的,唯一不同的地方在于周期性从上到下显示,从左到右显示。需要从 x 、y 与时间一起考虑。

噪音 noise

上面的 random 更多的只是伪随机数,通过获取整数部分、小数部分来实现效果。图形的显示不是黑就是白,没有过渡,缺少平滑。
这里的 noise 方程式,则使得 2D 的噪音平滑:

float noise (in vec2 st) {
  vec2 i = floor(st);
  vec2 f = fract(st);

  // Four corners in 2D of a tile
  float a = random(i);
  float b = random(i + vec2(1., 0.0));
  float c = random(i + vec2(0., 1.0));
  float d = random(i + vec2(1., 1.0));

  // Smooth Interpolation

  // Cubic Hermine Curve.  Same as SmoothStep()
  vec2 u = f * f * (3. - 2. * f);
  // u = smoothstep(0., 1., f);

  // Mix 4 coorners percentages
  return mix(a, b, u.x) +
    (c - a)* u.y * (1. - u.x) +
    (d - b) * u.x * u.y;
}

生成式设计中的 noise 应用:上面提到的 noise 生成的图片更多的是块状模糊的,也被叫做 Value Noise。下面提到了另外一种 Gradient Noise。通过这个 例子 而已看得出两者的区别。

下面看看习题:

  1. 你还能做出什么其他图案呢?花岗岩?大理石?岩浆?水?找三种你感兴趣的材质,用 noise 加一些算法把它们做出来。
float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);

    vec2 u = f*f*(3.0-2.0*f);

    return mix( mix( dot( random2(i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ),
                     dot( random2(i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x),
                mix( dot( random2(i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ),
                     dot( random2(i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y);
}

void main() {
  vec2 st = gl_FragCoord.xy / u_resolution.xy;
  st.x *= u_resolution.x / u_resolution.y;
  vec3 color = vec3(0.0);
  color = vec3(1.) * smoothstep(.0, .2, noise(st * 4000000.));

  gl_FragColor = vec4(1. - color, 1.0);
}

这个图,是调试着调出来的,像格子衫的布料,一块一块的米格子,很好看。通过尽量放大倍数,来实现效果的。

  1. 用 noise 给一个形状变形。
float shape(vec2 st, float radius) {
	st = vec2(0.5) - st;
  float dotDirection = length(st) * 2.0;

  float newRadius = radius * (noise(st * u_time) + 1.);
  
  return 1. - smoothstep(newRadius, newRadius + 0.007, dotDirection);
}

void main() {
	vec2 st = gl_FragCoord.xy/u_resolution.xy;
	vec3 color = vec3(1.0) * shape(st,0.5);

	gl_FragColor = vec4(color, 1.0 );
}

这个是圆形的边界散点图。如何画圆,在之前章节里面有介绍过,中心点 (0.5, 0.5),半径长度则是通过 newRadius 表示,传入的常数 redius,随着时间变化,通过 noise 处理,从而达到边界散点的效果。

3, 把 noise 加到动作中会如何?回顾第八章。用移动 “+” 四处跑的那个例子,加一些 random 和 noise 进去。
这个涉及到之前第八章画的十字架,最后的图如下:

float box(in vec2 _st, in vec2 _size){
  _size = vec2(0.5) - _size * 0.5;
  vec2 uv = smoothstep(_size, _size+vec2(0.001), _st);
  uv *= smoothstep(_size, _size+vec2(0.001), vec2(1.0)-_st);
  return uv.x*uv.y;
}

float cross(in vec2 _st, float _size){
  return  box(_st, vec2(_size,_size/4.)) +
          box(_st, vec2(_size/4.,_size));
}
void main(){
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  vec3 color = vec3(0.0);

  st.x += noise(vec2(u_time, 0.)); 
  st.y += noise(vec2(0., u_time));
  color += vec3(cross(st,0.25));

  gl_FragColor = vec4(color,1.0);
}

将 st 的 x/y 值加上 noise 时间的效果,达到 “+” 四处跑的效果,而不是规律运动。

Simplex Noise

前面的 Noise 实现方式过于复杂,对于 N 维你需要插入 2 的 n 次方个点,而 Simplex Noise 采用三角形来替换正方形,并把平滑函数改成四次 Hermite 函数,效果更平滑。

习题: 做一个 shader 来表现流体的质感。比如像熔岩灯,墨水滴,水,等等。

float snoise(vec2 v) {
  const vec4 C = vec4(0.211324865405187,  // (3.0-sqrt(3.0))/6.0
                      0.366025403784439,  // 0.5*(sqrt(3.0)-1.0)
                      -0.577350269189626,  // -1.0 + 2.0 * C.x
                      0.024390243902439); // 1.0 / 41.0
  vec2 i  = floor(v + dot(v, C.yy) );
  vec2 x0 = v -   i + dot(i, C.xx);
  vec2 i1;
  i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod289(i); // Avoid truncation effects in permutation
  vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
      + i.x + vec3(0.0, i1.x, 1.0 ));

  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m ;
  m = m*m ;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
  vec3 g;
  g.x  = a0.x  * x0.x  + h.x  * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}

void main() {
  vec2 st = gl_FragCoord.xy / u_resolution.xy;
  st.x *= u_resolution.x / u_resolution.y;
  vec3 color = vec3(0.0);

  st = st + st * snoise(st + u_time / 50.) / 3.;
  color += smoothstep(.4 , 0.5, snoise(st * 5.));

  gl_FragColor = vec4(1.-color,1.0);
}

首先是先做出合适的泼墨图,这个在前文就有介绍到,采用的是 Gradient Noise 的方式,改造好后,则是加入时间变量,让 st 的坐标随着 snoise 下的时间变化。

网格噪声 Cellular Noise 与 分形布朗运动 Fractal Brownian Motion

GLSL 对 for 循环是不太友好,无法动态处理,当你有多个特征点的时候,若每个像素都计算一次到特征点的关系距离,其计算量也是很大的。为此,提出了 网格噪音 方式,即是:将空间分割成网状,每个像素点只计算其相邻块(一共八个)的特征点。

分形布朗运动:则是在循环的过程中叠加噪音,并提高频率降低振幅,来显示更好的细节。如下面的方式

#define OCTAVES 6
float fbm (in vec2 st) {
  // Initial values
  float value = 0.0;
  float amplitude = .5;
  float frequency = 0.6;

  // Loop of octaves
  for (int i = 0; i < OCTAVES; i++) {
    value += amplitude * noise(st);
    st *= 2.;
    amplitude *= .5;
  }
  return value;
}

fbm 函数在循环的过程中,叠加噪音,让图形有更多的细节。

域翘曲:通过 fbm 函数来扭曲 fbm,简单来讲就是多维的 fbm,通过下面来了解一下:

// 基本方式 f(p) = fbm( p )
float pattern( in vec2 p ) {
  return fbm( p );
}

// 添加第二次翘曲 (p) = fbm( p + fbm( p ) )
float pattern( in vec2 p ) {
  vec2 q = vec2( fbm( p + vec2(0.0,0.0) ),
                 fbm( p + vec2(5.2,1.3) ) );
  return fbm( p + 4.0*q );
}

// 添加第三次翘曲 f(p) = fbm( p + fbm( p + fbm( p )) )
float pattern( in vec2 p ) {
  vec2 q = vec2( fbm( p + vec2(0.0,0.0) ),
                 fbm( p + vec2(5.2,1.3) ) );

  vec2 r = vec2( fbm( p + 4.0*q + vec2(1.7,9.2) ),
                 fbm( p + 4.0*q + vec2(8.3,2.8) ) );

  return fbm( p + 4.0*r );
}

多次 fbm 之后生成类似 fbm 的纹理。比如下图:

总结

片元着色器和顶点着色器的学习很不一样,前者需要对图形实现的熟悉,比如常见的方法函数,后者则是三维空间变化,涉及到更多矩阵数据。后面的学习,将继续牢固基础,看更多的教程,同时也要开始筹划 three.js 的学习了。

43. nest 初始化

春节呆在家里不能外出,假期又特别长,刚好在学习 nest,于是就看了一遍源码。nest 是用 typescript 写得,用法自然也是基于 typescript,其源码用 vscode 阅读非常方便,基本上是读过里面最流畅的了,只是一个初始化过程,其涉及的操作非常多,逻辑上还是需要捋一捋。直接用 nest 仓库代码阅读调试会发现调试的时候,部分代码引入采用类似下面的方式:

// 在 core 目录下的 nest-application.ts
import { Logger } from "@nestjs/common/services/logger.service";

其直接引用 node_modules 里面的模块,但是源码里面怎么可能有 node_modules ,不是应该直接用相对路径?

nest 里面采用分包的形式打包,源码是 typescript 实现的,所以会将 packages 下面的模块通过编译生成普通 js 文件,于是为了方便调试,想到一个笨拙的办法,修改源代码的基础配置 tsconfig.base.json,这个配置是项目的基础 tsconfig,如下:

{
  // 添加如下配置
  "paths": {
    "@nestjs/*": ["../*"]
  }
}

所有 packages 下面的 tsconfig 都会扩展 tsconfig.base.json, 所以在基础文件里面配置路径别称,将 @nestjs/* 指向相对路径,就可以直接的智能交互引用的代码了,方便定位和阅读。

ps: 修改配置的时候发现一个奇怪的 bug,修改扩展配置文件 tsconfig ,vscode 无法做到实时更新,需要重新初始化一次才可以,比如重启 vscode。

IOC 控制反转 依赖注入

在开始源码前,需要了解一下 IOC 控制反转和依赖注入,nest 采用内置的 IOC 容器实现依赖注入的功能;关于控制反转和依赖注入可以看 这里

简单的来说,程序只用负责使用依赖就好了,至于依赖如何被创建不用用户关心,交给第三方 IOC 容器来负责 。这也是 nest 的特色依赖注入;而初始化的过程,则大部分都在创建这个 IOC 容器。

依赖注入写法的主要部分如下:

@Controller("api")
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post("login")
  async login(@Request() req) {
    const res = this.appService.findOne(req.name);
    return res;
  }
}

这里面不需要知道 appService 实例是如何创建的,只是需要直接用就可以了,将变量通过参数的方式传入进来,而不是在 constructor 里面去实例该变量;

容器初始化之扫描

按照官方提供的例子,一般业务启动如下:

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  await app.listen(3000);
}

bootstrap 分为创建应用和监听过程,其中创建应用主要是初始化依赖,而监听则主要是对中间件和路由进行初始化。

public async create<T extends INestApplication = INestApplication>(
  module: any,
  serverOrOptions?: AbstractHttpAdapter | NestApplicationOptions,
  options?: NestApplicationOptions,
): Promise<T> {
  // 设置 httpServer,即是 http 平台,默认采用 platform-express
  let [httpServer, appOptions] = this.isHttpServer(serverOrOptions)
    ? [serverOrOptions, options]
    : [this.createHttpAdapter(), serverOrOptions];
  // 创建全局应用配置,并根据该配置生成 container,该容器则是 IOC 容器载体;
  const applicationConfig = new ApplicationConfig();
  const container = new NestContainer(applicationConfig);

  this.applyLogger(appOptions);
  // this.initialize 初始化容器,赋予容器功能
  await this.initialize(module, container, applicationConfig, httpServer);

  const instance = new NestApplication(
    container,
    httpServer,
    applicationConfig,
    appOptions,
  );
  const target = this.createNestInstance(instance);
  return this.createAdapterProxy<T>(target, httpServer);
}

private async initialize(
  module: any,
  container: NestContainer,
  config = new ApplicationConfig(),
  httpServer: HttpServer = null,
) {
  const instanceLoader = new InstanceLoader(container);
  const dependenciesScanner = new DependenciesScanner(
    container,
    new MetadataScanner(),
    config,
  );
  container.setHttpAdapter(httpServer);
  try {
    this.logger.log(MESSAGES.APPLICATION_START);
    await ExceptionsZone.asyncRun(async () => {
      await dependenciesScanner.scan(module);
      await instanceLoader.createInstancesOfDependencies();
      dependenciesScanner.applyApplicationProviders();
    });
  } catch (e) {
    process.abort();
  }
}

创建的配置 applicationConfig 包含全局的 pipes/guards 等等,create 里面最主要是的 init 方法,该方法先生成加载器 loader 和依赖的 Scanner。初始化里面的 dependenciesScanner.scan 会做以下操作

  1. 注册核心模块 InternalCoreModule,该模块是容器的核心模块,对比提交记录可以发现,之前版本是没有核心模块,后面将 applicationConfig 里面非全局配置的功能集中到了 InternalCoreModule
  2. 将核心模块和用户配置传入的模块进行一次 scanForModules,遍历所有的模块,并根据随机 uuid、名称、scope 以及其他信息创建 token,以该 token 为 key 最后 set 到容器里面;如果是同一模块,若 scope 不一样,其在容器中注册的模块也会不一样;
  3. scan 所有(用户与核心)模块的依赖
public async scanModulesForDependencies() {
  const modules = this.container.getModules();

  for (const [token, { metatype }] of modules) {
    await this.reflectImports(metatype, token, metatype.name);
    this.reflectProviders(metatype, token);
    this.reflectControllers(metatype, token);
    this.reflectExports(metatype, token);
  }
  this.calculateModulesDistance(modules);
}

前面添加模块后,则立刻对所有模块的依赖进行 scan,对导入模块/输出模块与当前模块进行关联,而 providers/controllers 处理比较特别,所有的依赖注入项会在 providers 里面找,而这些用户的 providers 添加则是在 this.reflectProviders(metatype, token) 里面进行的:

public addProvider(provider: Provider): string {
  if (this.isCustomProvider(provider)) {
    return this.addCustomProvider(provider, this._providers);
  }
  this._providers.set(
    (provider as Type<Injectable>).name,
    new InstanceWrapper({
      name: (provider as Type<Injectable>).name,
      metatype: provider as Type<Injectable>,
      instance: null,
      isResolved: false,
      scope: getClassScope(provider),
      host: this,
    }),
  );
  return (provider as Type<Injectable>).name;
}

最终 providers 会生成一个 InstanceWrapper 实例,该实例下面的 metatype 指向原 provider 的类,而 instance 则是类的实例化,后面会提到。

在遍历所有的 providers 和 controlers 的同时 nest 还会收集其添加的 guards/interceptors/exceptionFilters/pipes/routeArguments 这些修饰器到模块的 injectables 里面,给后面使用。(routeArguments 是 nest 提供的专门的路由信息修饰器)

this.calculateModulesDistance(modules) 给已添加的模块计算其优先级,越晚加入的模块,优先级越低,比如导入的模块其优先级就要小于当前模块,这个优先级作用目前只在中间件里面看到,按照优先级排序注册中间件。

容器初始化之实例化

经过上面的铺垫相关的模块已经添加到 container 里面了,但是具体的依赖注入实现还在实例化,回到初始化里面 await instanceLoader.createInstancesOfDependencies()

实例化中先是处理原型,将原型上的方法扩展到 InstanceWrapper 实例里面(目前不知道有什么用。。。。。。可能只是单纯的扩展)。

public async loadInstance<T>(
  wrapper: InstanceWrapper<T>, collection: Map<string, InstanceWrapper>,
  module: Module, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper,
) {
  // 省略前面部分代码
  // instanceHost 则是前面InstanceWrapper下value
  // 如果该InstanceWrapper已经resolved了,则不需要后面继续遍历寻找参数
  if (instanceHost.isResolved) {
    return done();
  }
  const callback = async (instances: unknown[]) => {
    const properties = await this.resolveProperties(wrapper, module, inject,contextId, wrapper, inquirer);
    const instance = await this.instantiateClass(instances, wrapper, targetWrapper, contextId, inquirer);
    this.applyProperties(instance, properties);
    done();
  };
  await this.resolveConstructorParams<T>(wrapper, module, inject, callback, contextId, wrapper, inquirer);
}

public async resolveConstructorParams<T>(
  wrapper: InstanceWrapper<T>, module: Module, inject: InjectorDependency[],
  callback: (args: unknown[]) => void, contextId = STATIC_CONTEXT,
  inquirer?: InstanceWrapper, parentInquirer?: InstanceWrapper,
) {
  // 省略前面部分代码
  const dependencies = isNil(inject) ? this.reflectConstructorParams(wrapper.metatype as Type<any>) : inject;
  const optionalDependenciesIds = isNil(inject) ? this.reflectOptionalParams(wrapper.metatype as Type<any>) : [];

  let isResolved = true;
  const resolveParam = async (param: unknown, index: number) => {
    try {
      if (this.isInquirer(param, parentInquirer)) {
        return parentInquirer && parentInquirer.instance;
      }
      const paramWrapper = await this.resolveSingleParam<T>(wrapper, param, { index, dependencies }, module,
        contextId, inquirer, index);
      const instanceHost = paramWrapper.getInstanceByContextId(contextId, inquirerId);
      if (!instanceHost.isResolved && !paramWrapper.forwardRef) {
        isResolved = false;
      }
      return instanceHost && instanceHost.instance;
    } catch (err) {
      const isOptional = optionalDependenciesIds.includes(index);
      if (!isOptional) {
        throw err;
      }
      return undefined;
    }
  };
  // 最后得到需要注入的实例
  const instances = await Promise.all(dependencies.map(resolveParam));
  isResolved && (await callback(instances));
}

实例过程中会遍历模块下的所有 providers/injectables/controllers,最后依次完成实例化,实例化通用方法为 loadInstance。可以看到通过解析参数的方式来获取依赖 dependencies,然后解析依赖,通过 resolveSingleParam 获得对应 provider,再进入 callback 回调。

只是具体如何解析依赖?在 resolveConstructorParams 方法里面,通过 reflectConstructorParams 可以拿到参数,比如

export class AppController {
  constructor(private readonly appService: AppService) {}
}

这里的 appService 参数,就是通过 reflectConstructorParams 获得的,先知道需要哪些依赖才能注入,只是如何知道有那些参数呢?这里的实现卡了很两天才明白, 因为 reflectConstructorParams 实现很简单:

public reflectConstructorParams<T>(type: Type<T>): any[] {
  const paramtypes = Reflect.getMetadata(PARAMTYPES_METADATA, type) || [];
  // 省略部分代码
  return paramtypes;
}
export function Injectable(options?: InjectableOptions): ClassDecorator {
  return (target: object) => {
    Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
  };
}

通过获取 'design:paramtypes' 的元数据就可以获得参数了,只是代码里面并没有用相关的修饰器,将参数传进去,官方文档提到:

A provider is simply a class annotated with an @Injectable() decorator

只是 Injectable 修饰器的实现明显不是提供 'design:paramtypes' 元数据,甚至 @Injectable() 里面什么数据都没有传递。于是这里就陷入了僵局,按照官方意思是只要用了 @Injectable() 就可以。测试的时候,将 @Injectable() 去掉发现依赖不能注入了,编译后的代码则是:

AppController = __decorate(
  [
    common_1.Controller("api"),
    __metadata("design:paramtypes", [app_service_1.AppService])
  ],
  AppController
);

明明没有用到 'design:paramtypes' 相关的修饰器,结果编译出来的代码就是有的。。。。。。实在很奇怪。直到谷歌 'design:paramtypes' 的时候发现,原来这个 typescript 搞的鬼

When you enable metadata through the "emitDecoratorMetadata" property, the TypeScript compiler will generate the following metadata properties:
'design:type', 'design:paramtypes' and 'design:returntype'.

嗯,依赖注入里面,typescript 已经帮你把参数给拎出来了,直接访问元数据,key 为 'design:paramtypes' 就可以了。

回到之前,获取到参数的类,但是距离可用还差很远,需要获取参数对应的 provider 的 InstanceWrapper,获取到 InstanceWrapper 之后也不能直接用,如果该 InstanceWrapper 没有被 resolved 过,则需要递归继续加载该 provider 的 loadInstance 方法。resolveConstructorParams 方法下最后的 instances 则是需要注入的实例了,而不是 null,因此需要递归,就是将底层的类实例好后传入上级作为参数,直到顶端。

类如何被实例?实例的过程发生在 const instance = await this.instantiateClass(instances, wrapper, targetWrapper, contextId, inquirer) 里面,这里的 instances 参数是需要注入的实例,而这些实例也同样来自于 instantiateClass 方法:

public async instantiateClass<T = any>(
  instances: any[],
  wrapper: InstanceWrapper,
  targetMetatype: InstanceWrapper,
  contextId = STATIC_CONTEXT,
  inquirer?: InstanceWrapper,
) {
  // 省略部分代码
  const instanceHost = targetMetatype.getInstanceByContextId(
    contextId,
    inquirerId,
  );
  if (isNil(inject) && isInContext) {
    instanceHost.instance = wrapper.forwardRef
      ? Object.assign(
          instanceHost.instance,
          new (metatype as Type<any>)(...instances),
        )
      : new (metatype as Type<any>)(...instances);
  } else if (isInContext) {
    const factoryReturnValue = ((targetMetatype.metatype as any) as Function)(
      ...instances,
    );
    instanceHost.instance = await factoryReturnValue;
  }
  instanceHost.isResolved = true;
  return instanceHost.instance;
}

可以看到通过 new 的形式生成新的实例赋值到 InstanceWrapperinstance,并将 isResolved 设置为 true,表示后面不再递归该 provider 了。由此可见这个 isResolved 很重要,在添加 providers 的时候,也会根据 provider 类型来修改 isResolved,如果是 Custom providers,则有可能 isResolved 一开始就是 true

应用初始化之中间件

上面的 IOC 控制容器初始化好了之后,会进入业务主程序的下一步 await app.listen(3000)

public async init() {
  const useBodyParser =
    this.appOptions && this.appOptions.bodyParser !== false;
  // 注册 platform-express 的中间件,就是bodyParser.json和bodyParser.urlencoded
  useBodyParser && this.registerParserMiddleware();
  // registerModules 注册websocket模块和微服务模块,
  await this.registerModules();
  await this.registerRouter();
  await this.callInitHook();
  await this.registerRouterHooks();
  await this.callBootstrapHook();

  this.isInitialized = true;
  this.logger.log(MESSAGES.APPLICATION_READY);
  return this;
}

先是注册常规的解析中间件,后面的 registerModules 里面会注册上用户自定义的中间件,常见的中间件用法如下

export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes(AppController);
  }
}

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req, res, next: Function) {
    console.log("Request...");
    next();
  }
}

可以看到应用方需要显式的使用 configure 才能使用该中间件,因为源码里面是采用 await instance.configure(middlewareBuilder) 的方法。而 config 正如字面意思配置中间件,只是起到给入口的作用,更多的是让后面 applyforRoutes 方法,结合起来能够将中间件与路由挂勾上,尤其是 forRoutes若不用上 forRoutes 定义路由,则模块下的中间件不会注册上。

public forRoutes(
  ...routes: Array<string | Type<any> | RouteInfo>
): MiddlewareConsumer {
  const { middlewareCollection, routesMapper } = this.builder;

  const forRoutes = this.mapRoutesToFlatList(
    routes.map(route => routesMapper.mapRouteToRouteInfo(route)),
  );
  const configuration = {
    middleware: filterMiddleware(this.middleware),
    forRoutes: forRoutes.filter(route => !this.isRouteExcluded(route)),
  };
  middlewareCollection.add(configuration);
  return this.builder;
}

这里传入的路由配置会被 mapRouteToRouteInfo 解析,传入的可以是简单的路由地址字符串,也可以是路由集合,更可以是对应的 controller 类。接着前面配置的 LoggerMiddleware 会和路由信息一起被添加到中间件集合里面,最后存入 middlewareModule, key 则是模块的 token 信息。

应用初始化之路由

中间件添加完之后就是添加路由信息,相比较于 express 之类的,nest 采用方式既不是统一的路由配置,也不是约定目录的路由,而是采用和 spring 一样的注解来定义路由。对应源码入口处理部分如下:

public resolve<T extends HttpServer>(applicationRef: T, basePath: string) {
  const modules = this.container.getModules();
  modules.forEach(({ controllers, metatype }, moduleName) => {
    let path = metatype
      ? Reflect.getMetadata(MODULE_PATH, metatype)
      : undefined;
    path = path ? basePath + path : basePath;
    // 遍历模块,注册每个模块下的controllers集合
    this.registerRouters(controllers, moduleName, path, applicationRef);
  });
}
private applyCallbackToRouter<T extends HttpServer>(
  router: T,
  pathProperties: RoutePathProperties,
  instanceWrapper: InstanceWrapper,
  moduleKey: string,
  basePath: string,
) {
  const { path: paths, requestMethod, targetCallback, methodName } = pathProperties;
  const { instance } = instanceWrapper;
  const routerMethod = this.routerMethodFactory
    .get(router, requestMethod)
    .bind(router);

  const stripSlash = (str: string) =>
    str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str;

  const isRequestScoped = !instanceWrapper.isDependencyTreeStatic();
  const proxy = isRequestScoped
    ? this.createRequestScopedHandler(instanceWrapper, requestMethod, this.container.getModuleByKey(moduleKey),
        moduleKey, methodName)
    : this.createCallbackProxy(instance, targetCallback, methodName, moduleKey, requestMethod);

  paths.forEach(path => {
    const fullPath = stripSlash(basePath) + path;
    routerMethod(stripSlash(fullPath) || '/', proxy);
  });
}

第一个 resolve 方法简单的遍历,加上 registerRouters 在对单个 controller 遍历,可以获得所有的路由信息,而第二个 applyCallbackToRouter 则是路由的重点,routerMethodplatform-express 请求通用方法,比如:post/get 之类,可以通过它建立路由,相应的第一个参数 stripSlash(fullPath) || '/' 是路由的访问路径,而第二个参数 proxy 则是路由处理回调。proxy 创建则涉及到非常多的内容:

整体的 proxy 会通过 create 创建新的路由实例,该实例会先获取信息 getMetadata。可以直接先看看该方法,再回到 create :

public getMetadata(
  instance: Controller,callback: (...args: any[]) => any,
  methodName: string, module: string, requestMethod: RequestMethod,
): HandlerMetadata {
  const cacheMetadata = this.handlerMetadataStorage.get(instance, methodName);
  if (cacheMetadata) {
    return cacheMetadata;
  }
  // 路由传参信息
  const metadata =
    this.contextUtils.reflectCallbackMetadata(instance, methodName, ROUTE_ARGS_METADATA) || {};
  const keys = Object.keys(metadata);
  const argsLength = this.contextUtils.getArgumentsLength(keys, metadata);
  const paramtypes = this.contextUtils.reflectCallbackParamtypes(instance, methodName);
  const getParamsMetadata = (moduleKey: string, contextId = STATIC_CONTEXT, inquirerId?: string,
  ) => this.exchangeKeysForValues(keys, metadata, moduleKey, contextId, inquirerId);

  const paramsMetadata = getParamsMetadata(module);
  // 路由信息里面,如果是 @Response 或者 @Next 则为 true
  const isResponseHandled = paramsMetadata.some(({ type }) =>
    type === RouteParamtypes.RESPONSE || type === RouteParamtypes.NEXT);
  // 有无重定向修饰器 如@Redirect
  const httpRedirectResponse = this.reflectRedirect(callback);
  // 有无渲染修饰器 如@Render('index')
  const fnHandleResponse = this.createHandleResponseFn(callback, isResponseHandled, httpRedirectResponse);
  // post 响应默认状态码是 201,其他方式都是 200 ,可以通过 @HttpCode 来修改状态码
  const httpCode = this.reflectHttpStatusCode(callback);
  const httpStatusCode = httpCode ? httpCode : this.responseController.getStatusByMethod(requestMethod);
  // 如@Header('Cache-Control', 'none') 修改响应头
  const responseHeaders = this.reflectResponseHeaders(callback);
  const hasCustomHeaders = !isEmpty(responseHeaders);
  const handlerMetadata: HandlerMetadata = {
    argsLength, fnHandleResponse, paramtypes, getParamsMetadata,
    httpStatusCode, hasCustomHeaders, responseHeaders,
  };
  this.handlerMetadataStorage.set(instance, methodName, handlerMetadata);
  return handlerMetadata;
}

对于 Controller 其通常会采用一系列的修饰器,比如在其参数里面,添加 @Request() req。生成路由的时候也需要把这些信息提取出来,这些修饰器配置的元数据 key 是 ROUTE_ARGS_METADATA,value 则是代码中的 metadatareflectCallbackParamtypes 方法和前文提到的获取依赖方式一样,采用 'design:paramtypes' 获取 controller 方法的形参。其他的基本上都是获取类或方法上修饰器的信息。还有个重要功能,是否响应需要模板渲染,比如渲染 html 页面,这个时候则可以用到 @Render('index'),可以看官方文档,需要注意的是 html 文件需要保存在 views 目录。

再回到创建路由 proxy 的入口 create

public create(
  instance: Controller, callback: (...args: any[]) => any, methodName: string,
  module: string, requestMethod: RequestMethod, contextId = STATIC_CONTEXT, inquirerId?: string,
) {
  // 上面提到的获取contorller下路由对应方法的信息,如参数/请求头等等
  const { argsLength, fnHandleResponse, paramtypes,
    getParamsMetadata, httpStatusCode, responseHeaders, hasCustomHeaders,
  } = this.getMetadata(instance, callback, methodName, module, requestMethod);

  const paramsOptions = this.contextUtils.mergeParamsMetatypes(
    getParamsMetadata(module, contextId, inquirerId), paramtypes);
  const contextType: ContextType = 'http';
  // 提取模块中的 pipes/guards/interceptors
  const pipes = this.pipesContextCreator.create(instance, callback, module, contextId, inquirerId);
  const guards = this.guardsContextCreator.create(instance, callback, module, contextId, inquirerId);
  const interceptors = this.interceptorsContextCreator.create(instance, callback, module, contextId, inquirerId);

  const fnCanActivate = this.createGuardsFn(guards, instance, callback, contextType);
  const fnApplyPipes = this.createPipesFn(pipes, paramsOptions);

  const handler = <TRequest, TResponse>(args: any[], req: TRequest, res: TResponse,
    next: Function) => async () => {
    fnApplyPipes && (await fnApplyPipes(args, req, res, next));
    return callback.apply(instance, args);
  };

  return async <TRequest, TResponse>(req: TRequest, res: TResponse, next: Function) => {
    const args = this.contextUtils.createNullArray(argsLength);
    fnCanActivate && (await fnCanActivate([req, res, next]));

    this.responseController.setStatus(res, httpStatusCode);
    hasCustomHeaders &&
      this.responseController.setHeaders(res, responseHeaders);

    const result = await this.interceptorsConsumer.intercept(interceptors, [req, res, next],
      instance, callback, handler(args, req, res, next), contextType);
    await fnHandleResponse(result, res);
  };
}

上面 create 返回则是路由响应的处理方式了。可以看到前面部分的 pipes/guards/interceptors,是根据全局、controller以及其下面的 method 的修饰器获取对应的名称,再从模块的 injectables 获取对应的 InstanceWrapper,从而得到的数组。其后面还有对应的处理方法 fnCanActivatefnApplyPipes,从代码上可以看到,路由响应里面先是执行 fnCanActivate 也就是守卫,后面设置响应,然后是 interceptors,最后是 fnApplyPipes,这里以 fnCanActivate 为例子,看看其实现:

public createGuardsFn(guards: any[], instance: Controller, callback: (...args: any[]) => any,
  contextType?: TContext): (args: any[]) => Promise<void> | null {
  const canActivateFn = async (args: any[]) => {
    const canActivate = await this.guardsConsumer.tryActivate<TContext>(
      guards, args, instance, callback, contextType,
    );
    if (!canActivate) {
      throw new ForbiddenException(FORBIDDEN_MESSAGE);
    }
  };
  return guards.length ? canActivateFn : null;
}
public async tryActivate(
  guards: CanActivate[], args: any[], instance: Controller,
  callback: (...args: any[]) => any, type?: TContext,
) {
  if (!guards || isEmpty(guards)) {
    return true;
  }
  const context = this.createContext(args, instance, callback);
  context.setType<TContext>(type);

  for (const guard of guards) {
    const result = guard.canActivate(context);
    if (await this.pickResult(result)) {
      continue;
    }
    return false;
  }
  return true;
}

上面可以看到,guard 是通过调用 canActivate 来实现,如果没有实现该方法,则会抛出报错 new ForbiddenException(FORBIDDEN_MESSAGE),后面的代码也就不执行了。最后路由代理具体业务的执行,则是在 create 提到的 handler 里面执行。

这里回过头看一下,前面代码可以发现 getMetadata 里面生成的 getParamsMetadata,会用在 createcreatePipesFn 方法里面用到:

public createPipesFn(
  pipes: PipeTransform[],
  paramsOptions: (ParamProperties & { metatype?: any })[],
) {
  const pipesFn = async <TRequest, TResponse>(args: any[], req: TRequest,
    res: TResponse, next: Function,
  ) => {
    const resolveParamValue = async (
      param: ParamProperties & { metatype?: any },
    ) => {
      // index 为参数序号,表示第几个参数
      // type 为修饰器类型,如 @Request
      const { index, extractValue, type, data, metatype, pipes: paramPipes } = param;
      const value = extractValue(req, res, next);

      args[index] = this.isPipeable(type)
        ? await this.getParamValue(
            value,
            { metatype, type, data } as any,
            pipes.concat(paramPipes),
          )
        : value;
    };
    await Promise.all(paramsOptions.map(resolveParamValue));
  };
  return paramsOptions.length ? pipesFn : null;
}

可以发现 createPipesFn 方法里面只是返回一个 pipesFn这个 pipesFn 作用在于生成参数 args,而这个 args 是从路由处理代理里面传过来,const args = this.contextUtils.createNullArray(argsLength) 是个空数组!,由于引用对象的特性,该空数组将会在 createPipesFn 实现参数回填,最后再 callback 也就是对应路由执行 method 里面传递 args 作为参数进去。

打比方如 async login(@Request() req) {} 参数 req 会在 createPipesFn 里面根据修饰器类型返回 req 对象,并被赋值到 args 数组的第一个元素里面,最后这个 args 则会成为 login 方法的传参。从而通过 pipe 的方式实现参数的传递。

当然 pipe 的作用不止如此,具体的大家可以探索一下;

应用初始化之其他

上面介绍了路由如何通过 platform-express 生成,还有一点其他的内容,回到 init 方法:

await this.callInitHook();
await this.registerRouterHooks();
await this.callBootstrapHook();

this.isInitialized = true;
this.logger.log(MESSAGES.APPLICATION_READY);

上面分别调用的是 nest 自定义的生命周期钩子,onModuleInit/onApplicationBootstrap 这两个钩子,而 registerRouterHooks 则是给路由添加无路由处理和异常处理;

总结

开始用 nest 写业务的时候,还是懵懵懂懂的,一知半解,好奇这些修饰器是如何用的,为什么 @Request 可以这么用,和使用多年的 Vue/React 甚至 egg 风格截然不同,看了源码之后有种豁然开朗的感觉,而且 typescript 阅读源码很方便,让生锈的脑袋不怎么费力的就读下来了,只是中间的跳转实在有点啰嗦,可能这就是面向对象的特点吧。

关于 nest 还有不少地方没有介绍到,这里主要介绍的是初始化过程,包括容器初始化和应用初始化。容器的初始化是获取所有的依赖项,形成一个 IOC 容器,并实例化依赖,也包含 controller。应用初始化则是中间件、微服务模块、websoket 模块的注册和路由的生成,而这些的前提也是 IOC 容器。

希望 2020,疫情好转,国运昌盛;

36. 基于 next.js 的 Blog 上线了

博客终于上线了,well,页面简简单单,嗯,能上就好了。原本是想要去年年底构建自己的线上博客的,后觉得还差很多,虽放弃了。如今想要在服务端多进行些尝试,可是苦于没有自己的网站,于是想通过构建一个线上博客来体验技术,刚好也是时候搭建了。

摸索

首先技术栈,必须是 react(工作中用 vue,自己的线下作品就用 react),再考虑服务端渲染,出现在眼前的就是 next.js,以及和 next.js 类似如阿里的 beidou.js。虽然是打算采用服务端渲染,但是博客,静态页面就够了,结合 react.js,去年很火的静态网站的 gatsby.js 也是首选。next.js 和 gatsby.js 吸引力都很大,都想尝试一下,鉴于 next.js 有服务端渲染能力,于是还是先选 next.js,后面再玩玩 gatsby.js。

其次是不是要上 github pages,既然是要做更多的服务端事情,那 github pages 就没有必要了,也不能多学习一下 linux 操作。只是第一版的还是想要试试 github pages,毕竟都没有试过。

最后是页面设计,苦于没有 UI 指点,所以想要参考别人怎么做的。国内的个人技术博客,大部分都是起于 15/16 年 hexo 风潮,看多了以后,最后选择 Hux Blog 为模板。再稍微改造一下。

搭建

next.js 里约定了,页面要放在工程 pages 文件里面,其中文件的路径就是浏览器访问路径,比如 pages/Index/Index.js 文件,对应的在浏览地址就是 http://localhost:3000/index。这里并不需要类似 expres 或者 koa 里面的路由,来分发模板文件给到浏览器。这一切 next.js 都帮你做好了,只要和平时一样写 react 页面就好了,后端模板渲染就不需要你操心。约定习俗自然是尤其好的,只是对于一个只有后端系统的我就不适合了,搭建的后端里面是需要接口访问的,于是在 next.js 里面使用 koa,以及 koa-router。

页面数据的获取,在服务端侧,next.js 提供了 getInitialProps 静态方法,该方法会在服务端渲染的时候执行,而不会在客户端执行,另外 getInitialProps 只支持顶层路由页面,不适用于子组件。于是可以在 getInitialProps fetch 数据返回,然后在客户端,将数据传递到组件,渲染出 DOM。

只是在 fetch 的过程中,自己的电脑出现问题,每当在 node 端 fetch 数据的时候,都会报错,提示 fetchError,具体为 'HPE_INVALID_CONSTANT',查看了 死月 大佬的一篇 博客,推测可能是请求头/响应头的问题,只是怎么样都看不到具体请求的 rawPacket。由于涉及到 node.js C 以及 C++ 部分,想要调试 node.js 源码,下载了 clion,采用 狼叔的 [node.js 源码执行])(https://www.zhihu.com/lives/878296775587409920) 的方式。发现 win 10 下执行配置文件的执行,不友好,首先要执行通过 pythone ./configure,只是接下来会提示文件缺失。。。。。。。后来随着时间过去,不知不觉 fetchError 的问题已经折腾了一周多了,严重耽误。其他电脑都是正常的,就是自己的电脑不行,于是放弃 fech 的形式,改用 koa 里渲染页面的时候,直接返回对应的数据。效果还是挺不错的,

主要页面为首页/博客内容/关于我,后面觉得关于我还是以后再写了。组件主要是 Banner/Head/Nav,其中 Head 组件的作用是切换浏览器 tab 页的标题。具体实现就不详细说了。

服务端的接口处理,则实现了两个 Home.indexList 和 Home.postContent 来获取数据。md 文件转换为 html 则采用了 markdown-it 以及 highlight.js 来实现博客的解析。要注意的是,由于 markdown-it 里面链接默认是在本窗口打开的,需要单独配置为 _blank 的形式。

less/css 配置

正常而言,采用官方的 @zeit/next-less,再实现 css 模块化就能满足开发要求了,如下:

const withLess = require('@zeit/next-less');
module.exports = withLess({
  cssModules: true,
  cssLoaderOptions: {
    localIdentName: "[local]___[hash:base64:5]"
  }
})

只是当需要在页面中 import global.css 全局样式文件,却无法得到正确的加载,尤其是使用 highlight.js 来高亮博客中的代码,需要导入特定的 css 样式。采用 @zeit/next-css 可以搭配普通的标签的样式规则,但是对于类以及 id 选择器,一点效果都没有。后来看了 @zeit/next-less 以及 @zeit/next-css 源码才发现,原来在 less 中配置的 css 模块化字段,同样在 @zeit/next-less 生效了,怪不得,没有起作用,最后通过 Object.assign 来覆盖之前设置的规则。

部署

之前对 github pages 的部署一窍不通,看了官方 wiki 后,却发现还有很多内容没有介绍到,比如具体要如何推送呢?

打算在 funfish.github.io 仓库上直接搭建,作为博客首页地址的。通过 next.js 的 issue 发现了几个例子(其实官方也有例子,叫做 gh-pages,直接用就好了。只是为何要缩写 github 呢。。。。。一我开始查不到 github 开头的例子,就走人了)。例子上介绍到部署的命令如下:

 rm -rf node_modules/.cache 
  && next build && next export 
  && touch out/.nojekyll 
  && git add out/ 
  && git commit -m \"Deploy Next.js to gh-pages\" 
  && git subtree push --prefix out origin gh-pages

构建然后导出,最后将 out 文件 push 到 gh-pages 分支。这个分支是专门用来给到 github pages 的。比如仓库 blog,其属于 Project Pages site,若要将该仓库展示到自己的 github pages 上面的话,其访问路径为 https://{username}.github.com/blog。该路径访问的内容就是 blog 仓库下的 gh-pages 分支的 index.html 文件,可以参考

可以发现通过仓库来实现 github pages,其访问路径始终会在尾部多一个目录。而对于 funfish.github.io 这个仓库,为 User and Organization Pages site,是不需要设置 gh-pages 分支的。只是初次部署提交后,发现 https://funfish.github.io 页面 404 了。如何是好?再次设置 gh-pages 也没有用,funfish.github.io 仓库是不支持 gh-pages 分支的,只有 master。后来发现 https://funfish.github.io 访问的是 funfish.github.io 仓库根目录下的 index.html 文件。无奈,只有创建别的项目仓库。采用 https://{username}.github.com/blog 访问 gp-pages 分支的形式,也就是目前的方式了。

采用 github pages 的方式,就需要在 next.config.js 里面配置 exportPathMap 了,对应不同的页面注入不同的数据,从而生成静态文件。

总结

线上 blog 的部署,也算是完成了小小的心愿吧,能够在 2018 年圣诞节前完成,也很是满意。当然 github pages 页面的访问速度,也是差强人意,明明是静态博客,首页的 DOMContentLoaded 居然要 1.4s,还是很简单的首页,免费的东西,果然质量一般般,只是还好至少是 http/2;

还是有很多功能没有上,静态博客这个也是后面会废弃掉的,会购买域名服务器等来配置自己的的后端系统。于是就有一下任务需要做的:

  1. gatsby.js 对静态博客访问的提升;
  2. 数据库保存博客等数据;
  3. 日志、监控、进程守护等;
  4. https/http 2 的加持作用;
  5. GraphQL 使用;
  6. 站点桌面 PWA 化;
  7. 站点采用 WebGL 增加互动;
  8. 评论模块添加;

46. Vue3 的hoist与diff

5 月天空最蓝的时候就想要学习 Vue3 的内容,只是耐心被炎热的夏天打散,代码久久都没有看一行。在工作和发呆中,深圳的秋天好像也来,官网落幕后的国庆,大家都回去团聚的时候,提笔回顾一下 Vue3 的内容,也顺便更新一下博客,算是除除草?今年其实更想的去其他深水区探索一下,只是从去年探索到今年,最后,还是回来看 Vue3 了,也是一种无奈吧。

Vue3

以前的 Vue 像一个黑匣子的,对于开发者而言只是用 sfc 写 template 就足够了,最多偶尔写写 render 函数,灵活度是远远没有 React 方便的。(惭愧,刚没有写几行,国庆又出去玩了,再次提笔已经是 7 号了。。。惭愧)。这次的 Vue3 可以说做的非常彻底,基本把所有 api 都提供出来了,你甚至可以自己写 compile 好的函数。

hosit 优化

在 Compile 阶段三部曲,transform 中会有一个编译优化的过程,可以看下面两段生成代码的差异:

// 原代码
createApp({
  template: `
    <div>
      <a data-name="1">
        <li>123</li>
      </a>
    </div>
  `,
}).mount("#app");

// compile 生成的代码 没有 hoist 的代码
export function render(_ctx, _cache) {
  return (
    _openBlock(),
    _createBlock("div", null, [
      _createVNode("a", { "data-name": "1" }, [
        _createVNode("li", null, "123"),
      ]),
    ])
  );
}

// 设置 hoistStatic 为 true 时
const _hoisted_1 = /*#__PURE__*/ _createVNode(
  "a",
  { "data-name": "1" },
  [/*#__PURE__*/ _createVNode("li", null, "123")],
  -1 /* HOISTED */
);
// compile 生成的代码
export function render(_ctx, _cache) {
  return _openBlock(), _createBlock("div", null, [_hoisted_1]);
}

可以看到开启 hoistStatic,会对静态代码部分,也就是 <a data-name="1"><li>123</li></a> 进行提升的处理,声明提升到 return 函数前面。只有在第一次编译,才会生成 _hoisted_1,这样当局部更新的时候不用在生成 _hoisted_1, 直接引用就好了。可以减少 vnode 的更新计算。

compile 函数里面传入的 hoistStatic = true,则会开启 hoist 节点的静态处理。对于 sfc 文件模式,会采用 doCompileTemplate, 其 hoistStatic 默认是 true,如果非 sfc 方式,并且直接调用 compile,则默认为 false。

生成 hoist

这里就有一个问题,什么节点才是静态节点?为什么不默认生成?
这个判断比较复杂,需要遍历树的所有节点。下面有几个规则:

  1. 根节点不做 hosit 处理,比如上面的 div 标签,避免父节点 props 透传使得其可能变成非静态节点。

  2. 对于 element 节点,需要节点树下的节点点没有 key、ref、绑定的 props、指令等以及变量文字 (还有很多种情况要讨论的,比如常量资源等)。判断子节点是否静态的时候会采用缓存方式,避免后续的重复判断子节点。

  3. props 也可以是 hoist 节点部分,当然不能是动态的部分,并且元素本身没有被 hoist 处理。

  4. 文本节点,本身为文本,如 <div />123 里面的 123 也会被静态化。

如果没有将节点静态化,会有进一步迭代子节点的情况。这里可以看到 将节点静态化,需要判断其所有子节点是否是静态。

如果 DOM 里面存在其他的比如 {{ state.count }} 这些内容,对应的部分则不会被提升,但是其他的可以,比如

<div>
  <a data-name="1">
    {{ state.count }}
    <li>123</li>
  </a>
</div>

这个时候 a 标签不会被 hoist,但是里面的 li 标签不受其影响,还会被静态化。

hoist 生成时会被 push 到上下文的 hoists 上,生成 code 的时候会被提前处理,最后如上面所示,会先声明 _hoisted_1,并在返回的渲染函数里面,将 _hoisted_1 作为 child 传入。

这里有个问题,为什么普通的非 sfc 文件的编译,没有默认开启 hoistStatic 功能?可能是执行时间问题,原本的 ast 生成和 transform 过程都会遍历一次树,若开启 hoistStatic,最坏情况下还会遍历一次树,并且没有起到任何作用,只是这个解释有点牵强,可能是给开发者更多的选择吧?毕竟 compile 方法需要注册才能使用,而注册的过程需要开发者自己配置。

stringifyStatic

hoist 时,可能会将节点字符串化,比如下面:

<!-- 内容: -->
<template>
  <div>
    <a href="1" />
    <a href="1" />
    <a href="1" />
    <a href="1" />
    <a href="1" />
  </div>
</template>
// 生成如下
const _hoisted_1 = /*#__PURE__*/ _createStaticVNode(
  '<a href="1"></a><a href="1"></a><a href="1"></a><a href="1"></a><a href="1"></a>',
  5
);
export function render(_ctx, _cache) {
  return _openBlock(), _createBlock("div", null, [_hoisted_1]);
}

可以看到原本会被 hoist 提升的静态节点,也就是 5 个会通过 createVnode 创建 vnode,现在进一步直接变成了字符串。自然是减少了 vnode 的生成,原本要生成 5 个 hoist 以及其 props 的,现在只要一个字符串。当然字符串节点最大的好处,还是渲染挂载 DOM 的时,可以让 parent.innerHTML = 字符串,简单直接效率高,不用一步步遍历子节点。这可以说是比上面的变量提升要彻底很多。完全静态的代码,直接设置 innerHTML,其他什么的都不需要。

当然这个功能只有编译阶段为非浏览器环境才会开启,也就是 nodejs 环境,为什么呢?因为静态节点字符串化的生成判断是比较耗性能的。

生成字符串节点,首先必须是 hoist 静态的节点。其次若子节点连续大于等于 20 个静态节点或者节点中有大于等于 5 个节点有 props,则会进入字符串化。

对于大于等于 20 个节点或者有大于等于 5 个节点有 props 的判断大致如下:

let nc = 0; // 当前节点数量
let ec = 0; // 存在 props 的节点数量
let i = 0;
for (; i < children.length; i++) {
  const hoisted = getHoistedNode(child)
  if (hoisted) {
    const node = child as StringifiableNode
    const result = analyzeNode(node)
    if (result) {
      nc += result[0]
      ec += result[1]
      // 节点 push 到 currentChunk 里面为后面 stringifyCurrentChunk 做准备
      currentChunk.push(node)
      continue
    }
  }
  i -= stringifyCurrentChunk(i)
  nc = 0
  ec = 0
  currentChunk.length = 0
}
stringifyCurrentChunk(i);

会对 parent 下所有的一级子节点遍历,如果遍历的时候发现节点可以静态化,就一直 continue,若不能,则对之前的可以静态化的节点进行 stringifyCurrentChunk 处理,并且重置 nc ec currentChunk

上面是总体的流程,重点是 analyzeNodestringifyCurrentChunk

前者 analyzeNode 会不断的遍历所有嵌套的子节点,比如如下结构,会被认为存在 3 个静态节点。

<a>
  <a>
    <a></a>
  </a>
</a>

analyzeNode 里面还会对 props 判断,如果 props 不是常见的静态类型,则最后有 result = false,分为以下情况:

  1. 如果已知的 attr,则可以静态化,因为 alt、src 这些,是 dom 本身就有的。
  2. 或者是已知的静态 binding,如 :src="1",这个可以静态化的,反之 :abc="1",就不可以。

对于子节点,如果存在孙节点,则会继续遍历,若节点存在 props,则 ec++

stringifyCurrentChunk 会对收集到的可以静态字符串化的节点进行处理,但是这里的静态化,也是一个遍历的过程:

const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
  JSON.stringify(
    currentChunk.map((node) => stringifyNode(node, context)).join("")
  ),
  String(currentChunk.length),
]);

每个节点,都会经过 stringifyNode 处理,最后 JSON 化。stringifyNode 里面会根据不同的节点类型来处理,并遍历树的所有节点,对于 class/style 还会特别处理。这里就不继续阐述里面的规则了。

可以看出来,静态节点字符串化,效果虽然很好,但是其会对节点树进行两次遍历,一次是判断是否可以字符串化,一次是将节点转为字符串 过程比较耗时。至于能否两次合为一次,自然是不能的。

只是有个问题,为什么会是 20 个节点以上或者是 5 个含有 props 的节点就判断可以字符串化呢?如果定义 20 个节点为阈值,是为了避免过渡字符串化,那 5 个含有 props 的节点,又是什么考虑呢?这不是明明小于 20 个节点吗,还是认为存在 props 的节点,一般都有 4 个属性?

diff

diff 的过程,在 Vue 和 React 里面,已经从原始的整个树更新对比,算法复杂度 O(n3) 转换成 O(n) 了。

However, the state of the art algorithms have a complexity in the order of O(n3) where n is the number of elements in the tree.

而这里的 O(n) 也不是最终的目标,比如 react,会将所有需要更新的串在一起,只处理有更新的节点(有点久了,应该是这样的吧),而 Vue3 的思路思路则是维护一个动态数组,dynamicChildren,应该说不是一个,而是每个 block 都存在 dynamicChildren,这个 dynamicChildren 是更新的主角,毕竟,静态节点就不用考虑更新了。

dynamicChildren 生成

dynamicChildren 的生成是与 createBlock 函数绑定的,只有在 createBlock 里面才有可能生成 dynamicChildren

比如下面代码

<div>
  <a data-name="1"><li>{{stat.count}}</li></a>
</div>

最后生成下面的 vnode:

// vnode
{
  type: 'div',
  children: [
    {
      type: 'a',
      props: ['data-name': 1],
      children: [
        { type: 'li', children: '2', patchFlag: 1 }
      ]
    }
  ],
  dynamicChildren: [
    { type: 'li', children: '2', patchFlag: 1 }
  ]
}
// 对应的 compile函数
function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _createVNode("a", _hoisted_1, [
      _createVNode("li", null, _toDisplayString(stat.count), 1 /* TEXT */)
    ])
  ]))
}

可以看到最后生成的 dynamicChildren 部分只包含了动态内容,在后面的 diff 过程则完全可以将 a 标签忽略掉。同时也可以看到 openBlockcreateBlock,前者需要在每次调用 createBlock 时触发,用来重置全局变量 currentBlock 数组,后者 createBlock 则会在子节点的 vnode 构建完成后,将这里的 div 标签生成 vnode,并将 currentBlock 赋予该 vnode 的 dynamicChildren 字段。

currentBlock 数组会在生成 vnode 的时候收集节点,具体如下:

if (
  (shouldTrack > 0 || isRenderingTemplateSlot) &&
  !isBlockNode &&
  currentBlock &&
  (patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
  patchFlag !== PatchFlags.HYDRATE_EVENTS
) {
  currentBlock.push(vnode);
}

这里的条件蛮多的,shouldTrack 表示目前处于实例生成更新阶段,也就是不是 compile 阶段,isBlockNode 是目前的 vnode 为 block 节点,也就是可以生成 dynamicChildrenpatchFlag 这个是重点,大于 0 表示其为动态内容(当然事件监听 PatchFlags.HYDRATE_EVENTS,不在这里面)。后面会重点介绍 patchFlag

dynamicChildren 对比

在更新的时候,会对比前一个 vnode 生成 dynamicChildren,与现在生成的 dynamicChildren,处理如下:

const patchBlockChildren: PatchBlockChildrenFn = (
  oldChildren,
  newChildren,
  fallbackContainer,
  parentComponent,
  parentSuspense,
  isSVG
) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    const container =
      oldVNode.type === Fragment ||
      !isSameVNodeType(oldVNode, newVNode) ||
      oldVNode.shapeFlag & ShapeFlags.COMPONENT ||
      oldVNode.shapeFlag & ShapeFlags.TELEPORT
        ? hostParentNode(oldVNode.el!)!
        : fallbackContainer
    patch(
      oldVNode,
      newVNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      true
    )
  }
}

patchBlockChildren 里面会对每一个新老的 child 一一对比,再通过 patch 来更新。只是看到这里就有一个问题了,在 for 语句里面可以看到是严格要求新老 dynamicChildren 数组的长度是一致的,只是真的会一样吗?

比如 v-for,都不知道最后有多少个遍历的节点,如何确定长度呢?抱着这样的想法,试试 v-for 编译生成的代码:

<div>
  <a data-name="1">
    <li v-for="i in 2">1</li>
  </a>
</div>
function render(_ctx, _cache) {
  return (
    _openBlock(),
    _createBlock("div", null, [
      _createVNode("a", _hoisted_1, [
        (_openBlock(true),
        _createBlock(
          _Fragment,
          null,
          _renderList(2, (i) => {
            return _openBlock(), _createBlock("li", null, "1");
          }),
          256 /* UNKEYED_FRAGMENT */
        )),
      ]),
    ])
  );
}

可以看到上面为了 v-for 新增加了 createBlock,也就是对 fragment 这个 vnode 也赋予了 dynamicChildren,这样就不管有几个循环节点,外部看到的只有一个 fragment 片段,这样就能一一对应上了,只是虽然套多了一层 fragment,外面是稳定了,但是里面呢? for 的数量还是不是固定的。

其实执行 _openBlock(true) 的时候,已经将 currentBlock 数组置为 false,所以 dynamicChildren 也无法生成,自然 v-for 生成的节点无法进入 patchBlockChildren 函数里面。v-fordiff,是所有子节点都要一一 diff,也就是全量对比。

当然也有例外,当 vnode 的 PatchFlagPatchFlags.STABLE_FRAGMENT 时,表示其是一个不变的 fragment,最后也会通过 patchBlockChildren 对比子节点,而不是普通全量对比,什么是稳定的不变的片段呢?比如 <li v-for="i in 2">1</li> 这个片段是不可能有动态改变,是稳定的。只是为什么 compile 的代码没有生成 PatchFlags.STABLE_FRAGMENT 呢?因为这部分 PatchFlags.STABLE_FRAGMENT 的判断,不能在浏览器侧 compile,需要在 nodejs 端。具体判断如下

  1. 需要在非浏览器端 compile。
  2. 如果 v-for 的右侧是个变量,则不是稳定节点,除非是 this/NaN/Infinity 这样的*操作。
  3. 其他的情况会通过 Babel 的 parse 方法来解析 v-for 的右侧生成 ast 树,可能存在 v-for="i in () => 1 + 1" 这样的*操作,当然常量 v-for=i in 2 也在这个范畴里面。需要遍历 ast 树,来确定静态的部分。

确定了 dynamicChildren 结构之后,就是进行 diff 操作。

patchFlag 与 diff

patchFlag 是优化模式 optimized mode 里用到的优化标识,通过位操作来判断当前 vnode 的需要执行的 diff 操作。常见的 patchFlag

  1. TEXT: 1,存在动态文本内容,比如 {{ state.count }}
  2. CLASS: 1<<1,存在动态的 class。
  3. STYLE: 1<<2,存在 style,如果是静态的字符串 <div style="color: red"/> 也会被解析成动态的 style。
  4. PROPS: 1<<3,存在除了 class/style 以外的动态 props 的情况。
  5. FULL_PROPS: 1<<4,表示 props 存在动态字段名,如 <div :[foo]="bar">,与上面的 CLASS/STYLE/PROPS 互斥。

以及前面提到的 STABLE_FRAGMENT,当然还有更多没有来得及了解的位运算情况,另外还有特殊情况如非位操作的 HOISTED: -1 这些。

生成的 patchFlag 会传入 vnode 里面,当进行 patchdiff 的时候就会用到,比如以下常见结构

if (patchFlag > 0) {
  if (patchFlag & PatchFlags.FULL_PROPS) {
    // patchProps 对每一个props 都重新对比,遍历新的props更新/新增,在遍历老的props,若不存在则删掉
  } else {
    if (patchFlag & PatchFlags.CLASS) {
      // 更新 class
    }
    if (patchFlag & PatchFlags.STYLE) {
      // 更新 style
    }
    if (patchFlag & PatchFlags.PROPS) {
      // 对动态props进行遍历
    }
    if (patchFlag & PatchFlags.TEXT) {
      // 更新动态文案
    }
  }
}

通过 patchFlag 可以精准的更新 props、class、style 和动态文案这些。这些基本就是日常操作了, dynamicChildren 直接更新到 vnode。现在看看复杂的情况,全量对比,比如在 v-for 的非稳定节点的 diff 操作,其中分为有 keychildren,和无 key 的。

先介绍一下 patchUnkeyedChildren,如果没有在 <li v-for="i in state.count"/> 里面找到 key 字段,则会进入。首先会取到新老 children 的公共最小长度,对该范围里面的节点,进行 patch 处理。超出长度的节点,若是老的 children 多,则卸载,若新的 children 多则进行挂载。

若是有 key,则会执行 patchKeyedChildren,(由于懒得做图,这里就简述一下)具体算法步骤:

const patchKeyedChildren = () => {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1; // 老 vnode.children 最后的索引
  let e2 = l2 - 1; // 新 vnode.children 最后的索引

  // 1. 从前往后,比如老: (a b)  新:(a b) c 的情况
  while (i <= e1 && i <= e2) {
    // 如果是同样类型的 vnode,即 type 和 key 是一样的,则 patch 对比,并且 i++
  }
  // 2. 从后往前,比如老: a (b c)  新:e (b c) 的情况
  while (i <= e1 && i <= e2) {
    // 如果是同样类型的 vnode,即 type 和 key 是一样的,则 patch 对比,并且 e1-- 以及 e2--
  }

  // 3. 如果新的包含老的,而新的比老的内容多,如 老的 (a b),新的 (a b) c
  if (i > e1) {
    while (i <= e2) {
      // 新增新增加的 child,同时 i++
    }
  }

  // 4. 如果老的包含新的,而新的比老的内容少,如 老的 (a b) c,新的 (a b)
  else if (i > e2) {
    while (i <= e1) {
      // 卸载老的多余的 child,同时 i++
    }
  }

  // 还有步骤 5.1/5.2/5.3 复杂情况看下面
};

上面的都是比较理想的情况,然而存在混乱的情况,比如
老的: a b c d e f g h
新的: a b f d e c g h

所以对于步骤 5 还有有三部曲:

5.1 为新的 children 生成一个 key:indexmapkeyToNewIndexMap 表示新 children 中 key 和索引的关系
5.2 设置长度为 e2 - i,每个元素的值为 0newIndexToOldIndexMap 数组。

  1. 遍历老 children 节点,从 keyToNewIndexMap 获取与老 child 相同 key 的索引,也就是对应新节点的索引,然后通过 patch 函数更新,若老节点没有 key,则寻找相同 type/没有 key 的元素来对比,若还找不到,说明这个老元素在新的里面不存在,可以卸载了。
  2. 若新老可以 patch,则会设置 newIndexToOldIndexMap[newIndex - s2] = i + 1,指明新节点在老节点中的索引关系。比如上面的例子 newIndexToOldIndexMap = [6, 4, 5, 3]
    5.3 挂载和移动,遍历需要操作的节点,如果是 newIndexToOldIndexMap 存在 0 的值,则说明是新节点,需要挂载上。另外上面的步骤只是更新了 vnode.el,如果出现步骤 5 中的位置错乱的情况,目前只是更新 dom 的内容,其顺序没有改变。这里需要根据 newIndexToOldIndexMap 来移动需要调整的节点

对于 5.3 里面 dom 位置的移动,Vue3 采用了 最长递增子序列概念,有动态规划的思维在里面(反正没有看懂算法),感兴趣的可以去了解一下。该最长递增子序列是作用是尽量减少 dom 的移动。

如果出现新 children 的节点没有按照老 children 的节点顺序排列,比如例子中的 f d e c,就会以 newIndexToOldIndexMap 为输入,求得最长递增子序列对应的索引,上面的例子则是:increasingNewIndexSequence = [1, 2]。 后面移动的时候从后往前遍历,节点从 c d e ff d e c,具体过程如下:

这里 index 值是 5.3 步骤遍历中的索引,递减,j初始值为 increasingNewIndexSequence.length - 1

  1. index = 3c.el 移动到 g 前面, index--;
  2. index = 2,遇到 e 的时候由于 index === increasingNewIndexSequence[j],均为 2,则不进行移动,increasingNewIndexSequence 的索引 j--。同理轮到 d 也是类似操作。
  3. index = 0f.el 需要移动到 d 前面。到这里移动结束。

可以看到原本是四个节点的,通过最长递增子序列的对比,可以明确哪些节点需要移动的,最后只要执行两次移动就完成了,可以说是 dom 移动的最优解吧。

到这里 diff 的过程差不多告一段落了~~~

总结

这次 Vue3 的代码,看着很舒服,可能是 ts 的问题,哪里不懂点哪里。

虽然 Vue3 还有很多内容没有研究透彻,甚至上面的部分内容,还有不少疑惑,比如 hoist 为什么只能在 nodejs 端开启?dynamicChildren 这种方式是最优解吗?好像 react 的方式更好吧?生成 STABLE_FRAGMENT 的所有情况?以及其他没有学习到的,比如事件处理,插槽,比如 v-if v-model 这些。只是还是想要去别的领域走走,去深水区走走。所以 Vue/React 的学习应该会告一段落了。

47. 可变字体探索与 require 扫盲记

国庆后一场秋雨一场寒,属于东南季风的台风带来了明显的降温,又到了一个尴尬的温度,长袖短裤都有人穿。这个温度,感觉很舒服,尤其是在海边骑单车的时候,沿着沙河路的时候,城市灯光的点缀,观景台边的海涛声、阵阵袭人的秋意就来了。

本篇是介绍两个琐事,都是工作中遇到的,一个是可变字体探索,一个是 require 扫盲记。

Variable Fonts

好像从前几个月开始,就接触了可变字体,以前设计推荐的是使用 5 种字体,你没有看错,在项目里面用到了 5 、种字体,不同的粗细,不同的高瘦,每个字体都基本在 8M 左右,通过不同的字体来展示设计的风格,真是。。。挺好的。今年开始有新的字体,没有以前的 5 种,只用一个可变字体了,通过一个字体来展示之前 5 个的字体,可以说很是优秀,当然对开发而言,统一的字体最是简单,而且一个字体意味着只要加载一种就好了,之前的要加载 5 种字体,虽然一个可变字体的体积是 20 M。

可以通过这个网站 玩一下可变字体。

字体,对于开发者而言,默认基本都是采用系统的字体,比如系统差别、中西文差别,还有最后的衬线字体,比如我们公司就喜欢用 androidRoboto 默认字体来显示数字。如果采用自己的字体的话,会把其放在最前面,所以最前面是 OPPOSANS, Roboto, Noto Sans CJK SC, Source Han Sans CN 后面两个是思源字体,毕竟 OPPOSANS 是和思源字体结合的。。。。

通过设置 font-variation-settings: "wght" 550 可以调整字体的粗细,比如 OPPOSANS 字重可以调整到 1000-1000 区间,实现无极调整,不像以前的字体,只有一百倍数的 font-weight,而且要一个字体文件就够了。还有其他的比如 wdth ital 这些都可以设置。
比如下图

还能在这个基础上用上 font-weight,当然这个就不规范了。目前 font-variation-settings 的兼容性还是比较好的,除了 ie 和部分比较老的浏览器不支持外,其他都没有问题的。

字体的普通处理

如果是采用系统的字体那一切都挺好的,但是作为设计,作为一家最求美感的公司,就是要有自己的字体,于是普通字体的 10M 的体积,加载速度就可以劝退大部分人了。为了平滑顺利过渡字体,一般采用的是如下几个方案:

  1. **font-face 定义的时,采用 swap 来显示,系统会优先采用已有的字体,避免字体加载导致的阻塞,使得文字无法显示;**当然这种方案会导致字体加载成功时,页面切换会从当前字体切换到自定义字体,导致用户体验稍差。如果自定字体体积小,可以不采用 swap 方式。
  2. 字体文件预加载,就是在 link 标签里面采用 preload 的方式,字体资源在浏览器里,属于优先级较低的资源,通过 link 的预加载可以显著的提高优先级,避免字体加载时间过长,导致切换时候带来的不好体验
  3. **字体体积,上面更多的是辅助优化,对于中文字体而言,最重要的是体积。中文不同于其他字母语言,有非常多的字,一个字体 10 M 的体积要如何处理呢,正常会对字体文件做提取,只保留可能要用的字,也就是 glyphs。**比如只用到 这个字,那就提取字体包里的 ,这样字体文件就可以压缩的非常小了
  4. 最后是 woff、woff2 这些新格式带来的优化,以及更好的压缩算法带来的帮助。

字体的提取历程

这里要介绍是可变字体的提取问题,先看看普通字体提取,之前用的是 font-spider,使用下来可以满足字体的压缩,提取需要的子集,用法很方便,如下:

<!-- test.html -->
<style>
  @font-face {
    font-family: "source";
    src: url("../font/source.eot");
    font-style: normal;
  }
  body {
    font-family: "source";
  }
</style>
<body></body>

再通过指令 font-spider ./test.html 就可以从 source.eot 字体包里面压缩出仅仅包含 一个字的字体,当然会有一点小问题,比如垂直方向的行间距变小了,但是总体问题不大,10M 的字体包,最后只剩下几 kb。这个时候如果用软件 FontForge 查看的话,可以看到 保留下来了,其他被移除了。

上面左边是正常的字形,右边则是压缩之后的效果,可以看到压缩后周围的小伙伴都被吓跑了。

只是到了可变字体,压缩就不是这样了,简简单单的 font-spider 打包出来的字体就不能用,会出现字体镂空的情况,而且关键是不能调整可变字体的 wght,设置了也不起作用,简直就是和普通字体差不多,不再是可变字体了。

于是翻箱倒柜的,在 font-spider 里面转了一圈,结果发现里面处理字体的内容不多,更多的是对输入文件和样式处理,通过模拟的浏览器环境,自研的 browser-x(大佬自己写的 Node.js 实现的虚拟浏览器) 来获取样式,保证不同的的 font-family 打包出不同的字体,分析输入的参数,文字最后输出四种格式的字体,woff woff2 svg ttf 这些,当然在 WebFont 里面看到了不少冗余的代码,一度让我误解了,比如 weight stretch 这些属性,就不能使用。。。。可能也是大佬弃坑了吧,最后落实到压缩的还是 fontmin 这个库,也有三方压缩的工具都是基于 fontmin 的。

fontmin 是一款中间件机制的字体处理工具,比如 glyph 可以用来压缩字体,比如 ttf2woff 可以转换字体。比如下面的官方例子:

var Fontmin = require("fontmin");

var fontmin = new Fontmin().use(
  Fontmin.glyph({
    text: "天地玄黄 宇宙洪荒",
    hinting: false,
  })
);

fontmin 代码里面的 glyph 插件代码中可以看到如下形式:

var TTF = require("fonteditor-core").TTF;
var TTFReader = require("fonteditor-core").TTFReader;
var TTFWriter = require("fonteditor-core").TTFWriter;
function minifyTtf(contents, opts) {
  opts = opts || {};
  var ttfobj = contents;
  if (Buffer.isBuffer(contents)) {
    ttfobj = new TTFReader(opts).read(b2ab(contents));
  }
  var miniObj = minifyFontObject(ttfobj, opts.subset, opts.use);
  var ttfBuffer = ab2b(new TTFWriter(opts).write(miniObj));
  return {
    object: miniObj,
    buffer: ttfBuffer,
  };
}

function minifyFontObject(ttfObject, subset, plugin) {
  if (subset.length === 0) {
    return ttfObject;
  }
  var ttf = new TTF(ttfObject);
  ttf.setGlyf(getSubsetGlyfs(ttf, subset));
  if (_.isFunction(plugin)) {
    plugin(ttf);
  }
  return ttf.get();
}

敢情 fontmin 也是套娃的。。。最后核心的字体处理还是要跑到 fonteditor-core 里面,怎么说呢, fontmin 是一个优秀的集成商,有字体压缩,还有字体格式转换这些功能,虽然大部分是基于第三方的。而且不管是 fontmin 还是 font-spider 也有四五年没有更新主要内容了,作者也都弃坑了。那对于 16 年底才发布的可变字体,好像不支持也是可以理解的。

table

介绍到 fonteditor-core 就要提一下 table 的概念,这个是布局信息表,其包含了字形的位置、对齐、基线等等信息,字体文件则是由这一系列的表构成的,其中有部分表是可选的。

字体目录是字体文件的指南,提供访问其他表所需的信息,包含两部分:偏移子表(offset subtable)和表目录(table directory)。偏移子表记录了字体文件中 table 的数量,并提供了快速访问表目录的方法。偏移子表后面就是表目录,表目录主要包含了表的 tag、校验、偏移、长度等信息,字体文件中的所有表都在表目录里面有入口。

看了不少文档,每个文档对必选的 table 都有自己的解释,综合一下,下面是其中有几个是非常必要的 table:

  1. cmap:字符代码到字形索引之间的映射关系,字符代码也就是字符的 Unicode,获得索引也就可以根据索引从字体中加载这个字形。
  2. head:字体的各种基本信息,如版本、创建、修改时间,还包括基本字体数据,如 unitsPerEm、xMin, yMin 等。
  3. hhea:水平排列信息,如 ascender、descender、lineGap 等水平排列时候的布局信息。
  4. hmtx:水平参数,如间距,如果是字形之间是等距的,那只需要一个间距就可以了。
  5. maxp:最大需求表,包含字形数量,表示字形的内存需求情况。
  6. name:命名内容,如字体名,授权信息等等。
  7. post:PostScript 表,用于打印。
  8. glyf:字形数据,也是最重要的一个表了。
  9. loca:偏移和字符索引映射关系表。

上面几个表的介绍可能理解不太到位地方,因该差不多大致如此吧。另外,还有一些比如 OS/2: 用于 windows 系统的配置,所以对跨平台的字体就非常需要了,但是若是针对 Mac 这些就不必了。

具体的字形什么的,用 FontForge 软件打开任意一个字体就可以看到了,比如下面的:

你甚至都可以修改字形。。。。

至于字体从加载到渲染出来的流程可以参考一下知乎上的介绍

加载字体文件
确定要输出的字体大小
输入这个字符的编码值
根据字体文件里面的 Charmap,把编码值转换成字形索引(就是这个字符对应字体文件中的第几个形状)
根据索引从字体中加载这个字形
将这个字形渲染成位图,有可能进行加粗,倾斜等变换。注意这里的倾斜和倾斜字体不同,它只是从算法上对位图进行变换,与专门制作的加粗字体是不一样的。

上面介绍的是 cmap 根据字符代码拿到字形索引,再从 loca 拿到索引对应的字形偏移,最后到 glyf 加载字形的过程。loca 表可以参考以下图:

每个字形都有自己长度,从而形成相对于 0 位置的偏移,而 loca 表则是记录字形索引到字形偏移的映射表。

当然这里面还有很多的表的内容没有谈到,比如和 TrueTypeCCFSVG 以及 BitMap 相关的表,还有一个是高级表,比如 GSUBglyf 的替换表,之前提到的一个字符代码最后可以映射到字形,但是如果是连字的时候,就不一定是简单的字形叠加,例如下面的:

可以看到单独一个字的时候都是好好的,但是一旦结合在一起,就是不是 f + i = fi 了,而是有新的字形。这一点在阿拉伯语中也是的,字形在不同的位置有不同的显示。。。。。(原来阿拉伯语这么神奇,简直就是蝌蚪文)。

除了高级表,还有色彩相关的,其他比较杂的,最后还有一个是 OpenType Font Variations 可变字体,也是 OpenType 规范中,里面有 avarcvarfvargvarHVARMVARSTATVVAR 这几个。

可变字体的 table

可变字体,如前面提到的,可以让设计者将多个字体合并为一个字体,下面的示意图很好的介绍了字重和字宽度变化导致字形的变化:

这里面看到的 widthweight 都是 fvar 表所描述的,用来存储轴的信息,以及命名实例,其中命名实例是可选的字段。轴的信息,比如 wght(100-1000)width(10-200),包含了轴名称、最小最大值和默认值等,命名实例则是由轴与轴之间定下的命名的特定坐标,如下面几个:

可以看到轴 wght = 400 以及 wdth = 100 形成的坐标 Regular,也就是命名实例。Regular 是给特定坐标提供的预设名称,也是该子字体的名称,可以让使用者直接使用。使用可变字体的时候,如果没有指定子字体,其采用默认轴值。(css 里面修改 font-variation-settings,也就是实例了)。

对于可变字体,有两个表是必须的:fvarSTAT(style attributes),后者是样式属性,每个在 fvar 里面的每一条轴和子字体都需要在 STAT 里面有对应的信息。STAT 用来区分字体族下面的不同的字体,支持动态属性,比如 fvar 里面的 wght(无极),也支持静态属性,比如 italic 是否为斜体这些,展示 Variable Font 下的样式名称,比如 Medium 这样的字体。

其他的表则是描述 fvar 里面字体轴变化时字形的变化情况,例如 avar,是非线性的轴变化数据,例如字体的 width 轴,若变化区间是 100-200,线性的时候,则 150 表示字体的字形宽度是两个极值的正中间,但是非线性变化,就使得值不是均匀的变化的,150 可能不是字形宽度上的正中间状态。这种非线性变化也符合用户习惯。还有 gvar,存储字形在轴上的变化信息,描述 glyp 中各个点的变化情况,可以说是非常重要的。

字体提取工具

看了上面的 table 介绍,字体的处理,其实就是对 table 的处理,fonteditor-core 对可变字体的处理,看了一下源码的结构,well,根本就没有可变字体的表处理,连 fvar 的踪迹都没有。

于是开启大海捞针的方式,在 github 里面找,最后发现一个 opentypejs/opentype.js 仓库,卧槽,难道是官方的嫡系部队?只是打开到结构目录还是很失望,都是三四年前的代码了,和 fonteditor-core 差不多,虽然有 fvar 表,但是其他可变字体的表一个都没有。抱着试一试的想法,用一下,最后的打包出来的字体,虽然比 fonteditor-core 好不少,但是压根就不可变。。。。毕竟连 gvar 也没有。最后看到了这个roadMap,里面介绍到:

本来决定要放弃了,毕竟官方也不支持系列,但是总觉得有问题,难道可变字体没有工具?都好几年历史了,没有人造轮子吗。。。。

最后找到了字体处理的重量级库 fonttools,一个 python 库,打开一看密密麻麻的的 table 处理,有 50 个以上的处理,对比一下 fonteditor-core 的 18 个表处理,简直是。。。。。。在 fonttools 里面也找到了各种各样的可变字体处理表,比如 gvar,只是对 python 不是很熟悉,而且一上来就看源码,有点吃力,所以就放弃了(想起了看 esbuild 源码的经历)。

fonttools 里面有很多工具,提取字体用的是 pyftsubset,通过指定文件字符来确定要输出的字形,基本上一顿操作下来,从 22M 的字体包,压缩到 300kb。正常来做这就可以了,但是 原本字体包还包含了斜、高度轴,这些轴,项目用不上,而且 wght 也就用到了 550-1000 的范围,能不能去掉剩下的部分呢? 这样不就可以完美压缩字体了,甚至 wght 就用了 5501000 两个值,其他的能不能抛弃掉呢?可能这个就要用专业的设计工具了(比如 Adobe Illustrator?),目前在 fonttools 没有看到更多可操作空间,如果有大佬晓得一定要告知。

一般可变字体的体积是要大于单个字体的(字体族里面的单个字体),只有当需要用到同一字体族的多个字体的时候,可变字体收益才很大。当然如果需要艺术字那就另当别论了。另外 font-variation-settings 是属于比较基础的 API 了,如果要设置字重的话,可以使用 font-weight: 550 是不是很熟悉?这个和以前的 CSS 是一致的,只是 CSS Fonts Level 4 做了扩展,当然还有其他几个轴的,比如 font-stretch

require

相比于字体,require 可以说是以前的一个知识盲区。在 Vue 里面,如果需要加载资源可以采用 require 方式引入,但是时不时的总会遇到无法加载资源的问题。直到一次想要把资源路径作为 props 传入组件,再通过 require 来获取,结果是获取到图片了,但是还引发了另外一个严重的问题,页面的样式错乱?

通过审查打包出现的代码,发现原本完全没有引入的 scss 文件都被打包到样式文件里面,如果去掉 require(urlProp) 则一切正常,这个就很神奇了,而且前者的打出的包还很大。有种奇奇怪怪的感觉。

后面耐心的看 webpack 文档才晓得:

A context module is generated. It contains references to all modules in that directory that can be required with a request matching the regular expression. The context module contains a map which translates requests to module ids.

如果采用 require('./template/' + name + '.ejs') 的方式,那 template 文件下面的所有 ejs 文件都会被引用,形成一个上下文的 map 对象,导致该目录下原本不会被使用的文件,也被打包使用上了,这也就是为什么使用了 require(urlProp) 会加载上错误的资源,可想而知,若 require 里面完全采用传参的方式,会使其无法分析正确的 Directory, 于是从根文件 src 开始查询文件。。。。。

至于要如何破局呢,urlProp 为了可扩展性,是要从外部传入的,而里面要读取资源只能用 require 了,直到看到了下面的 require.context 的方式,表达式如下:

require.context(
  directory,
  (useSubdirectories = true),
  (regExp = /^\.\/.*$/),
  (mode = "sync")
);

通过在外部指定目录,和正则就能获得正确的资源路径,再传给 urlProp 就完美了。

上面的 mode 配置呢,其实是和 webpackMode 类似的,有 synceagerlazylazy-onceweakasync-weak 一共六种。其中sync 是默认的,会直接打包到文件里面,而 lazy 则会生成可延迟加载单独的 chunk

这里我用到的是 lazy-once。为什么呢,因为我需要从 require.context 里面引入的资源非常多,肯定是要拆包的,而 lazy 虽然是懒加载了,但是所有文件都单独形成 chunk,导致增加了很多文件,lazy-once 就很舒服,将所有文件合成一个 chunk,只需要通过 promise 的方式获取正确的路径就可以了,比如 urlProp(oneFileName).then(src => list.push(src)) 这样的方式。

总结

require 部分算是一个小知识点,至于深入的理解,比如 Directory 目录的获取和分析,感觉有点类似,可能是 @babel/parser 的形式,通过 ast 分析表达式来获取目录?后面的理解就没有去研究了,倒是解决了一直以来使用 require 的困惑(指不定以前有好多写的不太正常的 bug,采用 require 多加载了多余文件。。。。。呵呵呵)。

字体部分,更像是一个新领域的探索,想要不断的优化页面,而新版本的字体就是重中之重了,从开始的通过 node.jsdebug,到最后定位到 fonteditor-core 再到 fonttools,可以看到前端的字体轮子还是少了(比如参照 fonttools 代码,更新 fontedior-core ?)。更多的是学习字体的结构,看各个 table 的作用,对字体的展示也有初步的理解,但是没有去研究代码层面的实现,没有深入去,更多的是浅尝辄止,可能兴趣就到这里吧,没有更多的想法了,想要深入探索更多的东西,更有价值的吧。

写完的时候又一个台风飞过,今年的台风真是奇怪。

参考

技术文档,当然微软的是 Opentype,苹果的是 TrueType 与 AAT。

  1. microsoft OpenType 1.8.3 specification
  2. apple Font Tables
  3. 参数化设计与字体战争:从 OpenType 1.8 说起 很有趣的一篇历史介绍,想不到可变字体,出道快 30 年了,结果 16 年才成为统一标准。。。。。。

21. preact源码 - 组件与回收机制

前言

前面介绍到 diff 方法,但是我们只是从简单的例子开始的,并没有用到组件,而组件才是最重要的部分,毕竟一切的一切可以是组件。

组件 Component

先看看 Preact 输出的 Component 长什么样子:

export function Component(props, context) {
  this._dirty = true;
  this.context = context;
  this.props = props;
  this.state = this.state || {};
}

extend(Component.prototype, {
  setState(state, callback) {
    let s = this.state;
    if (!this.prevState) this.prevState = extend({}, s);
    extend(s, typeof state==='function' ? state(s, this.props) : state);
    if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
    enqueueRender(this);
  },
  forceUpdate(callback) {
    if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
    renderComponent(this, FORCE_RENDER);
  },
  render() {}
});

平时使用组件的时候,大致都是这样 class Clockwarp extends Component 通过 extends 来实现继承 Component,有点 prototype 的意思。在 Component 类里面有 state/props/setState/render,其中 setState 方法先判断 state 是不是函数,也就是这种写法:this.setSate((preState, props) => {}) 这个时候才会会执行 state 方法,如果有回调,会被 push 到 _renderCallbacks 里面。在看看 enqueueRender:

let items = [];
export function enqueueRender(component) {
  if (!component._dirty && (component._dirty = true) && items.push(component)==1) {
    (options.debounceRendering || defer)(rerender);
  }
}
export function rerender() {
  let p, list = items;
  items = [];
  while ( (p = list.pop()) ) {
    if (p._dirty) renderComponent(p);
  }
}
export const defer = typeof Promise=='function' ? Promise.resolve().then.bind(Promise.resolve()) : setTimeout;

enqueueRender 方法是为了延迟当前的组件的再渲染,采用的是 Promise方法,如果没有就用 setTimeout 代替,当然 Promise.resolve() 之后调用的 then 实现上是要优先于 setTimeout 的。

组件 diff 机制

上面代码可以看到 setState/forceUpdate 最后都会调用 renderComponent 方法,看名字就知道是渲染组件的意思,但是在介绍 renderComponent 之前,先看看上篇博客里面介绍的,diff 过程里面,对于组件的处理:

  1. vnode 是 Component的形式,调用 buildComponentFromVNode 方法,最后会返回处理过的 dom 节点。
    如若是组件,则会调用 buildComponentFromVNode 方法,而实际上, buildComponentFromVNode 最后也是会调用 renderComponent 方法,所以先看看 buildComponentFromVNode 的实现:
export function buildComponentFromVNode(dom, vnode, context, mountAll) {
  let c = dom && dom._component,
    originalComponent = c,
    oldDom = dom,
    isDirectOwner = c && dom._componentConstructor===vnode.nodeName,
    isOwner = isDirectOwner,
    // props就是vnode的attribute/children/nodename.defaultProps
    // 传递最新鲜的porps,常见的子组件更新,都是依赖于props变化
    props = getNodeProps(vnode);
  while (c && !isOwner && (c=c._parentComponent)) {
    isOwner = c.constructor===vnode.nodeName;
  }
  // 如果vnode 和 dom 是由同类的组件生成则直接 setComponentProps,当然还需要!mountAll || c._component 成立。
  if (c && isOwner && (!mountAll || c._component)) {
    setComponentProps(c, props, ASYNC_RENDER, context, mountAll);
    dom = c.base;
  }
  else {
    // dom由组件生成,而vnode和生成dom的组件实例不是同一构造器生成的。则说明要卸载当前组件,替换上新的。
    if (originalComponent && !isDirectOwner) {
      unmountComponent(originalComponent);
      dom = oldDom = null;
  }
  // 根据nodeName生成新的组件 c。
  c = createComponent(vnode.nodeName, props, context);
  // 如果该类组件没有卸载过,而存在dom来diff,则将c.nextBase指向dom,后面做diff用
    if (dom && !c.nextBase) {
      c.nextBase = dom;
      oldDom = null;
    }
    setComponentProps(c, props, SYNC_RENDER, context, mountAll);
    dom = c.base;
    if (oldDom && dom!==oldDom) {
      oldDom._component = null;
      recollectNodeTree(oldDom, false);
    }
  }
  return dom;
}

buildComponentFromVNode 有几个概念是要分清楚的,如果 dom 是由一个组件生成渲染的,则 dom._component 是指向渲染出 dom 的组件实例。而生成的组件实例的 base 属性又会指向 dom 节点。初次渲染时候会直接是进入下面的 else 语句。对于不同的组件则先卸载之前的组件,让后生成新的组件 cnextBase 指的是卸载的同类组件的 base 属性,也就是上个该类组件生成的 dom 节点。为什么要这样做呢?答案是提高效率。假设组件替换是这样的 A -> B ->A,在 A 组件卸载的时候,就会将 A 生成的 dom 节点缓存下来,当 B 组件卸载,A 组件再次渲染的时候,这个时候就会用上之前 A 组件生成的 dom 节点,与这次 A 组件渲染出的做 diff对比,这样看是不是很高效?可以看看 createComponnet:

export function createComponent(Ctor, props, context) {
  let list = components[Ctor.name],
    inst;
  if (Ctor.prototype && Ctor.prototype.render) {
    inst = new Ctor(props, context);
    Component.call(inst, props, context);
  }
  else {
    inst = new Component(props, context);
    inst.constructor = Ctor;
    inst.render = doRender;
  }
  if (list) {
    for (let i=list.length; i--; ) {
      if (list[i].constructor===Ctor) {
        inst.nextBase = list[i].nextBase;
        list.splice(i, 1);
        break;
      }
    }
  }
  return inst;
}

这里的 components 是缓存的卸载的组件集合。通过简单的判定,将生成的新组件的 nextBase 指向卸载的同类组件的 nextBase,其实也是后者 'base'了。

在创建组件之后是 setComponentProps:

export function setComponentProps(component, props, opts, context, mountAll) {
  if (component._disable) return;
  component._disable = true;
  // 将vnode传入的attribute/children/nodename.defaultProps里面的ref/key传给组件。
  if ((component.__ref = props.ref)) delete props.ref;
  if ((component.__key = props.key)) delete props.key;

  if (!component.base || mountAll) {
    if (component.componentWillMount) component.componentWillMount();
  }
  else if (component.componentWillReceiveProps) {
    component.componentWillReceiveProps(props, context);
  }

  if (context && context!==component.context) {
    if (!component.prevContext) component.prevContext = component.context;
    component.context = context;
  }

  if (!component.prevProps) component.prevProps = component.props;
  component.props = props;
  component._disable = false;
  if (opts!==NO_RENDER) {
    if (opts===SYNC_RENDER || options.syncComponentUpdates!==false || !component.base) {
      renderComponent(component, SYNC_RENDER, mountAll);
    }
    else {
      enqueueRender(component);
    }
  }
  if (component.__ref) component.__ref(component);
}

setComponentProps 里面现实执行组件的 componentWillMount/componentWillReceiveProps 方法。将 props 传给组件的 props。接着是进行 renderComponent 方法,这个时候传参已经是 component, opts, mountAll, isChild,没有vnode 了。renderComponent 方法在 setState 也有提到,是更新组件的最重要的步骤,而 renderComponent 关键点就是会修改组件的 base 也就是 dom,接下来看看 renderComponent 方法实现:

export function renderComponent(component, opts, mountAll, isChild) {
  if (component._disable) return;
  let props = component.props,
    state = component.state,
    context = component.context,
    previousProps = component.prevProps || props,
    previousState = component.prevState || state,
  previousContext = component.prevContext || context,
  // base和nextBase是 dom,或者undefined/null
    isUpdate = component.base,
    nextBase = component.nextBase,
    initialBase = isUpdate || nextBase,
    initialChildComponent = component._component,
    skip = false,
  rendered, inst, cbase;
  
  if (isUpdate) {
    component.props = previousProps;
    component.state = previousState;
    component.context = previousContext;
    // 通过shouldComponentUpdate判断是不是要执行更新
    if (opts!==FORCE_RENDER
      && component.shouldComponentUpdate
      && component.shouldComponentUpdate(props, state, context) === false) {
      skip = true;
    }
    else if (component.componentWillUpdate) {
      component.componentWillUpdate(props, state, context);
    }
    component.props = props;
    component.state = state;
    component.context = context;
  }

  component.prevProps = component.prevState = component.prevContext = component.nextBase = null;
  component._dirty = false;

  if (!skip) {
    // 渲染结果先。传入poprs,state。
    rendered = component.render(props, state, context);

    if (component.getChildContext) {
      context = extend(extend({}, context), component.getChildContext());
    }

    let childComponent = rendered && rendered.nodeName,
      toUnmount, base;
    // 如果render结果还是组件的话,继续render就好了,但是首次render要建立父组件和子组件关系。
    if (typeof childComponent==='function') {
      let childProps = getNodeProps(rendered);
      inst = initialChildComponent;
      // 说明执行过了,和上次渲染一样,inst是rendered的实例class。为再次进入的时候,要求是同一组件,key也要一样。
      if (inst && inst.constructor===childComponent && childProps.key==inst.__key) {
        // 对于组件更新,则重新获取其props,再来就好了
        setComponentProps(inst, childProps, SYNC_RENDER, context, false);
      }
      // 第一次进来的时候/不相同的时候
      else {
        toUnmount = inst;

    component._component = inst = createComponent(childComponent, childProps, context);
    // nextBase 的传递
        inst.nextBase = inst.nextBase || nextBase;
        inst._parentComponent = component;
        setComponentProps(inst, childProps, NO_RENDER, context, false);
        // 指明inst是子关系,不用重复继承和执行钩子函数与componentDidMount,因为已经可以了
        renderComponent(inst, SYNC_RENDER, mountAll, true);
      }

      base = inst.base;
    }
    else {
      cbase = initialBase;
      // 如果有component._component,说明上次里面生成的rendered是function,而cbase是该function生成的节点,
      // 在本轮中rendered已经不是function了,故需要设置子组件_component为null。
      toUnmount = initialChildComponent;
      if (toUnmount) {
        cbase = component._component = null;
      }

      if (initialBase || opts===SYNC_RENDER) {
        // 需要先置为null,在重新指向
        if (cbase) cbase._component = null;
        base = diff(cbase, rendered, context, mountAll || !isUpdate, initialBase && initialBase.parentNode, true);
      }
    }
  // 生成的dom和原本的dom不一样,并且子组件也不一样的情况下
    if (initialBase && base!==initialBase && inst!==initialChildComponent) {
      let baseParent = initialBase.parentNode;
      // 如果执行了上面else里的diff,那diff中initialBase,已经被rendered替代了,initialBase没有挂载在任何节点上了,并且parentNode为null了。
      // 所以下面的命令是针对上面typeof childComponent==='function'的情况的?但是在该情况的renderCompnent里面也会进入diff,
      // 所以是根本进不来的?
      if (baseParent && base!==baseParent) {
        // 这里是用本轮的base 替换掉上轮的base
        baseParent.replaceChild(base, initialBase);
        // 如果没有_component,为何要清理base?有base/nextbase,自然应该是要有_component的。
        // 已经replace了,为何还要remove base呢?
        if (!toUnmount) {
          // 防止在recollectNodeTree过程里面_component被unMounted,而是直接remove节点就好了
          initialBase._component = null;
          recollectNodeTree(initialBase, false);
        }
      }
    }
    // 发生的情况只能是rendered从组件变为普通vnode或者其他组件,所以要卸载掉子组件。
    if (toUnmount) {
      unmountComponent(toUnmount);
    }

    component.base = base;
    if (base && !isChild) {
      let componentRef = component,
        t = component;
      while ((t=t._parentComponent)) {
        (componentRef = t).base = base;
    }
    // 指明dom的_component 指向组件,而_componentConstructor指向组件的构造器
      base._component = componentRef;
      base._componentConstructor = componentRef.constructor;
    }
  }
  if (!isUpdate || mountAll) {
    mounts.unshift(component);
  }
  else if (!skip) {
    if (component.componentDidUpdate) {
      component.componentDidUpdate(previousProps, previousState, previousContext);
    }
    if (options.afterUpdate) options.afterUpdate(component);
  }
  if (component._renderCallbacks!=null) {
    while (component._renderCallbacks.length) component._renderCallbacks.pop().call(component);
  }

  if (!diffLevel && !isChild) flushMounts();
}

renderComponent 函数比较长。首先是第一个 if 语句里面,判断是否是 isUpdate,判断依据是有无 component.base,初次加载组件的时候,组件本身是没有 base 属性,最多才有 'nextBase' 属性,所以 isUpdate 用来区分是否是更新组件,如果是的话,执行组件的 shouldComponentUpdatecomponentWillUpdate 方法,并判断是否执行后面一长串的渲染。

rendered = component.render(props, state, context) 先是得出要渲染的 VNode,如果 rendered 还是组件,则还要进行判断是否前后两次渲染子组件不一样或则是初次渲染。如果不是组件则主要是执行 diff 函数,生成新的 dom 节点 base。 最后由于自组件变更或则消失,则卸载子组件。将生成的 base dom 节点指向组件的 base 属性,而 dom 节点还要新增 _component/_componentConstructor 属性。最后如果是初次加载则将组件放入初次加载组件的队列里面,准备执行 componentDidUpdate afterUpdate。底部的 flushMounts 则是,当是最顶部的一次 diff 递归进入尾声了,就执行 options.afterMount 和所有初次加载组件的 componentDidMount 方法。另外还有 component._renderCallbacks,在组件状态变化,也就是在用 setState 的时候,如果存在第二个参数 callback, 则会在这个时候执行 callback

回收机制

之前最早遇到回收问题是出现在 diff 函数上面,经常可以看到 recollectNodeTree(dom, true) 这句话,看看 recollectNodeTree 方法:

export function recollectNodeTree(node, unmountOnly) {
  let component = node._component;
  if (component) {
    unmountComponent(component);
  }
  else {
    if (node[ATTR_KEY]!=null && node[ATTR_KEY].ref) node[ATTR_KEY].ref(null);
    // 移除node节点
    if (unmountOnly===false || node[ATTR_KEY]==null) {
      removeNode(node);
    }
    // 再来一次遍历
    removeChildren(node);
  }
}
export function removeChildren(node) {
  node = node.lastChild;
  while (node) {
    let next = node.previousSibling;
    recollectNodeTree(node, true);
    node = next;
  }
}

这里可以看到,对于非组件,则可以要先执行节点的 ref,这个 ref是什么?在组件 setComponentProps 最后一行代码还有个 component.__ref。其实这两个都是一样的,都是节点 attribute 里面的 ref 属性,也就是说组件初次加载的时候,或则回收组件/Dom 的时候都会执行,如果不仅仅是卸载还会在父节点上移除 node。从而实现回收。最后的 removeChildren 只是遍历用的。基本上只要涉及到老节点的回收都会用到 recollectNodeTree 里面。在 innerDiffNode 最后也会对没有用的节点回收。除了节点 diff 的问题外,还有组件卸载的时候,也会调用 removeChildren 方法:

export function unmountComponent(component) {
  if (options.beforeUnmount) options.beforeUnmount(component);
  let base = component.base;
  component._disable = true;
  if (component.componentWillUnmount) component.componentWillUnmount();
  component.base = null;
  let inner = component._component;
  if (inner) {
    unmountComponent(inner);
  }
  else if (base) {
    if (base[ATTR_KEY] && base[ATTR_KEY].ref) base[ATTR_KEY].ref(null);
    component.nextBase = base;
    removeNode(base);
    collectComponent(component);
    removeChildren(base);
  }
  if (component.__ref) component.__ref(null);
}

上面可以看出卸载组件的时候,会调用 componentWillUnmount 方法,接着执行 ref ,将卸载的组件生成的节点转移到 nextBase 上面,再执行 removeChildren

最后还有一个贯穿所有 diff 机制的传参 mountAll,具体作用就算是组件更新,也将其作为初次加载,就是能执行 componentWillMount 与 componentDidMount 方法。

总结

这次的 Preact 之旅就到这里,代码虽少,但是还是活灵活现的展示了 diff 功能。源码里面有着无数行空行,以及代码解释,然而整体大小才 1000 行多点,min 之后更是只有 10kb 大小,相当袖珍。大家有机会还是去接触一下。

34. React Suspense data fetching 探究

最近 React 16.6 中提出了新组件 Suspense 允许 React 挂起组件渲染直到 IO 的数据返回。这个特性在 JSConf Iceland 中 Beyond React 16 里面 Dan 就介绍到了,并在半年后的今天登陆 React 16.6。

Suspense 简单用法

在官网教程里面介绍到 Suspense 与 React.lazy 结合做 Code-Spliting ,自然是可以这么用的,只是这更多的是代码分割,除了代码分割以外 IO 的处理,在 Beyond React 16 演讲中还提到了 data fetching,Dan 的第二个 demo 主要提到的也是 data fetching。下面先看看 Suspense 的一个简单的非代码分割的 demo:

const Img = lazy(async () => {
  await delay(2000);
  return {
    default: ImageResource
  }
})

const ImageResource = props => <img {...props} />;

class App extends Component {
  render() {
    return (
      <div className="App">
        <Suspense fallback={<div>Loading...</div>}>
          <Img src="https://www.baidu.com/img/bd_logo1.png?where=super" />
        </Suspense>
      </div>
    );
  }
}

在官网教程提到 React.lazy 要动态的调用 import 方式注入组件,返回一个 Promise。 同时 Promise 返回一个 default 的组件。所以就可以采用上面的方式,而不必用 import 方式,只是如此还是类似于代码分割,没有达到数据获取返回之前挂起组件的思路。

Dan 的例子

在 Dan 的演讲里面多处地方有用到 Suspense 的意识,也就是 createFetcher 与 this.deferSetState 方法。deferSetState 通过异步修改组件显示与否,当 createFetcher 传参 Promise 状态结束后才执行异步 deferSetState,显示已经接收到数据的组件。这就是挂起 Suspense 的作用了。在 React 16.6 里面没有提供 deferSetState,可能也是更多的异步操作留到 17.0 大版本上。而 Suspense 组件,已经可以实现 children 的挂起行为了。所以可以猜测,Suspense 组件作用就是 this.deferSetState

Dan 的 demo 里面,采用的 createFetcher 方法,类似于 simple-cache-provider 包 ,提供数据缓存控制。由于 Dan 的 demo 里的获取数据函数 Promise 没有写明具体的实现方式,不知道里面有没有什么巧门,还好在 demo 最后里有提到图片更新的 Promise,具体代码如下:

const imageFecther = createFether(
  (src) => new Promise(resolve => {
    const image = new Image();
    image.onload = () => resolve(src);
    image.src = src;
  })
)

function Img(props) {
  return (
    <img 
      {...props}
      src={imageFecher.read(props.src)}
    >
  )
}

这整个组件 Img 还是很简单的,若是平时的直接将 Promise 返回值到 img 标签的 src 将得到的是 [Object Promise],而不是 url。只是为何凭借一个 resolve,Suspense 组件就知道可以有数据进来,可以解除挂起状态,并让 src 得到想要的 url 嗯?

由于 simple-cache-provider 包已经不再更新而且在 react 项目里面找不到了,所以单独下载 simple-cache-provider 包。这个缓存控制包,代码在300+,主要 api 有 createCache、createResource 和 SimpleCache。上面 demo 中的代码采用 simple-cache-provider 包实现,就要用到 createCache 来创建缓存,createResource 来获取资源。简化一下 simple-cache-provider 包,替换 createCache 以及 createResource,其具体实现如下:

const cacheResourceSimple = {};

const createResourceSimple = function (miss) {
  return (resource, key) => {
    if (!resource[key]) {
      resource[key] = {
        key,
        value: false,
      }
      const _suspender = miss(key);
      _suspender.then((value) => {
        resource[key].value = value;
      });
      throw _suspender;
    } else {
      return resource[key].value;
    }
  }
}

const imgFetcher = createResourceSimple(
  src =>
    new Promise(resolve => {
      const img = new Image();
      img.onload = () => resolve(src);
      img.src = src;
    })
)

function Img (props) {
  return (
    <img 
      {...props}
      src={imgFetcher(cacheResourceSimple, props.src)} 
    />
  )
}

图片组件的挂起表现正常,基本上就实现 Dan 的挂起图片的功能了。

只是上面的代码为什么可以实现图片组件的挂起呢?明明 imgFetcher 函数返回的也是一个 Promise,最后 src 将得到的还是 [Object Promise] 呀。抱着这样的疑惑,打断点试了试,发现上面代码神奇之处在于 throw _suspender。在 simple-cache-provider 包里面的就是这样实现的,执行 _suspender 之后抛出错误。

在这里就要回到 react 源码了,当 throw _suspender 的时候,会被 renderRoot 方法捕获 catch ,进入 throwException 抛错函数来处理,而 throwException 里面
会判断 fiber 是否是 Symbol(react.suspense) 类型,并且挂起的组件非空,则会执行下面。

thenable.then(onResolveOrReject, onResolveOrReject);
var nextChildren = null;

简单的就是挂了个 then。当图片 resolve 之后,会执行 _suspender.then,接下来又会执行 thenable.then ,从而使得该组件在有数据返回的时候立刻异步渲染。后面的 nextChildren 置空,则是使得组件在数据没有返回前的渲染好像只是渲染 null 一样。具体 react 实现是复杂的,一环接一环的,这里就不做具体分析了。但是 抛出 createResourceSimple 的传参 Promise 给到 react 捕获到 ,才有了 suspense data fetching 的行为。

总结

实现 Suspense 组件的另外一个更大的作用 suspense data fetching 的方法,只需要将 IO 接口请求的异步函数当作错误抛出,就可以了。官网里面没有介绍到这个方式,可能也是考虑到功能还不完善?丑媳妇迟早要见公婆的。

参考

  1. Beyond React 16 Dan 的这个视频,看到前面的 CPU time slicing 就兴奋不已了,倒是忘记后面新功能。

44. nest 技术点

上文提到的初始化,还是有不少纰漏的地方,而且只是粗糙的介绍了初始化的过程,还有很多细节点没有介绍到。下面着重介绍一下:

循环引用

一般使用的时候是不推荐循环引用的,只是有的时候,需要用到循环引用,那要如何处理呢?按照官方介绍的是 forwardRef 函数来表示引用关系,比如 @Inject(forwardRef(() => CatsService)) 这样的方式。循环引用,包含正向引用和模块的引用,这里介绍一下正向引用,也就是依赖引用。

前文初始化中提到:resolveSingleParam 通过迭代获取了需要注入的依赖,需要传入的依赖通过 instances 传入到 callback,再在 callback中完成该 provider 的实例化,从而完成初始化。在循环引用里面,也是要通过 resolveSingleParam 来解决循环问题。而该方法下面,有两个功能:

public resolveParamToken<T>(
  wrapper: InstanceWrapper<T>,
  param: Type<any> | string | symbol | any,
) {
  if (!param.forwardRef) {
    return param;
  }
  wrapper.forwardRef = true;
  return param.forwardRef();
}

public async resolveComponentHost<T>(
  module: Module,
  instanceWrapper: InstanceWrapper<T>,
  contextId = STATIC_CONTEXT,
  inquirer?: InstanceWrapper,
): Promise<InstanceWrapper> {
  const inquirerId = this.getInquirerId(inquirer);
  const instanceHost = instanceWrapper.getInstanceByContextId(contextId, inquirerId);
  if (!instanceHost.isResolved && !instanceWrapper.forwardRef) {
    // 正常过程
    await this.loadProvider(instanceWrapper, module, contextId, inquirer);
  } else if (
    // 循环引用
    !instanceHost.isResolved &&
    instanceWrapper.forwardRef &&
    (contextId !== STATIC_CONTEXT || !!inquirerId)
  ) {
    instanceHost.donePromise &&
      instanceHost.donePromise.then(() => this.loadProvider(instanceWrapper, module, contextId, inquirer));
  }
  // 省略部分代码
  return instanceWrapper;
}

可以看到 resolveParamToken 里面由于存在 formward 的关系,InstanceWrapper 的 forwardRef 属性被设置为 true,并且拿到了 @Inject(forwardRef(() => CatsService)) 里面 CatsService 这个类。后面通过这个类名,找到对应的 instanceWrapper,并到达 resolveComponentHost 方法。在该方法的条件里面循环引用会走到 donePromise。只是这里第一个问题,前面提到的传入 resolveComponentHost 的 instanceWrapper 是注入项 CatsService,而引用方的 wrapper 设置了 forwardRef 的,但是注入项 CatsService 并没有,所以不可能到循环里面的。那这是为什么呢?

在 debug 的时候就发现代码执行一直是跳来跳去的,并不是阅读顺序上的从上到下。这是由于采用了多个 map 结构,比如下面的:

private async createInstancesOfProviders(module: Module) {
  const { providers } = module;
  await Promise.all(
    [...providers.values()].map(async wrapper =>
      this.injector.loadProvider(wrapper, module),
    ),
  );
}

对于 Promise.all 会遍历其下面的所有异步事件,只是里面的执行顺序是如何的呢?如果遇到异步里面有异步如何处理? 对于第一个异步事件,会执行里面的同步语句,直到遇到第一个 await 语句,会执行其里面的同步语句,若遇到 await再执行里面的同步部分,直到返回非异步语句,并开始执行 Promise.all 下的第二个异步语句,按这样的逻辑依次循环

回到前面,在 resolveParamToken 之后,由于异步返回的问题,会先执行其他遍历的异步语句到 resolveParamToken 之后,于是 CatsService 的 instanceWrapper 也被设置了 forwardRef

进入 donePromise.then 操作,要执行 then 需要有 resolve 的过程,但是 resolvecallback 里面执行,而随后则马上返回 CatsService 的 instanceWrapper。按照前文介绍的,依赖的注入,是不断的迭代传入实例的,但是这里本来是需要继续迭代的,现在进入了 Promise 里面,传递链被中断,要如何返回含有实例的 instanceWrapper ?这也是可以由上面的 Promise.all 的问题来解答,在一个遍历里面出现中断,执行 Promise.all 的下一个异步,从而使得对象 instanceWrapper.instance 能够异步的添加上实例。

后面遍历的时候已经获取到 CatsService 的实例了,在 callback 里面 resolve 的时候,则会回到前面的 donePromise.then 从而继续加载实例,由于存在 isResolved,这里就有第二个问题了,既然遍历的过程中已经创建实例了,为什么还有继续 donePromise.then 的过程呢?这里有个猜测:可能避免循环漏掉了的问题吧,只是具体用途也没有想出来。

除了正向引用,还有模块引用,也和正向引用有点类似,依赖于 forwardRef 写法。模块引用里面,采用的是缓存判断,如果缓存里面有该模块,则不会继续当前遍历。

typescript 在编译循环引用的参数时候,参数会被转为 undefined,是无法识别的。正向引用需要 inject 修饰器来添加额外的参数,来覆盖参数的 undefined,同样的模块引用也需要 forwardRef 来表示非直接循环,从而可以编译出正确的参数。(至于为什么 typescript 无法编译出循环参数,我就不晓得了。。。。)

注入作用域

这里虽然翻译是叫做注入作用域,但是,感觉更多的是隔离的作用。前文提到的依赖注入,有个特点,就是由依赖生成的实例,会被所有引用方共享。 这在大部分时候是没有问题的,只是有时可能有特别的需求,比如需要用到依赖生成实例的静态变量,导致依赖生成的实例共享就会被相互污染。于是就有了注入作用域的概念,字面理解:注入的依赖有自己的作用域,而不会在所有需要的类**享。

具体的使用方法:

@Injectable({ scope: Scope.REQUEST })
export class CatsService {}

Injectable 里面的传参只可以配置 scope 字段或者不传,表示 CatsService 的作用域,不传的话,默认则是在所有类**享依赖的实例。常见的配置有:

  1. DEFAULT 依赖的实例在所有需要的类**享;
  2. TRANSIENT 在每个需要依赖的类中,单独传递实例,而不与其他类共享,为单例模式;
  3. REQUEST 依赖的实例不会在初始化中生成,而是在每次请求的时候,都会重新生成对应实例,并在该请求的所有类**享该实例;

先看看 TRANSIENT 模式,按照前文说的依赖注入的方式,一旦一个依赖被标记为 isResolved,其实例就已经是生成的了,下次还需要该依赖的时候,则直接用已经生成的实例。TRANSIENT 模式在第一个依赖生成的时候,就和默认方式不一样了。

public async loadInstance<T>(
  wrapper: InstanceWrapper<T>, collection: Map<string, InstanceWrapper>,
  module: Module, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper,
) {
  const inquirerId = this.getInquirerId(inquirer);
  const instanceHost = wrapper.getInstanceByContextId(contextId, inquirerId);

  if (instanceHost.isPending) {
    return instanceHost.donePromise;
  }
  const done = this.applyDoneHook(instanceHost);
  const { name, inject } = wrapper;
  const targetWrapper = collection.get(name);
  if (isUndefined(targetWrapper)) {
    throw new RuntimeException();
  }
  if (instanceHost.isResolved) {
    return done();
  }
  const callback = async (instances: unknown[]) => {
    const properties = await this.resolveProperties(wrapper, module, inject,contextId, wrapper, inquirer);
    const instance = await this.instantiateClass(instances, wrapper, targetWrapper, contextId, inquirer);
    this.applyProperties(instance, properties);
    done();
  };
  await this.resolveConstructorParams<T>(wrapper, module, inject, callback, contextId, wrapper, inquirer);
}

public getInstanceByContextId(contextId: ContextId, inquirerId?: string) {
  if (this.scope === Scope.TRANSIENT && inquirerId) {
    return this.getInstanceByInquirerId(contextId, inquirerId);
  }
  // 如果不是 TRANSIENT 则会返回静态实例,也就是通用实例
  const instancePerContext = this.values.get(contextId);
  return instancePerContext
    ? instancePerContext
    : this.cloneStaticInstance(contextId);
}

这里有个细节地方,在加载实例的通用入口 loadInstance 里面,getInstanceByContextId 方法会判断是否是 TRANSIENT 模式,如果是,则会进入 getInstanceByInquirerId 根据引用方类来获得实例,自然不同的。getInstanceByInquirerId 正如名字,通过引用方,也就是 InquirerId 来获取实例,getInstanceByContextId 则是通过上 ContextId 来获取实例,而 ContextId 默认是静态实例的 id,也就是 1。在 getInstanceByInquirerId 里面会通过 InquirerId 在通过获取引用方的实例集合,再通过 contextId 获得最后的实例,这个就是 TRANSIENT 的特色,依赖通过实例的引用方的 InquirerId,再通过 ContextId 来获取,所以不同的类,注入相同的依赖类,实例的时候,也会获取到不同的依赖实例

getInstanceByContextIdgetInstanceByInquirerId 若无法根据 contextId 获得实例,都会有一个克隆实例的机制,getInstanceByContextId 里面是 cloneStaticInstance,这里看看 getInstanceByInquirerId 返回的 cloneTransientInstance

public cloneTransientInstance(contextId: ContextId, inquirerId: string) {
  const staticInstance = this.getInstanceByContextId(STATIC_CONTEXT);
  const instancePerContext: InstancePerContext<T> = {
    ...staticInstance,
    instance: undefined,
    isResolved: false,
    isPending: false,
  };
  if (this.isNewable()) {
    instancePerContext.instance = Object.create(this.metatype.prototype);
  }
  this.setInstanceByInquirerId(contextId, inquirerId, instancePerContext);
  return instancePerContext;
}

可以看到返回的 instancePerContext 是一个新对象,对象里面的 instance 实例已经为 undefined,并且 isResolvedisPendingfalse;这样若一个依赖已经被实例过了,当别的类需要这个依赖的实例,遍历的时候就会进入 loadInstance 函数,并克隆实例,从而获得新的实例对象可以继续遍历。

注入作用域之 REQUEST

REQUEST 模式不会在一开始初始化的时候就实例好所有的依赖,而且是在请求的时候去实例化依赖。DEFAULTTRANSIENTREQUEST 都会在初始化的时候创建路由,但是 REQUEST 是在发生请求的时候,再创建新的上下文环境,每个请求都是新的。在创建路由的时候,会根据 isDependencyTreeStatic 的返回,来判断是不是要实现 REQUEST 的路由:

public isDependencyTreeStatic(lookupRegistry: string[] = []): boolean {
  if (!isUndefined(this.isTreeStatic)) {
    return this.isTreeStatic;
  }
  // 为 REQUEST 模式,则 isTreeStatic 为 false
  if (this.scope === Scope.REQUEST) {
    this.isTreeStatic = false;
    return this.isTreeStatic;
  }
  if (lookupRegistry.includes(this[INSTANCE_ID_SYMBOL])) {
    return true;
  }
  lookupRegistry = lookupRegistry.concat(this[INSTANCE_ID_SYMBOL]);

  const { dependencies, properties, enhancers } = this[
    INSTANCE_METADATA_SYMBOL
  ];
  let isStatic =
    (dependencies &&
      this.isWrapperListStatic(dependencies, lookupRegistry)) ||
    !dependencies;

  if (!isStatic || !(properties || enhancers)) {
    this.isTreeStatic = isStatic;
    return this.isTreeStatic;
  }
  // 省略下面的代码
}

isDependencyTreeStatic 计算是否是静态实例,比如 DEFAULTTRANSIENT 模式。上面还可以看到 isWrapperListStatic 这个功能,如果一个类注入了依赖,若依赖是 REQUEST 模式,则这个类也会是 REQUEST 模式,从而实现作用域的传递。

这个 isDependencyTreeStatic 在初始化的时候,其实就已经调用了,在 instantiateClass 里面的 isStatic 方法就通过 isDependencyTreeStatic 来判断是否是静态实例,如果是则 instantiateClass 会 new 一个实例。

下面看一下 REQUEST 下路由处理函数的机制:

public createRequestScopedHandler(
  instanceWrapper: InstanceWrapper, requestMethod: RequestMethod,
  module: Module, moduleKey: string, methodName: string,
) {
  const { instance } = instanceWrapper;
  const collection = module.controllers;
  return async <TRequest, TResponse>(req: TRequest, res: TResponse, next: () => void) => {
    try {
      const contextId = this.getContextId(req);
      this.container.registerRequestProvider(req, contextId);

      const contextInstance = await this.injector.loadPerContext(
        instance, module, collection,contextId);
      await this.createCallbackProxy(
        contextInstance, contextInstance[methodName], methodName,
        moduleKey, requestMethod, contextId, instanceWrapper.id,
      )(req, res, next);
    } catch (err) {/*省略部分代码*/}
  }
}

ContextId 默认是静态 id,在 REQUEST 模式下,若发生请求的时候,则是当前请求的 ContextId,若没有则创建一个随机数 Math.random() 普通的请求最后都会创建一个随机数,至于其他的情况就不明确了,只是随机数还是有可能重复的,当然官方有介绍到由于采用的 WeakMap,里面的 key 是个对象,什么对象呢?{ id: 1 }。里面的 id 可能会重复,但是 key 已经是个不同的对象,所以就算是重复请求同一个路径,key 是不同的对象,那 ContextId 就是安全的。

loadPerContext 方法则是加载实例调用的,还是 loadInstance 方法,然后经过 getInstanceByContextId 又是一个全新的 instanceWrapper,里面的 instance 实例已经为 undefined,并且 isResolvedisPendingfalse,于是又可以继续迭代下去。最后 createCallbackProxy 则和普通的模式一样了。

还有复杂的,比如 REQUESTTRANSIENT 结合,与循环依赖结合等等,这些复杂的情况就不一一讨论了,正常人都不会这么用的。。。。

中间件

在初始化的过程中,其实省略了中间件是如何加入应用,并结合路由的过程,只是简单描述了中间件添加到配置中。主要也是这部分没有什么特别的,顺着代码下去就能明白,这介绍一下最后的环节:

private async bindHandler(
  wrapper: InstanceWrapper<NestMiddleware>, applicationRef: HttpServer,
  method: RequestMethod, path: string, module: Module,
  collection: Map<string, InstanceWrapper>,
) {
  const { instance, metatype } = wrapper;
  if (isUndefined(instance.use)) {
    throw new InvalidMiddlewareException(metatype.name);
  }
  const router = applicationRef.createMiddlewareFactory(method);
  const isStatic = wrapper.isDependencyTreeStatic();
  if (isStatic) {
    const proxy = await this.createProxy(instance);
    return this.registerHandler(router, path, proxy);
  }
  // ..。省略后面代码
}

private async createProxy( instance: NestMiddleware, contextId = STATIC_CONTEXT) {
  const exceptionsHandler = this.routerExceptionFilter.create(
    instance, instance.use, undefined, contextId,
  );
  const middleware = instance.use.bind(instance);
  return this.routerProxy.createProxy(middleware, exceptionsHandler);
}

private registerHandler(
  router: (...args: any[]) => void, path: string,
  proxy: <TRequest, TResponse>(req: TRequest, res: TResponse, next: () => void) => void,
) {
  const prefix = this.config.getGlobalPrefix();
  const basePath = validatePath(prefix);
  if (basePath && path === '/*') {
    path = '*';
  }
  router(basePath + path, proxy);
}

通过 createProxy 创建的代理返回的 this.routerProxy.createProxy 和初始化路由最后实现代理的方式一致,而 router 的实现 applicationRef.createMiddlewareFactory(method) 和初始化路由里面的路由方法创建 const routerMethod = this.routerMethodFactory.get(router, requestMethod).bind(router); 是一模一样的。

于是可以发现中间件的注册,其实和普通路由的注册一样,只是路由的实现是通过对应的 method,而中间是通过 use 方法。最后当请求发送过来的时候,先依次通过这些中间件处理,在 next() 下流转,最后才到路由处理函数。本质还是用到 express 中间件的方法。

32. react之Reconciliation

React 的特点在于其异步渲染,fiber机制,是其他类 react 框架无法比拟的。前面介绍了些很基本的异步渲染。接下来介绍一下传说中的 diff 算法吧。其实这个在 React 官方文档 Reconciliation 里面早有介绍(advanced guide 里面的内容很多初级 React 工程师应该都没有看过,然而 advanced guide 里面包含了 context、错误边界、HOC、render props 以及 Reconciliation,没有看过的还请多刷几遍)。其中两大基准假设如下

  1. Two elements of different types will produce different trees.
  2. The developer can hint at which child elements may be stable across different renders with a key prop.

这两个准则很好的优化树的对比算法,遇到不同类型的元素自然是会生成不一样的树,而通过 key 这个 react 特性的属性可以来判断新旧元素是否是相似的,来减少优化步骤。

reconcileChildFibers

diff 的过程其实就是 reconciliation 的过程,在生成 workInProgress tree 的时候,新 fiber 的生成就取决于老 fiber 与相关 props。

元素的更新都需要有对应的子 fiber。下面以 fiber.tag 为 HostComponent 来举例子,HostComponent 指的就是一般的 'div' 'span' 等等常见的 HTML 标签。生成/更新子 fiber 的方式如下:

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime,
): Fiber | null {
  const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }
  // 新child是否为非空对象,
  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // 对于非空对象的情况
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 普通的 div/span 等元素其 $$typeof 都是 REACT_ELEMENT_TYPE
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            expirationTime,
          ),
        );
      case REACT_PORTAL_TYPE:
        // react 新API portal 组件
        return placeSingleChild(
          reconcileSinglePortal(
            returnFiber,
            currentFirstChild,
            newChild,
            expirationTime,
          ),
        );
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 如果child是文本或者数字,就直接替换就好了,fiber都不用生成
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        expirationTime,
      ),
    );
  }

  if (isArray(newChild)) {
    // 对于数组的情况, react 新支持的 <></> 等方式,元素包含多子元素也是数组
    return reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      expirationTime,
    );
  }

  if (getIteratorFn(newChild)) {
    // 需要迭代,这里先不管。
    return reconcileChildrenIterator(
      returnFiber,
      currentFirstChild,
      newChild,
      expirationTime,
    );
  }

  if (isObject) {
    throwOnInvalidObjectType(returnFiber, newChild);
  }
  // 省略部分抛错提示处理
  // 剩下的都当作空处理,也就是 delete 掉
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

分类处理 newChild,并生成不同的 fiber。child 包括以前不兼容的 array 的形式。对于 HostComponent 而言,其 newChild 是 jsx 解析后的对象,type 为盒子类型,props 为其属性,包括 children,子元素。前面只是分类处理,而 reconcileSingleElement 才是生成 diff 的关键。

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  expirationTime: ExpirationTime,
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  while (child !== null) {
    if (child.key === key) {
      // newChild 的 key 是否和 currrent 的 child.key 一样?
      if (
        child.tag === Fragment
          ? element.type === REACT_FRAGMENT_TYPE
          : child.type === element.type
      ) {
        // key 一样外,type 必须也是一样的哦。
        deleteRemainingChildren(returnFiber, child.sibling);
        const existing = useFiber(
          child,
          element.type === REACT_FRAGMENT_TYPE
            ? element.props.children
            : element.props,
          expirationTime,
        );
        existing.ref = coerceRef(returnFiber, child, element);
        existing.return = returnFiber;
        return existing;
      } else {
        deleteRemainingChildren(returnFiber, child);
        break;
      }
    } else {
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  const created = createFiberFromElement(
    element,
    returnFiber.mode,
    expirationTime,
  );
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

从 current 里面找到对应的 key,也就是老元素的 key,如果新老元素的 key,以及 type 是一样的,则认为是同一个元素发生更新,这个时候会直接创建和之前一样的 fiber,包括 stateNode 实例也是一样的,其 alternate 则为 current。只是会传入新的 props,给到生成的 fiber。对比先后生成情况可以发现:

  1. 处理 newChild 的时候,可以直接复用之前 current.alternate,修改其 pendingProps 以及 effect list 就可以了,而不是新建一个 fiber。同时可以直接复用 stateNode,只要后面再做更新处理就好,远比新生成 stateNode,开销要小。
  2. 对于更新的当前 fiber,由于存在 alternate。所以沿着 workInProgress tree 的子级时候,就可能存在 current.child 和新生成 fiber 的 newChild 做对比,又是一个 diff 的过程了。反之,如果一开始不配,直接生成新的 fiber,则下轮的时候,没有对应的 current,又是生成新的 fiber,其开销要比 diff 多很多。
  3. 节省内存不会反复创建 fiber。

可以看到这里符合上面的两个基本点,相同类型的 type 才复用 fiber,否则,删除掉,并重新创建 fiber。而对于单元素而言,相同 type 还是不够的,必须相同 key,值得一提的是,没有设置 key 的时候,key 为 null,所以还是相同 key 值。但是对于复杂情况,如多元素 key 的判断,则要依赖于数组,也就是 reconcileChildrenArray 方法。

reconcileChildrenArray

对于有 key 的情况,按照官网介绍的 reconciliation keys。通过设置 key 可以有效的保留之前元素,而不是每次都去对比考虑新建/修改一个元素。但是 key 的分布可以是散列,没有规律的。react 又要如何识别呢?通过建立一个 map 的结构就可以快速识别。

function mapRemainingChildren(
  returnFiber: Fiber,
  currentFirstChild: Fiber,
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild;
  while (existingChild !== null) {
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    existingChild = existingChild.sibling;
  }
  return existingChildren;
}

这里将以前的 old fiber 通过建立 Map 的方式,搭建一个字典对象,当新元素需要对比的时候,先瞅一瞅 existingChildren 里面有没有对应的 key,当然 type 也必须要是一样的。另外除了 key 之外,children 里面的排序也很重要。当没有 key 的时候,existingChildren 的键名将会是 index。在处理 key 的时候也是要小心不和 index 混淆了。创建 fiber 的时候,每个 fiber 都有 index 字段。数组的 children 转为 fiber 的时候,元素在 children 里面的顺序就是其 index。所以对于数组的情况,如果没有设置 key,通过 index 字段,react 也可以识别是不是同一个 fiber 的更新,但是 index 是有序的,这也照就很多不变,插入删除一个都会导致后面的全部更新。key 的设置还是很重要的。

react 里面对于数组元素,例如通过 map 遍历返回的,都是要有 key 的标识,这样可以提高下次渲染的重用,提高其性能。

在 preact 里面也是类似的。将所有 old 元素循环下来,通过 keyed 保存对应的 key 和 child,在对新的元素做 key 判断,从而找到对应的元素。

completeWork

这里会对上面 fiber 的 stateNode 更新,和之前。react 源码下一步 里面的 diff 属性就有介绍到。前文是 diff fiber,这里是 diff stateNode。

到最后就是 commitRoot 里的三大循环了。

effectList

在 fiber 的 completeUnitOfWork 里面会收集所有的有 side-effect 的 fiber,将其通过 nextEffect 字段联系起来,成为一个单向的链表。其顺序是先子节点后父节点,只有有 side-effect 的节点才会被添加进来。也就是是说如果父节点 fiber 没有 side-effect,就是没有变动的话,是不会出现在 effectList 链表里面。

A是祖节点,B/C 为父节点,D/E/F 为子节点,蓝色线为父子关系,绿色线为 effectList 关系。当 D/E/F 有变化,而 B/C 没有变化的时候,形成的 effectList 如左图所示,这里 A.firstEffect 为 F,A.lastEffect 为 D,D/E/F 形成 nextEffct 关系。于是 commit 阶段的时候,只是对 D/E/F 做处理,而 B/C 是没有的。也是优化更新,实现局部更新的方式。

对于右图,则是当 B 也发生变化的时候,这个时候 A.lastEffect 为 B,D.nextEffect 为 B。父节点是在子节点之后出现的。通过这样的 effectList 的构建,形成单向的链表,而不是多层次的递归。

总结

React 的 diff 算法里面体现更多的是复用的概念,往往更新只是一小部分的,但是 react 的循环却是从头到尾一直进行的。通过两棵新老 fiber tree 的对比,可以有效避免创建多余的 fiber,提高其性能。对于日常开发的我们更重要的是牢记开头说的两点

  1. Two elements of different types will produce different trees.
  2. The developer can hint at which child elements may be stable across different renders with a key prop.

5. You-Dont-Know-JS之作用域与闭包

前言

读的书多了,渐渐也能有自己的体会,书有平庸之作,也有佳作,但有些时候我觉得更多要看人。
在看了Vue和backbone的源码后,越发感觉自己的代码水平有待提高,但常见的各种动物书都看过了,一时间不知道看啥书好。于是在傻乎逛了一圈后,发现《你不知道的JavaScript》这本书,严格来说是上卷,很早以前就听大名,仿佛和高程三齐名,只是一直以没有时间为理由,没有接触。巧的是在傻乎上发现《你不知道的JavaScript》已经在github上出了中文版,而且是全套完整的,幸福来的太突然。细读之,颇有收获,故在此分享。

编译器

文中一开始就讲到编译器理论,离开大学后就没有接触过这东西,再次看到,觉得很nice;
编译过程通常来说分为三步:1.分词/此法分析,这个有点类似搜索引擎里面的拆字; 2.解析,将步骤一生成的代码片段表示为一个抽象语法树;3.生成代码就是将抽象语法树转化为可执行代码;

正如上文说所,引擎在执行代码前,代码会先经过编译器编译;如var a = 2,编译过程会出现编译器和作用域之间的互动,编译器在遇到var a的时候会通过作用域判断有无该声明,没有就声明一个a变量,有就略过;而代码编译后执行的情况,则是引擎向作用域请求,去寻找a变量,再进行赋值操作;就这样var a = 2被分成两步操作。
看到这里,你大概就发现作用域在里面扮演的中间角色,编译过程中将变量参数创建在作用域内,而执行阶段引擎通过访问作用域得到变量,并进行其他操作;分工是如此的明!!作用域有点像数据库的味道,被互相用来用去。。。。文中还提到了LHS和RHS查询,LHS查询简而言之赋值操作,RHS则是取值操作;如console.log(a)这里面就涉及到两个RHS,console的查找和a的RHS引用。
提到LHS和RHS可以加深我们对编译器,引擎和作用域三者之间关系的理解,书中还有趣的提到三者之间聊天的情况,值得玩味;关于LHS和RHS,还涉及到了引擎抛出的错误ReferenceErrorTypeError,前者正如字面解释引用错误,当RHS查询的时候,若在作用域没有发现这个变量,就抛出异常;相同的如果作用域解析成功了,但是进行非法操作,就会TypeError

词法作用域,函数与块儿作用域

在词法作用域里面提到了evalwith这些被好多人提到的黑暗操作,本以为一辈子都见不到了,没想到在自家项目上居然看到有人用。。。立马delete掉;eval这些是很灵活,但是它会大大的影响编译过程中的优化,因为eval这些的出现会导致编译中产生的词法作用域无效:

但如果 引擎 在代码中发现一个 eval(..) 或 with,它实质上就不得不 假定 自己知道的所有的标识符的位置可能是无效的,因为它不可能在词法分析时就知道你将会向eval(..)传递什么样的代码来修改词法作用域,或者你可能会向with传递的对象有什么样的内容来创建一个新的将被查询的词法作用域。

IIFE立即调用函数表达式,最开始接触的时候是在看jQuery里面用到的,这样可以把自己声明的变量和外界隔离开来,避免环境污染;let和const带来的块级作用域,自然是无可辩驳的,以前全局作用域和函数作用域,现在已经基本不用var只用let和const了。。。。。var重复声明就直接覆盖,而且变量提升还会带来undefined的覆盖;以下面例子

var tmp = new Date();

function f() {
  console.log(tmp);
  if (false) {
    var tmp = 'hello world';
  }
}

f(); // undefined

输出结果将会是undefined,而使用块级作用域就很不一样了

提升,作用域闭包

变量提升,在之前的博客中也有提到过,书中再次用编译的**解释。对于变量声明,编译中自然是在的目的之一就是在作用域创建该变量,后面才轮到引擎执行可执行语句,于是产生声明提升,执行的时候才有初始化,正如下面代码

foo(); // 不是 ReferenceError, 而是 TypeError!

var foo = function bar() {
    // ...
};

提升部分有兴趣可以看看之前一篇博客:void 0 以及let const var 的理解

闭包的概念就有点老生常谈了,如今ES6有了块作用域的概念,一切都好办多了,模块间的import和export语法,也让代码运用更加灵巧,只是结合这本书出版时间,不难发现,在以前这概念可是相当新颖的;

6. You-Dont-Know-JS之this 与对象原型

前言

遥记得以前刚开始写JavaScript的时候,起步就是jQuery,用的也简单,后来想学习原生的JavaScript,刚上来就遇到this的问题,每次都要去看阮老师那篇2010年写的博客,再不就去点开那篇一直珍藏着的JavaScript 秘密花园,每次看完都以为知道,但是后来遇到了总是要再回去一下,直到之前面试,以防万一又看了一遍。。。。。。书中提及的this,在这里更是要总结一下

谈谈this

function foo() {
    var a = 2;
    console.log(this.a)
}

foo() //undefined

上面的代码简单吧,相信大部分人都会认为打印出来的是2,但是结果却是undefined,难道this不应该指向自己吗?英语白学了不成。。。。。well,讲真,这里的this不是指向foo函数的,而是全局作用域,就是window,咦?为啥?或许你把foo函数的调用点,foo()理解为window.foo()就能豁然开朗了,那a自然是window的a。正如上面所提到的foo,它没有什么乱七八糟的修饰,而且调用点在全局,所以自然是全局的a变量,如果写成一下方式就是:

function foo() {
    var a = 2;
    console.log(this.a)
}
function bar(){
    this.a = 3; 
    foo()
}

bar() //3

得到的值自然是3,虽然也是window.bar(),但是调用点在bar函数内部呀。

上面提到的就是默认绑定,简单的方法就是查看调用点,根据调用点来判断作用域;一般都是被解析成全局变量;书中提到的this绑定有还有隐含绑定,明确绑定和new绑定

隐含绑定其实上文多少都提到过,如下代码:

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2

这里的调用点使用了obj的环境来引用,so,this自然就指向obj,这个有点类似上文提到过的window.foo(),这一类通过调用点也是可以清晰知道this的指向;

明确绑定,这里就是指显式的设置this了,用apply和call的方法,明确的指出要绑定的上下文
最后一种就是new绑定,这个涉及到new操作,new操作符首先会创建一个对象,并绑定上this,除非放回其他的对象,否则放回new新建的对象,如下文所说:

只要 new 表达式之后的 constructor 返回(return)一个引用对象(数组,对象,函数等),都将覆盖new创建的匿名对象,如果返回(return)一个原始类型(无 return 时其实为 return 原始类型 undefined),那么就返回 new 创建的匿名对象。链接

讲了这几个this之后你大概就知道怎么用了吧,另外还有箭头函数的this问题,这也是个值得注意的点,箭头函数的this就是定义时候所在的对象,而不是使用时候,并且this的指向是不可变的

对象

书中提到了对象,还有defineProperty的使用,以及class的使用,这些都是JSer基本要掌握的,这里就不介绍了,尤其实在ES6出来之后。
但是文中提到用object.create(),使用如下Bar.prototype = Object.create( Foo.prototype ),在原型继承里面一般我们都是用Bar.prototype = new Foo()居多,问题也提到,这样做会出现一些意料之外的副作用,well,有时候我们就是要用到这些副作用呢。。。。

20. preact源码 - diff机制

前言

每次看到有人谈起 React 的 diff 机制的时候,总觉得很厉害的样子,所以自然这里也是立马就想介绍 diff 机制。

diff 机制

以下面为例子来介绍:

import { h, render } from 'preact';

render((
  <div id="foo">
  <span>Hello, world!</span>
  <button onClick={ e => alert("hi!") }>Click Me</button>
  </div>
), document.body);

render 方法的实现如下:

import { diff } from './vdom/diff';

export function render(vnode, parent, merge) {
  return diff(merge, vnode, {}, false, parent, false);
}

这里面 merge 是需要对比的 VNode 节点,vnode 就是传入节点,parent 则是挂载的节点。可以发现传入到 render 方法里面,最终还是会调用 diff 方法。看看 diff 的实现:

export function diff(dom, vnode, context, mountAll, parent, componentRoot) {
  // 初始化的时候才进入,每次进入diff函数都会自增,递归的level
  if (!diffLevel++) {
  // SVG处理,判断是否是在SVG里面diff。
    isSvgMode = parent!=null && parent.ownerSVGElement!==undefined;
    // 只有dom存在,且DOM没有__preactattr_属性,才为true;一般也就是初次进来的时候
    hydrating = dom!=null && !(ATTR_KEY in dom);
  }

  let ret = idiff(dom, vnode, context, mountAll, componentRoot);
  // 挂载生成ret到parent去,也就是document.body
  if (parent && ret.parentNode!==parent) parent.appendChild(ret);

  if (!--diffLevel) {
    hydrating = false;
    // 执行options.afterMount方法,和所有初次挂载的组件的componentDidMount方法
    if (!componentRoot) flushMounts();
  }

  return ret;
}
export function flushMounts() {
  let c;
  while ((c=mounts.pop())) {
    if (options.afterMount) options.afterMount(c);
    if (c.componentDidMount) c.componentDidMount();
  }
}

**render 传参 merge,也就是 diff 方法传参 dom,是用来和 vnodediff 的前节点。**可以看到上面 diff 方法主要作用是生成 ret,并将其挂载到 parent 上面去,并在最顶部的递归层,一般 componentRootundefined/false,可以执行所有已经加载的组件的 componentDidMount 方法。 idiff 的实现如下:

function idiff(dom, vnode, context, mountAll, componentRoot) {
  let out = dom,
    prevSvgMode = isSvgMode;
  // 如果vnode是空,直接处理为''
  if (vnode==null || typeof vnode==='boolean') vnode = '';
  // 若果vnode是字符串或则数字,便捷方式
  if (typeof vnode==='string' || typeof vnode==='number') {
    // 通过splitText方法来判断是不是文本节点。若果dom是文本,就直接直接dom的nodeValue替换为 vnode
    if (dom && dom.splitText!==undefined && dom.parentNode && (!dom._component || componentRoot)) {
      if (dom.nodeValue!=vnode) {
        dom.nodeValue = vnode;
      }
    }
    else {
      // 如果dom不是文本节点,就创建vnode的文本节点,并在dom的parent上替换掉dom。
      out = document.createTextNode(vnode);
      if (dom) {
        if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
        recollectNodeTree(dom, true);
      }
    }
  out[ATTR_KEY] = true;
  // 这里的out是个dom节点,而不是VNode
    return out;
  }
  // 如果传入的 vnode 是函数,也就是class实例,是个组件,就返回buildComponentFromVNode的执行结果
  let vnodeName = vnode.nodeName;
  if (typeof vnodeName==='function') {
    return buildComponentFromVNode(dom, vnode, context, mountAll);
  }
  isSvgMode = vnodeName==='svg' ? true : vnodeName==='foreignObject' ? false : isSvgMode;
  vnodeName = String(vnodeName);
  // 将vnode里面填充上dom的子元素,只有dom存在且为文本的时候才不进入。
  if (!dom || !isNamedNode(dom, vnodeName)) {
    // 生成 vnodeName 的元素节点
    out = createNode(vnodeName, isSvgMode);
    if (dom) {
      // 将dom里面的节点都添加到out里面
      while (dom.firstChild) out.appendChild(dom.firstChild);
      // 最后直接用out替换掉dom
      if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
      recollectNodeTree(dom, true);
    }
  }

  let fc = out.firstChild,
    props = out[ATTR_KEY],
    vchildren = vnode.children;
  // 给dome节点,将attributes属性添加到__preactattr_属性里面
  if (props==null) {
    props = out[ATTR_KEY] = {};
    for (let a=out.attributes, i=a.length; i--; ) props[a[i].name] = a[i].value;
  }
  // hydrating为false的时候,若vnode只有一个节点string就直接替换掉out的第一个子节点。自然out后面的其他节点都会被除去
  if (!hydrating && vchildren && vchildren.length===1 && typeof vchildren[0]==='string' && fc!=null && fc.splitText!==undefined && fc.nextSibling==null) {
    if (fc.nodeValue!=vchildren[0]) {
      fc.nodeValue = vchildren[0];
    }
  }
  // 只要有vchildren和out有子节点就来innerDiffNode,
  else if (vchildren && vchildren.length || fc!=null) {
    innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null);
  }
  //把vnode的属性都传到out,还有props吧。
  diffAttributes(out, vnode.attributes, props);
  //还原之前的isSvgMode值
  isSvgMode = prevSvgMode;

  return out;
}

这里的 idiff 方法,看着比较复杂,实际上还是对传参 vnode 进行分类判断,分为下面几种情况:

  1. 简单的类型 string/number 之类的,直接用 vnode 替换掉 dom 元素,返回 vnode 的文本节点。
  2. vnode 是 Component的形式,调用 buildComponentFromVNode 方法,最后会返回处理过的 dom 节点。
  3. vnode 只有一个节点,且为文本,并且 dom 情况也是一样的,就替换掉 nodeValue。如若不是则调用 innerDiffNode 方法,来 diff dom 和 vnode。

这里面第一种情况是最基础的,vnode 是文本,就要替换掉对应的 dom,第二种情况是组件的方式,这里先不谈。第三种比较麻烦,是多个子节点情况,如若 dom 存在并且为文本节点,out 变量就是 dom 这个文本节点,否则 out 会是 vnodeName 的元素空节点,随后将dom 子节点转移到 out下面。接着设置 out__preactattr_ 属性。

在第三种情况时,对于前一种简答情况,如果 dom<div>123</div>,而 vnode 的 children 属性为文本的话,例如:vnode = {nodeName: 'SPAN', Children: ['sb'].....},则生成的 out<span>sb</span>,这种是简单的情况。复杂情况下需要调用到 innerDiffNode 方法。在介绍 innerDiffNode 之前,先看看 idiff 方法最下面的 diffAttributes 方法:

function diffAttributes(dom, attrs, old) {
  let name;
  for (name in old) {
    if (!(attrs && attrs[name]!=null) && old[name]!=null) {
      setAccessor(dom, name, old[name], old[name] = undefined, isSvgMode);
    }
  }
  for (name in attrs) {
    if (name!=='children' && name!=='innerHTML' && (!(name in old) || attrs[name]!==(name==='value' || name==='checked' ? dom[name] : old[name]))) {
      setAccessor(dom, name, old[name], old[name] = attrs[name], isSvgMode);
    }
  }
}

diffAttributes 方法就是将 vnode 里面 attributeprops 属性添加到 out 里面,最后返回的是 out 元素而不是 vnode!setAccessor 基本就是些条件语句,根据出入的属性名,来分类处理,看看就好了。就这样将 vnode 里面的 attribute 属性添加到 out 里面

innerDiffNode

idiff 第三种情况的复杂情况下下会调用 innerDiffNode 方法,实际上就是对 vnode 的子元素和 out 的子元素进行递归对比。先看看 innerDiffNode 的实现:

function innerDiffNode(dom, vchildren, context, mountAll, isHydrating) {
  let originalChildren = dom.childNodes,
    children = [],
    keyed = {},
    keyedLen = 0,
    min = 0,
    len = originalChildren.length,
    childrenLen = 0,
    vlen = vchildren ? vchildren.length : 0,
    j, c, f, vchild, child;
  // 对比的dom有children的时候
  if (len!==0) {
    for (let i=0; i<len; i++) {
      let child = originalChildren[i],
        props = child[ATTR_KEY],
        // key就是指平时写map循环的时候,数组里面的子vnode用来区分的key。如果child由component生成,则用component的__key。否则用props传入的key,如<div key={1}></div>这里面的key。
        key = vlen && props ? child._component ? child._component.__key : props.key : null;
      // child有__preactattr_属性,也就是之前有添加过__preactattr_属性,可以看idff方法里面的第三种。
      if (key!=null) {
        keyedLen++;
        keyed[key] = child;
      }
      // 如果child存在__preactattr_,或则child为文本,就将Dom的child缓存到children里面
      else if (props || (child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)) {
        children[childrenLen++] = child;
      }
    }
  }
  // vnode子节点长度不0
  if (vlen!==0) {
    for (let i=0; i<vlen; i++) {
      vchild = vchildren[i];
      child = null;
      // 试着去寻找vchild和keyed里面保存的相同之处,也就是key,如果vnode的子节点的key值,在out里面能找到的话,说明他们是应该一一对应的,child就是out里面对应的节点。
      let key = vchild.key;
      if (key!=null) {
        if (keyedLen && keyed[key]!==undefined) {
          child = keyed[key];
          keyed[key] = undefined;
          keyedLen--;
        }
      }
      // 按out里面存在的子节点,依次和vnode里面的子节点排排坐对比。依次来,如果是相同的node类型,就找到了对应的out节点。并且后面的undefined设置和自加自减操作都是为了优化循环;
      else if (!child && min<childrenLen) {
        for (j=min; j<childrenLen; j++) {
          if (children[j]!==undefined && isSameNodeType(c = children[j], vchild, isHydrating)) {
            child = c;
            children[j] = undefined;
            if (j===childrenLen-1) childrenLen--;
            if (j===min) min++;
            break;
          }
        }
      }
      // 对比生成新的child
      child = idiff(child, vchild, context, mountAll);

      f = originalChildren[i];
      if (child && child!==dom && child!==f) {
        if (f==null) {
          dom.appendChild(child);
        }
        else if (child===f.nextSibling) {
          removeNode(f);
        }
        else {
          dom.insertBefore(child, f);
        }
      }
    }
  }
  // 下面两个步骤都是移除节点
  if (keyedLen) {
    for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
  }
  while (min<=childrenLen) {
    if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
  }
}

innerDiffNode 的目的就是要 vnode 的每个 vchild 和能与其对应上的 out 下面的 child 进行对比,也就是调用 diff 方法,从而实现子节点之间的对比。在 innerDiffNode 里面对比找出 child 的过程,看上面代码中的解释就好了。在通过 idiff 方法生成新的 child 后,child 会被加入到 out 里面。从而一步步将 vnodechildren 移入作为 out 的节点。在遍历了所有的 vnodechildren 之后,还需要对下面两种 out 的子节点移除:

  1. out 子节点里面带 key 属性的节点,如果没有匹配上 vnode 的 children ,需要移除;
  2. 再次进入循环的 child 节点或则是首次进入非空字符串的文本节点,如果没有匹配上 vnode 的 children 也会被移除掉。

总结

diff 机制基本就是不断的遍历子节点和 vnode,来实现对比不同。将 vnode 里面的内容添加到 dom 里面,而将 dom 里面不需要的多余的子节点移除掉。所以这里还需要理解整体的移除机制,以及组件生成对比的机制,将在下篇文章里面介绍到。

18. 三种常见算法

前言

先吐槽。
金三银四,最近来我司计划招聘两名前端工程师,一名初级,一名中级,结果前来面试的人络绎不绝,让我也当面试官,结果呢。前来的有一两年工作经验的初级工程师,都是渣渣,不是基础差,就是广度不够,连笔试题目都做不出来,尤其是算法题目,简单的排序都做不出来。给我的感觉,连刚参加工作的我都不如。而后面试的两个中级工程师,面试后感觉也就比我差点,工作经验比我长点,可是这个期望薪水,是不是有点高呀。只是排序算法题大多用的是冒泡法,作为工程师不应该开口闭口都是快排吗。嗯,只是忽然想想自己也只是知道快排的**,具体怎么实现,就懵逼了,于是才有了这篇博客。

常见的冒泡法

冒泡法的概念,很是基础,基本上C语言入门书籍,都会介绍一遍。算法实现和其名字一样冒泡,(从小到大排)高个子从数组的低序号冒泡到高序号,并结束本轮循环。下轮循环的时候,剔除掉已经排好序的高个子,开始排下个高个子,这样需要写到两个循环。具体实现如下:

const bubbleSort = (arr) => {
  const arrLength = arr.length;
  let temp;
  for(let i = 1; i < arrLength; i++ ) {
    for(let j = 0; j < arrLength - i; j++) {
      if(arr[j] > arr[j + 1]) {
        temp = arr[j + 1];
        arr[j + 1] = arr[j];
        arr[j] = temp;
      }
    }
  }

  return arr;
}

如此计算,自然可以简单的想到啦,当然还有另外一种更傻的方法,就是每次都出一个数字正确排序,再求出下个数的正确排序,如此下来,也是能实现,只是算发并不不好看,先看看具体实现:

const rubbishSort = (arr) => {
  const arrLength = arr.length;
  let sameControl = {};
  let result = [];
  for(let i = 0; i < arrLength; i++) {
    let target = arr[i],
        left = 0,
        same = 0;
    for(let j = 0; j < arrLength; j++) {
      if(arr[j] < target) {
        left++;
      } 
      if(arr[j] === target) {
        same++;
      }
    }
    if(result[left] === undefined) {
      for(let resultIndex = left; resultIndex < left + same; resultIndex++) {
        result[resultIndex] = target;
      }
    }
  }
  return result;
}

上面算法一看还是很麻烦的,为了相同的元素还特地设置了 same 变量,虽然最后也是能实现排序的,只是其计算的步骤却是要比冒泡法高很多的,不仅仅有两个长度为 arrLengthfor 循环,在最后一个循环里面还要做两次判断。而冒泡法,第二个 for 循环 就要精简很多了。

大O表示法

要如何判断哪种算法更好呢?常用的比较方法是运行时间,通过运行时间来比较,运行时间越少的,自然越优,毕竟计算机是按照一条条指令并行处理的。大O表示法是种特殊的表示法,指出运行速度的快慢。

对于长度为 n 的数组,若用冒泡法需要循环 n 次,每次循环长度从 n 一直下降到 0,可以求得其运行时间为 n²/2,用大O表示法就是 O(n²),大O法是自然省略前面的常数的。若用第二种算法,逻辑基本差不多,但是其运行时间为 ,用大O表示法就是 O(n²)。可以看出来后一种的运行时间足足是前者的两倍之长。

虽然如此,但是 O(n²) 这样的时间,你可以忍?如果有1000个数字,那岂不要花 1,000,000 次计算。想想要是计算机这么搞,岂不是累死了。

快速排序算法

面对传统的这些方法,比如上面,每次排好一个位置都需要循环一遍,效率低下,实在麻烦,有没有更好的办法?分而治之就提供了一个很好的思路,就是要将问题从大化小,一个个简单击破,最后合并在一起就好了。在排序的体现上就是将待排序的数组不断的拆分成小数组,最后划分到根本不用排序的数组,再排序合并。

具体怎么做呢?先看看快速排序(Quicksort)的Javascript实现 这里阮老师给出的**是:

在数据集之中,选择一个元素作为"基准"(pivot)

**通过基准值pivot来实现分而治之的**,**拆分出小的单元,再仿佛拆分。具体如下:

var quickSort = function(arr) {
 if (arr.length <= 1) { return arr; }
 var pivotIndex = Math.floor(arr.length / 2);
 var pivot = arr.splice(pivotIndex, 1)[0];
 var left = [];
 var right = [];
 for (var i = 0; i < arr.length; i++){
  if (arr[i] < pivot) {
   left.push(arr[i]);
  } else {
   right.push(arr[i]);
   }
  }
  return quickSort(left).concat([pivot], quickSort(right));
};

这么一看很有分而治之的味道,每次将数组分为两半,分别排序,从而降低迭代次数,实现了优化。在最优的时候,每次都可以将数组分成两半,**于是调用栈为 O(logN),底数为2,每次调用排序数量为 n,所以时间复杂度为 O(NlogN),**相比于冒泡的 O(n²),快了特别多,如当 n = 10000 的时候,相差1000倍。这个过程其实和二分法有点类似了。

上面算法看着好简单,难道传说中的快速排序就是这样的?在上面的例子中可以发现,主要通过 left 和 right 来进行数据存储,也就是说其每次迭代的空间复杂度O(n),而迭代次数为 O(logN),所以总的空间复杂度为 O(NlogN)。此时,意味着随着待排序对象的加长,其所占用的空间会不断叠加,与之对比冒泡排序的空间复杂度。。。嗯,不就是 O(1)嘛,

空间复杂度的提高也会影响性能。那有没有运行时间又少,空间复杂度又低的呢?下面给出我自己下的方法:

const quickSort = (arr, left, right) => {
  if (arr.length <= 1) { 
    return arr; 
  }
  left = left || 0;
  right = right || arr.length - 1;

  const target = arr[left];
  const leftInit = left;
  const rightInit = right;
  let leftEnd;
  let rightStart;
  let temp;
  left++;

  if(left === right) {
    if(target > arr[right]) {
      temp = arr[left - 1];
      arr[left - 1] = arr[right];
      arr[right] = temp;
    }
    return false;
  }
  while(left < right) {
    while(arr[left] <= target && left < right) {
      left++;
    }
    while(arr[right] > target && left < right) {
      right--;
    }
    if(left < right ) {
      temp = arr[left];
      arr[left] = arr[right];
      arr[right] = temp;
    }
    if(left === right) {
      if(target > arr[left]) {
        arr[leftInit] =  arr[left];
        arr[left] = target;
        leftEnd = left - 1;
      } else if(leftInit !== left -1) {
        arr[leftInit] = arr[left - 1];
        arr[left - 1] = target;
        leftEnd = left - 2;
        rightStart = left;
      } else {
        rightStart = left + 1;
      }
    }
  }
  if(leftEnd !== undefined) {
    qs(arr, leftInit, leftEnd);
  }
  if(rightStart !== undefined) {
    qs(arr, rightStart || right, rightInit)
  }
}

(吐个槽,quickSort 方法,写了一个小时半才写好,一开始以为很简单,结果跑一下,才发现各种bug,实在惭愧,现在有点体谅那些写不出快速排序的面试者了)

具体的**可以参考这篇 博客。上面的 quickSort 的**还是很简单的,通过两边不断的推移,将小于 target 的数放在左边,大于 target 的数放在右边。只是可能出现左边的数都小于 target,又或者是左边的数都大于 target,所以需要特别处理,导致复杂度提高了。

这时候看到了 聊聊前端排序的那些事 上面介绍到排序 sort 在 Chrome 中的实现,核心部分还是快速排序,原来 Chrome 里面也是用 JavaScript 来实现 sort 方法的!具体源码,只是没有想到 Chrome 里面居然用了 partition 来跳转 js 代码,太可怕了。

chrome 里面先是对输入数组范围小于10的,采用插入排序,包括迭代过程中也是,否则采用快排。快排中采用三点取值,分别是首尾数值,和中间数值。中间数值的下标会根据数组是否大于 1000 来分情况生成,具体情况请看源码。随后对这三个数值排序,取中间值作为基准。采用的快速方法和上文中的第二种思路相似的,只是用了 for 循环和一个 do while 循环,实现起来也是麻烦不少,但是估计速度会更快,更优秀吧。

快排复杂度

前面有提到快速排序算法的运算时间为 O(NlogN),这是根据调用栈和每次调用量,来计算的,但是其调用栈却不总是 O(logN)O(logN) 是建立在每次循环取基准值刚好是该数组的中间值,如果不是中位数值呢?如对数组[1, 2, 3, 4, 5],这种混乱度低的数组,如果还是用从 0 下标开始的快速排序算法,那其计算下来和普通的冒泡法的运行时间是相似的。

由于这种原因,Chrome 里面的快排用三值分而治之,保证随机性,提高快排的运算效能。快排的最糟糕运行时间为 O(n²),最佳为 O(NlogN),当然平均时间为 O(NlogN),基准值取值随机的情况下, O(n²) 是低概率事件。

归并排序

聊聊前端排序的那些事 中看到Firefox采用归并排序,具体缘由文中后面后面也是介绍了一下历史,但是归并排序是什么呢?思路如下图所示

算法也挺简单的,如下:

const mergeSort = (arr) => { 
  const len = arr.length;
  if(len < 2) {
    return arr;
  }
  const middle = Math.floor(len / 2),
      left = arr.slice(0, middle),
      right = arr.slice(middle);
  return merge(mergeSort(left), mergeSort(right));
}

const merge = (left, right) => {
  let result = [];
  while (left.length && right.length) {
    if (left[0] <= right[0]) {
      result.push(left.shift());
    } else {
      result.push(right.shift());
    }
  }
  while (left.length) {
    result.push(left.shift());
  }
  while (right.length) {
    result.push(right.shift());
  }
  return result;
}

上面代码计算参考 这篇博客

归并也是采用分而治之的思路,和二分法的思路有些接近,就像上图一样,把数组整体分割成最小单元,两个元素或则一个元素。先从小数组中排序,再和相邻的数组间排序,一级一级往上走。可以知道这种方式下,调用栈肯定是 O(logN),而每次调用运算为 N,所以其运算时间为 O(NlogN)。其运行时间比快排还要稳定,是稳定排序,只是问题在于 result 数组,归并运算的问题在于其空间复杂度为 O(NlogN),和阮老师里面提到的快速算法空间复杂度是一致的,其实由于反复递归,每次递归的代码执行栈是很高的,相对于上文自己写的快速排序还是有很大差别的。

其他排序

其他排序还有一些很经典的:

  1. 插入排序。插排序如同打扑克牌,插入牌一样。从第二个元素开始,每个数都回溯其应该排序位置,和冒泡法的套路有点像。
  2. 选择排序;
  3. 堆排序;
    当然还有很多,只是怕全都看完容易忘记,还是记住三个常见有用的算法吧。

关于排序,还有个有趣的地方,数组的完全随机排列,洗牌算法和排序算法之间的矛盾问题,算是一个扩展吧。

ps: 为何网上查找的快速排序算法大多不是快速排序,或者是错误的,这如何是好。。。。。。

参考

  1. 快速排序(Quicksort)的Javascript实现
  2. 聊聊前端排序的那些事
  3. 十大经典排序算法

19. preact源码 - VDOM

前言

在工作上开始用 React 开发已经有四个多月了,不禁想看看 React 和 Vue 本质上有什么区别。当然一个是 jsx 文件,一个是 vue 文件,两个处理起来肯定是不一样的。想了想以后项目发展越来越大,肯定是要以 React 为主体的,深入了解 React 是必须的,尤其是 React 已经发展到 React 16 了,新特性都不晓得怎么用呢。为了减少初学习 React 源码的陡度,想着还是从 Preact 开始好了,毕竟后者声称兼容 React 而且,关键是体积小!

Babel 与 JSX

在进入 Preact 的介绍前,又必须要说说虚拟 DOM,这虚拟 Dom 听着神奇,在 Vue 里面也主角。所以必要介绍一下。

在 React 官网的开始学习教程上有下面这段代码

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

刚看到时候可以很明显的发现这是给 ReactDOM.render 传入两个参数,一个是 H1 标签,一个是 DOM 节点,后者很好理解,就是最平常的获取一个 id 为 root 的 DOM 节点,可是前者又是什么?在 Javascript 的所有类型里面是没有这种东西的,难不成是自己落后了?于是在 Chrome 的控制台里面打印,看一下,来一发 console.log(<div></div>),结果立马返回 Uncaught SyntaxError: Unexpected token < 这就提示说语法错误了,那是那里出错了呢?试试了试在 Preact 的 index 页面打印 console.log(<div></div>),代码却能够正常跑起来,而且 console.log(typeof <div></div>) 居然是 'object' 奇了怪了?

难道两处代码是不一样的?难不成 Preact 做了什么特别处理?随查看 Preact 的源码,但是没有任何迹象,传入的参数根本就没有做什么处理,而且传进来马上就会语法错误了,怎么可能执行呢?

最后打开控制台,查看 Sources 的打包文件,发现原来 console.log(typeof <div></div>) 变成了下面:

console.log(_typeof((0, _preact.h)('div', null)));

// 转变一下结果就是
console.log(typeof preact.h('div', null));

这。。。。。又是为什么呢?中间怎么这么多变化呢?在生成的代码阶段就不是简单的 div 了,那是哪里发生的呢?忽然想起以前讲 webpack 介绍的 Babel 降级问题,难道这里也是?查看 Babel 的配置文件 .babelrc,如下:

{
  "presets": ["es2015", "stage-0"],
  "plugins": [
  ["transform-react-jsx", { "pragma": "h" }]
  ]
}

看看下面的配置,在看看转义的那句话,这不就是将 jsx 用 h 程序转变的意思吗?在 package.json 里面也看到了 babel-plugin-transform-react-jsx,这个包是用于将 JSX 转换为 React 函数,用法则是在 .babelrc 里面设置 "pragma": "dom",后者是替换的函数名字,默认是 React.createElement,而在 Preact 中则需要设置为 h 函数。官网里面也有介绍到对于 Babel 5 和 Babel 6 的设置。

h 函数

上文中的 div 变成了 h 函数的实现,h 函数在 Vue 里面也经常可以看到,更不要提 React,那 h 函数是什么呢?

export function h(nodeName, attributes) {
  let children=EMPTY_CHILDREN, lastSimple, child, simple, i;
  for (i=arguments.length; i-- > 2; ) {
    stack.push(arguments[i]);
  }
  if (attributes && attributes.children!=null) {
    if (!stack.length) stack.push(attributes.children);
    delete attributes.children;
  }
  // 存在子节点,如text节点或者其他h函数
  while (stack.length) {
    if ((child = stack.pop()) && child.pop!==undefined) {
      for (i=child.length; i--; ) stack.push(child[i]);
    }
    else {
      if (typeof child==='boolean') child = null;

      if ((simple = typeof nodeName!=='function')) {
        if (child==null) child = '';
        else if (typeof child==='number') child = String(child);
        else if (typeof child!=='string') simple = false;
      }
    // 最终子节点都会推入children里面
    // 而对于简单节点则直接相加就好了
      if (simple && lastSimple) {
        children[children.length-1] += child;
      }
      else if (children===EMPTY_CHILDREN) {
        children = [child];
      }
      else {
        children.push(child);
      }

      lastSimple = simple;
    }
  }
  // p就是最终生成的 Vnode
  let p = new VNode();
  p.nodeName = nodeName;
  p.children = children;
  p.attributes = attributes==null ? undefined : attributes;
  p.key = attributes==null ? undefined : attributes.key;
  // 对生成的Vnode,都用options的vnode方法来处理
  // if a "vnode hook" is defined, pass every created VNode to it
  if (options.vnode!==undefined) options.vnode(p);

  return p;
}

export function VNode() {}

上面代码还是很好理解的,下面举个简单例子,对于 h('DIV', {id: 'abc'}, h('SPAN', null)) 怎会被转换为以下 Vnode:

{
  nodeName: 'DIV',
  attributes: {id: 'abc'},
  children: [
  {
    nodeName: 'SPAN',
    attributes: undefined,
    children: null,
    key: undefined,
  }
  ]
  key : undefined,
}

h 函数里面的 while 循环里面做了一件很特别的事情,本来只要将子节点统统 push 到 children 里面就好了,但这里通过相邻节点是否是简单方式 simple/lastSimple,若果是数字,字符串等,则直接合并在一起。这样有什么好处呢?减少要 diff 的节点,就是减少计算量,毕竟虚拟 Vnode 里面主要内容也是字符串等简单类型。而 VNode 构造函数,则是简单的一个实例而已。

总结

这里介绍了 VNode 的一些入门东西,但是这是后面学习 diff 机制的基础,也能够清晰知道 Preact 的操作对象不是 Document 上面的节点,而是一个个虚拟的 VNode,

11. 初识后端开发 express+MySQL

本文大部分内容是基于初识NodeJS服务端开发(Express+MySQL)

前言

本来只是想学习一下MySQL,毕竟隔几天就可以看到隔壁小伙伴在操作数据库有mySQL和Redis,好像还有mongodb?一直挺向往后端的,加上最近想自己打造一个个人博客,从数据库,服务器部署维护,后端nodejs实现集成,最后到前端展示,这些都想一一落实,于是开始数据库的学习。

正如本文开头所说的,大部分内容都是基于AlloyTeam的那边篇博客,本来没有必要写的,但是是第一次打通前后端的数据鸿沟,纪念一下,还是发表一下吧。

开始数据库MySQL

数据库安装可以直接去官网下载community版本,目前最新版本是8.0.2;
笔者环境是win10,在开始之前,先熟悉一下MySQL的基本命令

启动服务: net start MySQL

停止服务: net stop MySQL

上面这些命令都是要到命令行也就是终端里面键入的;
还有对数据库的操作,可以自行上网搜索教程,这里推荐21分钟 MySQL 入门教程,如果只是初接触是够用的了。
在终端里面访问MySQL需要提供root账号或者自己额外设置的账号命令为mysql -uroot -p,回车后输入密码就可以访问自己的MySQL了,接着就是创建数据库,创建表和插入数据这些过程。这上面一切都是在终端实现的,其实MySQL也有自己的图形界面,世面上有很多,而官方下载里面提供了
MySQL Workbench软件,在里面你能更方便的访问查看数据库;

设置express

如AlloyTeam里面介绍到的,通过安装全局的express来初始化搭建脚手架,快速建立工程。后面通过那篇博客的介绍也可以完成基本的MySQL和express的结合,但是这里提醒一下,由于里面代码没有给全,没有对util说明介绍,所以这里可以把userDao.js里面修改一下:

// userDao.js
// 删除下面这行代码
var $conf = require('../conf/conf'); 

// 将连接池的创建修改为:
var pool  = mysql.createPool($conf.mysql);

对于express不熟悉的话,可以看阮老师的express框架,当然少不了官方api文档;

接口设置

如果只是想要体验一下epress和MySQL,基本到这里就可以结束了,但是日常开发里面,哪有直接在地址栏输入访问数据的呢?正常的开发肯定是通过Ajax访问数据的啦!(开玩笑,现在谁还用Ajax。。。。。)

express设置环境

需求如此,自然要实现,首先是express设置。由于express配置里面用的是jade这类模板的开发,给人的感觉就像回到了以以前后端返回组装好的模板给到浏览器一样。为了方便直接用html文件开发,也就是最简单的访问/返回index.html静态资源文件。返回文件,就用到express的api:

// routes/index.js
res.sendFile(path.resolve(__dirname,'../views/index.html'))

由于我们的路由文件index.js在routes文件夹里面,此时的__dirname并不是指向工程根目录,而是routes文件夹,所以需要path模块来拼接成需要的路径。这里需要注意到的是由于在app.js文件里面设置了view enginejade,所以这里sendFile的时候不能省略文件的后缀.html要不然,无法识别html文件,会报错。
So,这样就回到了前端熟悉的环境了。

前端请求数据

index.js里面用fetch请求数据,这里需要自定义路径,由于是前后端都是我们负责,这里就省去了工作中等待后端提供接口的过程,so,nice!的体验。
前端fetch的数据地址为'/users/queryAll',而后端这里,需要在users.js里面添加如下代码:

router.get('/queryAll', function(req, res, next) {
    userDao.query(req, res, next);
})

对于userDao里面添加一个query的方法,形式上和userDao.add很接近,只是在数据返回不能只是简单的返回成功或失败,接口的定义最好是按照RESETfull来设计,这里我则按照平时工作上的接口来返回数据来设计:

res.status(200).send({
    code:200,
    data: {
        list: ret
    },
    msg: '成功'
})

这里的ret就是MySQL里面查询的数据,一般还是通过list字段返回。后端基本上就可以返回数据了。接下来就是前端的数据展示了。很简单是不是?

这里提个小插曲,fetch是可以远程获取数据的,但是获取得到的是Reseponse对象,而不是data,所以这里需要再进一步操作,通过res.json()解析成Promise对象。这个promise对象通过then继续操作,其传参才是后端返回的数据;

参考:

  1. 初识NodeJS服务端开发(Express+MySQL)

15. 白帽子讲web安全

前言

几个月前碰巧遇到人生第一个安全问题,在负责的输入部分没有做非法字符验证,于是随便写了个alert('hahaha'),导致保存后再次查看输入详情时,立马弹出对话框。究其缘由,是前端没有做字符验证,服务端也没有做验证,最后输入端采用jsp的后端渲染方式,于是一点开详情,就爆炸了。春节前有空就看了看《白帽子讲web安全》。

XSS跨站脚本攻击

XSS攻击,通常指黑客通过“HTML注入”篡改网页,插入恶意的脚本,从而在用户浏览网页时,控制用户浏览器的一种攻击。重点就是篡改网页

前面提到的例子,其实就是XSS,看着好像没有什么大问题,但是如果把输入部分的alert('hahaha')改成一段数据发送的代码,将cookie等重要信息发送到其他地址,那危害就十分显著了。

根据其效果可以分为:

  1. 反射型XSS:将用户操作“反射”给浏览器,如诱使用户点击恶意连接,也叫做“非持续性XSS”;
  2. 存储型XSS:将用户输入的数据“存储”在服务器。前面例子也就是存储型XSS了,也叫“持久型XSS”;
  3. DOM Based XSS:效果上也是反射型,只是通过修改DOM形成XSS。

XSS攻击

常见的XSS攻击有Cookie劫持,后台对权限的验证是基于id的,而这个id正是在cookie里面。后台并不知道访问者是张三还是李四,只要请求里面拥有证明是张三的cookie,那访问者就是张三,这个id就如同张三房间的门禁卡,而Cookie劫持就是获取这个门禁卡。通过XSS攻击,将当前域cookie发送出去。黑客只要有这个cookie就可以轻松登陆。
Cookie劫持,很多时候只要在Set-Cookie的时候给关键Cookie加上HttpOnly的标识,这样就无法通过Javascript读取Cookie了,该Cookie只会随请求头一起发出去。

当然对于前端更熟悉应该是GET/POST请求,正常使用就是按照接口定义发送请求就好了。同样的黑客也可以通过反射型XSS/DOME Based XSS的方式诱使用户点击来达到目的,比如让链接成为接口请求的url。

文中还提到几个小技巧,有些已经过时了,有些还是值得注意的:

  1. <base>标签可以重定义页面上使用相对路径的hosting地址,从而将访问的地址切到黑客准备的地址上。
  2. window.name,window.name是具有跨域效果的,这就使得其可以用来跨页面传递数据,这个数据可以是Cookie,亦或者是id什么的。

XSS防御

最常见的防御莫过于HttpOnly,用以解决XSS后的Cookie劫持,如果连Javascript都读取不到Cookie,只有请求的时候才会带上,那岂不是完美了。

当然HttpOnly并非解决XSS的,而是降低XSS后的损失。上文提到的三种XSS,其实都有个共性,由于同源策略,不会因为你浏览B页面,而在不同域/protocol的A页面上产生XSS,那XSS要如何产生呢?

没有非法的输入,就没有伤害,这些XSS的形成都是从输入开始的,比如反射型XSS,常见于有用户交互的地方,比如搜索框,可以检查搜索框输入处是否有做输入检查,比如:http://xxx/Discuz7.2/upload/ajax.php?infloat=yes&handlekey=123);alert(/Hacked by qiqi/);//只是访问url,但是由于没有检查输入,导致alert(/Hacked by qiqi/)被执行了,这不就实现脚本加载了,接下来只要诱骗用户点击就好了。

对于存储型XSS更是如此,尤其是注册、留言板、添加数据等等这些有输入存储的地方,都可能造成XSS攻击。在XSS的防御上,输入检查一般是检查用户的输入数据有没有一些特殊字符如尖括号“<”、“>”等。对于电话号码或者银行账号这些有特殊规则的倒是可以不用如此检查。

除了输入检查,还有就是输出检查,可以使用编码或转义的方式来防御XSS攻击。这样就是后端渲染直接将含有脚本代码输出的时候,也因为存在编码转义JavascriptEncode/HtmlEncode,不能直接执行。

CSRF跨站点请求伪造

CSRF就是跨站点请求伪造,实现起来也是很简单的,在一个诈骗页面里面执行正常页面的接口。因为此时此刻用户已经打开登陆过正常页面A了,如果诱使用户打开诈骗页面,并诱使其在诈骗页面里面点开新Tag页面或者iframe,请求A的接口,这个时候请求头是带有用户A页面的Cookie的,也就是可以得到后台认证,从而实现请求。

这种请求的方式不仅仅限制与GET,还可以用POST,form表单的形式请求。

CSRF的防御

在项目中用Node.js做中间层的时候,采用的就是Egg.js,而egg.js开始用着还好,后面有POST表单Form请求的时候,经常遇到Node.js后台提示invalid CSRF TOKEN,当时就觉得这个CSRF很奇怪了,听都没有听过。虽然官方文档说的很仔细了,但是时间紧迫,实在不知道怎么解决了,只能在配置文件里面,将所有的CSRF验证关闭。从而实现正常请求。现在回想过来,也是觉得神奇。

回头看看CSRF防御,首先是Referer的问题,只有Referer正确才能说明请求来自正常页面,而非黑客准备的页面。好像这样就能克服CSRF伪造的问题了,但是请求中referer其实不是实时都有的。于是需要另外一个思路CSRF Token

CSRF能够实现的根源在于所有的请求参数黑客都能猜中,能正常访问接口。那如果我们在每次请求的时候,都加入一个验证用的token,让token与Cookie里面的csrfToken配对,如果正确就是正常页面发送的请求,否之,则不是,这样黑客不就无招可使了?

黑客是可以发送正常页面的请求,但是由于同源策略的问题,诈骗页面是无法获取到正常页面的cookie的,虽然请求正常页面的接口时,请求头带有正常页面的Cookie,但是请求参数里面却没有csrfToken,因为csrfToken需要从正常页面的cookie里面获取到呀~~

ClickJacking点劫持

点劫持是一种视觉上的欺骗手段。攻击者通过一个透明、不可见的iframe覆盖在一个网页上面,让后诱使用户操作点击该iframe。如下图所示

由此衍生出来的还有图片覆盖,就是替换图片,添加a标签,指向其他页面。还有拖拽劫持等等方式,这些方式赖以生存的唯一点就是利用iframe来进行视图欺骗。所以防御方法就是禁止iframe跨域,当然现代浏览器都是禁止的。

其他

文中还提到了服务端应用安全,包括常见的SQL注入问题、文件上传漏洞、加密问题和PHP安全,但是举的例子都是和PHP结合的,由于PHP并不熟悉,所以这里就不介绍了。

文中介绍内容还是很基础的,基本就是科普一下,如果想要更深入了解可以看看i春秋,还有渗透测试,渗透工具之类的。

22. react-router 4 与 context

前言

最近我司要上线一个 Hybird 上的 SPA,17 年年底的时候已经写过 demo 给产品和 leader 看了,近期准备要上线。问题在于,当时准备仓促,又想要玩一玩 react,导致了用的版本是比较成熟的,嗯。。。。意思就是比较老的版本,react-router 是 3.x 版本,而 react 也只是 16.0 而已。对于有追求的我而言,升级势在必行。

问题所在

在 Vue 应用里面用 Vue-router 就是一个 routes 的事情,甚至连 routes 都可以不是嵌套解构,直接一维路由,毕竟业务少。到了之前写的 react 也是采用了这种方式,如下:

ReactDOM.render(
  <Router routes={RouteConfig} history={hashHistory}></Router>,
  document.getElementById('root')
);

RouteConfig 基本上也是一维结构,传入到 routes 就好了。然而 routes 这个 props 已经在 react-router 4 里面消失掉了,之前版本采用的是静态路由来配置的,而到了 react-router 4,则采用动态组件。。。如果还需要静态的方式可以采用 react-router-config。这个变化使得 react-router 4 升级变得麻烦,由路由配置变成动态映射,这里还有官网提到的哲学。吐槽一下:开始看官网教程的时候,感觉像一坨屎一样,东一块西一块的,不知道在说什么,这也是去年选 react-router 版本的时候直接放弃 V4 的原因。最近看这个官网,却越看越好,觉得写得相当的优秀用心,赞一个。

对于 APP 上面的页面过渡动画效果,则采用 react-addons-css-transition-group 的 ReactCSSTransitionGroup 组件,这也是比较成熟的方法了,这也是之前官网推荐的方式。让而到了当你点开npm上的介绍时候,发现原来 react-addons-css-transition-group 已经不被推荐了:

The code in this package has moved. We recommend you to use CSSTransitionGroup from react-transition-group instead.

In particular, its version 1.x is a drop-in replacement for the last released version of react-addons-css-transition-group.

然而现实是无情的,只能使用 react-addons-css-transition-group 的 V 1.x 版本,这对于一个前端工程师怎么可以容忍呢?新版本里面肯定有适合的 API 嘛,为什么一定要用 ReactCSSTransitionGroup 呢?然而官网一开始看也是烂得不能入眼(可能是英文的缘故没有耐心看)。最后还是在 react-router 4 的官网里面找到解决办法。

只是一开始傻乎乎的用,抄也没有抄全,部分按照自己的思路走,经常报错,只有全抄过来才对。。妈呀太可怕了。于是乎想要看看研究一下 react-router 4 的设计!

从 Router 出发的 Context

react-router 4 里面依然有 Router,精简一下,Router 代码如下:

class Router extends React.Component {
  static contextTypes = {
    router: PropTypes.object
  };
  static childContextTypes = {
    router: PropTypes.object.isRequired
  };
  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history,
        route: {
          location: this.props.history.location,
          match: this.state.match
        }
      }
    };
  }
  state = {
    match: this.computeMatch(this.props.history.location.pathname)
  };
  computeMatch(pathname) {
    return {
      path: "/",
      url: "/",
      params: {},
      isExact: pathname === "/"
    };
  }
  render() {
    const { children } = this.props;
    return children ? React.Children.only(children) : null;
  }
}

去除掉提示性报错,PropTypes以及需要在 componentWillMount/componentWillUnmount 和服务端渲染相关的操作 this.unlisten 部分,就只剩下这么一点点了。 Router 组件负责渲染子节点,没有就 null,简单吧。那要 Router 有何用?看看大头 childContextTypes/getChildContext 这又是什么?

React 是有自己的 Context API 的,只是不建议开发者使用,并称之为实验性特性,可能移除,不熟悉 Redux/MobX 的最好都不要碰 Context API,俨然是不让人用的样子。Context API 使用还挺简单的,只要在 context 的提供者组件上申明一下就好了,包括 childContextTypes 以及 getChildContext 方法,这样在子组件里面在定义声明一下 contextTypes 就能够使用了。子组件里面怎么使用呢?通过 contextTypes 声明后直接用 this.context 就能够访问了。上面的 Router 中,其子组件在声明后,若要访问 Router 中的 router,直接用 this.context.router 就好了,是不是很简单!甚至在组件的生命周期里面也有 Context 传过来,这岂不是非常好,这样就不用一直 props 参数到子组件了,用 Context API 就好了,为何官方是不推荐使用的呢?

官网提到:

问题在于,组件提供的context值改变,后代元素如果 shouldComponentUpdate 返回 false 那么context的将不会更新。这使得使用context的组件完全失控,所以基本上没有办法可靠的更新context。

这就是问题所在了,所以是不推荐的。嗯。。。至于 react-router 4 里面这么用嘛。。。。反正也没有用到 shouldComponentUpdate 钩子,而且大神这么用还显得非常溜呢。再查 Context API 的时候,忽然发现原来上个月 React 16.3 有了全新的 Context API,不再是不建议使用了

React 16.3 Context API

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

看到官网介绍,大大觉得这个功能很实在,这不就解决了父子/孙子组件之间的通信问题了吗,呀,那那 redux 是不是可以不用了。。。。当然不是。这里先不说为什么,先看看 React 16.3 Context API 有什么:

  1. React.createContext(defaultValue: T)
  2. Provider: React.ComponentType<{value: T}>
  3. Consumer: React.ComponentType<{children: (value: T)=> React.ReactNode}>

第一个是创建方法,生成一个 { Provider, Consumer } 对。Consumer 将从最近的 Provider 中读取 context value,如果没有匹配的 Provider,将从 defaultValue 中读取值。是不是也很简单?但是意义却是非凡的。Provider 和 Consumer 可以放在自己想想要用的组件上面,不用顾虑组件的层级关系。可以通过对创建的 React.createContext import 到想要用的组件就好了。这不就是相当于穿梭机嘛。数据飞来飞去多有趣。官网给了很好的例子,这里就不介绍具体用法了。反正记住:创建的{ Provider, Consumer } 对,Provider 组件提供 context value, 而这个 value 是以 props 的形式进入 Consumer 的子组件的,是不是很*,不是 this.context,而是通过 this.props 传入的!还是下面这个简单例子吧:

const ThemeContext = React.createContext({
  name: 'ni'
});
<ThemeContext.Provider value={name: 'noNi'}>
  <ThemeContext.Consumer>
  { context => (
    <span>{ context.name }</span>
  )}
  </ThemeContext.Consumer>
</ThemeContext.Provider>

上面例子是不推荐用的,太暴殄天物了,这里是只是简单介绍一下形式而已,**Context API 的优势在于多层次的嵌套组件!**Provider 组件里面的传值 value 一般不是固定的嘛,要不然传值干嘛?一般传入 state/props 作为 value,state/props 一变化就可以触发组件 Provider/Consumer 更新了。

可以看出这个 Context API ,和 Redux 的功能似乎有点重叠,都是通信问题。只是很明显的是 Redux 和 Context API 是有区别的,Redux 分离了数据和视图,而 Context API 还是在视图层做文章,并且过于灵活,不利于团队开发,不如 Redux 的数据控制来的规范清晰。并且 Redux 更多的是存储数据,Context API 更多还是一个状态的变化,一个从父组件传递到子组件的状态而已,这么看来更像是 state。另外呢,**对于 SPA 还有个问题,当页面切换的时候,需要传递给下个页面的信息,可以通过路由拼接参数传递,或者就是用 Redux 存储了,而这里 Context API 完全没有用武之地。。。还是很悲哀的。。**这么看来 Redux 还是很会有必要的。为此还有一篇澄清的采访。当然对于小项目嘛,这个 Context API 完全是福利呀,为了传递个 props 而已,就不要用沉重麻烦的 redux 啦,多幸福。

Router 与 Route

说 Context 好像说远了,回到 react-router 4 里面,Router 组件通过 Context API(老版本) 给组件传递了 Context,也就是 router,看看 router 是什么:

  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history,
        route: {
          location: this.props.history.location,
          match: this.state.match
        }
      }
    };
  }

里面有 history,这个也就是

import { Router } from 'react-router'
import createBrowserHistory from 'history/createBrowserHistory'

const history = createBrowserHistory();
<Router history={history}>
  <App/>
</Router>

通过 createBrowserHistory 方法创建的 history,并传入给到 Router 组件。Context 里面第二个是 route,可以看出 router.location 就是 history 里面的 location,而 route.match 是 Router 组件里面 state.match,这个 match 在后面会介绍到。

上面就是 Router 组件了。常见的 Route 组件 写法:

<Router>
  <div>
    <Route exact path="/" component={Home}/>
    <Route path="/news" component={NewsFeed}/>
  </div>
</Router>

再来看看 Route 组件代码:

class Route extends React.Component {
  // 已简化部分代码
  static contextTypes = {
    router: PropTypes.shape({
      history: PropTypes.object.isRequired,
      route: PropTypes.object.isRequired,
      staticContext: PropTypes.object
    })
  };
  static childContextTypes = {
    router: PropTypes.object.isRequired
  };
  getChildContext() {
    return {
      router: {
        ...this.context.router,
        route: {
          location: this.props.location || this.context.router.route.location,
          match: this.state.match
        }
      }
    };
  }
  state = {
    match: this.computeMatch(this.props, this.context.router)
  };
  computeMatch({ computedMatch, location, path, strict, exact, sensitive }, router) {
    if (computedMatch) return computedMatch;// 若Switch 组件已经帮我们计算好了,就返回
    const { route } = router;
    // 传的props有location,就用location,没有,就用 context.router.route
    const pathname = (location || route.location).pathname; 
    return matchPath(pathname, { path, strict, exact, sensitive }, route.match);
  }
  componentWillReceiveProps(nextProps, nextContext) {
    this.setState({
      match: this.computeMatch(nextProps, nextContext.router)
    });
  }
  render() {
    const { match } = this.state;
    const { children, component, render } = this.props;
    const { history, route, staticContext } = this.context.router;
    const location = this.props.location || route.location;
    const props = { match, location, history, staticContext };

    if (component) return match ? React.createElement(component, props) : null;
    if (render) return match ? render(props) : null;
    if (typeof children === "function") return children(props);
    if (children && !isEmptyChildren(children))
      return React.Children.only(children);

    return null;
  }
}

可以看出 Route 组件的重点有三处:

  1. 获取传递过来的 Context,并 getChildContext,新建 route。
  2. match变化,location.pathname 变化的时候,修改 match。
  3. 根据 props 内容的不同,分别以 component/render/Children 的方式渲染子组件。

这里重点看看 match,这个 match 是当前地址与该 Route 组件匹配关系。我们来看看 computeMatch 里面的 matchPath 方法:

import pathToRegexp from "path-to-regexp";
const matchPath = (pathname, options = {}, parent) => {
  if (typeof options === "string") options = { path: options };
  const { path, exact = false, strict = false, sensitive = false } = options;
  // 此时的parent就是 Router 里面的 state.math!如果当前路由是根路径的话, match 为 true,否之为 false;为根路径就正常渲染好了
  if (path == null) return parent;

  const { re, keys } = compilePath(path, { end: exact, strict, sensitive });
  // match:当前定义的路由 pathname 是否匹配 Route 的 props.path
  const match = re.exec(pathname);
  // 不匹配,则说明该 Route 组件没有匹配上,不会渲染任何子组件
  if (!match) return null;

  const [url, ...values] = match;
  const isExact = pathname === url;
  // 完全比配情况
  if (exact && !isExact) return null;

  return {
    path,
    url: path === "/" && url === "" ? "/" : url, 
    isExact,
    params: keys.reduce((memo, key, index) => {
      memo[key.name] = values[index];
      return memo;
    }, {})
  };
};
const compilePath = (pattern, options) => {
  // 去除缓存机制的主要部分
  const keys = [];
  const re = pathToRegexp(pattern, keys, options);
  const compiledPattern = { re, keys };
  return compiledPattern;
};

可以看出 matchPath 方法,主要还是依赖于 path-to-regexp 包,这个在 vue-router 里面有经常看到。通过这个包,可以将传入的 pathname,与 Route 组件 props 过来的 path 进行匹配。如果不匹配则 match 为 null,在渲染的时候如果是 component/render 的方式,则 match 为 null,同时 Route 组件的渲染结果就是 null,也就是意味着该 Route 组件不匹配 pathname。

看看computeMatch 方法里面的 pathname 变量。这个 pathname 可以是传入参数 location 的,也可以是 context.router.route 的 patchname,而后者就是 history.pathname,也是当前地址。**这个当前地址和传参 location.pathname 会不一样吗?**会的!当然会!通过控制 Route 的 props.location 就可以修改 matchPath 的传参 pathname,从而使得当前地址与该 Route 的匹配关系变更为 props 过来的 location.pathname 与该 Route 组件的匹配关系。是不是有种为所欲为的 feel。

Route 里面还有个值得注意的 props,是 exact,意思为是否全匹配。如果不设置,如果当要求的 pathname = '/one' 时,path 为 '/','/one' 或则 '/one/two' 的三个 Route 组件都会被匹配到。只有全匹配的时候才能精准匹配 '/one' 的组件。这也是为什么上面的 Route 写法里面会有一个 div 包裹两个 Route 组件,毕竟可能两个组件都匹配上,而 Router 组件又只能有一个 child。

Switch 组件

Switch 出场率还是很高的,而且 switch 这个单词也很形象,切换路由。常见的用法就是用 Switch 组件,包裹 n 个 Route 组件。Switch 组件只会渲染一个 Route 组件,如果不是精准的 Route 组件,也只会渲染一个,所以美名为切换:只能留一个的意思。

class Switch extends React.Component {
  // 已简化部分代码
  static contextTypes = {
    router: PropTypes.shape({
      route: PropTypes.object.isRequired
    }).isRequired
  };
  render() {
    const { route } = this.context.router;
    const { children } = this.props;
    const location = this.props.location || route.location;

    let match, child;
    React.Children.forEach(children, element => {
      if (match == null && React.isValidElement(element)) {
        const {
          path: pathProp,
          exact,
          strict,
          sensitive,
          from
        } = element.props;
        const path = pathProp || from;

        child = element;
        match = matchPath(
          location.pathname,
          { path, exact, strict, sensitive },
          route.match
        );
      }
    });
    return match
      ? React.cloneElement(child, { location, computedMatch: match })
      : null;
  }
}

这个 Switch 组件还是蛮简单的,对子节点 Route 组件进行遍历。对于 match 变量,和 Route 里面计算 math 的相同,都是调用 matchPath 方法,只是不同的地方在于传入 matchPath 的 pathname 参数是 Swith 自己的 props.location.patchname,而不是 Route 自己的 props.location.pathname。当计算出 match 之后,若符合就不会继续遍历其他 Route 组件了,从而实现仅渲染单个 Route 组件。

在回头看看 Route 组件里面的 computeMatch 方法,当有 props.computedMatch 的时候,直接返回 computedMatch,不会继续下面自己的 matchPath 方法了,这个 props.computedMatch 正是 Switch 计算出来 match 变量。直接让 Route 组件自己的 props.location 与 context.router.route 无效化。这就说明了 Switch 组件对其下面 Route 组件的渲染不仅是单独渲染,更有筛选作用。

这个时候在看看开头说的项目中遇到的 SPA 路由切换问题,官网给出的方案 采用的方式,通过 Switch 组件的 props.location 实现对其下面的 Route 组件渲染。当路由变化的时候,前一个 Route 组件不会立马消失,而是在 Switch 组件下继续现实,route 的 key 值并没有变化。同时新的 Route 也会继续出现,从而实现过渡效果。

其他组件

Redirct 组件里面需要注意的是 props.from 是在 Switch 组件里面生效的,const path = pathProp || from,来计算 computedMatch,最后的结果则是
从 match 的里面结合 to 得出的路径,不是简简单单的 '/nameList/:id',要具体到 id 是多少。当然这里还是用到了 path-to-regexp 模块。

StaticRouter 针对服务端渲染的问题,有新的 Context 传过来 staticContext,在 Redirct 里面用得比较多。

withRouter 是个 HOC 高阶组件,意在通过修饰者语句将组件传入 withRouter 里面。官网里面介绍了一点。withRouter 可以将 Route 组件里面 match location history 传给过来目标组件,用处还是挺好的。

MemoryRouter 和 Promt 组件都比较简单,这里就不介绍了。

参考

  1. 官方文档 react-router 4
  2. 官方文档 react 16.3 context

41. 秒开 hybrid H5 优化记

记得刚做前端,接手移动端 H5 的时候,特别想要将应用优化到极致,想要达到秒开,流畅接近原生的效果,只是业务需求下一直没有时间去做这样或者那样的优化。这次自己接手一个 hybrid H5 项目,做完业务之后,一直想要优化,刚好又是我一个人负责前端,于是将平时的想法收集起来,周六加班做了个深入的优化(可惜才过了四天,就被通知项目要移交到其他团队)。避免涉密,后面的数据,都做了稍微修改。

初始问题

这个项目是基于百度地图做了一个应用,开始存在两个问题,一个是首屏白屏问题;从点击进入到开始到有内容的阶段,有个明显白屏的时间,这个时间是包含 webview 初始化,以及首屏渲染的时间。该首屏渲染的时间,FP 的时间在 Fast 3G 下,大概为 9000+ ms。原本的系统,其实已经做了路由的懒加载了。另外一个百度地图渲染漂移问题。

对于上面的问题,一共做了七层优化,尤其是首屏加载问题。

百度地图

用 vue-baidu-map 来作为 Vue 项目的百度地图组件。只是在移动端存在严重的问题,其覆盖物在移动端渲染性能差,稍微用手拖动一下百度地图,其上面的文字或者自定义的图形都会出现颤抖,而在 pc 端是没有这样的问题,官网的示例也是如此,只是采用覆盖物-点的方式,却能很好的避免颤抖的情况。

若是直接采用百度地图的方式,而不是用 vue-baidu-map,其效果会好很多,不会有颤抖问题。只是想要试试新的 vue-baidu-map,而不是一直用老的方式。由于百度地图的代码没有开源出来,查看 vue-baidu-map 中的实现方式,也无特别收获, vue-baidu-map 只是做了一个 Vue 和百度地图的数据驱动的绑定而已,这给调试代码带来了很大的阻挠。后面为了方便调试, 采用 chrome 的 rendering 来调试代码,发现自定义覆盖物在拖动地图的时候,会反复变深绿色,而使用点覆盖物,只会时不时变深绿色。点覆盖物性能确实要比自定义以及其他覆盖物要很多。后面改为点覆盖物,效果真的提高了不少。

另外经过多次调试后发现高德地图真的要比百度地图好,有三维模式,而且 webview 支持好一些,只是定位没有百度地图准。

首屏优化

由于项目需要适配多语言,而之前的语言包,加起来有 1M+ 的大小,只是里面的冗余数据比较多,需要用到信息并没有那么多,于是采用 nodejs,对每个语言文件进行解析,输出对应的简化版本的 json 文件。nodejs 采用 walkdir 模块遍历所有语言包,并输出为简化版本文件。可以将包的体积减少到 3/5 的水平,并采用按需加载。

lottie 优化

为了更好的还原动画采用的是 lottie + json 数据的方式,实现动效。只是设计最后给出来的一个动效 html 都要接近 300kb,这个是无法接受的,而且其实动画尺寸非常小,就是个简单 icon,采用 30 帧的序列帧体积也要 170kb,很大,而且设计为了统一管理,统一规范,推荐的还是采用 lottie 方案。后面在一篇腾讯 alloyteam 的文章里面有介绍到 lottie-web 仓库的 lottie_light.min.js 只用 140+kb,完整版的要 240+ kb,虽然只支持 svg,但是已经很够用了。

再加上异步组件和懒加载 lottie_light.min.js 和对应的 json 数据,可以大大的减少首屏渲染压力

// 异步组件方式
components: {
  LottieComponent: () => import("./LottieComponent");
}
// 懒加载Lottie文件
const [lottie, lottieAnimationData] = await Promise.all([
  import(/* webpackChunkName: "lottieLightMin" */ "./lottie_light.min"),
  import(/* webpackChunkName: "lottieComponent" */ "./lottieComponentData")
]);

使得 lottie + json 数据文件大小在接近 200kb 的水平,并达到了按需加载的目的。

最后还想采用将 lottie 文件内置到客户端里面,请求的时候,拦截返回 lottie 文件给到前端就可以了,可惜客户端做的是小白。。。。

图形压缩

之前介绍过图像优化,该项目使用的图像有不是很多,通过有效压缩,可以减少 30% 的体积,只是需要注意的是有些首页的图像,在低于 10 kb 的时候,会被 Vue-cli3 打包进首页的 js 文件里面,导致文件臃肿,于是需要观察图片大小,以及配置对应的 vue.config 来达到最优解,本项目刚好是 10 kb, 附近有几个图像被打包进去了,修改 loader 对应的配置值就可以了。

prefetch 的问题

通过 chrome 的 performance 调试的时候发现,首页加载的时候会同时记载其他文件,包括所有的语言包都加载进来了。只是不是做了按需加载的处理了吗?其实,vue-cli 3 对项目的默认处理是将需要加载的文件都加载上,另外按需加载的文件,会用 link 链接的方式,并设置为 prefetch 来获得,初衷是好的,prefetch 的资源优先级最低,不会和当前需要的 js 文件抢优先级。只是这样有个严重问题,由于浏览器在 http 1.1 下允许同时发送 6 ~ 8 个网络请求,于是当首页的 js 文件下载的同时,存在空闲连接,其他的 prefetch 请求也会被发送出去。导致了和首页 js 文件抢夺有限带宽的情况。

根据这个情况需要修改 vue.config.js 中的配置,就可以了。

config.plugins.delete("prefetch");

经过上面几步下来,首屏渲染时间 FP 时间已经缩短到 4000+ms 了。

百度地图优化

通过 performance 再次分析发现,在首页初始化的时候,会把百度地图也加载上去,只是初始过程并不会直接渲染百度地图,而是有个和服务端交互的过程,这个过程会消耗几秒钟,之后才会显示出百度地图,这样的话,其实百度地图是不用打包进入首页的 chunk 文件的,可以异步加载。只是按照官网的介绍,vue-baidu-map 需要作为插件在 Vue 里面使用。如下方式:

import Vue from "vue";
import BaiduMap from "vue-baidu-map";

Vue.use(BaiduMap, {
  ak: "YOUR_APP_KEY"
});

这种方式 100%会将 vue-baidu-map 打包进首页的包里面。那如何避免呢?这个就要分析 Vue.use 里面的源码了。

export function initUse(Vue: GlobalAPI) {
  Vue.use = function(plugin: Function | Object) {
    const installedPlugins =
      this._installedPlugins || (this._installedPlugins = []);
    if (installedPlugins.indexOf(plugin) > -1) {
      return this;
    }

    // additional parameters
    const args = toArray(arguments, 1);
    args.unshift(this);
    if (typeof plugin.install === "function") {
      plugin.install.apply(plugin, args);
    } else if (typeof plugin === "function") {
      plugin.apply(null, args);
    }
    installedPlugins.push(plugin);
    return this;
  };
}

不难发现 Vue.use 最后执行的是 vue-baidu-map 的 install 方法,并传入 Vue 以及后面的参数对象。于是回头看 install 方法

install (Vue, options) {
  const {ak} = options
  Vue.prototype._BMap = () => ({ak})

  Vue.component('baidu-map', BaiduMap)
  Vue.component('bm-view', BmView)
  // 省略其他的组件注释
}

// _BMap 使用方法
const ak = this.ak || this._BMap().ak;

install 里面的主要功能一个是给 Vue 构造函数的原型传入 _BMap 方法,_BMap 会在 Map 组件初始化的时候使用。于是摆在面前的就有两个问题

  1. 能不能在组件里面使用 Vue.use 方法动态注册组件
  2. isntall 里面 Vue 构造函数问题:包括了 _BMap 方法挂载,以及组件注册问题问题

如果在组件里面引用 Vue.use 会发现此 Vue 非彼 Vue,即是引入的 Vue 和初始实例化的 Vue 的作用是有差别的,若使用同一个 Vue 函数,控制台又会提示其他问题。所以直接使用 Vue.use 在组件里面注册插件是不行的。那如果换成组件本身,用 this.use 呢,很可惜没有这个方法。这个时候可以看看 Vue 关于组件的源码:

// _createElement 函数里面
if (typeof tag === "string") {
  // 省略部分代码
  if (
    (!data || !data.pre) &&
    isDef((Ctor = resolveAsset(context.$options, "components", tag)))
  ) {
    // component
    vnode = createComponent(Ctor, data, context, children, tag);
  }
}
// createComponent 里面
function createComponent(Ctor, data, context, children, tag) {
  // 省略部分代码
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor);
  }
}
// extend 方法
Vue.extend = function(extendOptions) {
  // 省略部分代码
  const Super = this;
  const Sub = function VueComponent(options) {
    this._init(options);
  };
  Sub.prototype = Object.create(Super.prototype);
  Sub.prototype.constructor = Sub;
  // allow further extension/mixin/plugin usage
  Sub.extend = Super.extend;
  Sub.mixin = Super.mixin;
  Sub.use = Super.use;
};

可以发现 组件的创建其实也是 Vue 的继承关系 ,所以如果想在组件里面用 use 方法的话,就是 this.constructor.use 了,只是方法是可以用了,但是传入 vue-baidu-map 的不是 Vue 构造函数本身,而是组件的构造函数,这个构
造函数可以满足 install 方法里面的组件注册,调试一下,发现可以用~~只是其构造函数的 prototype._BMap 并不是 Vue 构造函数,完全不搭边,好在可以 vue-baidu-map 可以通过传递 this.ak 来解决问题;
最后的动态插件实现如下:

created() {
  import('vue-baidu-map').then(BaiduMap => {
    BaiduMap.install = BaiduMap.default.install;
    this.constructor.use(BaiduMap);
    this.baiduLoading = false;
  })
}

上面的方式就可以将百度地图三方库从首页中拆分来,最后首页必须加载文件大小总减少 108 kb 。将首屏 FP 时间压缩到 3000+ms,首屏文件 js 加载大小为 200+ kb 的样子。基本上在 4G 网络或者 wife 的情况下,就可以 1s 内刷新出来了。

做到这一步差不多也就可以了,只是精益求精,还是想要提前 FP 时间。

包体积优化再分析

再回头看剩余的包体积,主要包含了 vue 的运行时,vue-router 、vue-i18n 和 core.js。这几个模块都是无法分离出来的,整体大小已经有 150kb 了。还有另外一个大的模块,主要包含业务代码、 axios 相关的模块和被转成 base64 的图片。这些模块也是分离不开来的呀。后面要如何优化好?

暴力的 index.html

想起之前看到的一篇文章,Vue 项目骨架屏注入实践 想要学着同样处理一下,但是并没有相应的数据可以用到。

这个时候开始分析手头上的业务,初始记载后,会有一个请求服务器的过程,而服务需要多次轮询之后才有结果(服务端的性能太差了),等获取结果之后才会进入百度地图的页面,这就给了之前分离出百度地图包的契机了,反正百度地图不用立马加载。

那要如何解决中间的白屏问题呢?4G 下也要接近 1s。白屏时间为 webview 初始化,首页资源下载,vue 实例化,后面两者能不能都干掉呢? 仔细盯着首页初始化的过程,从白屏到初始化,到服务端多次轮询拿到结果,这个过程中,前端页面是没有什么大的变化的,只有一个 loading 的图案。webview 初始化后,加载的是打包生成的 index.html,然后再去加载其他 js 资源后,再运行 vue,index.html 只是一个充满连接的 html。于是一个想法就起来了,在 index.html 里面直接渲染出 loading 的界面,等 vue 实例化结束了之后,再隐藏掉,不就可以完美过度了吗?压根就不用等待其他 js 资源下载和 Vue 实例化。

于是很简单的,在根目录下,创建 public/index.html,并简单的用原生代码显示出 loading 界面,再调试一下,就完美了。

堪称完美,只要加载一个 index.html 就够了,怕 2G 下也是秒开吧~~~

48. Vue 插槽的一个 bug

最近工作比较忙,有想写的博客,但是一直没有下笔,想来也是有点懒了,还是要拔拔草。正值金三银四的,面试别人也以 Vue 源码居多,想要还是有必要学习一下插槽相关的内容。
这次的 bug 私以为还是 Vue 本身的问题,先看看一下代码:

// App.js
export default {
  components: {
    ChildContainer: ChildContainer,
  },
  data() {
    return {
      slotsData: {
        a: ["a", "ab", "abc"],
        default: [],
      },
    };
  },
  computed: {
    slots() {
      const scopedSlots = {};
      const { slotsData } = this;

      scopedSlots.a = (props) =>
        slotsData.a.map((item) => <div {...props}>{item}</div>);
      return scopedSlots;
    },
  },
  render() {
    const { slotsData } = this;

    return (
      <div id="app">
        <child-container scopedSlots={this.slots}>
          <div>default外的默认内容</div>
          {slotsData.default.map((item) => item)}
        </child-container>
      </div>
    );
  },
};

// ChildContainer.vue
<template>
  <div id="childContainer">
    <slot></slot>
  </div>
</template>;

可以看到上面的 App.js 采用 jsx 的写法,由于 ChildContainer.vue 只有一个默认的 slot,而 App.js 则同时通过 scopedSlotschildren 的方式传入了插槽内容。首先子组件里面没有使用到 a 插槽,所对应的内容不会渲染出去,最后会渲染出默认的内容为 default外的默认内容

此时表现一切都是正常的,this.slotsslotsData.default 各施其职,而且还充分利用了 computed 的缓存功能,避免重复的计算 slots。而当加载后,修改 slotsData.default 数据的发现,如 this.slotsData.default.push('slotsData的deflaut内容'),可以看到页面没有任何变化,难道是设置的姿势不对?这是再简单不过的,查看 slotsData.default 数据也是对的,只是为什么不渲染出来呢?于是换成 $set 来设置,已经是最完整的了,只是还是没有用。渲染函数确实再次执行了,但是输出的内容还是 default外的默认内容,问题出在哪里呢?

这个时候把 default外的默认内容 这一行代码注释掉,发现再次设置 slotsData.default 的时候,数据生效了,同时页面有渲染 slotsData的deflaut内容,岂不是奇了怪了。之前通过 Vue Dev Tool 还可以看到生成的 this.slots 这个 computed 内容多了个 _normalized 字段,而且其下面还有个 default 函数,当注释 default外的默认内容 这一行的时候,这个 _normalized 也没有了,什么时候多了这个字段呢?看来只能看看 Vue 的源码,之前看的时候一直避开 slot 部分的,完全是个黑盒。

组件输出的渲染函数的 slot 部分为 _vm._t("default"), 其中 _t 就是如下函数:

// 省略部分代码
function renderSlot(name, props) {
  const scopedSlotFn = this.$scopedSlots[name];
  let nodes;
  if (scopedSlotFn) {
    // scoped slot
    props = props || {};
    nodes = scopedSlotFn(props);
  } else {
    nodes = this.$slots[name];
  }
  const target = props && props.slot;
  if (target) {
    return this.$createElement("template", { slot: target }, nodes);
  } else {
    return nodes;
  }
}

// _render 函数里面
vm.$scopedSlots = normalizeScopedSlots(
  _parentVnode.data.scopedSlots,
  vm.$slots,
  vm.$scopedSlots
);

可以看到 renderSlot 的最终输出取决于 vm.$scopedSlots,没有的话再是 vm.$slots,而 $scopedSlots 的生成取决于

  1. _parentVnode.data.scopedSlots 节点 vnode 数据的 scopedSlots 字段,也就是上文业务中的 this.slots
  2. vm.$slots 实例自身的生成的 $slots,一般是通过 resolveSlots 解析标签来匹配获得实例的 slots 节点;
  3. vm.$scopedSlots 前一个 $scopedSlots

在上面例子中,当更新 default 的数据的时候,_parentVnode.data.scopedSlotsdefault 数据没有关系所以不会更新,而 vm.$slots 则是包含了更新了的 default 插槽的数据,也就是包含了 default外的默认内容 以及 slotsData的deflaut内容 两个节点,只是在 debug 过程中发现最后生成的 vm.$scopedSlots 有大大的问题。

先看看 normalizeScopedSlots 方法

// 省略部分代码
function normalizeScopedSlots(slots, normalSlots, prevSlots) {
  let res;
  const hasNormalSlots = Object.keys(normalSlots).length > 0;
  const isStable = slots ? !!slots.$stable : !hasNormalSlots;
  const key = slots && slots.$key;
  if (!slots) {
    res = {};
  } else if (slots._normalized) {
    return slots._normalized;
  } else {
    res = {};
    for (const key in slots) {
      if (slots[key] && key[0] !== "$") {
        res[key] = normalizeScopedSlot(normalSlots, key, slots[key]);
      }
    }
  }
  for (const key in normalSlots) {
    if (!(key in res)) {
      res[key] = proxyNormalSlot(normalSlots, key);
    }
  }
  if (slots && Object.isExtensible(slots)) {
    (slots: any)._normalized = res;
  }
  return res;
}

首次加载的时候,由于父节点的 scopedSlots 是一个 computed 返回的对象,最后会将生成的 res 赋值给 scopedSlots.__normalized,而这个 res 也包含了 vm.$slots 部分,也就是原本通过 computed 传入的对象是不包含 default 插槽的,但是 res 是全部的内容,也就会包含 default 内容,渲染内容为 default外的默认内容 的节点,最后会被挂载到 computed 输出值的 _normalized 字段。

首次渲染自然是没有问题的,因为 _normalized 也是最新的。当第二次执行 normalizeScopedSlots 的时候,由于 computed 缓存,这个 _normalized 字段也被缓存下来了,由于存在 _normalized,会返回上一次生成的 default 数据,不会包含最新数据的 vm.$slots 的数据返回,在后续的 renderSlot 一直是获取老的数据。

通过测试将 slotscomputed 变成 methods,问题就解决了,那这应该是算 Vuebug 了,没有设想到传入的 scopedSlots 是一个缓存值。只是要使用的话如何好呢,有两个方法:

  1. 彻底放弃在 jsx 组件里面嵌套插槽的写法,全部写在 jsxscopedSlots 里面;这样每次更新插槽数据,都会重新触发 computed 从而更新 jsxscopedSlots,只是这样一个插槽更新了,所有的插槽都要计算一次,效果还是稍微差了一点;
  2. 子组件为单文件组件,其采用 renderSlot 的渲染,那如果采用 jsx 指定插槽呢。比如 this.$slots.default 这样岂不是快哉,只是上面的还缺了点,还需要 $scopedSlots,所以应该是 this.$scopedSlots.default ? this.$scopedSlots.default(props) : this.$slots.default

scopedSlots 与 slots 如何区分?

scopedSlotsslots 是两个不同的部分,正如字面意思,前者是作用域插槽,后者是插槽,但是呢,具体区分更多的是按照 2.6 版本来的,比如如下写法:

<layout>
  <template v-slot:name> name </template>
</layout>

表面是是没有看到作用域的,但是采用了 2.6 的新写法,最后通过编译会输出 scopedSlotsvnode:

// compile 阶段的生成AST树过程
// 省略部分代码
function processSlotContent(el) {
  // slot="xxx"
  const slotTarget = getBindingAttr(el, "slot");
  if (slotTarget) {
    el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget;
    el.slotTargetDynamic = !!(
      el.attrsMap[":slot"] || el.attrsMap["v-bind:slot"]
    );
  }
  // 2.6 v-slot syntax
  if (el.tag === "template") {
    // v-slot on <template>
    const slotBinding = getAndRemoveAttrByRegex(el, slotRE);
    if (slotBinding) {
      const { name, dynamic } = getSlotName(slotBinding);
      el.slotTarget = name;
      el.slotTargetDynamic = dynamic;
      el.slotScope = slotBinding.value || emptySlotScopeToken; // 新的slot有slotScope
    }
  }
}
// compile 阶段的 ast 过程
// 省略部分代码
function closeElement(element) {
  if (currentParent && !element.forbidden) {
    if (element.elseif || element.else) {
      processIfConditions(element, currentParent);
    } else {
      if (element.slotScope) {
        // scoped slot
        // keep it in the children list so that v-else(-if) conditions can
        // find it as the prev node.
        var name = element.slotTarget || '"default"';
        (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[
          name
        ] = element;
      }
      currentParent.children.push(element);
      element.parent = currentParent;
    }
  }
}
// compile 阶段的 codegen 过程
// 省略部分代码
function genData(el, state) {
  if (el.slotTarget && !el.slotScope) {
    data += `slot:${el.slotTarget},`;
  }
  // scoped slots
  if (el.scopedSlots) {
    data += `${genScopedSlots(el, el.scopedSlots, state)},`;
  }
}

可以看到 processSlotContent 里面上半部分是对 slot=name 判断,是对老的写法的处理,而下面部分则是对 2.6 版本的新写法 templatev-slot 组合的处理,新的部分最后输出包含了 slotScope 字段,而旧的版本没有。由于上面例子没有设置作用域,所以 slotScopeemptySlotScopeToken 也就是 _empty_ 字符串。在随后的 closeElement 里面,若有 slotScope,则会将其设置到父节点的 scopedSlots 里面,形成一个插槽对象。

到这里都是生成 AST 的过程,后面 codegen 阶段,会根据新老写法的不同,生成 slotscopedSlots 数据,其中 genScopedSlots 返回的是编译好的渲染函数,而 slot 则不同,插槽内容,以 children 的形式存在与父节点中,只是其属性有 slot 而已。

生成的 scopedSlots 数据会传入到 vnode 里面,最后传入上面提到的 normalizeScopedSlots 返回给实例的 $scopedSlots,而 $slot 则会根据前面传入的 vnodeslot 数据生成。

上面是父组件里面生成 ChildContainer 的插槽信息,包括生成 scopedSlots 数据这些,而最后在 ChildContainer 编译阶段,会根据 slot 标签名的不同生成对应的 VNode,方法如下:

function renderSlot(name, fallback, props, bindObject) {
  const scopedSlotFn = this.$scopedSlots[name];
  let nodes;
  if (scopedSlotFn) {
    // scoped slot
    props = props || {};
    if (bindObject) {
      props = extend(extend({}, bindObject), props);
    }
    nodes = scopedSlotFn(props) || fallback;
  } else {
    nodes = this.$slots[name] || fallback;
  }

  const target = props && props.slot;
  if (target) {
    return this.$createElement("template", { slot: target }, nodes);
  } else {
    return nodes;
  }
}

可以看到有 $scopedSlots 就会直接输出,没有会出采用 $slots 的数据,fallback 则是默认的插槽内容。最后返回的是 VNode 数据,给到子组件。

最后在 normalizeScopedSlots 可以发现,$slots 的也是会传入 $scopedSlots 里面的,所以项目中直接用 $scopedSlots 就可以了,同时 _parentVnode.data.scopedSlots 数据也会传给 $slots 里面的,某种程度说,是 $scopedSlots$slots 区别不大的。

总结

跟着问题学习源码,还是很快的,只是觉得,自己还在看 Vue 源码的,有点不太行,一直想要突破到别的领域的,看来遥遥无期。

35. 对布局和视频加载的想法

最近上线了一个官网主题页,也是我的第一个对外页面,嗯,很符合活动页特点,开发周期短,逻辑含量低。但是毕竟以前没有接触过,写下来还是有不少感受的。

布局

布局最常见的方案就是居中布局 + 响应式布局。居中布局可选方案多,为了兼容 ie 9,选取的是 text-align: center 的方式;响应式布局为了提高维护性,以 pc 端设计稿的 100px 为 1rem。立马开工,只是没有一会就发现问题了:

  1. 设计稿除了居中,还要左对齐;
  2. 字体不能采用rem的形式,否则会出现渲染不清晰,甚至是低于12px的情况,而且字体是否需要响应式?
  3. 图片采用响应式,但是标准是多大,设计稿为 2560px 宽,难道按照 2560px 来吗?

对于第一个问题,设计稿里面的结构看似是居中的,但实际上只有 banner 以及第二模块主题是居中的,第三四模块都是和第二个模块左对齐而已,其大小不一,并非居中。对于这个问题好办,设置 pandding-left 即可,内部元素 float: left,再清除浮动即可实现左对齐了,或者是 text-align: left
第二个问题和第三个问题要一起来看,首先字体是不能采用响应的,虽然当前现代浏览器已经支持 12px 以下的字体(Edge 丧心病狂,字体大小可以支持到 1px),但是设计稿的两边空白自然是可以随着响应布局裁剪的,而中间部分才是给用户看的核心。因此图片和文案是不可以采用简单的响应式的。在以前的主题版本里面中间部分是不做响应处理的,如此下来对整体视窗的最小尺寸 min-width 就要做要求,否则 inline 元素会滑到下一行。

再三考虑下,采用图片响应处理,以设计稿大小为基准,但是采用 三种媒体查询 1440px/1680px/1920px 。比如设计稿上是 4rem,在 1440px 的时候,则会变成 4 * (1920 / 1440)rem,这样在响应布局的时候,原本很大的图片就不会显的特别小,而在 1920px 以上的时候直接响应布局就好了。同样的字体也采用三种媒体查询,专门设定大小而不是采用响应的形式,保证图片和字体之间的协调,避免字体特别大的情况。

定下方案后,由于采用 less css预处理器,在媒体响应里面直接套用 mixin,很是方便,结束后让设计师过来看,结果立马提到字体为何这么小?看着不协调,虽然我做了媒体查询,但是在 设计师里面字体是处于不能改的范畴 。同时出现了另外一个问题,在小屏的时候,整体布局是偏右的,没有达到视觉上的居中。对于前者就采用字体大小按照设计稿来就好了。而后面的问题,则是在一开始设置 pandding-left 采用的是绝对的百分数。只是这个百分数,由于图片不是100%的 rem 布局,增加了媒体查询,并且字体也不是 rem 布局,是固定的尺寸,于是这个百分数就偏大,导致整体靠右。于是通过反复的计算固定布局、响应和媒体查询,最后得到:

padding-left: calc(22% - 170px + unit(2.5 + (@screenRate - 1) / 2, rem));

类似于上面的写法,感觉进入了死胡同,写法过于复杂,其实可以简单的采用 js 来计算处理,获取居中的 padding-left 值,但是能用 css 的为何不用 css 呢?

上面提到的四个屏幕格式,但是还漏了出来已经有三四年的 4k 屏。对于这样的屏幕,图片也采用响应,会比 2560 屏幕大上 50%,属于还是能够接受的范畴。至于居中问题,padding-left,由于采用的是具体的数值计算,也同样能够保持居中效果,只是由于图片和字体之间的间距采用了 max-with,所以最后效果还是会偏左一点。这里也可以采用媒体查询来解决掉偏左的情况。

视频加载

可以看到初进入页面是一个 video。视频高度和宽度占满整个屏幕,进来就立刻播放了,是个关键的关键。先看看这个页面,这是 UC 的官网,一进来的时候,是白屏的,尤其是在 fast-3G 的网速下,中间白屏大概在 4s 左右。这是由于初次进来,资源加载时间会过长,导致中间白屏。为了避免这样的问题,可以采用的是 video 的 poster,其表示在用户播放视频之前显示的内容,如果不设置,在第一帧之前,什么都不显示也就是白屏。于是我们很直接的 采用了视频的第一帧,作为 poster ,使得视频的播放就能够平滑过渡。

只是即便如此,视频的播放还是有比较明显的延迟,查看 network,发现 video 加载在 waterfall 中靠后,结合之前看的从Chrome源码看浏览器如何加载资源,作为 video 资源,其加载优先级自然是最低的,当然除了 prefetch 资源以外。banner 里面显示的视频出现速度影响着整个页面的出现的效果,于是这里 采用 preload 的形式 ,banner 的视频加载优先度也从底部上升到了顶部。只是效果并不明显,明明 700ms 不到就可以下载视频了,而结果却是接近 2s 的时刻,才开始播放视频。在观察 network 的时候发现一个很神奇的请求,banner 视频同时请求了两次,一次是 preload 的时候加载资源,一次是正常解析到 video 标签的时候的请求,而这个请求返回是 206,范围请求资源,只是不是请求过了吗?在控制台了给了提示表示 preload 的资源没有被使用上!

这就明显了,banner 的资源没有用上,所以重新请求了,只是为什么没有使用 preload 的资源?点开 mdn 上面的例子竟然发现也是同样的情况,preload 是作用了,但是视频会加载两次,虽然第二次的请求接口只有几kb 的大小?这个这个时候看看控制台的提示:

A preload for 'http://xxxxxx' is found, but is not used because the request headers do not match.

当把资源改为图片的来实现图片预加载的时候,也出现重复加载资源情况,这个时候控制台如下提示:

A preload for 'http://xxxxxx' is found, but is not used because the request credentials mode does not match. Consider taking a look at crossorigin attribute.

提示跨域的属性可能设置错误了。把跨域设置去掉不可以实现正常加载了。那视频 video 呢?其请求头是不一样的,chrome 里面的 link 里面的视频资源居然不是采范围分请求的形式加载,而是直接加载整个资源,但解析到 video 的时候,则采用范围加载。。。这个。。。请求头的 Accept-Encoding Range chrome-proxy 是不一样的。

在 caniuse 里面发现 opera 也是这支持 preload 的,结果情况类似,第二次请求的请求头也不一样,但是 opera 返回的响应是 304,不是 206,可以看出基本就是 206/304 了。

至于 edge 浏览器直接把 preload 标识当作 prefetch 了,直接挂起了。

stackoverflow 上只有一个类似的提问,但是没有人回答。倒是 developers.google 里面有一篇Fast Playback with Video Preload,总结:

HTTP Range requests are not compatible.

还是不兼容!

当网络速度慢的时候,banner 处的视频加载速度,直接影响了播放开始时间,这个时候用 preload 是有显著效果的。网页资源多,video 在首屏加载的时候就会处于落后的情况,这个时候用 preload 也是极好的。只是有如下限制:

  1. 资源的请求不能是范围请求;
  2. 若 preload 加载资源未完成,浏览器在加载 video 对应的资源时会再次请求,这就对资源大小有要求了;
  3. 浏览器表现不一,尤其是对 video;

12. Koa初识

前言

写的好好的,勘正完要提交push了。结果由于初用vscode,点击了放弃更改,好好的一篇koa文章就不见了,心疼自己1s,占个坑吧,以后有机会再重新写。

30. react 时间调度

前言

继续前面两篇 react 的分析。提到 react 16,除了 fiber 机制之外,还有其调度机制。这里就不得不提 requestIdleCallback 了,react 采用了 requestIdleCallback 的**来实现调度,为什么说**呢,因为 requestIdleCallback 是新出的 api,兼容性差,很多现代浏览器都不支持。于是 react 团队就写了一个 ployfill。

requestIdleCallback

关于 requestIdleCallback,请先看看 MDN 上面的介绍。requestIdleCallback 的出现意使得浏览器可以在空闲的时候执行 callback,这对单线程的 V8 而言就相当又用了。假设你需要大量涉及到 DOM 的操作的计算,在运算时,浏览器可能就会出现明显的卡顿行为,甚至不能进行任何操作,因为是单线程,就算用

在输入处理,给定帧渲染和合成之后,用户的主线程就会变得空闲,直到下一帧的开始,或者有优先的待处理任务,或者是存在输入。用户所看到的页面都是一帧一帧渲染出来的,下图中可以看到一帧的过程:

虽然上面是介绍 requestAnimationFrame 时用到的图片,但是也很详细的介绍到一帧里面浏览器都做了些什么,一帧里面除了上面干的活外就是空余时间 idle 了。可以看看 W3C requestidlecallback 的介绍:

Layout 和 paint 就是我们常见的重排和重绘,也就是 render 的部分,而 Task 就包含了各种任务,包括输入处理,js 处理等等。完成这些事情还有多少时间剩余呢?一般是按照 60fps 来处理的。至于为什么是 60fps 呢,大多数浏览器遵循 W3C 所建议的刷新频率也就是 60fps 了,requestAnimationFrame 里面就是按照频率来的。60 fps 就已经能够保证看到的动画不会一卡一卡了。所以在一帧 16.66ms 的时间里面空闲的时间就是 requestIdleCallback 调用处理的阶段了。

requestIdleCallback 参数

MDN 里面介绍到语法,这里再提一下:

var handle = window.requestIdleCallback(callback[, options]);

callback 是要执行的回调函数,会传入 deadline 对象作为参数,deadline 包含:

  1. timeRemaining:剩余时间,单位 ms,指的是该帧剩余时间。
  2. didTimeout:布尔型,true 表示该帧里面没有执行回调,超时了。

options 里面有个重要参数 timeout,如果给定 timeout,那到了时间,不管有没有剩余时间,都会立刻执行回调 callback。

React 中的实现

先看两张 amazing 的图:
首先是 React 16 之前版本的

在之前的版本里面,若 React 要开始更新的时候,就会处于深度调用的状态,程序会一直处理更新,而不会接受处理外部的输入。如果更新的层级多而深则会导致更新时间长的问题。到了 React 16 fiber 的阶段呢,如下所示;

可以明显的看到每次处理完一部分之后,react 都会从非常深的调用栈上看看有没有其他优先要做的事情,有则开始做其他事情,如输入事件等等,结束后再回过头来继续之前的事情。是不是很神奇!这种时间丝滑般的设计,对于大量数据的渲染很有帮助。看看 react 中设计的 requestIdleCallback ployfill。

const localRequestAnimationFrame = requestAnimationFrame;
// 链表头部与尾部
let  headOfPendingCallbacksLinkedList = null;
let  tailOfPendingCallbacksLinkedList = null;
// frameDeadlineObject 为传入callback的参数 deadline
const frameDeadlineObject = {
  didTimeout: false,
  timeRemaining() {
    // 通过 frameDeadline 来判断,该帧剩余时间
    const remaining = frameDeadline - now();
    return remaining > 0 ? remaining : 0;
  },
};
// export 对外函数,也就是 requestIdleCallback ployfill
scheduleWork = function(callback, options) {
  const timeoutTime = now() + options.timeout;
  const scheduledCallbackConfig: CallbackConfigType = {
    scheduledCallback: callback,
    timeoutTime,
    prev: null,
    next: null,
  };
  // 省略将scheduledCallbackConfig插入到链表里面过程
  if (!isAnimationFrameScheduled) {
    isAnimationFrameScheduled = true;
    localRequestAnimationFrame(animationTick);
  }
}
// requestAnimationFrame 调用函数
const animationTick = function(rafTime) {
  isAnimationFrameScheduled = false;
  // 更新 frameDeadline
  frameDeadline = rafTime + activeFrameTime;
  if (!isIdleScheduled) {
    isIdleScheduled = true;
    window.postMessage(messageKey, '*');
  }
}
// 省略消息监听处理部分

// 执行 callback,与传参 deadline
const callUnsafely = function(callbackConfig, arg) {
  const callback = callbackConfig.scheduledCallback;
  callback(arg);
  // 总是会删除调用过的 callbackConfig
  cancelScheduledWork(callbackConfig);
}

cancelScheduledWork = function(callbackConfig) {
  // 在链表中删除对应节点,并维护好pre以及next关系
}

可以看出这里是采用 requestAnimationFrame 来代替 requestIdleCallback,这也很好理解。关键地方在于 deadline 参数的传递。这里用了 frameDeadlineObject 来表示,每次 requestAnimationFrame 的时候都会更新 frameDeadlineObject 对象里面的 frameDeadline 基线。frameDeadline 正如其名,就是每次 requestAnimationFrame 开始的时间以及该帧时长之和。只要 now() 的时间大于它,自然是表示没有空闲时间。

比如在一个帧里面,可能第一个 callback 运行时间过长,frameDeadline - now() 不为正数,则不会无法继续执行。程序会在下一帧开始的时候执行 input 事件什么的,若有空闲时间才执行 idle callback。

上文中通过链表的结构,每次都将传入的 callback 和 timeoutTime 保存起来,以 pre/next 的形式来维系。并在 callUnsafely 里面调用完之后就删除掉。回顾一下上面处理流程,通过 scheduleWork 传入 callback,用 requestAnimationFrame 方式第一次启用 animationTick,并用事件的方式 window.postMessage 消息的方式来调用。其中省略的消息监听的处理如下:

// messageKey 为特点字符串
const idleTick = function(event) {
  if (event.source !== window || event.data !== messageKey) {
    return;
  }
  isIdleScheduled = false;
  callTimedOutCallbacks();
  let currentTime = now();
  // 空闲时间判断
  while (
    frameDeadline - currentTime > 0 &&
    headOfPendingCallbacksLinkedList !== null
  ) {
    const latestCallbackConfig = headOfPendingCallbacksLinkedList;
    frameDeadlineObject.didTimeout = false;
    callUnsafely(latestCallbackConfig, frameDeadlineObject);
    currentTime = now();
  }
  // 继续下一个节点,调用requestAnimationFrame
  if (
    !isAnimationFrameScheduled &&
    headOfPendingCallbacksLinkedList !== null
  ) {
    isAnimationFrameScheduled = true;
    localRequestAnimationFrame(animationTick);
  }
}
window.addEventListener('message', idleTick, false);
// 如果设置了 timeoutTime 的话,自然是无脑执行到底的,而不会把时间让渡予下一帧
const callTimedOutCallbacks = function() {
  const currentTime = now();
  const timedOutCallbacks = [];
  let currentCallbackConfig = headOfPendingCallbacksLinkedList;
  while (currentCallbackConfig !== null) {
    if (timeoutTime !== -1 && timeoutTime <= currentTime) {
      timedOutCallbacks.push(currentCallbackConfig);
    }
  }
  // 存在 timeoutTime 的事件,并且发生超时了,那就执行,不考虑帧的问题了
  if (timedOutCallbacks.length > 0) {
    frameDeadlineObject.didTimeout = true;
    for (let i = 0, len = timedOutCallbacks.length; i < len; i++) {
      callUnsafely(timedOutCallbacks[i], frameDeadlineObject);
    }
  }
}

animationTick 结合 idleTick 形成消息传递事件的发送方和接收方,同时也分别是 requestAnimationFrame 回调函数和触发函数。通过 messageKey 来识别是否是通知的自己。idleTick 里面的循环判断和 timeRemaining 相同,判断是否有空闲时间,有才进行 callUnsafely,执行 callback。

fiber 与 requestIdleCallback

在上面的代码好像都没有看到 timeRemaining 使用的地方哦,其实在 workLoop 里面才会有判断

function workLoop(isYieldy) {
  if(isYield) {
    while (nextUnitOfWork !== null && !shouldYield()) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  }
}
function shouldYield() {
  if (deadline === null || deadlineDidExpire) {
    return false;
  }
  if (deadline.timeRemaining() > 1) {
    return false;
  }
  deadlineDidExpire = true;
  return true;
}

对于异步更新的每次执行 performUnitOfWork 前都会判断一次是否有空余时间,有才会继续。通过这个地方判断是否有空闲时间。

前者 idleTick 里面是在初始化的时候判断,能否立马执行 performWork 函数,以及在该帧里面能够执行链表的下一个 performWork。
后则 workLoop 是在 render/reconcilation 阶段的 workLoop 循环里面判断空闲时间,有就继续。当然在第二个阶段 commit 是没有检查空闲时间过程的。从而实现了之前版本没有现实的方式。当一次组件更新时间较长的时候,仍然运行 input 等操作,同时,更重要的是不会发生局部更新。事件能打断的只是 render/reconcilation 阶段,这个阶段不会发生任何的真实 DOM 的变化。这也是调度里面最神奇的地方,空闲时间的检查仅仅发生在 render/reconcilation 阶段。

只是该阶段还是会执行 ComponentWillUpdate 这些生命钩子,well,react 团队表示着没有关系。

于是到了timeRemaining之后,render/reconcilation 阶段就会被打断,继续处理浏览器的其他输入事件。并在输入后执行

expirationTime 与优先级别

之前我们计算 expirationTime,都是按照同步来算的,也就是值为 1(SYNC)。既然是同步自然就不需要调度来控制任务系统了。当 expirationTime !== SYNC 的时候,才进入 requestIdleCallback 的任务调度

function requestWork(root, expirationTime) {
  if (expirationTime === Sync) {
    performSyncWork();
  } else {
    // 进入调度
    scheduleCallbackWithExpirationTime(expirationTime);
  }
}

这里的 expirationTime 是 root.expirationTime。也就是说 React 当前是处于同步还是调度模式,是由 root 的 expirationTime 决定的。这也就是说明了其模式分两种,一种是同步,一种是调度。而调度的优先级将取决于 expirationTime。

##总结
本文更多的只是结合 requestIdleCallback 来介绍异步相关过程,但是更多的内容还是没有介绍到,将放在下篇文章 react 开启异步渲染与优先级 介绍到。

参考

  1. W3C requestidlecallback
  2. requestAnimationFrame Scheduling For Nerds

8. vue-router源码分析

前言

用了Vue快一年多了(虽然中间间断好长时间),就越发的对其周边的生态感兴趣,尤其是对Vue-router和Vuex,Vue-router是单页面应用的核心部件,基本上的路由跳转都依赖它,项目上用的比较多的Vonic也是基于于Vue-router的;而Vuex只是在状态变化较多,需要store的时候才用上。本文先介绍Vue-router(2.7.0),有时间再介绍Vuex;

从示例开始

下面是官方给出的示例basic,清晰的介绍了VueRouter最基本使用方法:

// 1. 安装插件,同时注册<router-view>和<router-link>,并且劫持$router和$route
Vue.use(VueRouter)

// 2. 定义路由组件
const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

// 3. 创建路由
const router = new VueRouter({
  mode: 'history',
  base: __dirname,
  routes: [
    { path: '/', component: Home },
    { path: '/foo', component: Foo },
    { path: '/bar', component: Bar }
  ]
})

上面代码就可以构成最简单的Vue-router示例,当然创建好的router还需要加入Vue的option中。
可以发现一切的开始在于Vue.use(VueRouter),use之后,直接使用Vue-router里面的api就好了。看看Vue里面use的用法:

@Vue.js

Vue.use = function (plugin: Function | Object) {
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  if (installedPlugins.indexOf(plugin) > -1) {
    return this
  }

  // additional parameters
  const args = toArray(arguments, 1)
  args.unshift(this)
  if (typeof plugin.install === 'function') {
    plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') {
    plugin.apply(null, args)
  }
  installedPlugins.push(plugin)
  return this
}

在Vue.js里面不难发现,use方法主要功能就是执行插件,若有install方法就执行install,并在将该插件push到内部变量_installedPlugins数组里面;而Vue-router的index.js文件里面VueRouter.install = install,install变量从install.js文件导入,所以Vue.use(VueRouter),相当于执行了install.js导出的install方法。
再看看install方法都做了些什么:

Vue.mixin({
  beforeCreate () {
    if (isDef(this.$options.router)) {
      this._routerRoot = this
      this._router = this.$options.router
      this._router.init(this)
      Vue.util.defineReactive(this, '_route', this._router.history.current)
    } else {
      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
    }
    registerInstance(this, this)
  },
  destroyed () {
    registerInstance(this)
  }
})
// 劫持$router,getter方法返回的是VueRouter
Object.defineProperty(Vue.prototype, '$router', {
  get () { return this._routerRoot._router }
})
// 劫持$router,getter方法返回的是VueRouter的路由对象
Object.defineProperty(Vue.prototype, '$route', {
  get () { return this._routerRoot._route }
})
// 注册router-view和router-link全局组件
Vue.component('router-view', View)
Vue.component('router-link', Link)

Vue.minxin作用是将混合对象的方法和组件合并,install.js里面则是为每个组件都添加beforeCreate钩子和destroyed钩子;在beforeCreate里面只有Vue实例化的时候才会进入true语句里面(router选项是配置在Vue对象里面),其他的组件创建时候this.$options没有router对象,只有this.$options.parent才有router对象。如此,Vue实例化的时候,会对router进行初始化this._router.init(this)和'_route'的劫持。registerInstance方法是专门针对router-view组件,分析router-view组件的时候会介绍到。

init 初始化VueRouter实例

VueRouter这个class的实例化过程中会根据配置的选项mode,判断是要进行HTML5History,HashHistory还是AbstractHistory,默认下就是HashHistory,其兼容性是最好的;
而install方法里面重要的就是调用VueRouter实例的init方法:

init (app: any /* Vue component instance */) {
  // 判断是否已经处理过app
  // ...
  // 切换路由
  if (history instanceof HTML5History) {
    history.transitionTo(history.getCurrentLocation())
  } else if (history instanceof HashHistory) {
    const setupHashListener = () => {
      history.setupListeners()
    }
    history.transitionTo(
      history.getCurrentLocation(),
      setupHashListener,
      setupHashListener
    )
  }
  // history实例的cb
  history.listen(route => {
    this.apps.forEach((app) => {
      app._route = route
    })
  })
}

在init里面对于HTML5History和HashHistory,进行history.transitionTo而history是在前文提到的VueRouter里面实例化的, history.getCurrentLocation()对于hash模式,就是window.location.hash#符号后面的地址;而 history.setupListeners()则是监听hashchange事件,并执行history.transitionTo

路由匹配

看看transitionTo如何实现:

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const route = this.router.match(location, this.current)
  this.confirmTransition(route, () => {
    this.updateRoute(route)
    onComplete && onComplete(route)
    this.ensureURL()
  // ...
  }, err => {
  // ...
  })
}

在transitionTo中,对于hash模式,传参是路径字符串(location),和监听的回调函数(onComplete/onAbort);第一步调用VueRouter实例的match方法,返回一个匹配的route对象。在介绍route对象之前,需要先了解create-route-map.js,里面的路由字典生成函数createRouteMap,其返回:

return {
  pathList,
  pathMap,
  nameMap
}

pathList:是自然是示例中routes的path集合,pathMap则是每个path对应的路由记录对象字典,nameMap则是每个name对应的路由记录对象字典;路由记录对象里面其他选项都较好理解,里面的regex用了'path-to-regexp'模块,可以对路由记录对象里面的path处理为正则表达式,方便和当前路由进行配对;另外路由记录里面还有parent选项,当routes下面某个路由有children的时候parent指的就是上一级的路由记录对象。
回过头来,继续看match方法,该方法传入的参数是当前路由hash部分和current对象,current对象可以追溯到route.js里面的Object.freeze(route),返回的是冻结了的路由对象,值得注意的是这个路由对象的matched,matched数组是所有传入createRoute的record路由记录对象及其所有父路由记录对象。在所有初始化的过程中,this.current的path就是'/'。
match里面现实如下:

  function match (
    raw: RawLocation,
    currentRoute?: Route,
    redirectedFrom?: Location
  ): Route {
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location
    if (name) {
    // ...
    } else if (location.path) {
      location.params = {}
      for (let i = 0; i < pathList.length; i++) {
        const path = pathList[i]
        const record = pathMap[path]
        if (matchRoute(record.regex, location.path, location.params)) {
          return _createRoute(record, location, redirectedFrom)
        }
      }
    }
    // no match
    return _createRoute(null, location)
  }

normalizeLocation方法则是对当前hash和当前路由对象做比较,生成path,query,hash三个键以及_normalized: true,_normalized可以用于判断是否已经对当前hash和路由对象对比过了,在match的else if语句里面,可以看到对pathlist进行遍历,存储的路由记录对象的regex和生成的path对比,若能匹配上,则对location对象的params为path里面解析出来的参数;最后match会返回_createRoute函数,该函数在匹配的路由记录对象没重定向和别名时,会返回一个路由对象。而这个路由对象和match传参里面的current同出自createRoute方法,返回的结构自然也是一样的,于是就有猜想this.current会不会赋值为normalizeLocation生成的location呢?结果发现还真是这样。

确认切换以及_route劫持

上面提到transitionTo中执行的路由确认,生成新的路由对象route,接着confirmTransition结构如下所示:

  1. 创建abort中止方法,判断当前current对象是否和路由对象route是相同路由,如果是则返回中止函数
  2. 创建执行队列queue针对current和route,按需执行
  3. 创建迭代器iterator,在iterator里面执行hook,而hook是queue队列中的函数
  4. 执行runQueue,迭代上文3中的iterator,并在最后执行回调
    confirmTransition中的queue队列如下:
const queue: Array<?NavigationGuard> = [].concat(
  // beforeRouteLeave方法
  extractLeaveGuards(deactivated),
  // 全局路由切换前动作
  this.router.beforeHooks,
  // beforeRouteUpdate方法
  extractUpdateHooks(updated),
  // beforeEnter方法
  activated.map(m => m.beforeEnter),
  // 异步组件
  resolveAsyncComponents(activated)
)

其中在Vue-router的官方文档里面介绍了组件内部的方法beforeRouteEnter,beforeRouteUpdate ,beforeRouteLeave,可以对应queue里面的两个方法,而queue里面的beforeEnter,是写在routes里面的方法名beforeEnter;至于文档里面提到的beforeRouteEnter,则对应runQueue方法内部,执行的extractEnterGuards方法,也是最后执行的钩子;
迭代器iterater的是否进入下一步迭代,是由传入hook里面的to来确定的(这个to为何物?要具体到每个方法的next函数传参)。
在transistorTo中,传给confirmTransition的除了route,还有onComplete,确认切换的回调函数,代码如下:

// confirmTransition的onComplete方法
this.updateRoute(route)
onComplete && onComplete(route)
this.ensureURL()

// fire ready cbs once
if (!this.ready) {
  this.ready = true
  this.readyCbs.forEach(cb => { cb(route) })
}

好奇的你估计会问怎么onComplete里面还有个onComplete,后面这个回调是transistorTo自己的,也就是我们前文提到的history.setupListeners,至于updateRounte方法,则如下所示:

updateRoute (route: Route) {
  const prev = this.current
  this.current = route
  this.cb && this.cb(route)
  this.router.afterHooks.forEach(hook => {
    hook && hook(route, prev)
  })
}

将获得的路由匹配中创建的路由对象route指向this.current,这也涉及到我们前面所说的两者都是由createRoute生成的;this.cb,该方法在init初始里面的末端有涉及如下:

history.listen(route => {
  this.apps.forEach((app) => {
    app._route = route
  })
})

Vue实例化的时候,也初始化history.cb,实现对_route的赋值修改,但是其并没有在初始化的时候执行,Vue实例化中history.cb的赋值是在transitionTo之后的,也就是在updateRoute之后,但是在后面的路由跳转中,因为history.cb已经初始化,则会执行history.cb()。这也就实现了install过程里面对$route的数据劫持,其返回this._routerRoot._route就是route路由对象。
至于ensureURL,这个就神奇了,Vue-router中是以最新的路由对象为标准来修改hash的,为了确保window.location.hash的正确性,会在确认切换路由回调里面再次确认当前hash是否与当前路由对象的记录一直,不一致的话,以最新的路由对象为标准再次修改window.location.hash。
在Vue实例化中beforeCreate有一下一句:

Vue.util.defineReactive(this, '_route', this._router.history.current)

defineReactive这是Vue里面观察者劫持数据的方法, 而这里是劫持_route,当_route触发setter方法的时候,则会通知到依赖的组件;

组件

在install的过程里面已经将router-link和router-view两个组件注册好了,稍微看一下源码就不难发现,这两个组件用的都是render方法渲染组件
对于router-link,默认标签tag为a标签,也是h函数的第一个参数,而数据对象data,有on和attrs,on是router-link里prop过来的事件,默认为click事件;而attrs处理时候,调用了router.resolve(this.to, current, this.append)在index.js里面的resolve方法也是调用了match方法,返回匹配的路由route,虽然和transistorTo方法里match传参格式不同,但是结果都是返回路由对象route。
在h函数创建Vnode的时候,data.class还会根据传参,当前路由来设置对应的class样式。
router-link里面还会自动创建a标签,并且当click事件触发的时候会调用内部的handler函数,当props的replace为false的时候,会触发transitionTo方法,并切换路由,点击a标签当然要触发跳转,而该transitionTo的回调则是修改window.location.hash的方法,从而修改地址栏的hash。当然由于前文提到的在Vue实例化过程中,我们在transitionTo的回调里面用了setupListeners去监听hashchange事件,所以在hashchange监听函数里面也会调用transitionTo方法,但是因为此时路由对象已经是最新的得了,所以不会进一步切换。

对于router,值得注意的部分是registerRouteInstance,也是最开始的install里面提到的,beforeCreate和destroyed都可能触发这个方法。registerRouteInstance其功能和路由对象里面的match:记录路由对象的instances相关联,就是会将对当前的router-view组件添加到对应的路由记录的instance里面,并在router-view组件destoryed的时候将该instance置为undefined;而这个instance的主要作用是在confirmTransition中的queue中使用到的,以及issue#750里面提到的。

History

上文提到的都是HashHistory下的,当然其实还有HTML5History模式,HTML5History顾名思义,用的HTML5的特性,老版本的浏览器会有兼容问题,所以默认情况下是hash模式,可以自己手动开启;
HTML5提供了两个api:

  1. history.pushState()
  2. history.replaceState()
    分别添加和更新浏览器的历史纪录,pushState方法会在transitorTo的回调里面调用,类似于hash模式下的pushHash,而replaceState则类似与replaceHash方法。在init初始化的时候,还有HTML5History还有直接对事件popstate监听,popstate类似于hashchange事件,同样的也会有transitionTo调用,主要作用也是监听浏览器的前进后退功能,基本上是大同小异的;

至于AbstractHistory就更简单了,不是用于浏览器的,自然没有window.location的负担,没有浏览器的后退前进按钮,所以历史浏览记录用个数组和index代替就好了。实现简单,这里就不再谈了

ps: 附上Vue-router 0.4.0 src/transition.js里面对router-view切换时候组件处理的思路,2.7.0版本已经没有这部分注释了

A router view transition's pipeline can be described as
follows, assuming we are transitioning from an existing
chain [Component A, Component B] to a new
chain [Component A, Component C]:
A A
| => |
B C
1. Reusablity phase:
-> canReuse(A, A)
-> canReuse(B, C)
-> determine new queues:
- deactivation: [B]
- activation: [C]
2. Validation phase:
-> canDeactivate(B)
-> canActivate(C)
3. Activation phase:
-> deactivate(B)
-> activate(C)
Each of these steps can be asynchronous, and any
step can potentially abort the transition.

参考资料

  1. ajax与HTML5 history pushState/replaceState实例

50. 图像分类应用的一次探索

knn-banner.jpeg
去年开年后,两度准备换工作,到最后入职新公司,前前后后快 5 个月了。等入职后发现新环境的比以前强不少,一上来的要学习的内容多,除了忙还是忙的,有点瞎忙乎的感觉,兜兜转转的博客也就拉下了很多。想想博客还是要继续写的,哪怕是一点点积累。最近在家办公三周了,楼下还在广播核酸检查的通知,周末也无法外出,想想还是学点东西好了。

偶然间接到一个图像相关的任务,虽然其他同学已经做的差不多了,挂着 AI 的名头,算法主导识别的过程,甚至后面还用到了 opencv,这一切都很陌生,一开始还担心自己搞不懂的,只是接手后,发现蛮有趣的。

knn 介绍

想一下图像要如何根据算法分类呢?同一个物体可以受到很多因素印象,比如光照、视角、大小的影响,甚至是一个算法判断出了向左边转头马,那如何识别向右边转头的马呢?图和图之间如何区分?图像分类的过程,有点像让机器去学习如何分类,提供大量的数据,去学习分类,数据本身是有标注分类的,然后根据学习到的特征来对输入的进行分类,几年前的识别精度就已经高于肉眼了。图像识别涉及到的神经网络学习,是很大的范畴,幸运的是用的 knn 还不是深度学习,连神经网络的范畴都没有接触到,只是很适合用在我们这个任务场景。

knn 全称为 k-Nearest Neighbor。先介绍 Nearest Neighbor 分类器,分类的过程不是无中生有,不像排序算法那样直接,分类会根据提供的训练集,也就是分好类的图像,来判断输入图像的分类,而 Nearest Neighbor 算法将会拿着输入的图片和训练集中每一张图片去比较,然后将它认为最相似的训练集标签给到测试图片

knn-01.png

上面的例子使用 32 * 32 的图像,左边是训练集,右边第一列是输入,而第一列后面是算法找到的最接近第一列的 10 个图像。虽然有点模糊,但是还是可以看到,命中率不是 100% 的,比如青蛙里面甚至出现了战机,这是因为飞机的背景是白色的,和输入的青蛙高度相似,最后导致误判。这里面是如何比较输入图像和哪个形似的呢?

knn-02.png

这里用的是 L1 曼哈顿距离,用 python 来表示的话则是: distances = np.sum(np.abs(self.Xtr - X[i,:]), axis = 1), 当然还有另外一个 L2 欧式距离,复杂一点可以自己去了解。

前面是 Nearest Neighbor 分类器,那 knn 又是如何处理的?Nearest Neighbor 分类器会去计算每个训练图和测试图之间的 L1 距离,然后取最小的一个,但是最小距离就代表着该图片的分类吗?一种比较好的方法是通过 k 个最临近的来判断。比如下面图

knn-03.png

图像的突出的点,代表训练集的分类,其他像素点的颜色代表其归属的分类。当是 k = 1 也就是普通的 Nearest Neighbor 分类时。可以看到正中间存在一个黄色点,所有其周围都是黄色区域,但是很明显这是异常的,除了该黄色点,周围突出点都是绿色的,分类器将该黄色点周围区域分类为黄色是错误的。可以看到当 k = 3 的时候,中间区域都是绿色的。那是不是 k 值越大越好呢?通过大量测试可以发现 k 在 7 的时候可以达到比较好的效果,当然成功率也不过 32%。

成功率这么低为什么还要用呢?因为这里的业务场景比较单一,不同图像之间具有明显的差异,距离不一样,其直观上也会明显不一样,所以可以通过 knn 来区分。

knn python 实现

这里先给出 python 的方式,直观易懂, predict 部分就是输出

class KNearestNeighbor(object):
  def __init__(self):
    pass

  def train(self, X, y):
    self.X_train = X
    self.y_train = y
    
  def predict(self, X, k=1):
    y_pred = np.zeros(X.shape[0])
    for i in xrange(X.shape[0]):
      closest_y = []
      distances = np.sum(np.abs(self.X_train - X[i,:]), axis = 1)
      closest_y = self.y_train[np.argsort(distances)[:k]]
      y_pred[i] = np.argmax(np.bincount(closest_y))

    return y_pred

输入的 X 是多个图像,每次都会计算单个输入图像和所有测试图像的 distances,对所有的 distances 排序,前 k 个作为最邻近的测试图像,然后取里面出现次数最多的分类作为最后结果。和 js 不同,python 里面可以用 numpy 库直接对多维数组进行计算,不用一层一层的遍历,操作相当丝滑。

knn tfjs 实现

那对于前端er要如何使用呢?搜索 npm 可以看到一个 @tensorflow-models/knn/knn-classifier 库,里面直接使用了 K-Nearest Neighbors 算法,这个感情好,直接使用就好了。使用方法如下:

// 创建分类器并加载神经网络
const classifier = knnClassifier.create();
const mobilenet = await mobilenetModule.load();

// 添加 img0/img1 训练图,同时标注img0/img1的分类 0/1
const img0 = tf.browser.fromPixels(document.getElementById('class0'));
const logits0 = mobilenet.infer(img0, 'conv_preds');
classifier.addExample(logits0, 0);

const img1 = tf.browser.fromPixels(document.getElementById('class1'));
const logits1 = mobilenet.infer(img1, 'conv_preds');
classifier.addExample(logits1, 1);

// 通过输入的图 x 来预测其是分类 0 还是 1
const x = tf.browser.fromPixels(document.getElementById('test'));
const xlogits = mobilenet.infer(x, 'conv_preds');
console.log('分类:', classifier.predictClass(xlogits));

实例里面喂了两张图片 img0/img1,分别对应不同的类 0/1,最后通过调用 classifier.predictClass 就可以实现图像分类,是不是很简单?只是为什么用到 tf?明明是一个和神经网络不沾边的算法,后面的计算规则是什么样的?

这里先介绍一下 mobilenet 的用法。

mobilenet

mobilenet 是一个轻量的深度可分离卷积神经网络,开箱即用的预训练模型,用于移动或者嵌入式的应用场景,避免模型过于庞大导致的高内存和响应过长问题,可以用于 ImageNet 的图像分类,其目前有超过 1 千万张图片,是一个大型视觉数据库。

本文用到的是 mobilenet V1,卷积的过程可以分为深度卷积(depthwise convolution)和 1 * 1 卷积(pointwise convolution),可以有效的降低卷积运算的参数量和运算量。简单看下深度可分离卷积和标准卷积的不同。

knn-05.png

图 a 是标准卷积核 DK * DK * M * N,其中 M 为输入通道,图 b 是深度卷积核, M 个 DK * DK,图 c N 个是 1 * 1 * M 卷积核。

这里可以看一个直观点的卷积神经网络的操作:

knn-04.png

上面是一张图片经过反复的卷积、激活函数、池化最后输出到全连接层的过程,能有效的对车分类。在 mobilenet V1 里面也有类似的操作,只是这里稍微不同,是先经过深度卷积再到 1 * 1 卷积:

knn-06.png

左边是标准过程,右边是深度可分离卷积过程。

同时整个网络包含结构如下:

knn-07.png

MobileNet 网络结构里面第一层是标准的 3 * 3 卷积核,接下来是 13 个上图中的深度可分离卷积,最后通过 MEAN 平均池化来操作,输出 1 * 1024。

上面的网络计算核心位置在:

// graph_executor.ts  GraphExecutor.execute
execute(inputs: NamedTensorMap, outputs?: string[]): Tensor[] {
// 省略其余代码
  for (let i = 0; i < orderedNodes.length; i++) {
    const node = orderedNodes[i];
    if (!tensorsMap[node.name]) {
      const tensors =
          executeOp(node, tensorsMap, context, this._resourceManager) as Tensor[];
      tensorsMap[node.name] = tensors;
      // 省略其余代码
    }
  }
  // 省略其余代码
}

通过 orderedNodes 里面的结构顺序来,先是完成网络结构里面的第一层标准卷积,然后是其余的 13 层。orderedNodes 的单个节点结构如下:

export declare interface Node {
  signatureKey?: string;
  name: string;
  op: string;
  category: Category;
  inputNames: string[];
  inputs: Node[];
  inputParams: {[key: string]: InputParamValue};
  attrParams: {[key: string]: ParamValue};
  children: Node[];
  rawAttrs?: {[k: string]: tensorflow.IAttrValue};
  defaultOutput?: number;
  outputs?: string[];
}

节点里面定义了要进行的操作,以及输入节点是哪些,辅助参数等,通过遍历 orderedNodes,一步步的完成计算图。

回到前面用 knn 识别图像的例子里面,mobilenet.infer 再处理图像数据之前,需要加载模型,会发起请求 https://tfhub.dev/google/imagenet/mobilenet_v1_100_224/classification/1/model.json?tfjs-format=file 获取对应的模型数据,也就是卷积神经网络里面用到的参数,比如卷积核等。加载模式后,mobilenet.infer 将数据从 0 到 255 标准化为 0 到 1 的 tensors 对象,并 resizeBilinear 转换为 244 * 244 * 3 的大小,再执行 GraphModel.execute,通过深度可分离卷积神经网络输出图像 1 * 1024。

knn/knn-classifier

mobilenet 里面生成的图像特征,会先通过 knn/knn-classifier,进行分类。输入的训练集的时候,会有图像的特征以及其对应 lable,同一个 lable 的数据会归类到一块。

图像的特征输入时,会进行范数计算,方式为 tf.div(vec, vec.norm())范数 计算可以方便我们操作训练集数据和输入数据,举例子:

// 输入训练集
const train = [a, b, c];
const trainNorm = Math.sqrt(a * a + b * b + c * c);
const trainUnitNormal = [a / trainNorm, b / trainNorm, c / trainNorm];

// 输入矩阵
const input = [x, y, z];
const inputNorm = Math.sqrt(x * x + y * y + z * z);
const inputUnitNormal = [x / trainNorm, y / trainNorm, z / trainNorm];

这里的范数指的是两个矩阵之间的距离,具体的就是欧式距离。可知如果 input 和 train 完全相等,则 trainUnitNormal * inputUnitNormal === 1knn/knn-classifier 将训练集和输入矩阵转换后的 unitNormal 相乘,并从大到小排序。可知如果欧式距离为 1 的会排在最前面,也就是认为最相似度的图像。

计算了输入和训练集中间的距离,正常是采用为 1 的那个分类就好了,为了提高准确性,会对前 k 个进行判断。calculateTopClass 对前 k 个数据,通过偏移和序号来计算分类,最后返回权重最高的一个分类。

for (let i = 0; i < topKIndices.length; i++) {
  const index = topKIndices[i];
  for (const label in this.classDatasetMatrices) {
    if (index < classOffsets[label]) {
      votesPerClass[label]++;
      break;
    }
  }
}

// Compute confidences.
let topConfidence = 0;
for (const label in this.classDatasetMatrices) {
  const probability = votesPerClass[label] / kVal;
  if (probability > topConfidence) {
    topConfidence = probability;
    topLabel = label;
  }
  confidences[label] = probability;
}

除了 label 外还会返回对应的 classId, 每个输入的训练集都会有对应的递增的 classId,外部用 label 可以了。

knn opencv 实现

上面提到了 knn 的 python 实现,和 tfjs 的实现,最后还有 opencv 的,当然对于前端,opencv 也提供了 webassembly 版本,只是没有在 opencvjs 里面找到 knn 相关函数,只有 python 版本的,由于具体实现逻辑也不清楚就不在这里介绍了。

或者用 opencvjs 实现上面的 python 操作,本质也是添加训练图像,然后对输入图像对比,计算距离,取距离最小的前 k 个图像,并获取其分类。只是意义不太大,本质还是 knn 算法。

knn 小结

从业务的角度出发,knn 可以很好地实现图像分类,但是如果目标图像有些许变化,可能会导致分类异常,这个需要手动调整训练集。如果搭建一个神经网络去分类,又复杂化了,由于能力有限,后续就没有继续研究了。

tfjs 提供的 MobileNet,其训练数据来源 ImageNet,如果我们的输入图像是阿猫阿狗这些来源于生活的图像,输入图像的些许变化通过 MobileNet 能够被有效分析的。只是这里输入图像特定的前端界面,不在 MobileNet 范畴里面。所以目前还用不到 tfjs 里面神经网络的能力,相当于是大刀小用了,不过后续也可以在上面扩展下,增加卷积,倒是一个比较好的方向。

简单的通过 python 的方式,或者用 js 的方式也能实现 knn 算法,就是没有直接用 tfjs 的 knn/knn-classifier 方便。

knn 这里也遗留了不少问题,除了提到的目标对象稍微变化会导致分类异常,无法通用化,还有以下问题:

  1. 缺少无监督学习,每次 UI 界面有调整,都要手动更新训练集
  2. 模型结果持久化,由于采用 tf,启动时候的模型训练会消耗非常多时间,导致初始化过慢

除了 knn 算法能对图像分类外,还有两个比较简单的朴素贝叶斯算法、svm 分类,这两个也没有涉及卷积神经网络,就不多介绍了。

opencv 对比

knn 可以比较粗暴的对图像进行分类,然后在业务里面还需要对图像进一步识别,需要获取相似度以及异常区域标记。

相似度计算

相似度计算有不少方法,一开始我们用的是 compareHist 通过直方图的方式计算图像灰度值分布的相似情况,如果元素出现错位等情况或者纹理结构相似但是亮度不一致的情况,会导致计算的相似度有明显偏差。最后用的是 ssim 结构相似性来处理,会从亮度、对比度、结构等三个方面处理,具体的计算方式可以看看 getStructureSimilarity,这里可以修改高斯模糊的大小来调整分块的影响。

knn-08.png

具体计算过程可以参考上面的公式,其中 C1C2 是常数。opencv4nodejs 里面的计算也和上图原理类似。

分割白色背景的相似度计算

前面的 ssim 相似度计算已经可以很好的满足我们的使用要求了,只是由于场景存在大面积的白色区域,用户关注界面往往是其中有内容的部分,比如浏览器里面的谷歌搜索的结果页,就会有大量空白区域,不是用户关注的。下面个例子:

knn-14.png

同样的内容,但是周围空白区域大小不一样,文字处在的位置也不一样,其相似度会低到 0.899。相似度不为 1 本身,是符合预期的,只是出于业务需要,要去除掉空白的区域,只校验有内容的部分。尤其是前端响应式的适配,在大屏下可能会留下大面积的空白。出于业务考虑这里提出了白色背景分割相似度算法:

第一步是将背景去除,保留分割的图像。业务场景不需要针对每个内容区域都进行分割,比如上图的 LowCodeEngine 不用细分到每个字母,整体内容就好了,对这样的业务特征,检测水平两边内容的起始点和结束点,以及垂直方向的开始点和结束点,就是该内容的区域了。

参考Python + opencv 实现图片文字的分割的方式,采用方式是扫描每一行,从左到右扫描,记录该行的信息,但是不用细化到每个文字,记录到内容区域就好了。方式如下:

function calcContentHorizonRange(img, line) {
  let contentStart = 0;
  let contentEnd = 0;
  img.scan(0, line, img.bitmap.width, 1, (...[x, , idx]) => {
    // ...起始点和结束点计算
  });

  return {
    lineHasContent: contentStart !== 0 || contentEnd !== 0,
    contentStart: contentEnd === 0 ? 0 : contentStart,
    contentEnd,
  };
}
// 通过垂直方向,从上到下,再对每行进行从左到右的扫描
while (i < imgHeight) {
  const { lineHasContent, contentStart, contentEnd } = calcContentHorizonRange(img, i);
  // 边缘检查
  // 通过 lineHasContent 判断垂直方向连续内容区域,从而获得垂直方向的连续有内容的集合
}

calcContentHorizonRange 可以获取到每行的内容起始点和结束点的信息,如果连续有内容,则为一个有内容的垂直区域。再对该垂直区域的水平方向的起始和末尾做判断,遍历每行,分别取最小 contentStart 和最大值 contentEnd,作为该区域的水平方向起始点和结束点。最后得到就是去除背景的区域。

第二步相似度计算,相似度计算还是采用 ssim 的方式,只是这里由于分割成一块块区域了,需要一一去对比,只是由于分割后的图像不一定大小相等,同时也可能存在区域缺失的问题、边界问题,这几个细节需要专门处理的。最后整体的相似度计算是采用:

for (let i = 0; i < compareList.length; i++) {
  const area = compareList[i].width * compareList[i].height;
  sumArea += area;
  sum += compareList[i].similarity * area;
}

由于是长方型的相似度计算,所以长宽相乘就是面积,面积代表相似度的权重,最后累加得到整体的相似度。可以计算到上面两张图的相似度最后会为 1。

异常图计算

异常标红的绘制过程,先成灰度图,absdiff 对比异常部分,再用 findContours 方式,对比新图像和原图像的异常,然后用红边绘制轮廓,需要避免绘制原图像的与新图像的异常。还是用分割白色背景的相似度计算里面的图做对比,其异常标红图如下所示:

knn-13-diff.png

异常轮廓其实也反应了图像的相似度问题,如果不存在异常的轮廓,那是不是说明了原图和新图是一致的呢?

增量式算法

用 knn 可以很好的将图像分类,然后对每个类里面的图像通过 opencv 对比得到相似度和异常情况,只是分类和下一个分类之间的关系呢?分类和下一个分类之间也是存在不同的,而且这种不同有的是允许的,而有的则是异常的。通过分析场景变化,初步认为分类和下一个分类之间只存在增量的变化,其他变化可以认为是都是异常,同时如果模块和模块之间是相同的,并且位置发生变化也是异常。

图像分割

模块如何划分呢?这里有几个图像分割方法,漫水算法,分水岭算法。其中漫水算法用的是 opencv 自带的 floodFill 方法,每次通过给定种子点来注水,注水意思是查找和种子点联通的颜色相近的点,就像 ps 里面的魔术棒,适合用于单个区域选择,这里不合适用在模块划分里面。分水岭算法则会从全局出发,多个局部最低点开始注水,而不用自己选择种子点,随着水位的升高,相邻区域最终会相遇,从而出现分水岭,也就是我们的图像的分割边界了,这里介绍一下分水岭算法的一个实现

分水岭算法
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
cv.threshold(gray, gray, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU);
// 获取背景
let M = cv.Mat.ones(3, 3, cv.CV_8U);
// cv.erode(gray, gray, M);
cv.dilate(gray, opening, M);
cv.dilate(opening, bg, M, new cv.Point(-1, -1), 2);
// 距离变换
cv.distanceTransform(opening, distTrans, cv.DIST_L2, 3);
cv.normalize(distTrans, distTrans, 1, 0, cv.NORM_INF);
// 获取前景
cv.threshold(distTrans, fg, 0.1 * 1, 255, cv.THRESH_BINARY);
fg.convertTo(fg, cv.CV_8U, 1, 0);
cv.subtract(bg, fg, unknown);
show(unknown, 'unknown.png');
cv.connectedComponents(fg, markers);

for (let i = 0; i < markers.rows; i++) {
    for (let j = 0; j < markers.cols; j++) {
        markers.intPtr(i, j)[0] = markers.ucharPtr(i, j)[0] + 1;
        if (unknown.ucharPtr(i, j)[0] == 255) {
            markers.intPtr(i, j)[0] = 0;
        }
    }
}
cv.cvtColor(src, src, cv.COLOR_RGBA2RGB, 0);
cv.watershed(src, markers);

图像的某个区域如果肯定是前景对象或则背景,则标记为某个值,而不确定的部分则标记为0,然后通过原生方法 watershed (分水岭算法)得到边界,最后其边界的值会为 -1。

背景可以操作形态学腐蚀与膨胀获取,而前景对象则是通过距离变换、threshold 处理得到。而不确定的区域则通过两者相减得到。watershed 传入的第二个参数,掩膜 markers 的不确定区域需要为 0,但是经过 connectedComponents 处理后标记的背景为 0 了,需要 + 1 来提升标记值。操作结果如下图:

knn-09.png

最后输出图通过红色标记区域,可以看到对上面部分的文字能很好的识别出来,而下面的徽章部分也会被标记出来,但是徽章里面的文字,由于前景对象和背景对象获取的差异,使得 marker 里面不存在徽章的文字差异,最后导致文字无法被识别出来。可以看到徽章里面仅有个别圈被标识出来,无法在我们的业务场景里面使用。

轮廓检测算法

轮廓检测采用灰度图、阈值和轮廓的方式来处理,比较简单的就能获取到图像的轮廓,代码实现:

cv.cvtColor(src, gray, cv.COLOR_BGR2GRAY, 0);
cv.threshold(gray, gray, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU);
cv.findContours(gray, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);

处理方式和上面的异常图计算方式是类似的,效果如下:

knn-11.png

和分水岭算法的问题类似,上面文字部分可以很好的识别出来,但是徽章里面的文字无法检查到。findContours 方法里面第四个传参 cv.RETR_EXTERNAL 指的轮廓的查找模式,其中:

  1. cv.RETR_EXTERNAL: 只提取最外面的轮廓;
  2. cv.RETR_LIST:表示提取所有轮廓并将其放入列表;
  3. cv.RETR_CCOMP:表示提取所有轮廓并将组织成一个两层结构;
  4. cv.RETR_TREE:表示提取所有轮廓并组织成轮廓嵌套的完整层级结构;

这里如果采用 RETR_CCOMP 模式则可以很好的获取到所有的轮廓了,如下图:

knn-10.png

只是这里回到原本的意图,按模块分割图像,cv.RETR_CCOMP 直接分到了最小的单元了,比如 code 单词里面的字母 od 都被分成两块了,当然分水岭算法也有这个问题。模块的划分是为了后续的相似度对比检查,细分到最小单元容易导致相似度检查异常,而按照模块来对比则会好很多,比如字母 od 中间的圈是很相似的,但是字母 od 本身是不相似性,只是采用 cv.RETR_EXTERNAL 又会出现下面徽章部分遗漏的问题。那如何处理好呢?

cv.RETR_TREE 模式提取轮廓的同时会组织轮廓的嵌套关系。对于模块的概念,如何划分呢?初步认为超过一定面积大小的区域则认为也是子轮廓。最后判断方式为外轮廓和超过一定区域的子轮廓。

轮廓里面有如下概念

每个轮廓有他自己的关于层级的信息,谁是他的孩子,谁是他的父亲等。OpenCV 用一个包含四个值得数组来表示:[Next, Previous, First_Child, Parent]`

可以看到 NextPrevious 就是兄弟轮廓,而后面的则是子轮廓和父轮廓对应的序列。轮廓关系有个经典图:

knn-15.jpg

这里面 2a 和 2 不属于同级关系,而是父子关系,2a 是 2 的子层级,而 3 又是 2a 的子层级,4 和 5 是兄弟关系。每个轮廓都有其对应的父子,兄弟关系,如果没有就是 -1。

function workInLayer(index) {
  if (index === undefined || index === -1) return;

  const hier = hierarchy.intPtr(0, index);
  if (hier[2] !== undefined && hier[2] !== -1) {
    // 存在 grandChild
    workChildLayer(hier[2]);
  }
  if (hier[0] !== undefined) {
    workInLayer(hier[0]);
  }
}

function workChildLayer(index) {
  // 判断面积大小,超过一定面积的子轮廓,才继续迭代
  collectPartitionRectList(index, 30);
  const hier = hierarchy.intPtr(0, index);
  // 处理兄弟节点
  workChildLayer(hier[0]);
  // 处理该节点的子节点
}

for (let i = 0; i < contours.size(); ++i) {
  const hier = hierarchy.intPtr(0, i);
  // 只处理最外层 EXTERNAL
  if (hier[3] !== -1) {
    continue;
  }

  collectPartitionRectList(i);
  // 处理子轮廓,会调用 collectPartitionRectList
  workInLayer(hier[2]);
}

hierarchy.intPtr 的方式可以拿到每个轮廓对应的层级关系,在通过关系的索引分析得到我们需要的模块,从而实现区域的划分。

可以看出轮廓检测算法比分水岭算法更加的灵活,方便我们对复杂情况的处理,在加上其他的区域大小的判断,能较好的解决模块划分问题。

模块相似度对比

模块区分还需要对模块的相似对对比,这里会先将模块按照位置排序,采用一一对比的方式,如果相似度大于一定值,则认为是同一个模块。模块之间出现位置偏移,或者新增元素都会被认为是异常。位置偏移判断比较简单,通过位置判断就可以了,只是需要注意的是模块位置可能出现的复杂变化,比如原本是 abc,现在变成 adcb,这里 bc 都存在着变化,问题点是如何对比这种错乱的循序关系。

最理想的是一个个对比,从第一个开始到最后一个,取最相似和最靠进当前位置的一个,只是这样时间复杂度太高接近 O(2),而且每次相似的计算非常耗时,会导致整个程序运行非常慢。观察分类和下一个分类之间的的图像可以发现,模块基本顺序是不会变化的,只是会出现个别模块的新增加,以及少部分的消失。于是可以按顺序对比,如果下一个分类出现不相同的模块则跳过,继续对比下一个分类的下个模块,同时也要发现 abcadcb 这样的变化。

可想而知如果下个分类里面没有上一个分类的某个模块那会一直对比直到最后一个元素,只是如果考虑我们的场景,要对比模块是不是发现了移动和变化,所以如果超过一个距离有或者间隔太多模块了,则可以认为是两个毫不相关的模块了,也就是不用继续对比下去了,从而提前结束该模块对比。

opencv 小结

关于 opencv 的还有很多事情可以做的,比如动态区域识别,如果输入的图像是,动态的,比如前端常见的 loading 动画,就会出现识别问题;相似度对比,分模块的方式还可以继续摸索寻找最合适业务的一条路,并且 opencv 还可以和 tfjs 结合到一起,或者完全可以用 mobilenet 分割图像的方式,只是初入,后面的方向还有待探索。

总结

春节前一两周接手任务,到 3 月中开始写博客,边写还边查缺补漏的,之间差不多学了三个月,越学习越是觉得还有很多地方待研究的,看了 cs231n计算机视觉课程,当然只看前面 19 个课程,不理解的部分还反复看了三遍,学了好多东西,只是继续投入代价又非常高,而且缺少方向性的建议,就工作外的扩展。虽然不想浅尝辄止,不过后续项目也交付到其他团队,继续学习投入代价也高,所以探索之路就到这里了。

回顾下,这次应该是从 react 源码学习之后久违长时间的研究另外一个方向,并且有不少收获,之前也长时间学习了 webgl,数据可视化,只是应用都非常少,基本只是在 demo 阶段。AI 方向学习要比前端门槛高不少,学习的过程中也一直子反思,深度学习要如何和前端结合上呢?除了对比页面差异,还有哪些呢?还是要落到图像、语音、视频识别检测上,毕竟图像不能回传到后端再返回处理结果,只是在我们团队好像也没有其他智能化可以挖掘的领域,前阵子比较火的 imgcook 去年声音也越来越少,前端智能化还是在探索的领域。

参考

  1. SSIM---结构相似性算法
  2. MobileNet v1模型详读
  3. Python + opencv 实现图片文字的分割
  4. Opencv分水岭算法
  5. Image Segmentation with Watershed Algorithm
  6. OpenCV-Python教程:22.轮廓层级
  7. 最新斯坦福李飞飞cs231n计算机视觉课程

13. 初识系列:nodejs之stream可读流

前言

这是初识系列的第一篇:stream可读流。刚接触stream的时候有点难以理解,在客户端开发,基本接触不到stream,顶多也就是文档下a载的时候,后端返回文件流,这个和stream沾边的东西。如此神秘,自然成为了首个研究的对象。nodejs对象里面有可读流,可写流,还有可读可写流,像HTTP响应Response对象就是可读流,而服务端的是可写流,下面介绍一下可读流Readable。

基本

常见用到可读流的情景是用fs.createReadStream(path[, options]),并通过监听可读流的dataend事件来操作,或则是用pipe方法将可读流的数据流到可写流里面。

可读流里面有两个构造函数,一个是Readable,一个是ReadableState。先看看ReadableState的构造函数:

function ReadableState(options, stream) {
  // ...省略部分代码
  // objectMode 对象流模式,返回的是对象而不是n字节缓存,hwm:高水位标志
  var hwm = options.highWaterMark;
  var defaultHwm = this.objectMode ? 16 : 16 * 1024;
  this.highWaterMark = hwm || hwm === 0 ? hwm : defaultHwm;
  this.highWaterMark = Math.floor(this.highWaterMark);

  //BufferList是可读流的缓冲区,其结构类似与C语言的链表,操作比数组快
  this.buffer = new BufferList();
  //缓冲区大小长度
  this.length = 0;
  // pipes:目标对象流,pipesCount为目标对象流长度
  this.pipes = null;
  this.pipesCount = 0;
  // flowing模式标志,ended为可读流结束标志,endEmitted:是否已经触发ended
  // reading:是否正在正在调用this._read方法
  this.flowing = null;
  this.ended = false;
  this.endEmitted = false;
  this.reading = false;
  // 异步标志置为true,用来控制'readable'/'data'事件是否立即执行
  this.sync = true;
  // 是否需要触发readable事件,
  this.needReadable = false;
  this.emittedReadable = false;
  this.readableListening = false;
  this.resumeScheduled = false;

  this.destroyed = false;

  // 目标流不处于drain状态时,等待drain事件的数量;
  this.awaitDrain = 0;
  // 是否正在读取更多数据,maybeReadMore函数
  this.readingMore = false;
  //.. 省略编解码相关部分
}

BufferList,就是读取过程中操作的缓冲池。ReadableState构造函数基本上是用来控制可读流的标志,其中最常见的就是flowing。可读流的工作模式分为flowing模式和pause模式。一般直接使用readable.pipe() 方法来消费流数据,因为它是最简单的一种实现,如果你想要精细控制,那就是通过控制flowing标志以及其他来实现。

添加chunk

可读流的数据从哪里来?或许最常见的就是用fs模块来createReadStream,然后直接读取就好了,似乎不涉及到chunk的添加过程。但是createReadStream又是如何创建可读流的呢?到最后还是需要Readable的push这个API,不断地push(chunk),也就是往缓冲区添加数据。下面介绍一下push方法:

function addChunk(stream, state, chunk, addToFront) {
  if (state.flowing && state.length === 0 && !state.sync) {
    stream.emit('data', chunk);
    stream.read(0);
  } else {
    // update the buffer info.
    state.length += state.objectMode ? 1 : chunk.length;
    if (addToFront) state.buffer.unshift(chunk);else state.buffer.push(chunk);

    if (state.needReadable) emitReadable(stream);
  }
  maybeReadMore(stream, state);
}

上面是addChunk方法,而Readable的push,会先检查objectMode,若不是objectMode,当压入的数据chunk是一个Buffer, Uint8Array或者string,objectMode就可以是any了,在readableAddChunk函数里面会state.reading = false添加块的过程并不是在执行_read方法。
回到addChunk方法,先看看else语句,可以发现添加chunk只是修改state.length,同时调用push/unshift来把chunk添加到缓冲区里面。并根据情况来触发readable事件。readable事件表明会有新的动态,要么有新的数据,要么到了流的尾部。而前面if语句里面,为flowing模式,并且缓冲区没有数据,且为同步模式下,才会触发data事件,并执行read(0)read(0)在满足条件的情况下,只是简单触发readable事件而不会读取当前缓冲区,后面会介绍到。addChunk结束部分还调用了maybeReadMore,在read部分会介绍到。

read

Readable.prototype.read方法,用于读取缓冲池里面的数据,其开始如下:

  if (n === 0 && state.needReadable && (state.length >= state.highWaterMark || state.ended)) {
    debug('read: emitReadable', state.length, state.ended);
    if (state.length === 0 && state.ended) endReadable(this);else emitReadable(this);
    return null;
  }

在开始的时候如果n=0,并且符合其他条件,则会执行emitReadable,接着触发readable事件,并返回null,这个时候read(0)并不会触发缓冲区的数据读取,只是简单的触发readable事件,这个实现还是很巧妙的。

  n = howMuchToRead(n, state);
  if (n === 0 && state.ended) {
    if (state.length === 0) endReadable(this);
    return null;
  }
  var doRead = state.needReadable;
  debug('need readable', doRead);
  // 数据比高水线低,那就需要读
  if (state.length === 0 || state.length - n < state.highWaterMark) {
    doRead = true;
    debug('length less than watermark', doRead);
  }

  if (state.ended || state.reading) {
    doRead = false;
    debug('reading or ended', doRead);
  } else if (doRead) {
    debug('do read');
    state.reading = true;
    state.sync = true;
    if (state.length === 0) state.needReadable = true;
    this._read(state.highWaterMark);
    state.sync = false;
    if (!state.reading) n = howMuchToRead(nOrig, state);
  }

这里面先是重新计算n,
n===NaN的时候,读取缓冲区的第一个节点或则所有缓冲数据,
n<=state.length返回n,并且当n>hwm的时候,重新计算高水位,为最小大于n的2^x,
n>state.length的时候,返回0,并使得state.needReadable=true
下面则是通过needReadable来判断是否执行_read方法,_read方法是需要自定义实现的,在方法里面手动添加数据到缓冲区里面,以便后面读取数据以及判断缓冲区长度。由于_read可能是同步的方法,修改缓冲区,所以需要重新评估n,以便后面获取数据。
后面部分,获取数据:

  var ret;
  if (n > 0) ret = fromList(n, state);else ret = null;
  if (ret === null) {
    state.needReadable = true;
    n = 0;
  } else {
    state.length -= n;
  }
  if (state.length === 0) {
    if (!state.ended) state.needReadable = true;
    if (nOrig !== n && state.ended) endReadable(this);
  }
  if (ret !== null) this.emit('data', ret);

  return ret;

这一部分就简单了,就是获取数据,设置needReadable,并触发data事件,供可读流监听,操作chunk。

needReadable的作用

在read方法里面,needReadable经常被修改为true,这个有什么用呢?
addChunk里面若执行if语句里面的read(0),由于需要state.length>=htm是不会触发readable事件的,相反执行else语句,添加chunk之后,就触发readable事件,并将所有的缓冲区数据读出来,相当于把之前的数据,和本次加的数据都读出来了。addChunk的最后面也会通过调用maybeReadMore来执行read(0),其实现如下:

function maybeReadMore(stream, state) {
  if (!state.readingMore) {
    state.readingMore = true;
    processNextTick(maybeReadMore_, stream, state);
  }
}
function maybeReadMore_(stream, state) {
  var len = state.length;
  while (!state.reading && !state.flowing && !state.ended && state.length < state.highWaterMark) {
    debug('maybeReadMore read 0');
    stream.read(0);
    if (len === state.length)
      // didn't get any data, stop spinning.
      break;else len = state.length;
  }
  state.readingMore = false;
}

当添加chunk的时候,若state.lenght<hwm则会执行stream.read(0),从而肯定会执行_read方法,导致缓冲区增加,直到缓冲区长度超过高水线,同时最后一次调用stream.read(0),也不会触发readable事件。
另外在Readable.prototype.on函数,readable事件的处理函数里面,异步调用read(0)
ps:maybeReadMore用了异步调用maybeReadMore_,是为了让本轮循环里面调用的_read执行完先,在_read里面的每个添加chunk的步骤,都会执行maybeReadMore函数,若同步执行maybeReadMore_,缓冲区数据将会远远超标。

数据读取ended

当数据读取结束后,要显式的执行push(null)/unshift(null)来调用onEofChunk方法。在onEofChunk里面,会通过emitReadable处理剩余的数据,并设置ended为true。emitReadable方法里面,通过while循环来读取剩余数据,使得state.length为0,并执行endReadable方法。

function endReadableNT(state, stream) {
  // Check that we didn't get one last unshift.
  if (!state.endEmitted && state.length === 0) {
    state.endEmitted = true;
    stream.readable = false;
    stream.emit('end');
  }
}

endReadable方法异步调用endReadableNT,置readable为false,并触发end事件。这样数据读取就结束了。

pipe管道

readable里面的pipe方法,主要部分是事件上的监听,核心部分如下:

  dest.emit('pipe', src);

  // start the flow if it hasn't been started already.
  if (!state.flowing) {
    debug('pipe resume');
    src.resume();
  }

触发pipe事件,同时开启flowing模式,并在结束的时候,会调用cleanup清理掉之前监听的事件。如果在pipe的时候,src又添加数据,而目标文件不处于drain状态,就需要监听drain事件,来单独处理。

45. 工程化新秀

炎热的七月,透着一点雨水,就这么来临。半年就如此过去了,看了不少内容,但是想写成博客的却越来越少,可能是人懒了。
最近不少工程化的新秀如后浪般出现,虽然不至于动摇 webpack 这个巨浪,只是对行业也有很深刻的影响,觉得蛮有意思,这里介绍一下:

esbuild

esbuild 做的事情很简单,打包压缩,没有其他的复杂功能,目前也没有其他的插件系统,倒是 esbuild 本身更像一个插件,有点像 webpack 刚出来那会的情况。

esbuild 最大特点就是快,飞快,其本身采用 Go 语言实现,加上高并发的特色,在打包压缩的路上,一骑绝尘。官方数据,和正常的 webpack 相比,在打包方面提高了 100+ 倍以上,这对于需要代码更新后立刻发版到线上的项目而言,超级有意义,这不就是大家一直追求的快速构建嘛。

在构建项目的时候,基本都可以看到这一幕,打包到最后,本以为要结束了,结果进度条一直在 90% 左右的位置,一动不动,尤其是项目大了之后。其实这个最后的过程,是代码丑化、压缩以及 tree-shaking 的过程。代码压缩这部分,在以前的 webpack,是 UglifyjsWebpackPlugin 来处理的,后来内置到 webpack 里面,再后来,由于 uglify-js 不支持 es6,改用 terser 作为 webpack 内置的默认打包压缩工具。即便如此,业务小的时候还好,上来后,打包的时间会非常长。

本地尝试

按照文档思索着建一个最小的 demo,来看看速度如何。按照首页的提示,采用如下内容,分别用 webpack 和 esbuild 来打包:

// 业务内容
import * as React from "react";
import * as ReactDOM from "react-dom";

ReactDOM.render(<h1>Hello, world!</h1>, document.getElementById("root"));

// webpack 配置,只是对jsx采用babel打包,同时还要配置babel的基本配置
module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },
};

// esbuild 指令内容,由于采用首页的方式一直报错,最后根据错误,改为如下指令
esbuild --bundle main.jsx --outdir=dist --minify --sourcemap

采用 esbuild 的时候,可以明显感觉到速度飞快,基本上 半秒不到就打包好了,而 webpack 嗯。。。三四秒的样子,速度还是很明显的,可能是因为项目小,没有 100 倍的感觉,但是 esbuild 基本上不用等。只是看看打包的体积,发现 esbuild 的体积比 webpack 的大三倍。这难道是时间换体积?经过排查是 process.env.NODE_ENV 的问题,esbuild 的版本里面包含了 development 和 production 两个模式的内容。官方文档有提示到:

Note that the double quotes around "production" are important because the replacement should be a string, not an identifier. The outer single quotes are for escaping the double quotes in Bash but may not be necessary in other shells.

process.env.NODE_ENV 变量需要配置,并且不能省略 "production" 的引号,只是在 json 里面,添加引号一直无法正常使用,去掉引号会导致无法识别变量,最后采用 api 的方式构建,如下

const { build } = require("esbuild");

build({
  entryPoints: ["./main.jsx"],
  outdir: "dist",
  minify: true,
  bundle: true,
  sourcemap: true,
  define: {
    "process.env.NODE_ENV": '"production"',
  },
}).catch(() => process.exit(1));

需要注意的是,define 里面的 key-value 结构的 value 不能是对象,不支持嵌套的 key。最后会打包有如下效果:

// 原本的 process.env.NODE_ENV 会被替换,development的内容会被设置为null
if (true) {
  checkDCE();
  module.exports = require_react_dom_production_min();
} else {
  module.exports = null;
}

最后回头一看发现和 webpack 打包的体积居然是一模一样的,esbuild 大了 0.5k 不到。另外有个有趣的现象,如果把 bundle 配置去掉,包的内容,真的只有上面的 react 的业务代码。

想看看 esbuild 的源码,专门学了一下 go 语言,发现还是蛮简单(可能是学比较基础)。只是三脚猫功夫直接看源码,还是云里雾里的,也就放弃了。

esbuild-webpack-plugin

看到 umi 里面支持 esbuild,具体可以看看 esbuild-webpack-plugin 的代码。结构是一个典型的 webpack 的插件,通过 esbuild 的 transform 这个 api 来实现打包,可以看看下面的配置:

const transform = async () =>
  await ESBuildPlugin.service.transform(source, {
    ...this.options,
    minify: true,
    sourcemap: !!devtool,
    sourcefile: file,
  });

官方介绍到,如果需要重复调用 esbuild 的 api,最好是实例化 esbuild,达到复用的方式,也就是采用 transform 这个 api。

可以看到上面的代码,采用的配置只是 minify 而已,没有对 bundle 处理,按照作者的介绍

esbuild 有两个功能,bundler 和 minifier。bundler 的功能和 babel 以及 webpack 相比肯定差很多,直接上风险太大;而 minifier 倒是可以试试,在 webpack 和 babel 产物的基础上做一次压缩,坑应该相对较少。

这样确实不错,让 esbuild 做最专业的事情,同时可以继续使用生态丰富的 webpack,而压缩则是 esbuild,作者说到: 试验性功能,可能有坑,但效果拔群,具体的时间效果也不对比了,送上传送门。效果只是减少 1/3,估计是 webpack 本身其他操作占用了时间。

这个插件有配合 umi 的部分,但也可以用到其他 webapck 项目里面。具体是要配置 optimization.minimizer 如下:

optimization: {
  minimizer: [new (require("esbuild-webpack-plugin").default)()];
}

正常的 webpack 会采用 terser 作为内置的默认压缩工具,这里面改为 Esbuild 就可以了。

ES Module

上面的 esbuild,可以说很好的解决了生产模式的压缩疼点,提高打包速度,但是开发环境呢?能用上 esbuild 吗?当然也是可以的,只是最优解并非如此。

有一次,看到一个线上地址 https://iconsvg.xyz/ 的页面,打开控制台一看发现居然是采用 ES Module 的形式,如下图。

现在的浏览器基本已经支持 ES 模块化了,直接模块化有什么不可以?直接用在生产环境会有很多问题,比如请求加载数量,比如兼容性,那对于开发环境呢?如 vite 和 snowpack 这样的工具已经就是 bundleless 的工具,在开发环境上采用 ES Module 的方式实现快速热更新。

对于 ES Module,目前文件扩展名为 .js 结构,有推荐采用 .mjs 后缀,可以更清晰的表明是个模块,由于兼容问题,现在采用 .js 后缀就可以了。

应用的时候要采用下面的格式,来声明这个脚本是一个模块:

<script type="module" src="main.mjs"></script>

如果没有声明 type="module" 浏览器会提示 Uncaught SyntaxError: Cannot use import statement outside a module 错误。

vite

vite 在开发环境通过解析文件返回到浏览器,不会有打包过程。这样当修改项目某个文件的时候,只会向浏览器发送更新该文件的请求,而不会去处理别的文件,最终打包的速度项目大小没有关系,可以很大提高开发环境热更新效率。需要注意的是 vite 在生产环境采用 rollup 打包。

开发服务器劫持

vite 在开发环境的定位和 webpack-dev-server 是有点像的,都是作为一个开发服务器,响应客户端的请求。先看看 demo 上具体的效果,官方直接提供一个 create-vite-app 项目作为起步脚手架模板,上面提供 vue 到 react 的模板,采用 template-vue 模板,启动的时间非常快,基本上按下回车差不多就跑起来了。可以看看下图:

几秒钟的时间,项目就启动完毕了,对比一下 vue-cli 3,差不多要 10s 的样子,当然也是由于业务体积的问题,少量的业务,webpack 自然是非常快的(复杂的例子,就没有了,因为 vite 支持的是 vue-next,老项目用的是 vue 2 可能支持力度不好,无法迁移)。

通过控制台可以看一下,发起的请求:

前面是 vite 加载过程,后面是 vue-cli 3 的项目,可以明显看到 vite 是直接请求了 .vue 文件以及 vue.js 文件,而 vue-cli 3 则是请求打包好后的开发文件,只是前图的 vite 里面明明是一个 App.vue 文件为什么会请求三次呢?这里就要说一下 vite 作为开发服务器对网络的劫持作用。

vite 里面会启动一个 koa 服务器,采用中间件的方式对请求的文件劫持,结构如下

// 省略部分代码
const resolvedPlugins = [
  moduleRewritePlugin,
  htmlRewritePlugin,
  moduleResolvePlugin,
  proxyPlugin,
  serviceWorkerPlugin,
  hmrPlugin,
  vuePlugin,
  cssPlugin,
  esbuildPlugin,
  jsonPlugin,
  assetPathPlugin,
  serveStaticPlugin,
];

插件的配置从查找模块、模块路径重写到 vue、css 等资源的处理,客户端请求什么内容,就由专门的中间件处理。比如入口,请求 http://localhost:3000/ 返回的是 index.html,但是结果如下:

中间的 script 部分是和原 index.html 不一样的。额外加载 hmr 文件,正是上面 vite 请求网络图里面的 hmr 请求,同时还注入了全局的环境变量 process.env.NODE_ENV,可以看一下是如何实现的:

// htmlRewritePlugin 的内容,下面是注入的代码
const devInjectionCode =
  `\n<script type="module">\n` +
  `import "${hmrClientPublicPath}"\n` +
  `window.__DEV__ = true\n` +
  `window.process = { env: ${JSON.stringify({
    ...env,
    NODE_ENV: mode,
    BASE_URL: "/",
  })}}\n` +
  `</script>\n`;
async function rewriteHtml(importer: string, html: string) {
  // 省略缓存以及script标签替换内容
  return injectScriptToHtml(html, devInjectionCode);
}
app.use(async (ctx, next) => {
  await next();
  if (ctx.status === 304) {
    return;
  }
  if (ctx.response.is("html") && ctx.body) {
    // 省略部分代码
    ctx.body = await rewriteHtml(importer, html);
    return;
  }
});

除了 html 的特殊处理外,vite 还会对 import { createApp } from "vue" 这样的 import 语句重写路径为 import { createApp } from "/@modules/vue.js",前者的路径客户端是无法正常找到的,通过重写 @modules vite 可以明白这是一个第三方模块包的请求,对于这些 node_modules 的包可以做一系列的优化,后面会介绍到。

vue 文件处理

对于 vue 单文件的处理,首个文件的访问路径还是源于 main.js 的正常 import,但是到了 vite,.vue 文件则会被 vuePlugin 处理,毕竟浏览器无法识别 .vue 文件,需要解析再返回给浏览器。先看看原始代码 App.vue:

// 原始文件
<template>
  <div class="hehe">522215{{ a }}</div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      a: 123
    };
  }
};
</script>

<style scoped>
.hehe {
  background: red;
}
</style>

拦截后输出的文件

截图是返回的 App.vue 文件,可以看到原始的 .vue 文件变成一个 js 文件,也就是上图的代码。上图仅保留了原 App.vue 里面的 script 部分,渲染模板 template 以及样式 style 在 script 部分里通过 import 的方式引入,一个 .vue 文件拆分成三个。于是就有了左边 network 里面请求的 App.vue?type=style&index=0App.vue?type=template。拆分成三个请求,每个请求各司其责,比如更新 template 的时候,就发送新的 template 文件到客户端,避免一次修改三个文件:script、template 和 style 混在一起发送,可以说很巧妙。

vuePlugin 里面的实现,更多的是对请求路径的参数判断,如果参数 type 为 undefined(就是 script)、template 以及 style,都分别处理,同时在 script 的时候,如果文件是 typescript,还会采用 esbuild 的 transform API 来编译代码。

三个请求的由来,其实可以追朔到 vue 对 sfc 文件的解析,在 sfc 单文件处理的模块里面,会通过 ast 的方式将文件拆分成,script、template 和 style 三个模式,自然 vite 里面应该按照这三个模式更新 vue 是最合理的。

热更新机制

上面截图以及代码部分可以看到 hmr 的字样,hmr 则是代表热更新的部分。热更新分为两部分,一部分在客户端,一部分在开发服务器。客户端的主要热更新的代码,在 html 访问的时候,已经通过 import "${hmrClientPublicPath}" 这样的方式加载,而 vite 也会通过 chokidar 来监听访问过的文件,当文件变化的时候,会通过 websocket 来通知客户端,再由客户端请求具体的更新代码。

// 客户端主要代码
socket.addEventListener("message", async ({ data }) => {
  switch (type) {
    case "connected":
      console.log(`[vite] connected.`);
      break;
    case "vue-reload":
    // 重新加载vue
    case "vue-rerender":
    // vue 组件重新渲染
    case "style-update":
    // 样式更新
    case "style-remove":
    // 移除样式
    case "js-update":
    // js更新,react项目更新依赖这个
    case "custom":
    // 自定义的,目前没有用到好像
    case "full-reload":
    // 整个页面重新加载,
  }
});

客户端对 vue-rerender 的指令,在加载文件后,会直接调用 vue-next 里面的热更新的函数:

// @vue/runtime-core > hmr > rerender 代码
function rerender(id: string, newRender?: Function) {
  const record = map.get(id)
  if (!record) return
  // Array.from creates a snapshot which avoids the set being mutated during
  // updates
  Array.from(record).forEach(instance => {
    if (newRender) {
      instance.render = newRender as InternalRenderFunction
    }
    instance.renderCache = []
    // this flag forces child components with slot content to update
    isHmrUpdating = true
    instance.update()
    isHmrUpdating = false
  })
}

可以看到这里将新的 render 注入,也就是 template 解析后生成的渲染函数,再调用实例的 update 方法,而这个 update 方法是,vue-next 里面渲染组件的主要入口,采用 effect 的方式。

服务端监听本地文件的变化。在 vue 的中间件里面,会对更新后的文件发送对应的指令,这里提一下重新加载 vue 文件和重新渲染 vue 组件的处理的方式不同。

// descriptor 是 vue-sfc 里面通过 ast 分析出来的描述器
if (!isEqualBlock(descriptor.script, prevDescriptor.script)) {
  return sendReload();
}

if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
  needRerender = true;
}

可以看到如果前后脚本不一致会重新加载,而如果只是模板不一样,则只会重新渲染组件。这里可以看到是对 vue 的处理,那如果是 react 项目呢?

react 项目处理

在上面的代码里面,我们经常可以看到 vue 的影子,比如 vue 的中间件,vue 的客户端的热更新代码,而对于 react 是需要特殊的配置的,这里我们看看 react 项目的配置时候需要的插件:

// @ts-check
const reactPlugin = require("vite-plugin-react");

/**
 * @type { import('vite').UserConfig }
 */
const config = {
  jsx: "react",
  plugins: [reactPlugin],
};

module.exports = config;

在 vite.config.js 里面需要按照如上配置,而之前的 vue-next 则是什么都不用写。可以明显感觉到 vite 里面 vue-next 是一等生,毕竟连客户端的热更新代码,都用到 vue 的热更新部分。。。。

通过 vite-plugin-react 可以向 vite 项目提供更多的中间件,这个也是类似于 vue 的中间件,只是一个是内置,一个第三方包来配置。通过劫持 html 返回自己的运行时更新代码 react-refresh 部分以及 vite 的 hmr 客户端代码。

//  vite-plugin-react 里面代码
module.exports = {
  resolvers: [resolver],
  configureServer: reactRefreshServerPlugin,
  transforms: [reactRefreshTransform],
};

// vite 里面处理插件的 transforms 的方法
app.use(async (ctx, next) => {
  await next();

  const { path, query } = ctx;
  let code: string | null = null;

  for (const t of transforms) {
    if (t.test(path, query)) {
      ctx.type = "js";
      if (ctx.body) {
        code = code || (await readBody(ctx.body));
        if (code) {
          ctx.body = await t.transform(
            code,
            isImportRequest(ctx),
            false,
            path,
            query
          );
          code = ctx.body;
        }
      }
    }
  }
});

reactRefreshServerPlugin 会先服务器添加中间件,当访问 html 代码的时候,则会注入基本的全局代码;transforms 则会在 vite 开发服务器搭建的时候,通过 transforms 方式添加中间件,对 jsx/tsx 文件处理,注入以下关键代码。

const header = `
  import RefreshRuntime from "${runtimePublicPath}";
  let prevRefreshReg;
  let prevRefreshSig;
  if (!window.__vite_plugin_react_preamble_installed__) {
    throw new Error(
      "vite-plugin-react can't detect preamble. Something is wrong. See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201"
    );
  }
  if (import.meta.hot) {
    prevRefreshReg = window.$RefreshReg$;
    prevRefreshSig = window.$RefreshSig$;
    window.$RefreshReg$ = (type, id) => {
      RefreshRuntime.register(type, ${JSON.stringify(path)} + " " + id)
    };
    window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
  }`.replace(/[\n]+/gm, "");

const footer = `
  if (import.meta.hot) {
    window.$RefreshReg$ = prevRefreshReg;
    window.$RefreshSig$ = prevRefreshSig;
    import.meta.hot.accept();
    RefreshRuntime.performReactRefresh();
  }`;

上面是注入的代码,header 和 footer。可以看出来来,主要注入的部分是热更新相关的。其中有个很特别的地方 import.meta.hot,这个是 vite 特有的标记;

For manual HMR, an API is provided via import.meta.hot.
For a module to self-accept, use import.meta.hot.accept:

export const count = 1;
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    console.log("updated: count is now ", newModule.count);
  });
}

这是 import.meta.hot 的用法,对于正常的需要热更新的代码文件,可以通过 import.meta.hot 这个条件语句判断。当内容更新的时候,加载内容,并执行下面 accept 的回调,至于回调里面如何处理,则需要自己控制了。react 采用的则是 import.meta.hot 的方式,更新的方式,当然是通过 react-refresh 来。

上面客户端热更新方式里面,有一种是 js-update,当 jsx 文件更新的时候,会通知到客户端执行 js-upload,并最终加载新的 jsx 文件,当然同时也包含上面的添加的代码。

在 js-update 里面,会分析服务端下发文件的 id 路径,而加载哪些文件,则是根据这个 id 路径来判断的,通过分析 id 的所有依赖,依次加载。这些依赖的来源,并不是 webpack 打包时候分析的 import 的包,而是需要用户调用 accept 或者 acceptDeps 显示添加的,以及依赖更新后的回调函数。比如下面的方式:

import { foo } from "./foo.js";

foo();

if (import.meta.hot) {
  import.meta.hot.acceptDeps("./foo.js", (newFoo) => {
    // the callback receives the updated './foo.js' module
    newFoo.foo();
  });

  // Can also accept an array of dep modules:
  import.meta.hot.acceptDeps(
    ["./foo.js", "./bar.js"],
    ([newFooModule, newBarModule]) => {
      // the callback receives the updated modules in an Array
    }
  );
}

官网介绍的这种方式,通过 acceptDeps 指明依赖的路径,当文件变化的时候(指的是自身或者 import 进来的文件),会加载 acceptDeps 中的文件,执行对应的回调。如果不需要指出具体的依赖,比如像 react 的方式,采用 import.meta.hot.accept(),表明是自身的更新,或者是自身 import 的文件的更新,重新加载本身,也就是 jsx 文件就好了。

回到 react 身上,采用 import.meta.hot.accept() 的方式加 react-fresh 的热更新,好像不是最稳妥的,毕竟每次修改,都要重新加载一次文件,再去更新,没有 vue 来得优雅。当然还有就是不像 sfc 那样需要拆分成三个文件。

vite 启动

前面介绍了 vite 的拦截,vue 和 react 的处理,但是在一开始的时候会解析 package.json 中的文件,对 dependencies 中的包缓存到 node_modules/.vite_opt_cache 里面,不管项目中有没有遇到。多次访问的时候,缓存可以提高访问速度,比如对 vue-next 访问速度的提高。

snowpack

snowpack 和 vite 都是优秀 ES Module 加载方案,发力的领域也是开发环境。vite 文档介绍到,项目依赖关系的处理是受到 snowpack 的启发,在开发环境上都是会启动一个开发服务器,并且解析返回速度也是类似的。可以看出来 vite 有不少方面是借鉴了 snowpack 。

当然 vite 有自己特色的部分,比如 热更新,可以做到深入到 vue 的热更新机制,以及调用 react 的热更新,当然 vite 里面 vue 是第一公民。snowpack 不同于 vite 的地方在于,其构建的时候,支持 webpack 和 Parcel 等,这样无疑对开发者更加友好。

这里很好的介绍了 snowpack 构建的 O(1) 的过程,基本上每次文件更新都小于 50ms。well,现在 webpack 5 也做了很多优化,本地开发没有这么不堪了。上图也适用于 vite,两者都是 ES Module 级别的构建。

snowpack 的劫持

snowpack 和 vite 很不一样,vite 使用 koa 中间件的方式,对不同的文件处理,snowpack 没有中间件的概念,没有 koa 甚至是 express,采用 http-proxy、http 和 http2 来搭建开服服务器。

先看看网络加载情况

可以看出在 vite 里面 App.vue 被拆分成三个文件加载,而这里,只是分成两个文件,app.js 包含 script 和 template, app.css.proxy.js 则是 style 部分。

snowpack 采用外部插件来解析 vue 的方式,比如 vue 项目里面的配置:

// snowpack.config.json 里面的配置
"extends": "@snowpack/app-scripts-vue"

// @snowpack/app-scripts-vue 里面的配置
const scripts = {
  "mount:public": "mount public --to /",
  "mount:src": "mount src --to /_dist_",
};

module.exports = {
  scripts,
  plugins: ["@snowpack/plugin-vue", "@snowpack/plugin-dotenv"],
  installOptions: {},
  devOptions: {},
};

// @snowpack/plugin-vue 里面的配置
module.exports = function plugin(config, pluginOptions) {
  return {
    defaultBuildScript: "build:vue",
    async build({ contents, filePath }) {
      // 采用 @vue/compiler-sfc 里面的parse来编译 sfc 文件,和 vite 的编译是一样的。
    }
  }
}

如上面的结构,通过加载插件里面的 build 方法,实现对 sfc 文件的解析,中间过程比 vite 要复杂一些,vite 的中间件体系很直观,而 snowpack 则是通过不断的分析 config.scripts 里面的配置(通过不断的调用 fs.stat 判断),来得到正确的文件路径以及对应的解析方式,比如 _dist_/App.js 最后会转换为 src/App.vue,并采用上述的 @snowpack/plugin-vue 的 build 方法加载 src/App.vue,得到打包后的 script/template 组成的部分,以及 css 内容。发送到客户端并作缓存处理后。

build 方法里面会通过 parse 编译 sfc 文件,得到的 descriptor 和 vite 的差不多,包含 script、template 和 style 三个部分,其中 script 部分的代码会和 tempalte 的代码合并也就是后面 App.js 的主体。 style 作为单独的部分不会立刻发送到客户端,而是先做本地缓存里面。

css 部分会有如下处理方式

// snowpack dev.js wrapResponse
if (responseFileExt === ".js") {
  code = wrapImportMeta({ code, env: true, hmr: isHmr, config });
}
if (responseFileExt === ".js" && cssResource) {
  code =
    `import './${path.basename(reqPath).replace(/.js$/, ".css.proxy.js")}';\n` +
    code;
}

可以看到在 App.js 里面添加 css 的 import 部分,这个和 vite 类似,只是 css 文件后缀采用 css.proxy.js 标识,而 vite 采用 type=style 的方式来区分。

另外 snowpack 对 html 的处理,会有一个 isRoute 变量来判断,并注入热更新等代码;

热更新机制

分为两套代码,客户端代码和开发服务器的代码,其中客户端的代码没有 vite 种类复杂

socket.addEventListener("message", ({ data: _data }) => {
  if (!_data) {
    return;
  }
  const data = JSON.parse(_data);
  debug("message", data);
  if (data.type === "reload") {
    reload();
    return;
  }
  if (data.type !== "update") {
    return;
  }
  runModuleAccept(data.url)
    .then((ok) => {
      if (!ok) {
        reload();
      }
    })
    .catch((err) => {
      console.error(err);
      reload();
    });
});

可以看出 snowpack 只有 reload 和 update 模式,没有 vite 那样复杂,但是其 js 部分更新逻辑是基本一致的,并且有很相同的 import.meta.hot 方式以及 import.meta.hot.accept 功能。基本和 vite 差不多,这里就不介绍了,当然 snowpack 不用判断 import.meta.hot 是不是在 if 条件语句里面。

snowpack 没有像 vite 那样在客户端采用 vue 的热更新。

snowpack 启动的时候,也会对依赖进行分析,不同的是它会将依赖放在 node_modules/.cache/snowpack/dev 下面。node_modules 包的请求路径也会被改写为 web_modules/vue.js 这样的特殊标记。

webpack

在 snowpack 还可以使用 webpack,官方专门维护了 @snowpack/plugin-webpack 插件,和上面的 @snowpack/plugin-vue 一样都归属于插件范畴,在解析文件的时候会用到,提供一个 build 方法,并且最后通过 webpack 打包文件。snowpack 提供了一些默认配置,比如 babel、MiniCssExtractPlugin 这些。如果要扩展的话采用以下的方式配置,和 vue.config.js 的方式蛮像的。

// snowpack.config.js
module.exports = {
  plugins: [
    [
      "@snowpack/plugin-webpack",
      {
        extendConfig: (config) => {
          config.plugins.push(/* ... */);
          return config;
        },
      },
    ],
  ],
};

这里 webpack 的处理方式蛮奇怪的,会将打包好之后的文件,手动注入到 html 里面,而不是采用默认的方式,可能是没有 index.html?可能也是受限于 snowpack 和 webpack 的结合?具体的也就没有深入研究了,感兴趣的可以看看。

总结

上面介绍了三款最近流行的打包工具,esbuild 用于生产环境,vite 和 snowpack 主要用于开发环境。esbuild 打包压缩速度远超同行,也被用于 vite 和 snowpack 里面,作为 JavaScript 文件和 Typescript 文件降级和编译的工具,esbuild 如果要用于生产的话,可以考虑使用 esbuild-webpack-plugin,仅仅作为压缩工具,效率也能提高不少。

vite 里面有不少借鉴 snowpack 的部分,当然也有自己特别的方式,比如中间件的结构,比如客户端更精准的热更新,当然和 snowpack 一样支持 webpack 更好了,只是目前看来难度不大?两者都可以用在生产,目前看来 vite 采用 rollup 打包,离主流 webpack 有点远,而 snowpack 支持 webpack 所以友好度更高。当然 vite 有尤大佬参与,自然不太一样。

本文还有不少源码没有深入介绍到,只是做一个稍微浅的解读,感兴趣的可以继续深入研究,如果能理解 esbuild 的 go 语言的源码就更好了。

38. 视频优化(2) 与 ios 滑动bug研究

去年项目上遇到视频加载问题的时候,想到了 preload 的方式来加载视频 虽然效果不佳。只是视频的问题还有很多没有解决,这次项目里面又再次遇到视频问题,

ie 9 视频显示问题:

目前已经抛弃了低版本的 IE,至少 IE 8 是不支持得了,但是 IE 9 还是要支持的。想到我厂在全国销量,人群分布情况,win 7 + IE 9 还是有很多的,具体的浏览器分布数据一直没有拿到。在 全球范围内,IE 份额已经只有 5.34% 了,但是在国内,ie 9 的份额居然还有 9.72%,ie 11 有 7.26%,ie 8 有 5.86%,咦,原来 ie 的份额都这么多多。。。如果没有天猫以及甚至淘宝的强势推广,可能这个份额会更高。目前看来,ie 9 这两年国内的市场份额没有降低的趋势,兼容 ie 9 也是必要的了。

这里 ie 9 的 video 显示问题,指的是有些 mp4 文件可以在 ie 9 播放,有的就不能。之前处理方式都是对不能在 ie 9 播放的视频,专门替换成图片,规避问题,但是 video 标签明明是支持 ie 9 的,为何有些可以,有些不能呢?在 win 7 系统上面可以清晰的看到,支持 ie 9 播放的视频,可以看到帧宽,帧高,而不能播放的视频,则看不到,很明显 win 7 不支持这些视,所以应该是视频源的问题。

一开始以为是视频丢失帧高帧宽,通过 ffmpeg 的 scale 方式给视频一个指定高度和宽度,发现当高度高度 1088 的时候视频无法播放,而低于 1088 的时候则正常显示,这又是什么问题?由于 scale 后清晰的显示模糊,采用软件格式工厂得出的视频,指定帧宽高后,视频质量堪忧,模糊是模糊了点,但起码能用,在 video 里面采用两个标签的形式,还是可以播放的。只是具体为何 ie 9 不能播放,还是没有找到原因。

直到某天不小心发现 这篇文字,可以看到 H.264 Video Decoder,哦,原来是 win 7 平台 H.264 解码器不支持超过 1920 × 1080

[!Note] In Windows 7, the maximum supported resolution is 1920 × 1088 pixels for both software and DXVA decoding.

只要将视频的高度压缩到 1920 × 1088 就可以了。原先不能在 ie 9 播放的视频,帧高都超过了 1088。这个时候也遇到了另外一个神器 HandBrake,压缩视频非常好用,张大神的 文章有介绍到。通过反复尝试,高于超过 1920 × 1088,无法在 ie 9 播放。这是 win 7 系统的问题。甚至 ie 11 也会有这样的问题

于是合理的处理方式是有两个视频源,一个是正常的,一个是为 win 7 上面的 ie 浏览器准备的低尺寸视频。当然这会增加设计师的工作。。。。

mp4 加载的3次请求

在之前的视频优化里面,就发现视频会有三次请求,如下:

只是视频一多,发现个别视频又不会有三次网络请求。尤其是 banner 位的视频,是要秒开的(well,虽然没有人要求),三次来回的请求占据了太多时间。后来反复查找资料,发现张大神的文章 从天猫某活动视频不必要的3次请求说起。简而言之就是 mp4 文件是由一个个 box 组成的,一级嵌套一级来存放媒体信息。其中视频文件的宽高、时长、码率、编码格式等存放在 moov box 里面,如下图

mp4 box 的信息,win 10 可以通过软件 mp4info 来查看,地址: https://pan.baidu.com/s/1PcAMVaX2cc8UV3vFxpBSFQ,提取码:3ua3;
这个工具可以查看其 box 信息,如下所示:

moov 后置,如上图,则浏览器会发送三次请求,具体请求内容看张大神的解释。而 moov 前置,若没有moov.udta.meta,也可能会有三次请求(不同浏览器策略不一)。可见这个 moov 的前后置以及 moov.udta.meta 情况影响加载是否有三次请求。

至于 moov 修改,或者添加,则可以通过 ffmpeg 修改,或者简单的通过上面提到的 HandBrake,里面有 Web Optimized 可以导出优化后的视频。

视频使用心得

桌面视频播放在突破了 ie 之后,基本没有什么问题,唯一要注意的是添加 poster 来过渡好视频首帧播放。

安卓播放的问题是最多的。由于视频多为手机录像,需要有手机框配套,并且有圆角,有浮层。为了避免各式各样的问题,采用的是做个折中的弹出层播放。也采用过手机框和录像一起作为整个视频播放,但是在安卓浏览器上,原本的视频的内部圆角出现了折痕,虽然不是很明显,但是也是够奇葩的了。

HandBrake 默认的配置里面,一般没有对应的视频源尺寸,多为 1080/720/480 等尺寸,可以自己创建新的 Preset,比如采用视频源方式。

HandBrake 尺寸设置里面,要注意裁边模式,默认是自动的,但可能会出现自动模式下,裁边错误的情况,这个时候就需要手动调整了。

HandBrake 可以通过 video 选项里面 Eencoder Preset 来设置编码的速率,越慢压缩效果越好,处理后视频体积越小,但是时间开销越多。

IOS scroll 引起的bug

ios 的 safari 从 ios 5 就带有弹性滚动,而如果你要有需要在 div 里面做滚动,若只是单纯的设置 overflow-y: scroll;,页面会滚动,但是也会变得非常僵硬,卡顿。为了达到原生的效果,需要有如下设置 -webkit-overflow-scrolling: touch; 。如此可以达到原生滑动的效果。然而这个设置会带来诸多意想不到的 bug。

首先是为了配合 scroll,自然要设定一个固定高度。遇到的问题简化为如下情况:

<div class="wrapper">
  <ul class="inner">
    <li class="inner-item">
      <div class="img-wrapper">
        <img src="" alt="" /> 
      </div>
      <div class="img-wrapper">
        <img src="" alt="" /> 
      </div>      
    </li>
  </ul>
</div>

.wrapper {
  position: absolute; 
  left: 0; 
  top: 0; 
  height: 100%; 
  width: 100%; 
  overflow: auto;
}
.inner {
  overflow: scroll;
  -webkit-overflow-scrolling: touch;
}
.inner-item {}
.img-wrapper {
  position: relative;
  overflow: hidden;
  width: 100px;
  height: 100px;
}
.img {
  position: absolute;
  height: 200px;
  bottom: 0;
  left: 0;
}

-webkit-overflow-scrolling: touch; 放在 inner 类里面,是由于放在 wrapper 类里面,会经常性导致页面滑不动,触发 ios 的橡皮筋效应,而不是页面的滚动。

遇到的神奇的 bug 是:页面初始化正常,并且能够正常滚动。但是里面的 image 却会在快速滚动中出现溢出情况,而且是第二张溢出,第一张不溢出,慢慢滚动还不会出现,防不胜防。

先回顾一下溢出问题,这里的 image 由于设置了绝对定位,顾其尺寸大小,受其最近的包含块影响。image 上级元素,position: relative;,所以包含块为 img-wrapper,其是不会产生溢出效果的。那怎么这样的问题呢?难道是 -webkit-overflow-scrolling: touch; 的锅?

于是查看 apple 的官方 文档,写道:

touch Native-style scrolling. Specifying this style has the effect of creating a stacking context (like opacity, masks, and transforms).

通过设置 -webkit-overflow-scrolling: touch; 会让元素的叠层水平和 opacity, masks, and transforms 之类的一样。而值得一提的是 transforms 也可以生成包含块。难道滚动的时候,img-wrapper 这个包含块不起作用了,倒是 inner 这个包含块其作用?这么说好,好像行得通,第一张图片没有溢出,是被 inner 给设定死了溢出空间了?为了提升包含块,于是分别在 img-wrapper 与 inner-item 设定 transform: translateZ(0)。结果 设置在 inner-item 的起作用了。看来 img-wrapper 的包含块无效化了,但是这个叠层关系实在有点远,看不出所以然。只能当作是个诡异事件。

后来觉得 transform 的设置还有个妙处,针对 ios 可以起到硬件加速渲染,避免图片出不了。

26. 网络请求

前言

一直好奇网络请求具现出来是什么样子的。从面试官的一个问题,在浏览器输入网址之后,到页面生成,中间会发生什么。为了这个问题,看了《计算机网络:自顶向下》,《图解HTTP》,学习了 node.js 中关于网络部分的源码,结果却还是自认为相差甚远,直到接触了路由表,才开始所有感觉,觉得补上缺失的一角。

网络结构

OSI 7 层模型定义了一个规范概念,而 TCP/IP 的4 层结构则给出了实现。两者的比较,网上多有资源,这里不做对比了,结合发快递的过程来说说吧:

  1. 第七层,应用层,常见协议 Http、Https、DNS、FTP。作用是为应用程序接口提供网络,直白来讲,就是提供请求/响应数据服务。数据也叫做应用层数据 Application Data;在快递上,就是你自己把要发的快递准备好,包括目的地 url 等。

  2. 第四层,传输层,常见协议有 TCP、UDP,建立维护端到端的连接,直白来说就是将网络层上面的传输,扩展为端与端的进程之间的服务,数据为 TCP Segment 或 UDP Datagram;如同快递员上门揽件,你要填写物流信息,然后快递员将包裹发送至第一个集散地。

  3. 第三层,网络层,常见协议就是 IP、DHCP 协议。作用是提供路由和选址功能。从你这边发送快递到目的地,不可能是快递员亲自做高铁发送过去的,需要一个集散地,而始发集散地到目的集散地中间可能有许多的路径选择,比如从深圳到北京,可能中途是 深圳 - 武汉 - 石家庄 - 北京,也有可能是 深圳 - 上海 - 北京。网络层上流通数据叫做 IP Packet,也叫做 IP 包。

  4. 第二层,数据链路层,常见协议有帧中继。常用到的设备是交换机。主要作用是提供ARP 查询报文,在 MAC 表里面找到目标 MAC 地址,然后发送数据。这样的作用体现在从一个路由器到另外一个路由器的过程中,当然也包括初始发送数据的接入交换机过程。在快递里面可以理解为,在集散地武汉里面从飞机拿下快递,要发送到北京,发现离北京最近的是石家庄,石家庄在哪里呢,要查表,可能用汽车也可能用飞机发送,但是这个路径还是原来的路径。这里的数据也就是以太帧,Ethernet Frame,也就是深圳路由到武汉路由之间的数据包都是以太帧的形式,只是在路由器里面会形成 IP 包。

上面差不多就是网络结构的基本。可以发现平时用户接触到的都是应用层,而传输层则是电脑和交换机之间的事情,网络层则是路由器选择路由的故事,数据链路更多的是以太帧的传输。但是懂这些只能理解大致的网络流动,对理解日常网络请求却是远远不够的,没有太多实质的帮助。

路由表

为了更好的理解网络请求,应该从路由表开始处理。路由表是什么?一张存储路由信息的表,决定了分发的路径。在路由器里面就有这样一张表来决定下一跳的选择。这张表在电脑里面也有,其存储的是本地计算机可以到达的网络方位以及如何到达,在 window 系统上可以在终端 cmd 上面执行命令 route print 来查看路由表。如个人本地电脑

(这是随便截图的,但是表述更加清晰)

目标网络就是 Network Destination;网络掩码:Netmas;网关:Gateway;接口:Interface;跃点数:Metric;

目标网络指的是一个网段。上面列举出个人计算机可以访问到的网关地址,但是当有网络请求的时候,采用哪个网关地址呢?总是要选一条的,如何选择?答:通过目标网络和网络掩码来选择下一跳的网关。用目的 IP 和网络掩码 做与操作 后,得出的结果符合路由表上的某一条目标网络,就选择该条的网关作为下一次的目标网络。

网关,接口,是发送以太帧时候的服务器地址。

跃点数指的是到达目的 IP 的跳跃次数,次数越低,成本也就越低。当具有多条到达相同目的网络的路由项时,TCP/IP会选择具有更低跃点数的路由项。

例如,当你要访问 192.168.1.7 IP 的时候,该目的 IP 与路由表上面的网络掩码做与操作,发现只有第 1,3 条匹配,那采取哪一条?遵循最长原则,于是采用第 3 条的网关 192.168.1.6 作为发送地址

可以发现网络掩码的与操作,对于我们来说其实就是取目标 IP 的前 n 位。例如第 3 条的网络掩码 255.255.255.0,就是取前 24 位,抛弃剩下的 8 位,来确定 192.168.1.0 这个网段。

确定下一跳的网关地址后,个人电脑准备发送的包,这个时候会将以太帧的头部 MAC 地址写为 192.168.1.6 的 MAC 地址。MAC 地址与 IP 地址的对应关系,在哪里找?存储在电脑的 ARP 缓存里面,通过 192.168.1.6 IP 找到对应的 MAC 地址,如果找不到则要通过 ARP 来广播查询 192.168.1.6 的 MAC 地址。至于广播如何查找该地址,还是通过上面的路由表来分发。

当以太帧转递到 192.168.1.6 的网关之后,会得到 IP 包,由 IP 层查询下一跳网关地址,依然是通过和网络掩码做与操作来匹配。然后写入下一跳的 MAC 地址,以以太帧的形式传递。通过这样的方式最终找到目的 IP 地址。

上面就是很清晰的数据链路层和网络层的切换了。网络层选择路由分发,数据链路层中写入 MAC 地址,并将过大的 IP 包分片,以以太帧的形式继续在介质中传递。

默认网关,也就是缺省网关,是路由表中的网络目标和网络掩码都为 0.0.0.0 的网关。当目标 IP 没有在路由表中找到对应的网络段,就会去到默认网关,因为任何 IP 和网络掩码 0.0.0.0 做与操作都是 0.0.0.0。这样也就不会出现有 IP 没有匹配上的情况。

如果网关是 127.0.0.1 呢?那就是本机地址,这个时候本机即是服务访问者,又是服务回答者,不同的只是端口差别。常见的如 web 开发里面,用 webpack 的 dev-server 来创建后台热更新服务时,跑路由表的时候,最后还是会由本机 dev-server 指定的某个端口应答。

内网 IP,公网 IP

经常遇到的 192.168 开头的地址,尤其是在 cmd 里面进行 ipconfig 里面查询电脑 ip 地址的时候。那这些是也是网络地址吗?不是的,这个是内网 IP。

上图是有特殊作用的 IPv4 网段,也就是在服务器地址里面,这些网段都不能公开使用的,有特殊用途,比如 192.168.0.0/16,就是前 16 位网段为 192.168 的 IP 地址都是私有地址,也就是内网 IP,外网是不能访问的。那这个内网 IP,怎么会存在呢?

服务器地址只有一个,但是连接的设备却有很多个,每个终端都需要一个 IP 地址,这个时候,就需要 网络地址转换(Network Address Translation)。地址不够就从上文提到的特殊作用的 IPv4 网段里面找,具体的是从私有 IP 里面,比如从 192.168.0.0/16 的地址池里面取一个 IP 出来,这个网段有 2^16 个主机可以分配,是远远足够的了。这样的私有 IP 段,还有 10.0.0.0 - 10.255.255.255 以及 172.16.0.0 - 172.31.255.255。路由器分配 IP 地址的时候,还会采用 DHCP 技术,即动态主机配置协议。个人电脑的 IP 地址并不是凭空生成的,只有 MAC 地址是固定的。 IP 地址是由服务器统一下发确定的。这里面就是用到了 DHCP 技术。服务器从地址池里面取一个空闲 IP 地址,以及对应的网络掩码、缺省网关、域名服务器 IP 给到客户端。

如若要访问外部服务器的某台内部地址的服务器,是不能直接访问该内部地址的,只能通过访问该服务器所在的公网 IP 地址,通过路由表到达目的路由器,再由该路由器/交换机的映射来确定访问哪台内网服务器。

对于同一网段的 IP 之间的访问,则没有路由器之间的跳来跳去。比如内部私有 IP 之间的通信的时候,由于网关是同一网关,则可以以内网 IP 来通信。

路由表添加

下面说个案例,也是最近工作中遇到的。比如你要访问公司内网里面的某台服务器。已知:
VPN 地址;120.24.1.1
服务器地址: 10.24.1.1

可以看出服务器地址是内网地址,不能直接用 Xshell 访问连接,这个时候就需要连接 VPN 了,通过用户和密码来连接到 VPN,这个时候由于已经连接上 VPN,本机的默认网关会添加上该服务器的默认网关,而且该默认网关的跳跃数是要远少于之前的默认网关的。所以最后的网络服务基本都会跑到该 VPN 上面去。这个时候通过新的默认网关就能够访问该公网 IP 下的内网服务器了。

这个时候存在另外一个问题,一般公司的 VPN,也就是连上后基本都是无法访问其他网络的,只能访问内部 IP 地址,这个时候往往为了访问外网,会将该 VPN 的 IPv4 设置 “在远程网络上使用默认网关” 给去掉。

这样路由表里面的默认网关还是有两个,只是 VPN 的默认网关的优先级也就是跃点数是要高于之前的默认网关的。使得外网访问都能走原先的默认网关而不是 该 VPN 的默认网关。

这个时候就会有另外一个问题,访问内网服务器的时候,由于走的是原先默认网关,由于原先默认网关不在 VPN 下,所以导致内网服务器连接不上。为了访问内网,可以向本地的路由表添加路由,即使将该内网服务器访问的网关指向 VPN 的默认网关如:

route add 10.0.0.0 mask 255.0.0.0 VPN的默认网关 metric 1 

通过该指令可以将 10.0.0.0/8 的目标 IP 下一跳跳到 VPN的默认网关上,metric 设置为 1,只是提高优先级,作用一般不大,毕竟有最长匹配原则的存在。

客户端的网络请求

上面介绍了网络层和数据链路层的过程。而传输层 TCP,则是应用层将 HTTP 格式的包丢入 SOCKET 让其传递就好了。

让我们来看看一个客户端角度的网络请求:

这里主要涉及的就是传输层和应用层,包括了重定向,浏览器缓存,DNS查询,TCP 连接等等,另外还有一个简化的图:

这里就更能直观看到了,应用层的 HTTP 与 DNS;传输层的 TCP 三次握手,发送数据,返回确认,以及四次握手关闭 TCP 进程。至于 TCP 为何要三次握手,关闭为何又是 四次握手,这个就不介绍了,太基本了,

最后的数据包如上所示。可以看到请求中 源IP/源端口,目标IP/端口,都会在数据包里面。

参考

  1. OSI模型
  2. 理解Windows中的路由表和默认网关
  3. IANA IPv4 Special-Purpose Address Registry
  4. 深入浅出浏览器渲染原理
  5. 计算机是如何聊天的?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.