0%

背景

在实现赤兔脚手架(Modern.js的fork)URL Imports的接口定义Loader时,遇到了ESLint的报错:

1
2
3
0:0  error  Parsing error: "parserOptions.project" has been set for @typescript-eslint/parser.
The file does not match your project config: chitu-lock/https_music-ox.hz.netease.com_/xxx/index.ts.
The file must be included in at least one of the projects provided

本文希望通过记录排查时的思路,来梳理遇到此类:1. 网上无法直接检索到解决方案;2. 且官方文档对此类错误没有详细描述问题的解决思路。

问题表现

我们使用了三种eslint的执行方式:

  1. 直接使用 eslint CLI 对指定文件进行 fix。执行成功。
  2. 定义 format 函数,内部使用 eslint 的 node包,对指定文件进行fix。执行成功。
  3. 在脚手架的 开始dev模式 & 文件变化 回调用调用 format 函数,在文件内容下载完成后直接对文本进行fix。执行失败。

排查过程

  1. 初次遇到这个问题时,表现是 eslint 没有对文件生效。因此我首先尝试将eslint的报错信息打印出来,就得到了背景中的报错信息:

    1
    2
    3
    const formatter = await eslint.loadFormatter('stylish');
    const resultText = formatter.format(results);
    console.log(resultText);
  2. 尝试Google这个报错问题,可以检索到这个stackoverflow的高分回答 :即有可能我们提供了错误的 parserOptions.project 参数,使 eslint 使用了错误的 tsconfig.json 文件。为了判断 eslint 使用的配置,尝试将其打印出来:

    1
    2
    const config = await eslint.calculateConfigForFile(filePath);
    console.log(config)

    通过打印的信息,我们可以看到 parserOptions.project 均为 ./tsconfig.json。顺便我们也对比了其他生成的eslint 配置,没有任何区别。

  3. 尝试从源码分析

    1. 通过在 typescript-eslint 中搜索报错信息可以发现,错误源于文件 packages/typescript-estree/src/create-program/createProjectProgram.ts 的 createProjectProgram 方法 。Typescript的工程监听器(Program)无法获取待 lint 文件的信息(currentProgram.getSourceFile 返回 空)
    2. 通过检查 getProgramsForProjects 源码可以发现我们在创建 Program 时的输入参数是一致,但是输出的 Program 却在第三种执行方式时产生了不一致的运行结果。那么只可能是两次运行时某种环境的不同导致了结果的不同。
    3. 再次深入到 Typescript 对 getSourceFile 函数的实现,可以发现 Program interface 中提供了 getSourceFiles 方法来获取所有源文件。将 2、3 两种执行方式进行对比,可以发现执行方式3刚好少了 chitu-lock 文件夹中的 ts 文件

原因分析

最后可以发现原因在于,使用 eslint 对文本进行 lint 操作时,filePath实际上还没有真实的文件存在。导致 Typescript 的 WatchProgram 没有对该文件进行监听,使 eslint 认为我们指定需要 lint 的文件不包含在配置的 ts工程 中,导致报错。

1
2
3
const results = await eslint.lintText(text, {
filePath
});

复盘

在排查过程中,在最后发现两次执行 getSourceFiles 返回结果的不一致后,没有继续深入源码实现进行排查。因为想到了两次执行在文件是否存在上存在不一致。在这次长时间的排查中有以下教训:

  1. step by step:功能实现需要一步步实现,对每一步的结果进行测试,这样在某个步骤出错时由于和上一步相差较小,可以较容易地缩小引起错误的范围。
  2. 控制变量:排查过程中时刻关注成功和失败的执行环境之间有哪些不同,尽管深入源码最终还是可以解决问题,但是排查效率低;从变量出发的排查往往更加高效。

哈希与加密

从下图中,我们可以看到哈希与加密的不同:

  1. 哈希是单向的,而加密是可逆的。
  2. 两者所生成结果的信息量不同:哈希算法通常用于数据摘要,生成相同长度的文本;而加密算法生成的密文长度与明文长度有关,加密生成的密文需要能够被解密恢复成明文。

哈希算法

一个优秀的哈希算法,需要满足以下几个特性:

  1. 正向快速:给定原文和 Hash 算法,在有限时间和有限资源内能计算得到 Hash 值;
  2. 逆向困难:给定(若干)Hash 值,在有限时间内无法(基本不可能)逆推出原文;
  3. 输入敏感:原始输入信息发生任何改变,新产生的 Hash 值都应该发生很大变化;
  4. 碰撞避免:很难找到两段内容不同的明文,使得它们的 Hash 值一致(即发生碰撞)。
    • 弱抗碰撞性:给定原文前提下,无法找到与之碰撞的其它原文
    • 强抗碰撞性:无法找到任意两个可碰撞的原文

常见的算法有:

  1. MD:主要包括 MD4 和 MD5 两个算法。MD4(RFC 1320)是 MIT 的 Ronald L. Rivest 在 1990 年设计的,其输出为 128 位。MD4 已证明不够安全。MD5(RFC 1321)是 Rivest 于 1991 年对 MD4 的改进版本。它对输入仍以 512 位进行分组,其输出是 128 位。MD5 比 MD4 更加安全,但过程更加复杂,计算速度要慢一点。MD5 已于 2004 年被成功碰撞,其安全性已不足应用于商业场景。
  2. SHA:由美国国家标准与技术院(National Institute of Standards and Technology,NIST)征集制定。首个实现 SHA-0 算法于 1993 年问世,1998 年即遭破解。随后的修订版本 SHA-1 算法在 1995 年面世,它的输出为长度 160 位的 Hash 值,安全性更好。SHA-1 设计采用了 MD4 算法类似原理。SHA-1 已于 2005 年被成功碰撞,意味着无法满足商用需求。为了提高安全性,NIST 后来制定出更安全的 SHA-224、SHA-256、SHA-384 和 SHA-512 算法(统称为 SHA-2 算法)。新一代的 SHA-3 相关算法也正在研究中。

数字摘要

由于哈希算法碰撞避免的特性,它通常被用于进行数字摘要。通过生成的数字摘要,可以进行数据的校验。如:网络下载的资源是否遭受过篡改、鉴权协议(挑战应答方式)。下面我们将以用户密码校验这个应用场景为例看下哈希算法的用途。

用户密码校验

为了保障用户密码的安全性,在数据传输与存储的过程中直接使用用户密码实际都是不安全的。通过哈希生成数字签名,在传输和校验的过程中,只匹配使用密码生成的数字签名而不是密码,可以保障密码的安全性。在以下的场景中,我们会假设:1. 数据的传输是不安全的,数据可能会被中间人截获、重放;2. 数据的存储也是不安全的,攻击者可能会拖库、撞库。

使用明文或者加密

通过明文传输显然是不安全的。而即便使用加密算法,一旦私钥泄露、数据库泄露,用户密码同样也会被泄露。

使用哈希

单纯使用哈希和明文传输实际上并没有本质区别。

  1. 从数据传输角度来看:攻击者可以进行中间人重放攻击,截取用户登录请求,使用同样的哈希值即可伪造用户进行登录。
  2. 从数据存储角度来看:攻击者获取数据库后,可以使用rainbow table,从哈希值中反向计算处可能的密码明文。

使用哈希+动态salt

所谓salt就是一段随机的字符串,通过在原文上加上这段字符串,可以增加攻击的成本。常用的salt就是验证码,由于验证码是动态生成的,这保障了每次能够成功登录的数字签名是不同的。它的优势体现在:

  1. 从数据传输角度来看:由于数字签名是动态的,无法再实施中间人重放攻击。
  2. 从数据存储角度来看:由于每个rainbow table对应的是一套哈希算法,因此对每个验证码都需要生成一个独立的rainbow table,这极大增大了暴力破解的成本。

使用bcrypt, scrypt

随着内存大小的提升、和显卡并行能力的支持,使用哈希+动态salt的方式也不再那么安全。而bscrypt、scrypt这类算法都有一个特点,即:算法中都有个因子,用于指明计算密码摘要所需要的资源和时间,也就是计算强度。计算强度越大,攻击者建立rainbow table越困难,以至于不可继续。由于计算强度因子的存在,随着算力的提升,通过调整因子,可以保障密码仍然不轻易被攻破。同时,算法的设计也确保了已有用户仍然能够正常登录。

以bscrypt为例,一个bscrypt的hash字符串的格式如下。由于cost字段的存在,即便后来调整了计算强度因子,老的用户仍然可以通过原有的低cost哈希值进行登录。

1
2
3
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash

