React原理 事件系统

创建事件集合

React会在初始化的时候生成事件系统所需要的事件对象集合。

  • 把系统事件按照优先级分为三大类,每种事件对应着一个优先级
事件类别 变量名称 说明 优先级 举例
独立事件 discreteEventPairsForSimpleEventPlugin DiscreteEvent=0 例如: click
用户阻塞事件 userBlockingPairsForSimpleEventPlugin
因为事件持触发,所以如果某段逻辑执行之间过长,会影响用户的交互
UserBlockingEvent=1 例如:drag
连续事件 continuousPairsForSimpleEventPlugin 需要系统一直监听是否触发的事件 ContinuousEvent=2 例如: animationEnd
  • 通过循环每种事件对应的数组,将事件保存在一下几种事件对象中
变量名称 变量对象 说明
allNativeEvents Set集合
保存所有原生事件的名称 例如 0:"cancel"
eventPriorities Map集
保存事件名称和事件优先级对应关系 例如 click=>0
topLevelEventsToReactNames Map集
保存原始事件名称和 React事件的对应关系 例如 "cancel" => "onCancel"
registrationNameDependencies Object
保存React事件和原生事件的对应关系 例如 onClick:(1) ['click'] 每个React事件对应一个数组用于保存合成事件对应关系
possibleRegistrationNames Object
保存小写的React事件名称和正确的驼峰命名事件的对应关系,用于校验用户输入 例如 onclick:onClick
  • 注册合成事件

合成事件: 某些React事件会对应多个原生事件 例如:

1
'onChange' => ['change', 'click', 'focusin', 'focusout', 'input', 'keydown', 'keyup', 'selectionchange']

合成事件会维护在registrationNameDependencies变量中,例如 onChange事件最终会变为 ``

创建事件对象

事件对象用于不用的事件类型,当React事件触发时,将会传入对应的事件对象,而不是原生的事件对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function createSyntheticEvent(Interface) {
function SyntheticBaseEvent(reactName, reactEventType, targetInst, nativeEvent, nativeEventTarget) {
this._reactName = reactName;
this._targetInst = targetInst;
this.type = reactEventType;
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
this.currentTarget = null;
_assign(SyntheticBaseEvent.prototype, {
preventDefault: function () {},
stopPropagation: function () {this.isPropagationStopped = functionThatReturnsTrue;},
persist: function () {},
isPersistent: functionThatReturnsTrue
});

return SyntheticBaseEvent;
}
var UIEventInterface = _assign({}, EventInterface, {
view: 0,
detail: 0
});

var SyntheticUIEvent = createSyntheticEvent(UIEventInterface);

事件绑定

React 创建 FiberRoot 根节点阶段, 会循环所有的原声事件,将事件绑定在 root 元素上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function listenToAllSupportedEvents(rootContainerElement) {
{
var listeningMarker = '_reactListening' + Math.random().toString(36).slice(2);
rootContainerElement[listeningMarker] = true;
allNativeEvents.forEach(function (domEventName) {
// 没有事件委托的事件,也就是不能冒泡到document的事件
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement, null);
}

listenToNativeEvent(domEventName, true, rootContainerElement, null);
});
}
}

根据事件的优先级不同,事件类型不同,绑定的事件处理函数也会不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
var eventPriority = getEventPriorityForPluginSystem(domEventName);
var listenerWrapper;

switch (eventPriority) {
case DiscreteEvent:
listenerWrapper = dispatchDiscreteEvent;
break;

case UserBlockingEvent:
listenerWrapper = dispatchUserBlockingUpdate;
break;

case ContinuousEvent:
default:
listenerWrapper = dispatchEvent;
break;
}

return listenerWrapper.bind(null, domEventName, eventSystemFlags, targetContainer);
}
  • 对于独立事件,如果更新时存在副作用,会在下一个事件前立即执行
1
2
3
4
function flushDiscreteUpdates() {
flushPendingDiscreteUpdates();
flushPassiveEffects();
}

对于用户阻塞事件,通过原生事件的targetsrcElement 获取到触发事件的元素,再通过定义在原生DOM上的属性 internalInstanceKey 找到DOM对应的Fiber节点

1
var targetInst = targetNode[internalInstanceKey];

通过 batchedEventUpdates 标记批处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function batchedEventUpdates(fn, a, b) {
if (isBatchingEventUpdates) {
// If we are currently inside another batch, we need to wait until it
// fully completes before restoring state.
return fn(a, b);
}

isBatchingEventUpdates = true;

try {
return batchedEventUpdatesImpl(fn, a, b);
} finally {
isBatchingEventUpdates = false;
finishEventHandler();
}
}

为不同的事件类型选择不同的事件对象,通过FiberNode上的props属性获取到事件,加入事件队列,
用队列来模拟冒泡事件和捕获事件

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
switch (domEventName) {
case 'click':
// Firefox creates a click event on right mouse clicks. This removes the
// unwanted click events.
if (nativeEvent.button === 2) {
return;
}

case 'auxclick':
case 'dblclick':
case 'mousedown':
case 'mousemove':
case 'mouseup': // TODO: Disabled elements should not respond to mouse events

/* falls through */

case 'mouseout':
case 'mouseover':
case 'contextmenu':
SyntheticEventCtor = SyntheticMouseEvent;
break;
}
while (instance !== null) {
var _instance2 = instance,
stateNode = _instance2.stateNode,
tag = _instance2.tag; // Handle listeners that are on HostComponents (i.e. <div>)

if (reactEventName !== null) {
var props = getFiberCurrentPropsFromNode(stateNode);
var listener = props[registrationName];

if (captureListener != null) {
listeners.unshift(createDispatchListener(instance, captureListener, currentTarget));
}

var bubbleListener = getListener(instance, reactName);

if (bubbleListener != null) {
listeners.push(createDispatchListener(instance, bubbleListener, currentTarget));
}
}
instance = instance.return;
}


dispatchQueue.push({
event: _event,
listeners: _listeners
});

循环事件队列,如果执行过stopPropagation 直接跳出循环,方法定义在上面的事件对象中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function processDispatchQueueItemsInOrder(event, dispatchListeners, inCapturePhase) {gfdg
var previousInstance;

for (var i = dispatchListeners.length - 1; i >= 0; i--) {
var _dispatchListeners$i = dispatchListeners[i],
instance = _dispatchListeners$i.instance,
currentTarget = _dispatchListeners$i.currentTarget,
listener = _dispatchListeners$i.listener;

if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}

executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
}

FAQ

  • React 为什么有自己的事件系统?

为了抹平浏览器间差异,封装了事件对象,模拟了事件冒泡和捕获。

  • 什么是事件合成?

一个React可能是一个事件,也可能是多个原生事件。而且事件对象也是封装过的对象。

  • 如何实现的批量更新?

通过 isBatchingEventUpdates 标记批处理开始, 一次用户阻塞事件中的所有相同类型事件,都会合并成一次更新

  • 事件系统如何模拟冒泡和捕获阶段?

通过队列,冒泡事件插入在队列尾部,捕获事件插入队列的头部

  • 如何通过 dom 元素找到与之匹配的fiber?

通过原生事件对象获取触发事件的DOM元素,通过DOM元素上的内置属性,获取fiber节点

  • 阻止默认行为 ?

原生事件: e.preventDefault() 和 return false 可以用来阻止事件默认行为,由于在 React 中给元素的事件并不是真正的事件处理函数。所以导致 return false 方法在 React 应用中完全失去了作用。

React事件 在React应用中,可以用 e.preventDefault() 阻止事件默认行为,这个方法并非是原生事件的 preventDefault ,由于 React 事件源 e 也是独立组建的,所以 preventDefault 也是单独处理的。

  • 事件是绑定在真实的dom上吗?如何不是绑定在哪里?

绑定在root节点上

③ ReactSSR 实现路由

整理文件结构

最后的文件结构会变为下面的结构,有一些文件需要我们去继续完善

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
src/
├── app koa服务器相关文件
│ ├── errorHandle.ts
│ └── index.ts
├── components 组件文件夹
│ ├── header
│ │ ├── header.tsx (新增)
│ │ └── index.tsx (新增)
│ ├── hello
│ │ ├── hello.tsx
│ │ └── index.tsx
│ ├── index.tsx 组件的入口
│ ├── login
│ │ ├── index.tsx
│ │ └── login.tsx
│ ├── router.tsx 客户端路由 (新增)
│ └── routes.tsx 路由子项,可以和服务端渲染公用 (新增)
├── config
│ └── default.config.ts
├── router
│ ├── index.tsx koa的路由配置
│ └── router.tsx 服务端路由 (新增)
├── server.ts koa服务器入口文件
└── util
└── errorTypes.ts

客户端添加路由

添加一个简单的<Header/>组件,使用<Link/>组件添加路由跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from "react";
import {
Link
} from "react-router-dom";

const Header:React.FC = () =>{
return <div>
<Link to="/">hello</Link>
<Link to="/login">login</Link>
</div>
}

export default Header;