bcrypt算法的结果就是使用Blowfish算法对文本”OrpheanBeholderScryDoubt”进行64次加密的结果。它利用了blowfish进行key change操作慢的特性,在key初始化的时候调用2^cost次ExpandKey函数。

彩虹表

​ 使用哈希来替代明文存储数据也并非安全的。仅管哈希无法通过直接逆向计算得到密码明文,攻击者仍然可以通过暴力计算得到明文和哈希的映射关系。以此间接地获得密码明文。

rainbow table就是一个预先计算好的存储哈希函数输入输出映射关系的表。通过空间换时间,攻击者可以通过表查询的方式快速获得密码明文。值得一提的是,rainbow table使用[哈希链](https://en.wikipedia.org/wiki/Rainbow_table#:~:text=to the right.-,precomputed hash chains)的方式来减小表的大小。在rainbow table中只存储哈希链的开始值结束值,在查询表时:

  1. 计算输入哈希值的哈希链
  2. 每次计算后,尝试匹配表中哈希链的结束值
  3. 匹配到结束值后,即可查询到包含该输入的哈希链的开始值
  4. 最后,通过开始值即可还原整条哈希链,在哈希链中我们就可以获取输入哈希值所对应的明文

以下图为例:

  1. 计算哈希链,尝试与rainbow table(左侧)中结束值(黄色)进行匹配
  2. 当执行两次R函数时,匹配到了项(passwd - linux23)
  3. 通过开始值(绿色)恢复哈希链,获取密码 - culture

Simple rainbow search.svg

可以看到,在图中,计算哈希链使用了多个R函数。这是一种减少哈希碰撞的优化的算法:只要不是在不同哈希链的同个序号发生碰撞,哈希链就不会出现合并的问题(因为R函数不同,同样的哈希值可以计算出不同结果)。仅管无法避免原型链的重复,但这可以减小总体碰撞的数量,增大指定表大小下获得正确结果的可能性。但同时它也将查询单个项的时间复杂度由O(N)增加到了O(N^2)。(但这是可以接受的,因为如果使用普通的算法,rainbow table在表项增加时会由于哈希链合并问题而变得低效;而通常会增加表的数量,在每个表中查询来解决这个问题,但这同样也会增加查询的次数,而且还增加了表大小。)

哈希链:在哈希链中,有两个关键的函数:1. H: hash function 哈希函数;2. R: reduction function 规约函数(能够将任意哈希值映射成特定字符的纯文本值,并非哈希函数的反函数)。这样,通过反复执行R和H,我们就可以得到一条哈希链(如下图所示)。由于H和R已知,对于任意输入的哈希值,通过计算结束值(即:kiebgt),我们可以判断它是否在数据库存储的某条哈希链之上;而通过开始值(即:aaaaaa)我们可以完整复原整条哈希链。

{\mathbf  {aaaaaa}}\,{\xrightarrow[ {\;H\;}]{}}\,{\mathrm  {281DAF40}}\,{\xrightarrow[ {\;R\;}]{}}\,{\mathrm  {sgfnyd}}\,{\xrightarrow[ {\;H\;}]{}}\,{\mathrm  {920ECF10}}\,{\xrightarrow[ {\;R\;}]{}}\,{\mathbf  {kiebgt}}

References

简介

React 18 的核心新特性基本可以归为下面两类:

  1. 并发特性。为了解决大量DOM节点同时更新造成的渲染延迟问题,以及子组件IO操作被父组件阻塞的问题。React新的Fiber Reconciler通过将任务拆分成小块,支持了并发渲染。既:允许React同时渲染多版本的UI。
  2. 新SSR架构。允许用户将应用拆分为更小的单元,使每个单元具备独立的fetch data (server) → render to HTML (server) → load code (client) → hydrate (client)流程。

在这之外,react18还:1. 引入了新的createRoot函数用于引入新特性以实现渐进式升级;2. 自动对更新进行批处理(automatic batching)。

新特性 - Client

并发特性

在更早的时候,react提出了实验性的特性concurrent mode。通过concurrent modes实现了并发的状态更新,这里的并发有两种内含:

  1. 对于CPU相关的更新(如创建DOM节点,执行组件生命周期函数),这意味着高优的更新能够打断正在执行的更新。
  2. 对于IO相关的更新(如请求数据),这意味着在数据返回前可以在内存中提前进行渲染。

在react 18的post中,作者将其重新命名为”concurrent rendering”。意味着react18中并发不再是以一种必须选择的作用于全局的模式(all-or-nothing “mode”),而是只会在需要时被新特性触发的特性。这也和react18的渐进式升级策略相关联。

CPU-bounded updates

为了提升运行性能和用户体验,react修改渲染的调度机制,这主要包含两点:

  1. 将一次更新分片,允许多次更新并发执行,也支持更新的打断
  2. 对更新区分优先级,优先执行高优更新任务
分片

在进入分片之前我们需要先区分一下vdom树的更新(reconciliation)和渲染(render)。更新器(reconciler)是react的核心,对比两次更新前后树结构的不同;而渲染器(renderer)则将更新结果渲染到DOM上。更新器是可插拔的,我们可以用ReactDOM将其渲染为浏览器的DOM,或者用react native将其渲染为客户端的View。而核心的更新器是唯一的。

image-20211204182738628.png

分片能力在react 16引入的Fiber Reconciler中就已实现。在此之前react使用的是Stack Reconciler,它使用递归的方式对整棵树进行更新。它的问题在于在触发一次更新后,知道这次更新任务的完成,JS线程会一直被占用而无法执行其他更加高优的任务(如:响应用户交互),造成了用户感知卡顿的问题。而Fiber Reconciler使用while loop拆解递归,将最小执行单元由一整棵树结构的更新降到了一个节点的更新;这就允许了更新被打断,为主线程提供了更多的调度能力。在新的调度能力的支持下,就可以完成高优更新优先渲染、中断过期更新等能力,更好地分配和节省计算资源。

如果你对react的实现细节感兴趣,可以阅读一下codebase overview

优先级

react18将状态更新分为两个类别(优先级):

  1. Urgent updates:需要快速反馈的交互,如:键盘输入、点击、触摸等
  2. Transition updates:UI从一个视图到另一个视图的转换

比如:在一个cms的表格场景中,当用户选中一个下拉框的选项来过滤列表的时候。用户期望在点击选项后下拉框快速收起并更新选中项(urgent updates),但是真实的过滤后的列表无需即时变化(transition updates)。

在18之前,所有的状态更新都是urgent updates,但实际上很多场景在18中可以归类为transition updates。但为了向后兼容,transition updates被作为一个可选的特性,只有当用户使用对应的API触法状态更新的时候才会被使用。

API

这个新的API就是startTransition

1
2
3
4
5
6
7
8
9
import { useTransition } from 'react';


const [isPending, startTransition] = useTransition();

// Mark any state updates inside as transitions
startTransition(() => {
setState(input);
});

useTransition hook 返回了:

  1. isPending:表示当前的状态更新还未被反映(渲染)到视图上
  2. startTransition:用于触发transition updates
    • startTransition会被同步执行,但是这个更新会被标记,在更新被处理时react会以此判断如何渲染更新
    • 渲染时,startTransition造成的更新是可以被打断的,它不会block页面。当用户的输入改变后,react将不会继续渲染过期的更新

应用场景

在以下的场景中,我们可以用startTransitionAPI来替代之前的状态更新API:

  1. Slow rendering:当一个更新需要消耗大量计算资源的时候
  2. Slow network:当一个更新需要react等待接口返回数据的时候

可以这么认为:当一个更新本身就需要耗费一些等待时间(等待可能是来源于大量计算的开销或网络的等待)时,那么用户不会在乎为这个更新再多等待一些时间,此时就可以使用startTransition API。以此,可以将计算资源更多地提供给urgent updates来提升用户体验。

react18和之前版本的性能比较可以见 这个案例

IO-bounded updates

现状

在目前,有三种渲染异步数据的方式:

  1. Fetch-on-render:在render函数中触发fetch(如:使用useEffect进行fetch),这经常会导致“waterfalls”(即将本可以并行的操作串联导致不必要的等待时间)。
  2. Fetch-then-render:等fetch完成后再进行渲染,但在fetch过程我们无法做任何事。
  3. Render-as-you-fetch:尽早开始fetch,同时开始渲染(在fetch返回之前),fetch返回之后重新进行渲染。

一个例子

在下面的代码中我们可以看到一个简化后的例子,描述了Render-as-you-fetch的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// This is not a Promise. It's a special object from our Suspense integration.
const resource = fetchProfileData();

function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}