添加 src/routes.ts 提取路由公用部分

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
import React from "react";
import {
Switch,
Route,
} from "react-router-dom";


import Hello from "./hello";
import Login from "./login";

const Routes = () => {
return (
<Switch>
<Route path="/hello">
<Hello />
</Route>
<Route path="/login">
<Login />
</Route>
<Route path="/">
<Hello />
</Route>
</Switch>
)
}
export default Routes;

添加 src/router.ts 客户端路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from "react";
import Koa from 'koa';
import {
BrowserRouter,
} from "react-router-dom";

import Routes from './routes'

const Router:React.FC<unknown> = () =>{
return (
<BrowserRouter>
<Routes />
</BrowserRouter>
)
}

export default Router;

src/index.ts 最终导出App

1
2
3
4
5
6
7
8
9
10
import ReactDom from 'react-dom';
import React from 'react';
import Router from './router';

const App = () => {

return <Router />
}

ReactDom.hydrate(<App/>, document.getElementById('root'));

服务端路由

添加 router/touter.ts

因为服务端并不能感知到路由的变化,所以需要手动传递路由

当客户端渲染了一个 <Redirect> 浏览器历史改变并显示了新的屏幕,在服务端不能改变App的状态,所以使用context拿到渲染的结果,如果可以拿到context.url,就可以知道重定向的结果。可以让我们在服务中发起重定向.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from "react";
import Koa from 'koa';
import {
StaticRouter,
} from "react-router-dom";

import Routes from '../components/routes'

const context={};

const Router:React.FC<unknown> = ({ctx}) =>{
return (
<StaticRouter location={ctx.url} context={context}>
<Routes />
</StaticRouter>
)
}

export default Router;

router/index.tsx

需要添加root元素,并匹配所有路径

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
import Router from '@koa/router';
import ReactDOMServer from 'react-dom/server';
import React from 'react';
import App from './router';
const router = new Router();

router.get("/(.*)",async (ctx,next)=>{
console.log(ctx);
ctx.body=`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id='root'>${ReactDOMServer.renderToString(<App {...{ctx}}/>)}</div>
<script src='/index.js'></script>
</body>
</html>
`
})

export default router;

② ReactSSR 最简单的同构

什么是同构

简单说同构就是前后端公用一套代码。

首先需要明确的是同构是SSR的一种实现方式,同构也是通过服务端渲染,将生成的HTML返回给浏览器展示,但是在React组件中绑定的事件会丢失,因为同构只返回了HTML字符串,并没有返回JavaScript文件。

所以可以把组件打包成JavaScript文件浏览器中在执行一次,用于绑定交互事件。真实的场景中还会有路由,和数据状态管理工具(redux等)的同步。

打包的组件也用于在服务端生成HTML字符串,它们公用一套代码这也是同构的本质。

打包组件

把组件打包成一个单独的文件,放在static静态资源目录下面,当浏览器访问的时候会从静态资源文件夹读取文件

webpack.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import * as path from 'path';
import * as webpack from 'webpack';