function ProfileDetails() {
// Try to read user info, although it might not have loaded yet
const user = resource.user.read();
return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
// Try to read posts, although they might not have loaded yet
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
  1. 首先,使用fetchProfileData发送获取数据的请求
  2. 同时,react开始渲染,在渲染ProfileDetailsProfileTimeline时,由于read函数发现数据还没有返回,就会显示最近的祖先Suspense中fallback的内容
  3. 随着数据返回,react将会重新尝试render,层层解锁suspense直到完整渲染

Suspense

上面的例子介绍了Suspense的使用方法。Suspense是react为组件渲染异步获取的数据提供的一个解决方案。

In the long term, we intend Suspense to become the primary way to read asynchronous data from components — no matter where that data is coming from.

在React18之前,Suspense唯一的使用场景就是用以在懒加载React.lazy组件的时候,显示加载中的状态。而Suspense的新功能为请求库提供了一个机制:一个组件渲染所需要的数据是否已经准备好了。它帮助请求库更好地与react进行集成但并非是一个请求库,目前Facebook内部使用的是Relay,而将来我们也将会看到更多请求库支持React Suspense。同时它提供了更友好地展示数据loading状态的方式,但并未将数据获取逻辑和UI组件进行耦合。

优点

  • 分离数据的获取和消费逻辑。在组件中对其依赖的需要消费的数据进行声明,由react自身控制渲染。而开发者可以自由控制数据获取时机(如:在用户点击,页面切换之前就开始请求)。同时请求库的提供者也可以自由控制数据获取逻辑(如:像relay这样batch请求)。

  • 在数据消费处(组件)声明数据依赖。这允许在build阶段进行静态代码分析来进行一些处理(如:relay就以此将数据依赖编译到独立文件中,并且集成GraphQL,以在一次请求中获取这个组件所需的数据)

  • 声明式的加载状态控制。通过Suspense API,可以更加方便地通过标签声明来控制哪些组件需要同时被加载,哪些可以分别展示不同的加载状态。当需求发生变更时也无需侵入性地改变逻辑代码。

  • 避免race condition。在此前,想象在useEffect中触发一个fetch,在then中再setState;如果多次请求,可能会出现老的请求在更晚返回并触发setState。而使用Suspense后,数据获取逻辑本身被作为state传入(类似于promise),这个state本身的产生是同步的,避免了race condition的出现。例子

    1
    2
    3
    4
    const initialResource = fetchProfileData(0);

    function App() {
    const [resource, setResource] = useState(initialResource);
  • 使用ErrorBoundary处理fetch错误。

Break Change

部分生命周期的执行时机和次数将会发生改变。我们可以将react的生命周期分为两类:render phase(渲染阶段)和commit phase(提交阶段),详见react-lifecycle-methods-diagram。在并发渲染时,一个组件的更新可能会被打断,也有可能会被重新恢复,这就导致了一个渲染阶段的生命周期可能会被多次执行,造成切换到并发渲染后组件发生预期外的表现。因此在将旧组件升级为并发渲染时,需要注意:

  1. 将render phase生命周期回调放到commit phase的回调中执行。
  2. 或保证render phase执行的逻辑是幂等的。即该回调中的side effect,多次执行单次执行对系统状态的影响相同

在开发阶段,我们可以:

  1. 通过React的Strict Mode来检测这些潜在的错误(Strict Mode并非直接检测副作用,而是将这些生命周期的回调执行两次以便于用户发现非幂等的副作用)
  2. 不再使用componentWillMount等生命周期,这些生命周期的替代方案可以参考官方文档(这也是为什么React16.9将这些函数命名为UNSAFE_componentWillMount等,并在控制台打印警告)

Automatic Batching

一个下面这个例子就完整介绍了automatic batching,直到then中的函数执行结束,react才会将更新渲染到dom上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);

function handleClick() {
fetchSomething().then(() => {
// React 18 and later DOES batch these:
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
});
}

return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}

优点

  • 性能:减少更新次数,提升性能
  • 稳定性:避免渲染半成品的状态而造成bug

历史

  • react16:一次setState就会触发一次更新。
  • react17:在react event handler(如onClick)中,batching会生效;但是在promises, setTimeout, native event handlers中(如上面这个异步的例子),batching不会生效。

不想batch

如果想要在setState之后立即更新,react也提供了新的APIReactDOM.flushSync来同步更新:

1
2
3
4
5
6
7
8
9
10
11
12
import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React has updated the DOM by now
flushSync(() => {
setFlag(f => !f);
});
// React has updated the DOM by now
}

新特性 - Server

服务端流式渲染

new streaming server renderer

曾经的SSR

流程

在此前,react ssr可以拆分为以下几步:

  1. server:为整个app获取数据

  2. server:将整个app渲染为HTML并在response中返回给client

  3. client:加载整个app的JS代码

  4. client:将JS逻辑关联到服务端产生的静态HTML(hydration)

    Hydration的解释: The process of rendering your components and attaching event handlers is known as “hydration”. It’s like watering the “dry” HTML with the “water” of interactivity and event handlers. (Or at least, that’s how I explain this term to myself.)

问题

可以看到流程中多次出现了“整个”。这就揭露了这种SSR模式的一个缺陷:在流程中,每一步都需要为整个app完成相应的计算才可以进入下一个步骤。

  1. You have to fetch everything before you can show anything
  2. You have to load everything before you can hydrate anything
  3. You have to hydrate everything before you can interact with anything

优化

问题的原因就在于“waterfall”: fetch data (server) → render to HTML (server) → load code (client) → hydrate (client)。这里的每个阶段都依赖于上个阶段的完成。并且每个阶段都是应用粒度的。

优化的关键就在于拆分。就如同Fiber将整个应用的更新拆分为组件粒度的更新,以实现更加复杂的调度功能;同样在SSR上也可以将应用粒度拆分为组件粒度。这样就可以避免短板效应(即加载最慢的部分拖慢了整个应用响应的时间)的出现。

新的SSR

特性

  1. Streaming HTML(server):尽早生成HTML并传输给client。HTML不再是单次请求返回,而是流式地传输给client,每一次更新都会包含:1. 新完成渲染的HTML内容模块;2. <script>标签,用于将HTML插入到正确的位置。

    API:切换renderToStringrenderToPipeableStream

  2. Selective Hydration(client):1. 允许尽早进行hydration操作,即便剩余的HTML和JS还没有被加载。2. 允许根据用户交互来改变hydration的优先级(Selective Hydration)。

    API:切换ReactDOM.renderReactDOM.createRoot,同时以<Suspense>来拆分整个应用SSR的粒度。

Streaming HTML

以下面这段代码为例。通过Suspense,React将不会等待评论模块(Comments)数据获取&渲染完成,在此之前就可以开始HTML的流式传输:

1
2
3
4
5
6
7
8
9
10
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
  1. React会将其他部分和用于替代评论模块的Spinner传输给客户端

  2. 当评论模块在服务端渲染完成并传输给客户端后,客户端会用其替换Spinner

Selective Hydration

同样以上面那段代码为例,hydration同样可以被拆分,如:

  1. 可能首先hydrate其余部分

  2. 再hydrate评论模块

由于每个模块的waterfall都是互相独立的(fetch data (server) → render to HTML (server) → load code (client) → hydrate (client))。hydration也不一定会在HTML流式传输结束后才开始,即可能存在下面这种情况:

  1. 其余部分的HTML stream

  2. 其余部分的hydration

  3. 评论模块的HTML stream

  4. 评论模块的hydration

Selective Hydration

值得一提的是,由于这种粒度的拆分,除了根据数据到达顺序的hydration顺序之外,我们还可以根据用户交互来更换hydration的优先级。假设除了评论模块,我们为上面的每个模块都套上了Suspense标签(即每个模块的SSR流程都是相互独立的):

  1. 目前的加载状态,React正在hydrate边栏模块

  2. 用户对评论模块进行了点击。由于评论模块还是静态资源,目前无法响应用户交互

  3. React判定评论模块的优先级更高,中止hydrate边栏模块,优先开始hydrate评论模块

  4. 评论模块Hydrate完成,React重新触发点击事件,此时评论模块就可以进行响应。此后,React会继续hydrate边栏模块

更多关于流式SSR的介绍可以见这个issue

Suspense

从并发渲染和新的流式SSR,我们可以看到从if(isLoading)这种命令式代码切换到<Suspense>这种声明式代码所带来的变化。通过显式地对加载状态进行声明,组件被人为分割,这个分割可能来源于:1. 代码加载的耗时;2. 依赖数据加载的耗时。通过这种声明,React可以对加载流程进行优化,将数据请求、Hydration、静态HTML生成等React管理的流程进行并行/并发,以达到优化性能的作用。

渐进式升级

React18采用了渐进式升级的策略。没有显著的对现有组件行为产生突破性变化的更新。在不使用新特性的情况下,可以在很小甚至没有代码变更下完成到React18的升级。

You can upgrade to React 18 with minimal or no changes to your application code, with a level of effort comparable to a typical major React release.

React18引入了新的ReactDOM.createRoot API,而不是使用它来替换原有的ReactDOM.render API。所有的新特性也只会在createRoot下生效,这避免了老版本代码因为新特性的引入而产生不可预期的执行结果。而对于新的并发特性(concurrent feature),官方博客同样提到,在对Facebook大量的组件进行升级的过程中,多数组件在无需代码变更的情况下就可以正常工作。

createRoot

在React18中,将会存在两个Root API:

  • Legacy root API:ReactDOM.render - 在这个API下的代码将会在legacy模式下被执行,它的执行逻辑和React17相同。这个API将被加上warning以提示它将被废弃,不推荐使用。
  • New root API:ReactDOM.createRoot - 生成一个使用react18的root,包含了react18的所有优化(包含并发特性)

root

在React中,root是顶层的数据结构,React用其获取整棵树的信息以进行渲染。

  • legacy:这个信息被保存在DOM中,对用户是透明的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import * as ReactDOM from 'react-dom';
    import App from 'App';

    const container = document.getElementById('app');

    // Initial render.
    ReactDOM.render(<App tab="home" />, container);

    // During an update, React would access
    // the root of the DOM element.
    ReactDOM.render(<App tab="profile" />, container);
  • new:这个信息是独立的一个object,需要执行它的render方法进行渲染

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import * as ReactDOM from 'react-dom';
    import App from 'App';

    const container = document.getElementById('app');

    // Create a root.
    const root = ReactDOM.createRoot(container);

    // Initial render: Render an element to the root.
    root.render(<App tab="home" />);

    // During an update, there's no need to pass the container again.
    root.render(<App tab="profile" />);

hydration

当使用ssr时,需要使用hydrateRoot来替换createRoot。注意第二个参数还传入了JSX,因为SSR的第一次渲染比较特殊,需要将卡护短组件渲染的树和服务端渲染的树进行匹配

1
2
3
4
5
6
7
8
9
10
import * as ReactDOM from 'react-dom';
import App from 'App';

const container = document.getElementById('app');

// Create *and* render a root with hydration.
const root = ReactDOM.hydrateRoot(container, <App tab="home" />);

// You can later update it.
root.render(<App tab="profile" />);

区别

  1. 更新效率:在旧API中,即便container没有变化,render函数还是需要重复传入;另一方面,root无需再存储在DOM中,数据会更加安全(虽然实现上,root现在依旧会存储在DOM中)。
  2. 适配新SSR:移除hydrate方法,将其作为root的一个属性;由于在允许部分hydrated的情况下,render的回调不再合理,它在新API中被移除了。

其他详细内容可见issue

里程碑

  • 2021-06-08 发布alpha包
  • 2021-11-15 发布beta包

References

背景

微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于 Web 端。它将 Web 应用由单一的单体应用拆解功能组合。每个功能可以隶属于不同的团队,使用不同的前端架构,部署在不同的地址。

微前端来源于前端业务间开发流程接耦的需求。即希望在从开发到上线的整个业务流程上,各个团队能够独立进行互不干扰:

  • 开发团队组织结构的演变:

    Monolithic Frontends

  • 微前端团队的组织结构:

    End-To-End Teams with Micro Frontends

定义

微前端是这样一种架构风格:将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品

Micro Frontend is a pattern to emerge for decomposing frontend monoliths into smaller, simpler chunks that can be developed, tested and deployed independently, while still appearing to customers as a single cohesive product.

摘自 https://front-hub.rdstation.com.br/docs/microfrontend

即:

  1. 对用户而言:是一个完整的单个产品
  2. 对开发者而言:是多个独立交付的前端应用

例子

如下是一个典型的微前端页面的结构。在一个公共的主应用中挂载了两个独立的子应用。三个应用代码分别存储于不同仓库,同时也部署于不同的地址。

image

由于子应用资源在运行时进行加载,满足了子应用间独立交付,互不干扰的需求。

广义的微前端

如果我们把微前端看作一个容器应用将各子应用结合起来。那么广义来说,我们可以按照集成方式将微前端分为两类:

  1. 构建时集成:如webpack的Code Splitting、npm包等方式(缺陷:发布阶段的耦合,无法独立交付)
  2. 运行时集成:
    1. 客户端集成:iframe、Web Components和其他复合的JS集成(如使用qiankun等微前端框架)方式
    2. 服务端集成:如 SSR 拼装模板,React18支持的Streaming HTML和Selective Hydration

接下来我们主要讨论的是运行时使用JS集成场景下的微前端,其他的集成方式选型可以参考这篇文章

应用场景

得益于微前端拆解的特性,目前多用于复杂的web应用/web站点(如:中后台页面、阿里云页面、figma插件系统)。但由于其潜在的加载性能问题,多用于对网络资源不敏感的应用。

在云音乐中,内部应用(如:cms后台)、B端应用(如:创作者中心)都是比较适合落地微前端的场景。

在以下场景中我们可以应用这项技术:

  1. 增量升级:允许渐进式重构。在历史系统中会存在一些过时的技术,而在新业务中使用这些技术会造成开发效率的降低。如:一些老的 cms 中使用 regular 框架,而现在云音乐的新业务都使用 react 进行开发。
  2. 独立部署:缩小单应用功能范围,降低变更风险,提升部署效率。随着业务中需求的堆积,单体应用代码量增加,构建时间也会增加,造成每次部署的长时间的等待。如:平台 cms 中存在 200+个页面。
  3. 团队自治:围绕业务功能纵向组建团队,而不是基于技术职能划分。一个业务中遇到的能力可能由不同团队来维护,使用同一个仓库会造成开发流程上的混乱;另一方面,同一个中台能力也可能提供给不同的业务方。如:用户中台 cms 提供了面向不同业务(心遇、云音乐)的用户管理能力。

微前端体系

components

字节的这篇文章提到了微前端体系:为了在企业级的业务中落地微前端,我们需要的不仅仅是一个微前端框架,而是一整套覆盖开发流程的完整体系。它包括:

治理体系

管理平台 & 发布流程

  • 应用管理:主子应用版本管理,入口地址
  • 依赖管理:主子应用间的依赖关系

开发配套

开发工具 & 流程

  • 流程文档
  • 集成联调:主应用独立调试、子应用独立调试、主子应用联合调试的方式

运行时容器

微前端框架、iframe、web component…

  • 应用加载

    • 入口文件格式:JS & HTML
    • 入口的注入方式:构建时注入 & 运行时注入
  • 生命周期 - 加载 / 挂载 / 更新 / 卸载

    • 加载:请求资源
    • 挂载:初始化
    • 更新:路由变化、主子应用双向通信
    • 卸载:清理
  • 沙箱隔离

    • JS隔离

      • snapshot:子应用挂载时对window进行快照,子应用卸载时恢复快照

      • wasm VM:子应用放在wasm的js解释器中执行(隔离过于严格,通信开销大)

      • with() + new Function(code) + Proxy

        • with():改变作用域,拦截对全局变量的查找。
        • new Function(code):只能访问全局作用域(于此相对,eval可以访问局部变量)
        • Proxy:对document、history、location的操作做劫持
      • with() + new Function(code) + Proxy + iframe

        • 其他与上面一致,但是取iframe的window解决了上面对windows浅拷贝导致的全局API逃逸问题
    • CSS隔离

      • 切换应用时卸载(同一时刻应用间CSS还是会互相干扰)
      • shadow dom
        • 严格隔离了主子应用CSS。但是无法解决所有问题,如:弹窗无法应用子应用样式
        • hack:在document.body上的插入也应用shadow dom,并同步css。但是还有一些难以解决的问题:1. 两个shadow dom间样式的双向同步;2. css in js的动态插入;3. 插入dom其他位置难以劫持
  • 路由同步

    • 主子应用共享浏览器历史,主子应用能够操控和响应路由
      • history模式
        • 主响应子:劫持子应用history.pushState,主应用接收通知后replaceState。参考
        • 子响应主:劫持子应用popstate事件的监听,主应用路由变化后主动触发。参考
      • hash模式
        • 响应hashchange事件
    • 子应用正确操控和响应路由(通过微前端访问和独立访问时子应用页面的url不同)
  • 应用通信

    • 主子应用通信
  • 异常处理

    • 加载失败,路由匹配失败…