const config: webpack.Configuration = {
entry:'./src/components/hello.tsx',
output:{
filename:'index.js',
path:path.resolve(__dirname,'static')
},
module:{
rules:[
{
test:/\.tsx?$/,
use:['babel-loader']
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
mode:'development',
}
export default config;

静态资源访问

koa提供了一个中间件 koa-static 用于静态资源访问

注意路径是否正确,如果是在打包好的文件夹中执行,可以通过PWD获取Node进程执行时候的位置

static 用于指定静态资源文件夹的名称,如果想访问static中的文件不需要在路径中添加static, 例如静态资源路径为 static/js/index.js,script 标签中的src路径为 /js/index.js

最后在返回的HTML中添加打包好的JS文件

src/server.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import koaStatic from 'koa-static';

router.get('/',async (ctx,next)=>{
ctx.body=`
<html lang="en">
<body>
${ReactDOMServer.renderToString(<Home />)}
<script src='/index.js'></script>
</body>
</html>
`
})

app
.use(koaStatic(path.join((process.env as any).PWD,'./static')))
.use(router.routes())
.use(router.allowedMethods())

app.listen(conf.PORT,()=>{
`server is running in port 3000`
});

点击无效? 请把组件放在root中

按照上面的步骤已经可以在页面中加载 index.js 的文件,但是点击事件并没有生效,这是因为还没有将组件插入到root节点中

虽然服务端通过HTML直接返回给浏览器可以展示组件,但是在浏览器中执行React打包好的文件的时候需要将节点挂载在元素上,通常为 <div id='root'></div>

所以尝试添加这样的逻辑

src/components/hello.ts

1
2
3
4
5
6
7
8
import React from 'react';

const Hello:React.FC = () =>{
return <div onClick={()=>{alert("hello")}}>Hello</div>
}

ReactDom.render(<Hello/>, document.getElementById('root'));
export default Hello;

很不幸会提示一个错误,document没有定义,因为这个组件用于生成HTML字符串,当他被执行的时候node环境中并没有document 这个全局变量

所以我们把他放到一个新的文件中

src/component/index.ts

1
2
3
4
5
import ReactDom from 'react-dom';
import React from 'react';
import Home from './home'

ReactDom.render(<Home/>, document.getElementById('root'));

这时没有再报错,但是收到了一个警告

Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v18. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML

需要把ReactDOM.render() 替换为 ReactDOM.hydrate()

1
ReactDom.hydrate(<Home/>, document.getElementById('root'));

① ReactSSR 渲染一个简单组件

客户端渲染 vs 服务端渲染

SSR:服务端渲染(Server side render)

优点:① 利于 SEO ②TTFP 首屏渲染时间比较快

缺点:① 复杂度增加 ② 服务器消耗资源增大,需要处理 IO 和执行 JavaScript

&nbsp;

CSR:客户端渲染(Client side render)

优点:① 前后端分离,加快开发效率

缺点:①TTFP 首屏渲染时间比较长 ② 不能 SEO

由服务端返回页面

万事开头难,现实服务器返回静态页面。

使用 Koa 搭建一个 Node 服务器,并在访问根路径的时候返回静态页面。

src/server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Koa from "koa";
import Router from "@koa/router";

const router = new Router();
const app = new Koa();

router.get("/", async (ctx, next) => {
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Hello
</body>
</html>`;
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(conf.PORT, () => {
`server is running in port 3000`;
});
使用 ES6 语法

你也可能注意到这里使用了 ES6 的语法,因此需要添加@babel/register这个工具,帮助我们在执行 ES6 的 Node 文件时即时编译。

添加 src/start.js 入口文件

1
2
require("@babel/register");
module.exports = require("./server");

添加.babelrc 配置文件

需要添加下面几个依赖,注意区分依赖环境

1
2
3
4
5
6
yarn add @babel/runtime

yarn add core-js@3

# 用于定义一些编译后的依赖函数
yarn add -D @babel/plugin-transform-runtime
1
2
3
4
5
6
7
8
9
10
11
12
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry",
"corejs": "3"
}
]
],
"plugins": ["@babel/plugin-transform-runtime"]
}

最后使用 node src/start.js 执行入口文件

使用 TS

添加 tsconfig.json 配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"compilerOptions": {
/* TypeScript文件编译后生成的javascript文件里的语法应该遵循哪个JavaScript的版本 "ES5", "ES6"/ "ES2015", "ES2016", "ES2017"或 "ESNext"*/
"target": "es6",
"jsx": "react",
/* 编译后生成的javascript文件中的module,采用何种方式实现,可选项为:"None", "CommonJS", "AMD", "System", "UMD", "ES6"或 "ES2015"。 */
"module": "commonjs",
/*采用何种方式解析(也就是查找)TypeScript文件中依赖的模块的位置*/
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
"outDir": "dist" /* Specify an output folder for all emitted files. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

添加 React 组件

我们想把 Hello 换成 React 组件试一试,新建 components 文件创建<Hello/>组件并导出

1
2
3
4
5
6
7
import Hello from "react";

const Home: React.FC = () => {
return <div>Hello</div>;
};

export default Hello;
1
2
3
4
5
6
7
import Koa from "koa";
import Home from "../components/home";

const router = new Router();
router.get("/", async (ctx, next) => {
ctx.body = <Home />;
});

再一次运行的时候遇到了问题

因为现在并不能编译 JSX 语法需要添加一些配置。

如果使用的babel-register,安装依赖后,可以修改.babelrc 文件添加对 react 的支持

1
2
3
{
"presets": ["@babel/preset-react"]
}

如果使用的是 TS,添加在tsconfig.json中添加 jsx 支持

1
"jsx": "react",

现在服务器可以正常启动了,但是我们的组件还不能正常加载

这是因为我们的组件只被解析为 React.Element 而不是字符串,所以我们需要使用 ReactDOM 提供的服务端渲染的方法,将 React.Element 转换为字符串

1
2
3
4
import ReactDOMServer from 'react-dom/server';
router.get('/',async (ctx,next)=>{
ctx.body= ReactDOMServer.renderToString(<Home />;
})

现在一个最简单的组件就通过服务端渲染并返回给浏览器。

React原理 海量数据处理

requestIdleCallback

图中是浏览器每一帧的生命周期,requestIdleCallback则会在某一帧结束后的空闲时间或者用户处于不活跃状态时,处理我们的工作。

靠自己人工的安排不必要的工作是很困难的。比如,要弄清楚一帧剩余的时间,这显然是不可能的,因为当requestAnimationFrame的回调完成后,还要进行样式的计算,布局,渲染以及浏览器内部的工作等等。为了确保用户不以某种方式进行交互,你需要为各种交互行为添加监听事件(scroll、touch、click),即使你并不需要这些功能,只有这样才能绝对确保用户没有进行交互。另一方面,浏览器能够确切地知道在一帧的结束时有多少的可用时间,如果用户正在交互,通过使用requestIdleCallback这个API,允许我们尽可能高效地利用任何的空闲时间。

当 myNonEssentialWork 被调用,会返回一个 deadline 对象,这个对象包含一个方法,该方法会返回一个数字表示你的工作还能执行多长时间:

1
2
3
4
requestIdleCallback(function myNonEssentialWork (deadline) {
while (deadline.timeRemaining() > 0)
doWorkIfNeeded();
})

调用 timeRemaining 这个方法能获得最后的剩余时间,当 timeRemaining() 返回0,如果你仍有其他任务需要执行,可以在下一次空闲时间继续执行

1
2
3
4
5
6
7
function myNonEssentialWork (deadline) {
while (deadline.timeRemaining() > 0 && tasks.length > 0)
doWorkIfNeeded();

if (tasks.length > 0)
requestIdleCallback(myNonEssentialWork);
}

如果任务耗时太长,可能回调函数永远不能执行,requestIdleCallback有一个可选的第二个参数:含有timeout属性的对象。如果设置了timeout这个值,回调函数还没被调用的话,则浏览器必须在设置的这个毫秒数时,去强制调用对应的回调函数。

如果你的回调函数是因为设置的这个timeout而触发的,你会注意到:

timeRemaining()会返回0
deadline对象的didTimeout属性值是true

注意事项
  • 对非高优先级的任务使用空闲回调。 已经创建了多少回调,用户系统的繁忙程度,你的回调多久会执行一次(除非你指定了 timeout),这些都是未知的。不能保证每次事件循环(甚至每次屏幕更新)后都能执行空闲回调;如果事件循环用尽了所有可用时间,那可能你的任务永远不能执行。在你需要的时候要用 timeout,但记得只在需要的时候才用。 使用 timeout可以保证你的代码按时执行,但是在剩余时间不足以强制执行你的代码的同时保证浏览器的性能表现的情况下,timeout就会造成延迟或者动画不流畅。设置了timeout,如果回调中的任务也是个长时间任务,可能会导致影响用户交互。最好的办是确认任务足够小。

  • 空闲回调应尽可能不超支分配到的时间。尽管即使你超出了规定的时间上限,通常来说浏览器、代码、网页也能继续正常运行,这里的时间限制是用来保证系统能留有足够的时间去完成当前的事件循环然后进入下一个循环,而不会导致其他代码卡顿或动画效果延迟。目前,timeRemaining() 有一个50 ms 的上限时间,但实际上你能用的时间比这个少,因为在复杂的页面中事件循环可能已经花费了其中的一部分,浏览器的扩展插件也需要处理时间,等等。

  • 避免在空闲回调中改变 DOM。 空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局, 你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。 如果你的回调需要改变DOM,它应该使用Window.requestAnimationFrame()来调度它。

  • 避免运行时间无法预测的任务。 你的空闲回调必须避免做任何占用时间不可预测的事情。比如说,应该避免做任何会影响页面布局的事情。你也必须避免 执行Promise (en-US) 的resolve和reject,因为这会在你的回调函数返回后立即引用Promise对象对resolve和reject的处理程序。可能导致任务阻塞。

回退兼容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
window.requestIdleCallback = window.requestIdleCallback || function(handler) {
let startTime = Date.now();

return setTimeout(function() {
handler({
didTimeout: false,
timeRemaining: function() {
return Math.max(0, 50.0 - (Date.now() - startTime));
}
});
}, 1);
}

window.cancelIdleCallback = window.cancelIdleCallback || function(id) {
clearTimeout(id);
}
上报数据

有时我们希望,避免在用户交互行为发生的时候立即上报数据,思路就是将上报信息添加到队列中,在空闲的时间处理

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
var eventsToSend = [];

function onNavOpenClick () {
eventsToSend.push(
{
category: 'button',
action: 'click',
label: 'nav',
value: 'open'
});

schedulePendingEvents();
}

function schedulePendingEvents() {

// 如果已经在调度中则返回
if (isRequestIdleCallbackScheduled)
return;

isRequestIdleCallbackScheduled = true;

if ('requestIdleCallback' in window) {
// 最晚两秒之后上报数据
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
} else {
processPendingAnalyticsEvents();
}
}

function processPendingAnalyticsEvents (deadline) {

// 重置状态
isRequestIdleCallbackScheduled = false;

// 针对没有实现 requestIdleCallback 的环境
if (typeof deadline === 'undefined')
deadline = { timeRemaining: function () { return Number.MAX_VALUE } };

while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
var evt = eventsToSend.pop();

ga('send', 'event',
evt.category,
evt.action,
evt.label,
evt.value);
}

// 如果还有任务在下次空闲时发送
if (eventsToSend.length > 0)
schedulePendingEvents();
}

渲染分片

对于大量数据渲染,可以考虑利用浏览器空闲时间分片处理

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
const generateColor = () => {
const r = Math.floor(Math.random()*255);
const g = Math.floor(Math.random()*255);
const b = Math.floor(Math.random()*255);
return 'rgba('+ r +','+ g +','+ b +',0.8)';
}
const getRandomPos = (width,height,distance = 0) => ({
x: distance/2 + (width - distance/2) * Math.random(),
y: distance/2 + (height - distance/2) * Math.random(),
})
const Circle = ({position,color}) =>{
return <div style={{
width:10,
height:10,
borderRadius:"50%",
backgroundColor:color,
position:'absolute',
left:position.x,
top:position.y
}}></div>
}

const getKey =()=> Math.random().toString(36).substring(2,8)
const renderCircle = (count,width,height)=> new Array(count).fill('circle').map(()=>(
<Circle position={getRandomPos(width,height)} key={getKey()} color={generateColor()}/>
))

const calcRenderCount = (count,perRenderCount,times) => {
if(perRenderCount * times > count) return count % perRenderCount;
return perRenderCount;
}

export default function Magnanimity(){
const [list,setList] = useState([]);
const total = useRef(20000);
const throttleCount= useRef(500);

const timeSpliceRender = useCallback((renderTimes,currentTime,width,height)=>{
if(currentTime > renderTimes) return;
const task = () => {
const renderCount = calcRenderCount(total.current,throttleCount.current,currentTime);
setList(list=> list.concat(renderCircle(renderCount,width,height)))
currentTime++;
}

requestIdleCallback((deadline)=>{
while (deadline.timeRemaining()>0) {
task();
}
timeSpliceRender(renderTimes,currentTime,width,height);
})

},[setList,
total,
throttleCount
])

useEffect(()=>{
const currentTime = 1;
const renderTimes = Math.ceil(total.current/throttleCount.current);
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
timeSpliceRender(renderTimes,currentTime,width,height)

},[
timeSpliceRender,
total,
throttleCount
])

return <div
onClick={()=>console.log("click")}
style={{
width:document.documentElement.clientWidth,
height:document.documentElement.clientHeight,
}}>{list}</div>
}

长列表渲染

比较容易想到的思路是:

  • 计算出列表能容纳的个数。

  • 计算需要渲染列表的高度

  • 筛选能显示在列表中的元素,为了保证用户体验,靠近列表头部和尾部的元素也可以渲染出来。

  • 为每个列表元素设置偏移量

  • 当滚动列表时,重新筛选显示元素并设置偏移量

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
function List({data}){
const [height,setHeight] = useState(0);
const [filterData,setFilterData] = useState([]);
const [scrollTop,setScrollTop] = useState(0);
const wrapperRef = useRef(null)

useEffect(()=>{
setHeight(()=> data.length * 40)
},[
data,
setHeight
])

useEffect(()=>{
const arr = [];
const boxHeight = wrapperRef.current.offsetHeight;

// 如果离列表头尾超过三个元素则不在显示
data.forEach((item,index) => {
if(index * 40 - 1 - scrollTop <= -80) return
if(index * 40 - 1 - boxHeight - scrollTop >= 80) return
console.log(item)
arr.push({
...item,
top: index * 40
})
});
setFilterData(arr);

},[
scrollTop,
data,
height,
setFilterData,
wrapperRef
])

const scroll = useCallback((e)=>{
// 滚动时重新筛选元素
setScrollTop(e.target.scrollTop)
},[setScrollTop])

if(!data || !Array.isArray(data)) return null;
return (
<div style={{
height:'100%',
width:'100%',
overflow:'auto'
}}
ref={wrapperRef}
onScroll={scroll}
>
<ul
style={{height,position:'relative'}}
>
{filterData.map(item => (
<li
key={item.key}
style={{
padding:'5px 4px',
position:'absolute',
left:0,
right:0,
top:item.top
}}
>
<div
style={{
background:'pink',
lineHeight:'30px',
height:'30px'
}}
>
{item.value}
</div>
</li>)
)}
</ul>
</div>
)
}

export default function Comp(){
const [data, setData] = useState(new Array(10).fill(1).map((item,index)=>({key:index,value:index})))
return <div style={{width:300,height:600,border:'1px solid #000'}}>
<List data={data}></List>
</div>
}

上面的实现存在几个问题:

  • 常量没有抽离,不能灵活配置

  • 每次滚动触发,需要遍历数据,性能损耗大

所以,可以优化为根据滚动高度计算出需要展示数据的区间,用transform变换待定绝对定位

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
function List({data=[]}){
const wrapperRef = useRef(null)
// 元素容器
const containerRef = useRef(null);
const [range,setRange] = useState([0,0])
const info = useRef({
// 屏幕外预渲染的个数
preRenderCount:2,
// 元素高度
itemHeight:40

})

useEffect(()=>{
const boxHeight = wrapperRef.current.offsetHeight;
const {itemHeight,preRenderCount} = info.current;

// 不需要关心data长度,如果截取的长度溢出,仍然会包含data中所有元素

const end = Math.ceil(boxHeight/itemHeight) + preRenderCount;
setRange([0,end]);

},[
info,
wrapperRef,
setRange
])

const scroll = useCallback((e)=>{
const scrollTop = e.target.scrollTop;
const boxHeight = wrapperRef.current.offsetHeight;

const {itemHeight,preRenderCount} = info.current;

// 顶部有两个缓冲元素,滚动高度还在这两个元素高度范围内,则前两个元素会被保留
const start = Math.floor(Math.max(0, scrollTop - preRenderCount * itemHeight) / itemHeight);

// 不需要关心data长度,如果截取的长度溢出,仍然会包含data中所有元素
const end = Math.ceil((scrollTop + boxHeight)/itemHeight) + preRenderCount;

// 计算内容区域偏移量 移动距离大于元素高度,重置视图框的位置
const offset = scrollTop - preRenderCount * itemHeight > 0 ? scrollTop - (scrollTop % itemHeight) - preRenderCount * itemHeight : 0;

containerRef.current.style.transform= `translate3D(0,${offset}px,0)`

setRange([start,end])

},[1
setRange,
wrapperRef,
containerRef
])

const {itemHeight} = info.current;
const height = useMemo(()=>data.length * itemHeight,[data,itemHeight]);
if(!data || !Array.isArray(data)) return null;
return (
<div style={{
height:'100%',
width:'100%',
overflow:'auto',
position:'relative'
}}
ref={wrapperRef}
onScroll={scroll}
>
<div style={{height}}></div>
{/* 注意:对ul列表的操作可能会触发上层滚动条的事件,所以仍然让他脱离文档流 */}
<ul
ref={containerRef}
style={{
position:'absolute',
top:0,
left:0,
right:0
}}
>
{data.slice(...range).map(item => (
<li
key={item.key}
style={{
height:itemHeight,
overflow:'hidden'
}}
>
<div
style={{
background:'pink',
lineHeight:'30px',
height:'30px',
margin:'5px 4px'
}}
>
{item.value}
</div>
</li>)
)}
</ul>
</div>
)
}

export default function Comp(){
const [data, setData] = useState(new Array(100).fill(1).map((item,index)=>({key:index,value:index})))
return <div style={{width:300,height:600,border:'1px solid #000'}}>
<List data={data}></List>
</div>
}

React原理 diff算法

随机树差异

查找两个随机树之间的最小差异是一个 O(n^3)问题。

如你所想,这么高复杂度的算法是无法满足我们的需求的。React 使用了一种更为简单且直观的算法使得算法复杂度优化至 O(n)。

React 只会逐层对比两颗随机树。这大大降低了 diff 算法的复杂度。并且在 web 组件中很少会将节点移动到不同的层级,经常只会在同一层级中移动。

diff 策略

  • Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。

  • 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。

  • 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

tree diff

基于策略一,React 对树的算法进行了简洁明了的优化,即对树进行分层比较,两棵树只会对同一层次的节点进行比较。

既然 DOM 节点跨层级的移动操作少到可以忽略不计,针对这一现象,React 通过 updateDepth 对 Virtual DOM 树进行层级控制,只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。

如下图,A 节点(包括其子节点)整个被移动到 D 节点下,由于 React 只会简单的考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,React diff 的执行情况:create A -> create B -> create C -> delete A。

由此可发现,当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 A 为根节点的树被整个重新创建,这是一种影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作。

component diff

  • React 是基于组件构建应用的,对于组件间的比较所采取的策略也是简洁高效。

  • 如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。

  • 如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。

  • 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。

如下图,当 component D 改变为 component G 时,即使这两个 component 结构相似,一旦 React 判断 D 和 G 是不同类型的组件,就不会比较二者的结构,而是直接删除 component D,重新创建 component G 以及其子节点。虽然当两个 component 是不同类型但结构相似时,React diff 会影响性能,但正如 React 官方博客所言:不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。

diff 过程

  • 判断新的子元素中是否有重复的 key,如果有则抛出警告。方法是通过创建一个 Set 对象,判断 key 时候已经存在。
1
2
3
4
for (var i = 0; i < newChildren.length; i++) {
var child = newChildren[i];
knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
}
  • 利用循环新的子元素数组,遍历老的子元素 Fiber
    然后通过调用 updateSlot ,updateSlot 内部会判断当前的 tag 和 key 是否匹配,如果匹配复用老 fiber 形成新的 fiber ,如果不匹配,返回 null ,此时 newFiber 等于 null 。跳出循环。
    如果是处于更新流程,找到与新节点对应的老 fiber ,但是不能复用 alternate === null ,那么会删除老 fiber 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],expirationTime,);
if (newFiber === null) { break }
// ..一些其他逻辑
}
if (shouldTrackSideEffects) { // shouldTrackSideEffects 为更新流程。
if (oldFiber && newFiber.alternate === null) {
/* 找到了与新节点对应的fiber,但是不能复用,那么直接删除老节点 */
deleteChild(returnFiber, oldFiber);
}
}
}
  • 当第一步结束完 newIdx === newChildren.length 此时证明所有 newChild 已经全部被遍历完,那么剩下没有遍历 oldFiber 也就没有用了,那么调用 deleteRemainingChildren 统一删除剩余 oldFiber 。
1
2
3
4
5
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
  • 当经历过第一步,oldFiber 为 null , 证明 oldFiber 复用完毕,那么如果还有新的 children ,说明都是新的元素,只需要调用 createChild 创建新的 fiber 。
1
2
3
4
5
6
7
8
9
10
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(
returnFiber,
newChildren[newIdx],
expirationTime
);
// ...
}
}
  • 针对移动元素的场景

mapRemainingChildren 返回一个 map ,map 里存放剩余的老的 fiber 和对应的 key (或 index )的映射关系。

接下来遍历剩下没有处理的 Children ,通过 updateFromMap ,判断 mapRemainingChildren 中有没有可以复用 oldFiber ,如果有,那么复用,如果没有,新创建一个 newFiber 。

复用的 oldFiber 会从 mapRemainingChildren 删掉。

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
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
if (_newFiber2.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}

lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);

if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}

previousNewFiber = _newFiber2;
}
}

if (shouldTrackSideEffects) {
// Any existing children that weren't consumed above were deleted. We need
// to add them to the deletion list.
existingChildren.forEach(function (child) {
return deleteChild(returnFiber, child);
});
}

位置交换 lastPlacedIndex初始化为 0,如果一个新元素的索引大于lastPlacedIndex表示他从后面一动到前面,那么位置保持不变,如果新元素的索引小于lastPlacedIndex表示需要移动到后面,会被打上移动的 flag,最后通过previousNewFiber(他保存着上一次的最后节点)和sibling将 Fiber 链表链接

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
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;

if (!shouldTrackSideEffects) {
// Noop.
return lastPlacedIndex;
}

var current = newFiber.alternate;

if (current !== null) {
var oldIndex = current.index;

if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags = Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags = Placement;
return lastPlacedIndex;
}
}
}

React原理 Suspense lazy

lazy

/react/packages/react/src/ReactLazy.js

lazy的本质是返回一个包含thenable的对象

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
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
// This might throw either because it's missing or throws. If so, we treat it
// as still uninitialized and try again next time. Which is the same as what
// happens if the ctor or any wrappers processing the ctor throws. This might
// end up fixing it if the resolution was a concurrency bug.
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// In case, we're still uninitialized, then we're waiting for the thenable
// to resolve. Set it as pending in the meantime.
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;

return moduleObject.default;
} else {
throw payload._result;
}
}

export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: -1,
_result: ctor,
};

const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer,
};

return lazyType;
}

Render阶段

beginWork中遇到LazyComponent类型组件,会调用mountLazyComponent 方法处理

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
function mountLazyComponent(_current, workInProgress, elementType, updateLanes, renderLanes) {
if (_current !== null) {
// A lazy component only mounts if it suspended inside a non-
// concurrent tree, in an inconsistent state. We want to treat it like
// a new mount, even though an empty version of it already committed.
// Disconnect the alternate pointers.
_current.alternate = null;
workInProgress.alternate = null; // Since this is conceptually a new fiber, schedule a Placement effect

workInProgress.flags |= Placement;
}

var props = workInProgress.pendingProps;
var lazyComponent = elementType;
var payload = lazyComponent._payload;
var init = lazyComponent._init;

// 第一次挂载组件时,因为promise状态不是完成状态,会抛出错误被上层的try catch捕获
var Component = init(payload);

workInProgress.type = Component;
// 如果已经加载成功,分析出异步组件的类型
var resolvedTag = workInProgress.tag = resolveLazyComponentTag(Component);

// 合并异步组件的props和通过lazy创建组件时传入的props
var resolvedProps = resolveDefaultProps(Component, props);
var child;

// 根据不同的组件类型处理,如果没有则抛出错误
switch (resolvedTag) {
//...
}
{
throw Error( "Element type is invalid. Received a promise that resolves to: " + Component + ". Lazy element type must resolve to a class or function." + hint );
}
}

下面是Suspense如何被创建并影响lazy创建的异步组件

React并没有直接创建Suspense组件,最开始的时候Suspense组件只是一个标识用于导出 REACT_SUSPENSE_TYPE as Suspense

节点类型的创建,在初始只会创建出根节点的 fiber,后续的创建在 beginWork 入口,进入 reconcile 过程,会判断节点可复用性,然后不能复用的就通过 createFiberFromTypeAndProps 创建新节点。

1
2
3
4
5
6
7
8
9
10
function createFiberFromSuspense(pendingProps, mode, lanes, key) {
var fiber = createFiber(SuspenseComponent, pendingProps, key, mode); // TODO: The SuspenseComponent fiber shouldn't have a type. It has a tag.
// This needs to be fixed in getComponentName so that it relies on the tag
// instead.

fiber.type = REACT_SUSPENSE_TYPE;
fiber.elementType = REACT_SUSPENSE_TYPE;
fiber.lanes = lanes;
return fiber;
}

beginWork中发现是Suspense类型会执行 updateSuspenseComponent

第一次执行创建子节点 workInProgress.child = mountChildFibers

第二次执行,建立节点间联系

1
2
3
4
5
6
7
fallbackChildFragment.return = workInProgress;

primaryChildFragment.sibling = fallbackChildFragment;

workInProgress.child = primaryChildFragment;

return fallbackChildFragment

由于第一次执行的时候lazy组件抛出错误,会被renderRootSync捕获

1
2
3
4
5
6
7
8
9
10
function renderRootSync(root, lanes) {
do {
try {
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function handleError(root, thrownValue) {
do {
try {
...
// 继续执行
throwException(...);
// 这里完成时 会将wip设置为自己的父节点 也就是 suspense 节点
workInProgress = completeUnitOfWork(workInProgress);
} catch (yetAnotherThrownValue) {
...
continue
}
// Return to the normal work loop.
return;
} while (true);
}

继续执行 throwException,这里会将抛出的 promise 放入子组件的 updateQueue

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
function throwException(
root: FiberRoot,
returnFiber: Fiber,
sourceFiber: Fiber,
value: mixed,
renderExpirationTime: ExpirationTime,
) {
...
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
// This is a thenable.
const thenable: Thenable = (value: any);

...
do {
if (
workInProgress.tag === SuspenseComponent &&
shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
) {
// 一个 set 结构存储在 updateQueue
const thenables: Set<Thenable> = (workInProgress.updateQueue: any);
if (thenables === null) {
const updateQueue = (new Set(): any);
updateQueue.add(thenable);
// 第一次新增
workInProgress.updateQueue = updateQueue;
} else {
// 追加
thenables.add(thenable);
}
...
// 同步设置
sourceFiber.expirationTime = Sync;

return;
}

...
workInProgress = workInProgress.return;
} while (workInProgress !== null);

}
...
}

commit阶段

commitWork 中会处理队列中的Promise

会对之前渲染的 fallback 组件标记删除,对新的渲染数据标记更新。

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
function attachSuspenseRetryListeners(finishedWork) {
// If this boundary just timed out, then it will have a set of wakeables.
// For each wakeable, attach a listener so that when it resolves, React
// attempts to re-render the boundary in the primary (pre-timeout) state.
var wakeables = finishedWork.updateQueue;

if (wakeables !== null) {
finishedWork.updateQueue = null;
var retryCache = finishedWork.stateNode;

if (retryCache === null) {
retryCache = finishedWork.stateNode = new PossiblyWeakSet();
}

wakeables.forEach(function (wakeable) {
// Memoize using the boundary fiber to prevent redundant listeners.
var retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);

if (!retryCache.has(wakeable)) {
{
if (wakeable.__reactDoNotTraceInteractions !== true) {
retry = tracing.unstable_wrap(retry);
}
}

retryCache.add(wakeable);
// 通过then方法在resolve之后执行
wakeable.then(retry, retry);
}
});
}
}

实现一个异步组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
*
* @param {*} Component 需要异步数据的component
* @param {*} api 请求数据接口,返回Promise,可以再then中获取与后端交互的数据
* @returns
*/
function AysncComponent(Component,api){
const AysncComponentPromise = () => new Promise(async (resolve)=>{
const data = await api()
resolve({
default: (props) => <Component rdata={data} { ...props} />
})
})
return React.lazy(AysncComponentPromise)
}
  • 用 AysncComponent 作为一个 HOC 包装组件,接受两个参数,第一个参数为当前组件,第二个参数为请求数据的 api 。
  • 声明一个函数给 React.lazy 作为回调函数,React.lazy 要求这个函数必须是返回一个 Promise 。在 Promise 里面通过调用 api 请求数据,然后根据返回来的数据 rdata 渲染组件,别忘了接受并传递 props 。
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
/* 数据模拟 */
const getData=()=>{
return new Promise((resolve)=>{
//模拟异步
setTimeout(() => {
resolve({
name:'alien',
say:'let us learn React!'
})
}, 1000)
})
}
/* 测试异步组件 */
function Test({ rdata , age}){
const { name , say } = rdata
console.log('组件渲染')
return <div>
<div> hello , my name is { name } </div>
<div>age : { age } </div>
<div> i want to say { say } </div>
</div>
}
/* 父组件 */
export default class Index extends React.Component{
LazyTest = AysncComponent(Test,getData)
/* 需要每一次在组件内部声明,保证每次父组件挂载,都会重新请求数据 ,防止内存泄漏。 */
render(){
const { LazyTest } = this
return <div>
<Suspense fallback={<div>loading...</div>} >
<LazyTest age={18} />
</Suspense>
</div>
}
}

React原理 HOC高阶组件

属性代理

属性代理,就是用组件包裹一层代理组件,在代理组件上,可以做一些,对源组件的强化操作。这里注意属性代理返回的是一个新组件,被包裹的原始组件,将在新的组件里被挂载。

1
2
3
4
5
6
7
8
9
10
function HOC(WrapComponent){
return class Advance extends React.Component{
state={
name:'alien'
}
render(){
return <WrapComponent { ...this.props } { ...this.state } />
}
}
}

优点:

① 属性代理可以和业务组件低耦合,零耦合,对于条件渲染和 props 属性增强,只负责控制子组件渲染和传递额外的 props 就可以了,所以无须知道,业务组件做了些什么。所以正向属性代理,更适合做一些开源项目的 HOC ,目前开源的 HOC 基本都是通过这个模式实现的。
② 同样适用于类组件和函数组件。
③ 可以完全隔离业务组件的渲染,因为属性代理说白了是一个新的组件,相比反向继承,可以完全控制业务组件是否渲染。
④ 可以嵌套使用,多个 HOC 是可以嵌套使用的,而且一般不会限制包装 HOC 的先后顺序。

缺点:

① 一般无法直接获取原始组件的状态,如果想要获取,需要 ref 获取组件实例。
② 无法直接继承静态属性。如果需要继承需要手动处理,或者引入第三方库。
③ 因为本质上是产生了一个新组件,所以需要配合 forwardRef 来转发 ref。

反向继承

反向继承和属性代理有一定的区别,在于包装后的组件继承了原始组件本身,所以此时无须再去挂载业务组件。

1
2
3
4
5
6
7
8
9
10
11
class Index extends React.Component{
render(){
return <div> hello,world </div>
}
}
function HOC(Component){
return class wrapComponent extends Component{ /* 直接继承需要包装的组件 */

}
}
export default HOC(Index)

优点:

① 方便获取组件内部状态,比如 state ,props ,生命周期,绑定的事件函数等。
② es6继承可以良好继承静态属性。所以无须对静态属性和方法进行额外的处理。

缺点:

① 函数组件无法使用。
② 和被包装的组件耦合度高,需要知道被包装的原始组件的内部状态,具体做了些什么?
③ 如果多个反向继承 HOC 嵌套在一起,当前状态会覆盖上一个状态。这样带来的隐患是非常大的,比如说有多个 componentDidMount ,当前 componentDidMount 会覆盖上一个 componentDidMount 。这样副作用串联起来,影响很大。

HOC的几种场景

强化props

强化 props 就是在原始组件的 props 基础上,加入一些其他的 props ,强化原始组件功能。举个例子,为了让组件也可以获取到路由对象,进行路由跳转等操作,所以 React Router 提供了类似 withRouter 的 HOC 。

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
function withRouter(Component) {
const displayName = `withRouter(${Component.displayName || Component.name})`;
const C = props => {
/* 获取 */
const { wrappedComponentRef, ...remainingProps } = props;
return (
<RouterContext.Consumer>
{context => {
return (
<Component
{...remainingProps} // 组件原始的props
{...context} // 存在路由对象的上下文history location
ref={wrappedComponentRef}
/>
);
}}
</RouterContext.Consumer>
);
};

C.displayName = displayName;
C.WrappedComponent = Component;
/* 继承静态属性 */
return hoistStatics(C, Component);
}
export default withRouter

分离出 props 中 wrappedComponentRef 和 remainingProps , remainingProps 是原始组件真正的 props, wrappedComponentRef 用于转发 ref。
用 Context.Consumer 上下文模式获取保存的路由信息。( React Router 中路由状态是通过 context 上下文保存传递的)
将路由对象和原始 props 传递给原始组件,所以可以在原始组件中获取 history ,location 等信息。

控制渲染

HOC 反向继承模式,可以通过 super.render() 得到 render 之后的内容,利用这一点,可以做渲染劫持 ,更有甚者可以修改 render 之后的 React element 对象。

1
2
3
4
5
6
7
8
9
10
const HOC = (WrapComponent) =>
class Index extends WrapComponent {
render() {
if (this.props.visible) {
return super.render()
} else {
return <div>暂无数据</div>
}
}
}
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
class Index extends React.Component{
render(){
return <div>
<ul>
<li>react</li>
<li>vue</li>
<li>Angular</li>
</ul>
</div>
}
}
function HOC (Component){
return class Advance extends Component {
render() {
const element = super.render()
const otherProps = {
name:'alien'
}
/* 替换 Angular 元素节点 */
const appendElement = React.createElement('li' ,{} , `hello ,world , my name is ${ otherProps.name }` )
const newchild = React.Children.map(element.props.children.props.children,(child,index)=>{
if(index === 2) return appendElement
return child
})
return React.cloneElement(element, element.props, newchild)
}
}
}
export default HOC(Index)
动态加载

dva 中 dynamic 就是配合 import ,实现组件的动态加载的,而且每次切换路由,都会有 Loading 效果,接下来看看大致的实现思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default function dynamicHoc(loadRouter) {
return class Content extends React.Component {
state = {Component: null}
componentDidMount() {
if (this.state.Component) return
loadRouter()
.then(module => module.default) // 动态加载 component 组件
.then(Component => this.setState({Component},
))
}
render() {
const {Component} = this.state
return Component ? <Component {
...this.props
}
/> : <Loading />
}
}
}
组件赋能

ref获取实例

对于属性代理虽然不能直接获取组件内的状态,但是可以通过 ref 获取组件实例,获取到组件实例,就可以获取组件的一些状态,或是手动触发一些事件,进一步强化组件,但是注意的是:类组件才存在实例,函数组件不存在实例。

1
2
3
4
5
6
7
8
9
10
11
function Hoc(Component){
return class WrapComponent extends React.Component{
constructor(){
super()
this.node = null /* 获取实例,可以做一些其他的操作。 */
}
render(){
return <Component {...this.props} ref={(node) => this.node = node } />
}
}
}

事件监控

HOC 不一定非要对组件本身做些什么?也可以单纯增加一些事件监听,错误监控。接下来,接下来做一个 HOC ,只对组件内的点击事件做一个监听效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function ClickHoc (Component){
return function Wrap(props){
const dom = useRef(null)
useEffect(()=>{
const handerClick = () => console.log('发生点击事件')
dom.current.addEventListener('click',handerClick)
return () => dom.current.removeEventListener('click',handerClick)
},[])
return <div ref={dom} ><Component {...props} /></div>
}
}

@ClickHoc
class Index extends React.Component{
render(){
return <div className='index' >
<p>hello,world</p>
<button>组件内部点击</button>
</div>
}
}
export default ()=>{
return <div className='box' >
<Index />
<button>组件外部点击</button>
</div>
}

总结

下面对 HOC 具体能实现那些功能,和如何编写做一下总结:

  • 强化 props ,可以通过 HOC ,向原始组件混入一些状态。

  • 渲染劫持,可以利用 HOC ,动态挂载原始组件,还可以先获取原始组件的渲染树,进行可控性修改。

  • 可以配合 import 等 api ,实现动态加载组件,实现代码分割,加入 loading 效果。

  • 可以通过 ref 来获取原始组件实例,操作实例下的属性和方法。

  • 可以对原始组件做一些事件监听,错误监控等。

注意事项

谨慎修改原型链

如上 HOC 作用仅仅是修改了原来组件原型链上的 componentDidMount 生命周期。但是这样有一个弊端就是如果再用另外一个 HOC 修改原型链上的 componentDidMount ,那么这个HOC的功能即将失效。

1
2
3
4
5
6
7
8
function HOC (Component){
const proDidMount = Component.prototype.componentDidMount
Component.prototype.componentDidMount = function(){
console.log('劫持生命周期:componentDidMount')
proDidMount.call(this)
}
return Component
}

不要在函数组件内部或类组件render函数中使用HOC

由于属性的引用不同,每次都会重新渲染

ref的处理

高阶组件的约定是将所有 props 传递给被包装组件,但这对于 ref 并不适用。那是因为 ref 实际上并不是一个 prop , 就像 key 一样,对于 ref 属性它是由 React 专门处理的。那么如何通过 ref 正常获取到原始组件的实例呢?在 ref 章节已经讲到,可以用 forwardRef做 ref 的转发处理。

修饰器的嵌套顺序

静态属性需要处理

React原理 context深入

context三中用法

定义context

1
2
3
const ThemeContext = React.createContext(null) //
const ThemeProvider = ThemeContext.Provider //提供者
const ThemeConsumer = ThemeContext.Consumer // 订阅消费者

使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ConsumerDemo extends React.Component{
render(){
const { color,background } = this.context
return <div style={{ color,background } } >消费者</div>
}
}
ConsumerDemo.contextType = ThemeContext

function ProviderDemo(){
const [ contextValue , setContextValue ] = React.useState({ color:'#ccc', background:'pink' })
return <div>
<ThemeProvider value={ contextValue } >
<ConsumerDemo />
</ThemeProvider>
</div>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ConsumerDemo extends React.Component {
render() {
return <ThemeConsumer>
{({ color, background }) => <div style={{ color, background }} >消费者</div>}
</ThemeConsumer>
}
}
function ProviderDemo() {
const [contextValue, setContextValue] = React.useState({ color: '#ccc', background: 'pink' })
return <div>
<ThemeProvider value={contextValue} >
<ConsumerDemo />
</ThemeProvider>
</div>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
function ConsumerDemo() {
const { color, background } = React.useContext(ThemeContext);
return <div style={{ color, background }} >消费者</div>
}

function ProviderDemo() {
const [contextValue, setContextValue] = React.useState({ color: '#ccc', background: 'pink' })
return <div>
<ThemeProvider value={contextValue} >
<ConsumerDemo />
</ThemeProvider>
</div>
}

displayName

context 对象接受一个名为 displayName 的 property,类型为字符串。React DevTools 使用该字符串来确定 context 要显示的内容。

1
2
3
4
5
const MyContext = React.createContext(/* 初始化内容 */);
MyContext.displayName = 'MyDisplayName';

<MyContext.Provider> // "MyDisplayName.Provider" 在 DevTools 中
<MyContext.Consumer> // "MyDisplayName.Consumer" 在 DevTools 中

源码

createContext 创建了一个包含, Provider 和 Consumer 组件的对象,通过_context属性形成相互引用的循环链表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function createContext(defaultValue, calculateChangedBits) {
var context = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue,
_currentValue2: defaultValue,
Provider: null,
Consumer: null
};
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context
};

{
var Consumer = {
$$typeof: REACT_CONTEXT_TYPE,
_context: context,
};
Object.defineProperties(Consumer, {
// 添加getter 和 setter
});

context.Consumer = Consumer;
}

return context;
}

如果当前类型的 fiber 不需要更新,那么会 FinishedWork 中止当前节点和子节点的更新。

如果当前类型 fiber 需要更新,那么会调用不同类型 fiber 的处理方法。当然 ContextProvider 也有特有的 fiber 更新方法 —— updateContextProvider

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
function updateContextProvider(current, workInProgress, renderLanes) {
// 通过type属性获取Provider组件
var providerType = workInProgress.type;
// 拿到createContext定义的上下文
var context = providerType._context;

// 获取传递到Provider组件的属性
var newProps = workInProgress.pendingProps;
var oldProps = workInProgress.memoizedProps;
var newValue = newProps.value;

// 方法内部通过context._currentValue = nextValue 给context赋值
pushProvider(workInProgress, newValue);

// 上一次props有值得时候需要判断是否进行子元素的调度
if (oldProps !== null) {
var oldValue = oldProps.value;

// 1.判断引用是否相同
// 2.尝试使用自定义函数判断是否相同 changedBits & MAX_SIGNED_31_BIT_INT) !== changedBits 如果不是有效数字则报错
// 3 通过 |0 操作将非法结果强制转换成0;
var changedBits = calculateChangedBits(context, newValue, oldValue);

if (changedBits === 0) {
// context没有变化,且子节点没有变化,legacy context没改变,则退出更新
// No change. Bailout early if children are the same.
if (oldProps.children === newProps.children && !hasContextChanged()) {
// 子元素引用没有变化则停止调度
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
} else {
// The context value changed. Search for matching consumers and schedule
// them to update.
propagateContextChange(workInProgress, context, changedBits, renderLanes);
}
}

// 获取到子节点并继续在子节点上调度
var newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}
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
function updateContextConsumer(current, workInProgress, renderLanes) {
var context = workInProgress.type;

var newProps = workInProgress.pendingProps;
var render = newProps.children;

// 如果子元素不是一个函数则抛出错误
{
if (typeof render !== 'function') {
error('A context consumer was rendered with multiple children, or a child ' + "that isn't a function. A context consumer expects a single child " + 'that is a function. If you did pass a function, make sure there ' + 'is no trailing or leading whitespace around it.');
}
}

prepareToReadContext(workInProgress, renderLanes);
// 获取最新的值
var newValue = readContext(context, newProps.unstable_observedBits);
var newChildren;

// 执行函数获取下一个节点
{
ReactCurrentOwner$1.current = workInProgress;
setIsRendering(true);
newChildren = render(newValue);
setIsRendering(false);
} // React DevTools reads this flag.

//继续在下一个节点上调度
workInProgress.flags |= PerformedWork;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}

React原理 Ref深入

Ref相关的问题

  • Ref是如何通过 createRefuseRef 创建对象的

  • React 对标签上的 ref 属性是如何处理的

  • React 内部处理Ref的逻辑是怎样的,如何做 Ref 转发的

创建Ref对象

React.create 源码 react/src/ReactCreateRef.js

1
2
3
4
5
6
export function createRef(): RefObject {
const refObject = {
current: null,
};
return refObject;
}

React.useRef /react/src/ReactHooks.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ReactCurrentDispatcher = {
/**
* @internal
* @type {ReactComponent}
*/
current: (null: null | Dispatcher),
};
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current;
return ((dispatcher: any): Dispatcher);
}
export function useRef<T>(initialValue: T): {|current: T|} {
const dispatcher = resolveDispatcher();
return dispatcher.useRef(initialValue);
}

useRef的初始化逻辑藏的比较深,当引入useRef的是否,dispatcher.current===null 并没有挂载方法。

而是通过 exports.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals; 挂载在 ReactSharedInternals对象上并导出给 react-reconciler 中初始化(最后打包的时候react-reconciler会打包在react-dom中)。

/react-reconciler/src/ReactFiberHooks.new.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
// This dispatcher handles an edge case where a component is updating,
// but no stateful hooks have been used.
// We want to match the production code behavior (which will use HooksDispatcherOnMount),
// but with the extra DEV validation to ensure hooks ordering hasn't changed.
// This dispatcher does that.
ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
}
}

简单来说 Ref 就是一个对象,其中的current属性用于保存DOM元素,或组件实例。useRef 底层逻辑是和 createRef 差不多,就是 ref 保存位置不相同,类组件有一个实例 instance 能够维护像 ref 这种信息,但是由于函数组件每次更新都是一次新的开始,所有变量重新声明,所以 useRef 不能像 createRef 把 ref 对象直接暴露出去,如果这样每一次函数组件执行就会重新声明 Ref,此时 ref 就会随着函数组件执行被重置,这就解释了在函数组件中为什么不能用 createRef 的原因。

为了解决这个问题,hooks 和函数组件对应的 fiber 对象建立起关联,将 useRef 产生的 ref 对象挂到函数组件对应的 fiber 上,函数组件每次执行,只要组件不被销毁,函数组件对应的 fiber 对象一直存在,所以 ref 等信息就会被保存下来。对于 hooks 原理,后续章节会有对应的介绍。

Ref的几种用法

String 类型Ref

在老的React版本中使用,新版本中已经不推荐使用,可以用 React.createRef 或回调形式的 Ref 来代替。v17版本中使用refs获取对象时,只会返回一个空对象,String类型的Ref会导致很多问题:

  • React必须跟踪当前渲染的组件,因为它不知道this指向谁,这会导致React变慢

  • 下面例子中,string类型的refs写法会让ref被放置在DataTable组件中,而不是MyComponent中。

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyComponent extends Component {
renderRow = (index) => {
// This won't work. Ref will get attached to DataTable rather than MyComponent:
return <input ref={'input-' + index} />;

// This would work though! Callback refs are awesome.
return <input ref={input => this['input-' + index] = input} />;
}

render() {
return <DataTable data={this.props.data} renderRow={this.renderRow} />
}
}
  • 如果一个库在传递的子组件(子元素)上放置了一个ref,那用户就无法在它上面再放一个ref了。但函数式可以实现这种组合。
函数类型Ref
1
2
3
4
5
6
export default class Index extends React.Component{
render=()=> <div>
<div ref={(node)=> this.currentDom = node } >hello word</div>
<Children ref={(node) => this.currentComponentInstance = node } />
</div>
}

当用一个函数来标记 Ref 的时候,将作为 callback 形式,等到真实 DOM 创建阶段,执行 callback ,获取的 DOM 元素或组件实例,将以回调函数第一个参数形式传入,所以可以像上述代码片段中,用组件实例下的属性 currentDom和 currentComponentInstance 来接收真实 DOM 和组件实例。

Ref对象
1
2
3
4
5
6
7
8
9
export default class Index extends React.Component{
currentDom = React.createRef(null)
currentComponentInstance = React.createRef(null)

render=()=> <div>
<div ref={ this.currentDom } >hello word</div>
<Children ref={ this.currentComponentInstance } />
</div>
}

Ref高级用法

Ref转发

初衷是用来实现将一个ref分发到一个组件的子组件中,这在写一些库的时候非常有用。

你可能会注意到,即使不通过refApi仅仅通过props的传递也可以获取,子组件的DOM。像下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Level1 extends React.Component{
render(){
return <Level2 topRef={this.props.topRef}/>
}
}

class Level2 extends React.Component{
render(){
return <input name='level2' ref={this.props.topRef}/>
}
}

class TopLevel extends React.Component{
topRef = React.createRef();
componentDidMount(){
console.log(this.topRef.current)
}
render(){
return <Level1 topRef={this.topRef}/>
}
}

这与Ref转发的本意不符,对于高可复用“叶”组件来说是不方便的。这些组件倾向于在整个应用中以一种类似常规 DOM button 和 input 的方式被使用,并且访问其 DOM 节点对管理焦点,选中或动画来说是不可避免的。也可以理解成是对原声DOM的封装,而且还能方便的获取到原声DOM的引用

下面的例子,通过Ref转发获取到了 <FancyButton/>组件的子组件

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
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="FancyButton">
{props.children}
</button>
));class Level1 extends React.Component{
render(){
return <Level2 topRef={this.props.topRef}/>
}
}
const Level1Ref = React.forwardRef((props,ref)=> <Level1 {...props} topRef={ref}/>)
class Level2 extends React.Component{
render(){
return <input name='level2' ref={this.props.topRef}/>
}
}

class TopLevel extends React.Component{
topRef = React.createRef();
componentDidMount(){
console.log(this.topRef.current)
}
render(){
return <Level1Ref ref={this.topRef}/>
}
}
export default TopLevel;

// 你可以直接获取 DOM button 的 ref:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;
  • 我们通过调用 React.createRef 创建了一个 React ref 并将其赋值给 ref 变量。
  • 我们通过指定 ref 为 JSX 属性,将其向下传递给
  • React 传递 ref 给 forwardRef 内函数 (props, ref) => …,作为其第二个参数。
  • 我们向下转发该 ref 参数到
  • 当 ref 挂载完成,ref.current 将指向

所以在最开的错误案例中,可以通过ref转发让叶组件获取ref,再通过props在将其在组件内部传递到需要的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Level1 extends React.Component{
render(){
return <Level2 topRef={this.props.topRef}/>
}
}
const Level1Ref = React.forwardRef((props,ref)=> <Level1 {...props} topRef={ref}/>)
class Level2 extends React.Component{
render(){
return <input name='level2' ref={this.props.topRef}/>
}
}

class TopLevel extends React.Component{
topRef = React.createRef();
componentDidMount(){
console.log(this.topRef.current)
}
render(){
return <Level1Ref ref={this.topRef}/>
}
}
合并Ref转发

理解了上面通过 forwardRefprops 共同传递ref,供给子组件消费,就很容易理解合并Ref转发

forwardRefref 可以通过 props 传递,那么如果用 ref 对象标记的 ref ,那么 ref 对象就可以通过 props 的形式,提供给子孙组件消费,当然子孙组件也可以改变 ref 对象里面的属性,或者像如上代码中赋予新的属性,这种 forwardref + ref 模式一定程度上打破了 React 单向数据流动的原则。当然绑定在 ref 对象上的属性,不限于组件实例或者 DOM 元素,也可以是属性值或方法。

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
// 表单组件
class Form extends React.Component{
render(){
return <div>{...}</div>
}
}
// index 组件
class Index extends React.Component{
componentDidMount(){
const { forwardRef } = this.props
forwardRef.current={
form:this.form, // 给form组件实例 ,绑定给 ref form属性
index:this, // 给index组件实例 ,绑定给 ref index属性
button:this.button, // 给button dom 元素,绑定给 ref button属性
}
}
form = null
button = null
render(){
return <div >
<button ref={(button)=> this.button = button } >点击</button>
<Form ref={(form) => this.form = form } />
</div>
}
}
const ForwardRefIndex = React.forwardRef(( props,ref )=><Index {...props} forwardRef={ref} />)
// home 组件
export default function Home(){
const ref = useRef(null)
useEffect(()=>{
console.log(ref.current)
},[])
return <ForwardRefIndex ref={ref} />
}
在高阶组件中转发Ref

高阶组件中,属性是可以透传的,但是ref不可以,ref是特殊属性,这就导致使用高阶组件的时候,仅仅通过ref不能传递到基础组件

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 logProps(WrappedComponent) {
class LogProps extends React.Component {
render() {
return <WrappedComponent {...this.props} />;
}
}
return LogProps;
}

class FancyButton extends React.Component {
focus() {
console.log('focus')
}
render(){
return <div>wefwef</div>
}
}

const HOCFancyButton = logProps(FancyButton);

class MyComponent extends React.Component {
ref = React.createRef();
componentDidMount(){
console.log(this.ref.current)
}
render(){
// 使用高阶组件的时候 ref指向的是LogProps,而不是FancyButton
return <HOCFancyButton ref={this.ref}/>
}
}

可以使用forwardRef在高阶组件中做Ref转发

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 logProps(WrappedComponent) {
class LogProps extends React.Component {
render() {
const { forwardedRef, ...rest } = this.props;
return <WrappedComponent {...rest} ref={forwardedRef} />;
}
}
return React.forwardRef((props, ref) => <LogProps {...props} forwardRef={ref} />);
}

class FancyButton extends React.Component {
focus() {
console.log('focus')
}
render() {
return <div>wefwef</div>
}
}

const HOCFancyButton = logProps(FancyButton);

class MyComponent extends React.Component {
ref = React.createRef();
componentDidMount() {
console.log(this.ref.current)
}
render() {
return <HOCFancyButton ref={this.ref} />
}
}
类组件通过Ref通信

有一种类似表单(Form)的场景,不希望表单元素的更新是通过父组件(Form)更新触发render并传递props到子组件来更新。而是希望不用触发父组件的render,直接子组件,子组件有自己的状态,这时就需要父组件能获取到子组件的实例。调用子组件实例方法,更新子组件内部状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Child extends React.Component{
receiveMessageFromParent = (msg)=>{
console.log(msg)
}
render(){
return <>
<button onClick={()=>this.receiveMessageFromParent('MessageFromChild')}>发消息给父组件</button>
<div>child</div>
</>
}
}

class Parent extends React.Component {
childRef = React.createRef();
sendMessageToChild = ()=>{
this.childRef.current.receiveMessageFromParent('MessageFromParent')
}
receiveMessageFromChild = (msg)=>{
console.log(msg);
}
render(){
return <>
<button type='button' onClick={this.sendMessageToChild}>发消息给子组件</button>
<Child ref={this.childRef} receiveMessageFromChild={this.receiveMessageFromChild}/>
</>
}
}
函数组件通信

useImperativeHandle可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用:

1
2
3
4
5
6
7
8
9
10
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} />;
}
FancyInput = forwardRef(FancyInput);

上面的例子在函数式组件中可以改写为:

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
function Child(props, ref) {
const inputRef = React.useRef();
const sayChild = useCallback(()=>{
console.log('child')
},[])
React.useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
},
sayChild
}));
return <>
<button onClick={()=> props.receiveMessageFromChild('MessageFromChild')}>发消息给父组件</button>
<input ref={ref}/>
</>
}
Child = React.forwardRef(Child)

const Parent = () => {
const ref = React.useRef();
React.useEffect(()=>{
// 可以拿到子组件定义的方法,或者操作子组件的DOM元素
console.log(ref.current)
},[ref])
const receiveMessageFromChild =useCallback((msg)=>{
console.log(msg);
},[])
return <>
<Child ref={ref} receiveMessageFromChild={receiveMessageFromChild}/>
</>
}

Ref的原理

对于整个 Ref 的处理,都是在 commit 阶段发生的。因为在 commit 阶段才会对真正的 Dom 进行操作,这是用 ref 保存真正的 DOM 节点,或组件实例。

对Ref的更新会调用两个方法 commitDetachRefcommitAttachRef 一个发生在 commit 之前,一个发生在 commit 之后

react-reconciler/src/ReactFiberCommitWork.js

1
2
3
4
5
6
7
8
9
10
11
// 在 commit 的 mutation 阶段会清空Ref
function commitDetachRef(current: Fiber) {
const currentRef = current.ref;
if (currentRef !== null) {
if (typeof currentRef === 'function') {
currentRef(null);
} else {
currentRef.current = null;
}
}
}

清空之后会进入DOM更新,根据不同的effect标签,操作真实的dom

最后 Layout 阶段会会更新Ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent: //元素节点 获取元素
instanceToUse = getPublicInstance(instance);
break;
default: // 类组件直接使用实例
instanceToUse = instance;
}
if (typeof ref === 'function') {
ref(instanceToUse); //* function 和 字符串获取方式。 */
} else {
ref.current = instanceToUse; /* ref对象方式 */
}
}
}

字符串形式的ref,最后被包装成一个函数,以函数的形式执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function coerceRef(returnFiber, current, element) {
// 会用_stringRef给函数做标记,如果相同则直接返回原来的函数引用
if (current !== null && current.ref !== null && typeof current.ref === 'function' && current.ref._stringRef === stringRef) {
return current.ref;
}

var ref = function (value) {
var refs = inst.refs;

if (refs === emptyRefsObject) {
// This is a lazy pooled frozen object, so we need to initialize.
refs = inst.refs = {};
}

if (value === null) {
delete refs[stringRef];
} else {
refs[stringRef] = value;
}
};

ref._stringRef = stringRef;
return ref;
}

事实上并不是每次创建或更新这两个函数都会执行

react-reconciler/src/ReactFiberWorkLoop.js

commitDetachRef 执行位置

每次都设为null,是防止内存泄漏 如果 ref 每次绑定一个全新的 对象(Ref.current,callback)上,而不清理对旧的 dom节点 或者 类实例 的引用,则可能会产生内存泄漏。

1
2
3
4
5
6
7
8
function commitMutationEffects(){
if (effectTag & Ref) {
const current = nextEffect.alternate;
if (current !== null) {
commitDetachRef(current);
}
}
}

commitAttachRef 执行位置

1
2
3
4
5
function commitLayoutEffects(){
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
}

想要挂载Ref,是必须要打上effectTag的标签,所以只有在Ref改变的时候才会更新

react-reconciler/src/ReactFiberBeginWork.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function markRef(current, workInProgress) {
var ref = workInProgress.ref;

if (
// fiber初始化的时候,且定义了ref属性
current === null && ref !== null
// fiber更新的时候,ref对象的引用已经改变
|| current !== null && current.ref !== ref
) {
// Schedule a Ref effect
workInProgress.flags |= Ref;
}
}

所以绑定匿名函数的写法,会导致函数每次都执行,因为函数的引用不一样

1
2
3
4
5
// 可以把函数定义为类的方法
<div ref={(node)=>{
this.node = node
console.log('此时的参数是什么:', this.node )
}} >ref元素节点</div>

被卸载的 fiber 会被打成 Deletion effect tag ,然后在 commit 阶段会进行 commitDeletion 流程。对于有 ref 标记的 ClassComponent (类组件) 和 HostComponent (元素),会统一走 safelyDetachRef 流程,这个方法就是用来卸载 ref。

react-reconciler/src/ReactFiberCommitWork.js

1
2
3
4
5
6
7
8
9
10
function safelyDetachRef(current) {
const ref = current.ref;
if (ref !== null) {
if (typeof ref === 'function') { // 函数式 | 字符串
ref(null)
} else {
ref.current = null; // ref 对象
}
}
}
  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信