微物料

应用的拆分粒度 & 加载方式。具体的分类可见这里

问题 / 难点

载入速度

流量负担(公共资源)

重复加载公共资源是微前端的隔离中附带的问题。微前端降低了耦合度,但提取公共资源以统一处理又会增加微应用间耦合度。

  • 公共依赖:主子应用间、子应用间的公共资源,如:公共组件的JS、CSS,公共的错误、性能监控、埋点功能的代码。(解决方案:如使用html的external JavaScript,webpack5的module federation)

  • 请求资源:在微前端中,主子应用经常会请求同一个接口,在首次加载时,实际上请求返回数据相同,需要避免重复请求带来的开销。(解决方案:如使用SWR缓存请求结果,主应用使用props向子应用传递请求结果)

加载链路

为了便于对依赖进行管理,通常我们会动态下发子应用配置而非在构建时注入,这也一定程度上延长了资源加载的链路,进而降低了页面加载速度。

管理 、操作的复杂性

  • 交付流程如何支持多应用(依赖管理,版本控制)
  • 质量保障(问题定位,独立发布的质量把控,开发规范)

在嵌套滑动或其他复杂的交互场景时,我们需要对用户的手势进行识别。判断应该由哪个元素来响应这个手势事件。如在两个嵌套垂直滚动的ScrollView中,我们需要判断用户的意图是作用于内部还是外部的ScrollView。

手势识别能力

对于滑动手势的识别,有下面三种能力层级(具体例子可见参考 - 3的视频):

  1. 滑动中识别(abbrev. 能力1)
  2. 滑动开始时识别(abbrev. 能力2)
  3. 通过上一次滑动手势识别(abbrev. 能力3)

由上至下,识别延迟增加,用户体验降低。

仅使用react-native

一个简单的例子

需求

以下面的浮层为例。在地图app中,我们需要实现一个纵向滑动的浮层,内部嵌套了一个纵向滑动的地点列表。

截屏2021-09-08 上午11.34.39

在结构上,我们可以这样理解上面这个例子:

1
2
3
4
5
6
7
<App>
<Map />
<Drawer> // 浮层
<Search />
<Locations /> // 地点列表
</Drawer>
</App>

场景

设想我们在浮层展开的状态下向下拖动地点列表,对应手势识别能力分别会是如下的交互形式:

序号 识别能力 交互 拖动次数
1 滑动中识别 用户下拉地点列表到底后,浮层开始下拉 1
2 滑动开始时识别 用户下拉地点列表到底后,无法继续下拉。再次下拉时浮层下拉 2
3 通过上一次滑动手势识别 用户下拉地点列表到底后,无法继续下拉。再次下拉无响应。再次下拉时浮层下拉 3

实现

对于上面这个简单的例子,我们可以通过一个ScrollView来实现滑动中识别的能力:

1
2
3
4
5
6
7
<ScrollView stickyHeaderIndices={[1]}>
<TransparentPlaceholder /> // 透明占位符
<Search /> // 搜索栏
<Location-1/> // 地点1
<Location-2/> // 地点2
...
</ScrollView>

通过stickyHeaderIndices配置,我们可以实现指定项目的吸顶功能。由于只使用了一个ScrollView,在用户的观感上,地点列表拖动到底之后,浮层就会开始下拉,体验流畅。

更加复杂的例子

需求

在下面我的播客浮层中,我们添加了可以横向滚动的tab。这使得在横划过后,内部嵌套的ScrollView存在了不同的垂直滚动状态,因此我们不再能够仅用一个ScrollView来模拟内外两层的垂直滚动。

截屏2021-09-24 下午2.20.51

思路

这其实是一个响应者(gesture handler)的问题:当内部ScrollView滚动到底时,根据用户手势的方向,需要判断由内层列表还是外层抽屉对手势进行响应。

对于外层的抽屉,有两种实现方式:

  1. 外层使用PanResponder(优点:更可控的外层浮层动画)

    事件拦截:通过onMoveShouldSetPanResponderCapture在需要时拦截用户的拖动事件,阻止内部列表的响应

    事件响应:通过onPanResponderMoveonPanResponderRelease对拖动事件进行响应,通过Animated.ValueXY将其绑定到抽屉的transitionY上
    5e96a0842dcade04fbfbc993a1y9cA7101

  2. 外层使用ScrollView(优点:更好的性能)

    事件拦截:通过scrollEnabled属性对事件进行拦截

    事件响应:使用nestedScrollEnabled实现嵌套的滚动,利用ScrollView原生的能力直接进行响应

实现

在这个需求中,我们使用了PanResponder的方法进行实现(方案1)。核心的手势响应代码可以参照参考 - 2.usePanResponder

不管PanResponder(方案1)还是ScrollView(方案2),都无法在滑动过程中切换响应的View,因此只能实现能力2

踩坑

在安卓端使用方案1进行实现时,由于ScrollView存在一些奇特的表现,我们需要猜测用户的意图以实现部分操作的能力2,而有些时候,由于猜测不准确,只能实现能力3

安卓端ScrollView的奇特表现:当ScrollView开始滑动的时候,onMoveShouldSetPanResponderCaptureonPanResponderMove无法被持续触发。(在测试中通过设置nestedScrollEnabled属性,我们的PanResponder可以拦截到更多事件,但仍然无法达到IOS上的效果)

用户意图的猜测:当用户下拉内部列表到底后,猜测用户下一步会收起浮层,提前锁定内部的ScrollView

使用react-native-gesture-handler

从上一节可以看到,仅使用react-native提供的功能,无法实现能力1的嵌套滑动效果。而react-native-gesture-handler优化了手势响应的机制,使其具备更加复杂的手势识别能力。在github issue的讨论中我们可以看到针对抽屉这个场景,如何选择正确的响应者对手势进行响应。同时库中还提供了一个demo,通过代码展示如何实现一个嵌套滚动的抽屉。

例子

需求

以上面这个地图例子为例。设想我们需要实现更加复杂的功能:即无论内部列表滚动到何种状态,向下拉动浮层的上沿(区域1)可以直接收起浮层。当我们还是使用单个ScrollView时,下拉会先造成内部列表下拉,然后再造成浮层下拉。

8jlE9BbvVgsUXzR

实现

针对这类需求,npm上已经有包利用demo的原理进行了封装:@gorhom/bottom-sheet。通过简单的代码,即可实现能力1的交互形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<BottomSheet
ref={bottomSheetRef}
index={1}
snapPoints={SNAP_POINTS}
onChange={() => {}}>
<View>
<Search /> // 搜索栏
<BottomSheetScrollView>
<Location-1/> // 地点1
<Location-2/> // 地点2
...
</BottomSheetScrollView>
</View>
</BottomSheet>

@gorhom/bottom-sheet内部依赖了react-native-gesture-handler和react-native-reanimated,使用时切忌遗漏安装对应的客户端包

注:在安卓中,react-native包导出的Touchable和ScrollView无法在BottomSheet中正常响应事件,具体解决方案可见官方文档的troubleshooting部分

总结

本文描述了嵌套垂直滑动的三种手势识别能力。并针对抽屉组件中嵌套ScrollView的不同需求场景,简述了仅使用RN提供的API & 使用react-native-gesture-handler,实现不同能力的方法。可以看到,react-native-gesture-handler提供了更加完整的基础能力,以实现复杂的手势响应能力;但与此同时也增加了代码的复杂度。

展望

在我的播客例子中,目前仅使用原生的方法实现了能力2的手势响应机制。通过react-native-gesture-handler,是否能够实现能力1的手势响应机制,以及如何实现,还待研究。

参考

1. react native gesture handler

Document: https://docs.swmansion.com/react-native-gesture-handler/docs/

Video: https://www.youtube.com/watch?v=V8maYc4R2G0&ab_channel=ReactConferencesbyGitNation

RN手势检测的问题

  1. gesture recognition logic is distributed between threads that run in parallel
  2. lack of an API that would allow for defining interactions between native gesture recognizers
  3. touch events recognized by JS responder system cannot be connected with native animated nodes

解决方案

在UI线程(native)识别多个手势,按需激活正确的单个响应器,并使用Animated Native Driver实现流畅动画

2. usePanResponder

外层抽屉浮层的pan responder配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import {
useCallback, useMemo, useRef
} from 'react';
import {
PanResponder,
Animated
} from 'react-native';

import {
usePropsRef, closerToZero, flag, limitInBetween
} from './utils';

const stablePositions = {
TOP: 0,
BOTTOM: 1
};

export default function usePanResponder({
// up为y减小,down为y增大
lockDown, lockUp,
captureUp, captureDown,
thresholdUp, thresholdDown, // 滑动绝对距离的阈值
// top为y最小值,down为y最大值
onTopReached, onBottomReached,
topBoundY, bottomBoundY,
defaultY, // 默认开始位置
}) {
const props = usePropsRef({
lockDown,
lockUp,
captureUp,
captureDown,
thresholdUp,
thresholdDown,
onTopReached,
onBottomReached,
topBoundY,
bottomBoundY,
defaultY
});

// 获取手势前的位置
const lastStablePosition = useRef(
Math.abs(defaultY - topBoundY) < Math.abs(defaultY - bottomBoundY)
? stablePositions.TOP : stablePositions.BOTTOM
);
const getStablePositionY = useCallback(
stablePosition => (
stablePosition === stablePositions.TOP
? props.current.topBoundY : props.current.bottomBoundY
), []
);

// 位置动画
const position = useRef(new Animated.ValueXY({ y: defaultY, x: 0 })).current;

// 动画position到指定位置
const transitionTo = useCallback((stablePosition) => {
const isBottom = stablePosition === stablePositions.BOTTOM;
Animated.spring(
position,
{ toValue: isBottom ? props.current.bottomBoundY : props.current.topBoundY }
).start(() => {});

lastStablePosition.current = stablePosition;
if (isBottom) props.current.onBottomReached();
else props.current.onTopReached();
});

// 手势监听器
const panResponder = useMemo(() => PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: (evt, gesture) => {
const verticalCapture = getVerticalPanValue({
upValue: props.current.captureUp,
downValue: props.current.captureDown,
elseValue: false
}, gesture);

return verticalCapture;
},
onPanResponderMove: (e, gesture) => {
const lock = getVerticalPanValue({
upValue: props.current.lockUp,
downValue: props.current.lockDown,
elseValue: false
}, gesture);
if (lock) return;

const targetY = getStablePositionY(lastStablePosition.current) + gesture.dy;
const limitedTargetY = limitInBetween(
targetY, [props.current.topBoundY, props.current.bottomBoundY]
);
if (limitedTargetY === targetY) { // in bound
position.setValue({ y: targetY });
} else { // over bound (use rubberband)
const overDistance = closerToZero(
targetY - props.current.topBoundY, targetY - props.current.bottomBoundY
);
position.setValue({
y: limitedTargetY + flag(overDistance) * calculateRubberband(
Math.abs(overDistance)
)
});
}
},
onPanResponderRelease: (e, gesture) => {
const lock = getVerticalPanValue({
upValue: props.current.lockUp,
downValue: props.current.lockDown,
elseValue: false
}, gesture);
if (lock) return;

const { dy } = gesture;
const absoluteDy = Math.abs(dy);
const flagDy = flag(dy);
if (lastStablePosition.current === stablePositions.TOP
&& absoluteDy > props.current.thresholdDown
&& flagDy > 0) { // to down
transitionTo(stablePositions.BOTTOM);
} else if (lastStablePosition.current === stablePositions.BOTTOM
&& absoluteDy > props.current.thresholdUp
&& flagDy < 0) { // to up
transitionTo(stablePositions.TOP);
} else { // reset position
transitionTo(lastStablePosition.current);
}
}
}), [position]);

return {
panResponder,
position
};
}

function calculateRubberband(overDistance) {
return Math.sqrt(overDistance);
}

function getVerticalPanValue({ upValue, downValue, elseValue }, gesture) {
const { dx, dy } = gesture;

// 竖直滑动
const panRatio = Math.abs(dx / dy);
const verticalDrag = panRatio < 2;

if (!verticalDrag) return elseValue;

if (dy < 0) { // up
return upValue;
} if (dy > 0) { // down
return downValue;
}

return elseValue;
}

3. 交互视频

  1. 能力1,滑动中识别(理想情况,用户只需交互一次)

  2. 能力2,滑动开始时识别(用户需要拖动两次,第一次拖动内部列表,第二次拖动外部抽屉)

最佳实践

使用lottie创建动画,修改lottie json中对应颜色的属性。

找到颜色属性的方法:让视觉提供两个颜色不同的json文件,作比较。

如需实现类似tintColor一样修改所有颜色的效果,可以使用Reference中的代码。

踩坑

常规格式动画图片(gif, webp, apng)

  • ios:tintColor不会用到所有格式的动画图片(gif, webp, apng)上
  • 安卓:对于asset中的webp图片,tintColor会生效

Lottie

  • 方案1:使用colorFilter,不生效(不知道是不是配置有错,网上文档较少)
  • 方案2:直接改json,让视觉提供色彩不同的两个json文件,compare后可以知道需要改哪些地方

Reference

修改lottie中所有颜色的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { useMemo } from 'react';

function replaceColor(object, color) {
if (typeof object !== 'object') return; // 不是object

for (const key of Object.keys(object)) {
if (key === 'k' && object[key].length === 4 && typeof object[key][0] === 'number') { // 替换颜色
// eslint-disable-next-line no-param-reassign
object[key] = [color[0], color[1], color[2], object[key][3]]; // 保留alpha
} else {
replaceColor(object[key], color);
}
}
}

// 将Lottie文件中的所有颜色替换为给定颜色
// targetColor格式 rgba(x,x,x,x)
export default function useLottieThemeColor(json, targetColor) {
return useMemo(() => {
const res = JSON.parse(JSON.stringify(json));

let colorVec;
try {
colorVec = JSON.parse(targetColor.replace(/(rgba\()([0-9., ]+)(\))/, '[$2]'));
colorVec = colorVec.map((v, index) => {
if (index >= 0 && index < 3) {
return v / 255;
}
return v;
});
} catch {
console.error(`Failed to parse theme color: ${targetColor} to color vector`);
return res;
}

replaceColor(res, colorVec);

return res;
}, []);
}

在react中,对于公共组件,与组件职责无关的埋点参数需要通过props深入传递,影响代码可读性和耦合度。本文介绍了一种通过HOC在祖先组件中添加埋点信息,并在埋点组件中读取的方式。通过独立数据流,提高了代码的可读性,降低耦合度。

问题示例

需求

两个业务组件:

  1. 音乐列表组件 - MusicList
  2. 音乐列表项组件 - MusicItem

有两个业务页面:

  1. 播放列表页面 - PlayListPage

  2. 收藏列表页面 - CollectionListPage

组织结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
////////// 组件 //////////

// 音乐列表
function MusicList({fetchList}) {
const [list, setList] = useState([])
useEffect(() => {
fetchList.then(res => {setList{res}})
}, [])

const musicItems = list.map(item => <MusicItem key={item.id} item={item} />)

return <div>{musicItems}</div>
}

// 音乐列表项
function MusicItem({item}) => {
return <div>{item.name}</div>
}

////////// 页面 //////////

// 播放列表页面
function PlayListPage() {
return <MusicList fetchList={fetchPlayList} />
}

// 收藏列表页面
function CollectionListPage() {
return <MusicList fetchList={fetchCollectionList} />
}

现在我们需要对以上页面进行埋点,埋点信息格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 页面曝光埋点 - 播放列表页面的曝光和收藏列表页面的曝光
const pageImpressLog = {
page: 'play_list' or 'collection_list',
type: 'impress',
}

// 音乐列表项的点击埋点 - 分别对两个页面的每个元素进行埋点
const itemClickLog = {
page: 'play_list' or 'collection_list',
type: 'click',
id: '5486234' // 音乐的id
}

问题

对于页面曝光埋点,我们可以直接在对应的页面上进行埋点。但是对于公共业务组件,埋点就显得比较繁琐:我们需要在通过props把page的信息传入到MusicList中,再由它传至MusicItem。这会带来两个问题:

  1. page信息实际上和MusicList / MusicItem 组件无关,这影响了组件的独立性。
  2. 当公共组件内有多层嵌套的时候,这个无关信息需要深入传递,增加了组件的复杂度。

分析

对于以上例子透露的问题,以及对需求进行分析,我们可以发现:

  1. 页面曝光和音乐列表项点击的埋点存在公共字段(如:page),可以复用,且使用公共字段的两个组件存在逻辑上的嵌套关系。

  2. 公共组件可以使用props中的信息,对属于自己的特殊字段进行埋点(如:音乐列表项点击埋点中的id字段可以从通过item.id从props中提取)。

  3. 我们希望使用独立于props之外的独立数据流来提供埋点信息,降低埋点代码对业务代码的污染。

解决方案

  1. 在祖先组件中注入和该组件相关的埋点信息
  2. 在后代组件中合并并使用所有祖先组件中注入的埋点信息

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
////////// 组件 //////////

// 音乐列表
function MusicList({fetchList}) {
const [list, setList] = useState([])
useEffect(() => {
fetchList.then(res => {setList{res}})
}, [])

const musicItems = list.map(item => <MusicItem key={item.id} item={item} />)

return <div>{musicItems}</div>
}

// 音乐列表项
function MusicItem({item}) {
+ const log = useLog()
const onClick = () => {
+ sendLog({
...log,
type: 'click',
id: item.id
})
}
return <div >{item.name}</div>
}

////////// 页面 //////////

// 播放列表页面
+ const PlayListPage = injectLog({
+ page: 'play_list'
+ })(
() => {
const log = useLog()
useEffect(() => {
sendLog({
...log,
type: 'impress'
})
}, [])
return <MusicList fetchList={fetchPlayList} />
}
)

// 收藏列表页面
function CollectionListPage = injectLog({
page: 'collection_list'
})(
() => {
const log = useLog()
useEffect(() => {
sendLog({
...log,
type: 'impress'
})
}, [])
return <MusicList fetchList={fetchCollectionList} />
}
)

从高亮行可以看到这种解决方案的示例:

  1. 在页面中使用injectLog注入公共埋点信息。
  2. 在埋点发送处使用useLog获取合并后的埋点信息,并发送。

一次失败的尝试

由于react使用DFS进行渲染,我的第一个想法是使用一个stack来存储所有祖先节点的渲染信息。injectLogHOC的简单版实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
const stack = []

function injectLog(log){
return (Component) => {
return (props) => {
stack.push(log)
const component = React.createElement(Component, props)
stack.pop(log)
return component
}
}
}

但是这里执行stack pop操作的位置有问题。因为React.createElement并不会执行render函数。它返回的是一个Element,是一个所渲染的元素的描述,可以理解为一个plain object。React会通过render函数返回的描述自己控制渲染的执行。实际上,react profiler提供了onRender API。在render执行结束后会调用该回调函数。但是由于profiler会影响性能,官方不建议在生产环境使用。

注入和读取

使用react context,独立与组件之外对埋点信息进行存储。通过provider和consumer实现了脱离props传参之外的数据流。

前言

当页面中存在大量诸如页面拖动等动画时,手动处理手势事件,并将其绑定到诸如div等元素的属性将是一件费力且低效的工作。而react-use-gesturereact-spring分别对手势动画进行了抽象,为使用react hooks创建交互式动画提供了一种更为便捷的解决方案。

本文将会先简要介绍使用react-use-gesturereact-spring创建交互式动画的方式,以及简要的实现解析。然后描述在开发过程中遇到的问题(兼容性问题&接口问题)及其解决方案。

react-use-gesture

简介

react-use-gesture

  1. 对浏览器的用户输入事件进行了封装,提供了统一的手势接口,使复杂的手势(如:拖动,缩放)易于配置。

  2. 提供了原生事件所没有的属性(如:速度,距离),丰富了事件所包含的信息。

使用

例子

一个典型的例子如下所示。使用手势hook中注册回调函数,回调函数负责处理事件并触发side effects。hook会返回一个bind函数,通过调用该函数,将其绑定到react节点上。

1
2
const bind = useDrag(state => doSomethingWith(state), config)
return <div {...bind(arg)} />

其中:

  1. 手势hook传入的第一个参数是回调函数,第二个参数是手势配置。
  2. bind函数中传入参数的方式,常用于给多个节点绑定同一个回调函数,详见这个例子

手势hook

目前支持的有普通手势:useDraguseMoveuseHoveruseScrolluseWheelusePinch,以及复合手势:useGesture。详细功能见列表

需要区分:

  • useDrag,useMove和useHover

    drag事件只有在用户使用控制器(鼠标 / 触控屏)拖动(按压 / 触摸)时触发;而move事件会在控制器hover(onPointerMove)时即触发。而hover事件只处理控制器进入和离开事件(onPointerEnter & onPointerLeave)

  • useWheel和useScroll

    wheel事件只会被鼠标触发。scroll事件只有在元素真实发生滚动的时候才会触发;而鼠标在元素上滑动滚轮就可以触发wheel事件。

手势配置

比较常用的配置项有:domTarget / eventOptions,以及用于控制手势范围的 bounds / distanceBounds / angleBounds / rubberband,用于控制swipe的 swipeDistance / swipeVelocity / swipeDuration 等。详细内容可以在这里找到。

其中:

  • domTarget用于替代bind函数,直接给dom ref绑定事件。当需要设置事件为passive来提升性能时,必须使用domTarget。

事件

recognizer会给事件回调传入丰富的事件信息,诸如:movement,offset,velocity等。详见文档

其中较为常用的几个属性为:

  • movement和offset的区别

    movement是单次拖动过程中手势拖动的向量,offset是在一个节点上所有手势拖动的向量和。

  • memo

    用于记忆用户的自定义事件属性

  • cancel

    取消当前事件

  • event

    原始事件

注意

  1. 为了避免和浏览器默认的拖动产生冲突(如:图片和链接的默认拖动行为),需要设置css:touch-action: none。同时,如需兼容firefox,还需要prevent default,详见这里

实现

初始化

  • 用户调用接口,传入handlers和config
  • 实例化controller
    • 实例化接口对应的recognizer,将handlers传入recognizer
    • 将recognizer需要注册的事件列表注册到dom

回调

  • 当recognizer监听到对应的手势时,触发handlers

性能优化

  • Event delegation

    在react-use-gesture提供了更加丰富的事件信息的同时,也增加了每次事件计算信息的性能消耗。通过事件代理对事件进行统一处理可以优化性能。

react-spring

简介

react spring提供了基于弹簧物理模拟的动画。它基于弹簧模型而不是常见的曲线 / 时长模型(尽管API中也支持指定动画时长),使其的动画效果更为自然。因为基于物理模型的动画将是连贯且更具交互性的。

Andy Matuschak (ex Apple UI-Kit developer) expressed it once: Animation APIs parameterized by duration and curve are fundamentally opposed to continuous, fluid interactivity.

使用

例子

React-spring的使用主要分为三步:

  1. 使用动画hook配置动画并获得返回的style属性和set函数
  2. 将style属性传入animated组件
  3. 使用set函数更新动画
1
2
3
4
5
6
7
import {useSpring, animated} from 'react-spring'

function App() {
const [style, set] = useSpring(() => ({opacity: 1, from: {opacity: 0}}))
// ... use set to update style
return <animated.div style={style}>I will fade in</animated.div>
}

注意:

  1. set函数并不是直接设置style,而是提供了一个目标,而useSpring会自己计算出下一帧该如何变化以接近目标。
  2. App函数并不会在set之后重新执行,因为style实际上是mutable的。
  3. 另外一种更新动画的方式是在useSpring中直接传入新的值,但例子中使用的方法性能更优。

动画hook

hook 描述
useSpring 动画化传入的参数
useSprings 创建多个动画,使用各自的动画配置,用于静态列表
useTrail 创建多个动画,使用同一个配置,每一个动画将会跟随前一个动画
useTransition 组件出现和消失的动画
useChain 串联动画,下一个动画会在上一个结束后开始

除了hook之外,react-spring还提供了动画组件,如:Spring,Trail等。参数与hook相似,这里不再赘述。

动画属性

  • 字段类型(number + string)

    reat-spring不仅仅支持例子中的数字类型的动画,也支持字符串类型(如:transform,color)的动画。详见Up-front interpolation

  • 插值(interpolate)

    同时,react-spring也支持使用插值函数将一个更新后的数值映射到真实的动画属性。它允许用户重复使用一个计算结果,将其应用到多个动画属性上,提高了性能。一个例子可以在这里找到。

更新动画

使用hook返回的set函数更新目标,参数同hook参数一致。见properties

  • from / to …

  • config

    通过 mass / tension / friction 设置基于物理模拟的动画,或使用duration来设置基于时长的动画。

    或者可以使用config.default / config.slow … 等预设动画。

实现

  • style:通过将css属性与动画组件直接绑定,跳过了业务组件的重新渲染,提高性能。

    监听动画属性:React-spring的hooks返回的style属性并不是简单的CSSProperties类型的数据,而是SpringValues类型。其中每个SpringValue通过fluids库的addFluidObserver函数监测变化。实现类似mobx中observable的效果。当style被传入animated组件时,getAnimatedState函数会提取props中的fluidValue并添加到依赖集中,并为每个依赖通过addFluidObserver监听变化。

    更新:当SpringValue更新时,会被加入一个Set。在每个动画帧(使用rafz库的onFrame函数,调用requestAnimationFrame)的最后,这些更新会被读取并通过flushCalls函数应用到组件上。

兼容性问题

Pointer event

问题

由于react-use-spring使用了pointer event,导致在低版本的浏览器中将无法使用。

解决方案

所幸pepjs提供了pointer event的polyfill方案,而其使用方式也十分简单:

  1. 安装pepjs

    1
    npm install pepjs
  2. 在代码中引入

    1
    import 'pepjs'
  3. 在对应的节点设置touch-action属性(浏览器为了优化性能,不指定touch-action默认不触发事件)

    1
    <button id='test' touch-action="none">Test button!</button>
  4. 注册事件

    • react

      1
      2
      3
      export function Pointable() {
      return <div touch-action="none" onPointerDown={(e) => console.log(e)} />
      }
    • DOM

      1
      document.getElementById("test").addEventListener("pointerdown", function(e) {console.log(e)}

注意:在使用pepjs时,当用户的触摸操作需要用到浏览器默认的行为(如:scroll)时,同样需要指定touch-action(如:touch-action=’auto’)。

IOS 13.0 & 13.1

问题

在IOS13.x及之后的版本中,移动端的safari&chrome开始支持pointer event。但是对于13.0 & 13.1,官方对于pointer event的实现(pointerevent.buttons的值)仍然有问题,见can i use。在这两个版本中,由于:

  1. pepjs不再polyfill
  2. react-use-gesture的实现依赖于pointerevent.buttons的值

造成了在13.0 & 13.1产生了一个pointerevent.buttons polyfill的真空地带,导致react-use-gesture失效。这个问题在issue中也有描述。

解决方案

因为这是pointer event实现的bug,react-use-gesture并不准备通过为这两个版本修改代码。因此我们需要自己fork一个react-use-gesture:

路径:./src/recognizers/DragRecognizer.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
onDragChange = (event: PointerEvent): void => {

...

// If the event doesn't have any button / touches left we should cancel
// the gesture. This may happen if the drag release happens outside the browser
// window.
- if (!genericEventData.down) {
+ if (!is_ios_13_0_or_13_1() && !genericEventData.down) {
this.onDragEnd(event)
return
}

...
}

注意

修改后的库会有一个问题,即:使用鼠标拖动移出浏览器窗口后释放鼠标,再移入时,在IOS13.0和IOS13.1中,将继续处于拖动状态。

踩坑

react-spring v9 资源汇总

React-spring目前(2021/02/07)最新的稳定版本是8.0.27,最新的release candidate是9.0.0-rc.3。但是由于官网的文档仍然是v8的版本,且我们在使用v9时遇到了很多奇怪的问题,并不建议使用v9。

如果你仍然想要使用v9,以下是一些文档的链接:

react-spring v9 常见问题及解决方式

  • Q:在开发环境无问题,部署环境出现TypeError(如: Uncaught TypeError: r.willAdvance is not a function

    A:因为v9 - rc.3在package.json中声明了sideEffect: ture。webpack时会进行剪枝(tree shaking)操作,导致报错(官方表示会在rc.4修复)。修复方法

    1
    2
    3
    4
    "scripts": {
    "react-spring-issue-1078": "find node_modules -path \\*@react-spring/\\*/package.json -exec sed -i.bak 's/\"sideEffects\": false/\"sideEffects\": true/g' {} +",
    "postinstall": "npm run react-spring-issue-1078",
    }
  • Q:useTransition / Transition 中各种不符合预期的表现

    A:首先检查是否使用了v9新的API格式。然后尝试在items参数中传入数组而不是其他类型。尽管在文档的示例中有很多非数组的例子,但这些都不是标准的使用方法,在v9中可能导致各种预期之外的动画效果。

  • Q:useTransition / Transition 中的 Multi-stage transitions 的stage数量和设定不符

    A:检查是否两个阶段设定的状态没有变化,如:

    1
    2
    3
    4
    5
    leave={[
    {opacity: 1},
    {opacity: 1},
    {opacity: 0},
    ]}

    上图的设定会导致中间的stage被无视,实际效果不是opacity: 1->1->0,而是opacity: 1->0。一个备用方案是设定用户无法感知的变化,如:

    1
    2
    3
    4
    5
    leave={[
    {opacity: 1},
    {opacity: 0.99},
    {opacity: 0},
    ]}

Motivation

在学习React Native动画的时候,看到了这段使用Animated的基础代码。可以看到这段代码并不使用setState来更新状态以实现动画效果。在本文中,笔者将会总结博客和源码中的相关资料,以期了解这段代码的实现原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const FadeInView = (props) => {
const fadeAnim = useRef(new Animated.Value(0)).current // 透明度初始值设为0

React.useEffect(() => {
Animated.timing( // 随时间变化而执行动画
fadeAnim, // 动画中的变量值
{
toValue: 1, // 透明度最终变为1,即完全不透明
duration: 10000, // 让动画持续一段时间
}
).start() // 开始执行动画
}, [fadeAnim])

return (
<Animated.View // 使用专门的可动画化的View组件
style={{
...props.style,
opacity: fadeAnim, // 将透明度绑定到动画变量值
}}
>
{props.children}
</Animated.View>
)
}

Do Work in JS || Native Driver Animations

这篇文章里提到了Animated的两种计算动画的方式和对应的优劣。

  1. 在JS线程中使用requestAnimationFrame计算动画参数,并使用Bridge将数据传输给原生代码
  2. 在动画开始前通过Bridge直接将动画信息传输给原生代码,由UI线程来计算动画参数

可以看到使用声明式的代码的方式定义动画便于使用UI线程来提高性能。
同时,不论是1还是2,都是由UI线程来渲染原生视图,因此可以跳过频繁的setStaterender来提高性能。

Source Code

Animated.Value

react-native库Libraries/Animated/nodes/AnimatedValue.js文件中描述了Animated.Value是如何工作的:
Animated构建了一个依赖的有向无环图。view属性的计算包含了两个阶段:

  1. Top Down Phase
    当Animated.Value被更新的时候,它会寻找并标记叶节点:即需要更新的view
  2. Bottom Up Phase
    从被标记的叶节点回溯,以此得到它所需的value。(这么做的原因是某些view的属性可能是由多个value组合而成,如:transform)

Animated.[Component]

Libraries\Animated\createAnimatedComponent.js文件中,可以看到在mount/update的时候,使用this._propsAnimated.setNativeView为AnimatedProps绑定了component。同时在Libraries\Animated\nodes\AnimatedProps.js文件中,使用__connectAnimatedView函数通过native tag标识将动画节点与对应的native view相连。

To build backend API, one of the problem is validate the external data. Because the incoming data is not necessary valid. The invalid data might come from user input, wrong call of API in frontend. It might also come from malicious attackers. Thus, validation in backend is neccessary no matter frontend did it or not.

Since I’m using Typescript to build backend in Todo project, it would reduce the redundancy of code if I can make use of existing type definition in type definition. This post introduces several approaches for data validation categorized by using JSON schema or not. In my project, I tried the approach that converting Typescript types to JSON schema.

Packages

Approach

  1. Use typescript-json-schema to pre-compile existing type to JSON schema.
  2. Use ajv to make use of the schema from 1 to validate the request data.

Code

  1. Typescript to JSON schema
    In my project, I store types used in Web API in common folder to use them both in frontend code and backend code.
    After write validation types in src/apiTypes, the following command in package.json is used to convert typescript to JSON schema file. (use install command name because it would be called after npm install. Please refer to lifecycle scripts)
    1
    2
    3
    "scripts": {
    "install": "typescript-json-schema src/apiTypes.ts * --noExtraProps --out src/schema.json"
    },
    The benefit of typescript-json-schema is that it allows using annotations to enhance the typescript properties in convertion. Like the following:
    1
    2
    3
    4
    5
    6
    7
    8
    export interface TodoItem {
    /**
    * @minLength 0
    * @maxLength 100
    */
    text: string,
    state: TodoItemState,
    }
  2. Validation with JSON schema
    After export JSON schema object from common, we can use it with ajv:
    1
    2
    3
    4
    5
    const ajv = new Ajv({
    schemaId: 'auto',
    allErrors: true,
    })
    ajv.addSchema(itemSchema, 'item')
    Then, the data could be validated like this:
    1
    2
    const validator = ajv.getSchema('item#/definitions/AddItem')
    const result = validator(data)
    For details in the URI parameter of ajv.getSchema function, please refer to the structuring complex schema