⑤ ReactSSR node中间层代理请求

代理请求

由于客户端和服务端公用一套请求的接口,所以需要接口同时适应客户端和移动端,这里可以选用 cross-fetchaxios

当在异步 action 中请求数据时,我们希望请求的是同域的服务

1
2
3
export const loadData =
() => (dispatch: Dispatch, getState: any, request: AxiosInstance) =>
axios("/api/products").then(({ data }) => dispatch(loadDataAction(data)));

而不是直接请求后端服务器。所以需要将 api 开头的请求转发到后端服务器请求。用到了一个 koa 的中间件 koa-proxies

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
import Koa from 'koa';
import koaBody from 'koa-body';
import koaStatic from 'koa-static';
import proxy from 'koa-proxies';
import path from 'path';
import router from '../router'

import errorHandle from './errorHandle'

const app = new Koa();

app
.use(async (ctx:any, next:()=>Promise<any>) => {
try {
await next();
} catch (err) {
ctx.app.emit('error', err, ctx);
}
})
.use(proxy('/api',{
target: 'https://fakestoreapi.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/,''),
}))
.use(koaStatic(path.join((process.env as any).PWD,'./static')))
.use(koaBody())
.use(router.routes())
.use(router.allowedMethods())

app.on('error', errorHandle);

export default app;

处理请求

现在客户端正常访问是可以的,但是服务端会报出错误,因为当刷新页面的时候服务端会做服务端渲染,这时直接调用了组件中获取数据的方法。

由于组件中的路径是以 /api 开头的绝对路径,所以会尝试在服务器中查找根路径下api文件夹,因为找不到报错错误。

一个思路是,区分服务端的请求和客户端的请求,分别为其创建不同的 axios 实例用于请求,但是为了避免像上一章中,每个请求分两种写,可以考虑在项目初始化的时候创建不同的 axios 实例,并通过参数传递到请求方法中,从而避免业务逻辑太多冗余。

src/util/request.ts

定义一个请求方法,为服务端和客户端创建不同实例

由于后端服务并不是api开头的接口,所以后端访问时,需要为其重写url路径.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import axios from "axios";

const serverInstance = axios.create({
baseURL: "https://fakestoreapi.com",
adapter: function (config) {
/* ... */
config.url = config.url?.replace(/^\/api/, "");
delete config.adapter;
return new Promise((resolve) => {
resolve(axios(config));
});
},
});

const clientInstance = axios.create({
baseURL: "/",
});

export { serverInstance, clientInstance };

在初始化 store 的时候,通过中间件把 axios 实例传入,让所用的异步 action 在请求前可以通过第三个参数拿到 axios 实例

src/store/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'
import reducers from './reducers';
import { StoreType as HelloStoreType } from '../components/hello';
import {clientInstance,serverInstance} from '../util/request'

const browserStore = ()=> createStore(reducers, (window as any).__HYDRATE_DATA__, applyMiddleware(thunk.withExtraArgument(clientInstance)));

const serverStore = () => createStore(reducers, applyMiddleware(thunk.withExtraArgument(serverInstance)));

export type StoreType = {
hello: HelloStoreType
}

export {
Provider,
browserStore,
serverStore
}

src/components/hello/action.ts

修改 action 方法,通过 axios 实例请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Dispatch, ActionCreator } from "redux";
import { AxiosInstance } from "axios";
import {} from "redux";

export const LOAD_DATA = "LOAD_DATA";

export type LOAD_DATA_TYPE = typeof LOAD_DATA;

const loadDataAction: ActionCreator<{ type: LOAD_DATA_TYPE }> = (payload) => ({
type: LOAD_DATA,
payload,
});

export type ActionTypes = LOAD_DATA_TYPE;
export const loadData =
() => (dispatch: Dispatch, getState: any, request: AxiosInstance) =>
request("/api/products").then(({ data }) => dispatch(loadDataAction(data)));

export const serverLoadData = loadData;

最后一步,由于我们统一了调用方法,现在服务端也会通过异步 action 方法调用接口

所以需要让服务端调用方法的时候,也像客户端一样通过bindActionCreators传入dispatch方法

通过服务端创建的store传入了dispatch方法,并且让中间件的参数生效。这时也不需要再组合不同接口返回的state,通过异步action方法,在拿到返回值之后,dispatch会触发并更新store

当所有的组件异步数据请求之后,在通过getState获取最新的store渲染页面

src/router/index.tsx

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
const router = new Router();

router.get("/(.*)", async (ctx) => {
const store = serverStore();
const promises: Array<any> = matchRoutes(routes, ctx.request.path).map(
({ route, match }) => {
return route.loadData
? bindActionCreators(route.loadData, store.dispatch)()
: Promise.resolve();
}
);
await Promise.all(promises);

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, store }} />
)}</div>
<script>
window.__HYDRATE_DATA__ = ${JSON.stringify(
store.getState()
)}
</script>
<script src='/index.js' defer></script>
</body>
</html>
`;
});

export default router;

④ ReactSSR 接入redux

创建Action

/src/components/hello/action.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
27
28
import { Dispatch, ActionCreator } from "redux";
import axios, { AxiosResponse } from 'axios'

export const LOAD_DATA = 'LOAD_DATA';

export type LOAD_DATA_TYPE = typeof LOAD_DATA;

interface FetchDataInterface {
(match?: any): Promise<AxiosResponse>
}

const fetchData: FetchDataInterface = (match) => axios('https://fakestoreapi.com/products');

interface ServerLoadDataInterface {
(match: any): Promise<any>
}

export const serverLoadData: ServerLoadDataInterface = (match) => fetchData().then(({ data }) => ({ hello: { shopData: data } }));

const loadDataAction: ActionCreator<{ type: LOAD_DATA_TYPE }> = (payload) => ({
type: LOAD_DATA,
payload,
})


export type ActionTypes = LOAD_DATA_TYPE;
export const loadData = () => (dispatch: Dispatch) => fetchData().then(({ data }) => dispatch(loadDataAction(data)));

创建reducer

/src/components/hello/reducer.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {AnyAction,Reducer} from 'redux';
import {
LOAD_DATA,
} from './action';

import { StoreType } from '.';

const initStore = {
shopData:[]
}


const hello:Reducer<StoreType,AnyAction> = (state=initStore, action) => {
switch (action.type) {
case LOAD_DATA:
return ({...state,shopData:action.payload})
default:
return state;
}
}

export default hello;

connect Hello组件

/src/components/hello/hello.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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import React from 'react';
import { bindActionCreators,Dispatch } from 'redux';
import {connect} from 'react-redux';
import { StoreType } from '../../store';
import Header from '../header';
import {loadData} from './action';

type HelloComponentProps = {
loadData:()=>void,
shopData:Array<any>,
};

const Hello:React.FC<HelloComponentProps> = (props) =>{
const {
loadData,
shopData
} = props;


React.useEffect(()=>{
loadData();
},[])

return <div>
<Header/>
{
shopData.map(item=> <h6 onClick={()=>{alert("hello")}} key={item.id}>{item.title}</h6>)
}
</div>
}



const mapStateToProps = (state:StoreType) => {
return {
shopData:state.hello.shopData
}
}

const mapDispatchToProps = (dispatch:Dispatch) => {
return {
loadData: bindActionCreators(loadData,dispatch)
}
}


export default connect(mapStateToProps,mapDispatchToProps)(Hello) ;

修改 Hello 组件入口文件 index.ts

  • 暴露服务端渲染时需要的数据请求方法

  • 添加store类型,暴露到外部的 store/index.js 统一描述store类型

1
2
3
4
5
6
7
8
9
10
11
12
13
import Hello from "./hello";
import {serverLoadData} from './action';


export interface StoreType {
shopData:Array<any>
}

export {
serverLoadData
};

export default Hello;

创建全局的store

src/store/index.ts

用于生成store对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'
import reducers from './reducers';
import { StoreType as HelloStoreType } from '../components/hello';

const browserStore = ()=> createStore(reducers, (window as any).__HYDRATE_DATA__, applyMiddleware(thunk));

const serverStores = (__HYDRATE_DATA__: any) => createStore(reducers, __HYDRATE_DATA__, applyMiddleware(thunk));

export type StoreType = {
hello: HelloStoreType
}

export {
Provider,
browserStore,
serverStores
}

src/store/reducers.ts

合并所有组件中的reducer

1
2
3
4
5
6
import { combineReducers } from 'redux';
import hello from '../components/hello/reducer';

export default combineReducers({
hello
})

服务端预加载数据,改造routers

之前的routers是一个JSX元素,现在想调用组件暴露出的服务端请求数据的方法,并在拿到结果重新渲染组件,生成html字符串,并返回给浏览器

所以第一步:改造routers让我们可以拿到数据请求的方法

src/components/routes.tsx

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
import Hello, { serverLoadData as helloServerLoadData } from "./hello";
import Login from "./login";

const routes = [

{
path: "/hello",
exact: true,
component: Hello,
loadData: (match:any) => helloServerLoadData(match)
},
{
path: "/login",
exact: true,
component: Login,
},
{
path: "/",
component: Hello,
exact: true,
loadData: (match:any) => helloServerLoadData(match)
},
];

export default routes;

src/components/router.tsx

循环生成路由组件

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

import routes from './routes'

interface RouterProps {
ctx?: Koa.BaseContext
}

const Router: React.FC<RouterProps> = () => {
return (
<BrowserRouter>
<Switch>
{routes.map(route => (
<Route {...route} key={route.path} />
))}
</Switch>
</BrowserRouter>
)
}

export default Router;

匹配路由对应的组件

react-router-config 用于匹配包括子路由在内的所有路由配置对应的组件

在拿到所有的匹配项之后,循环调用所有组件的数据请求方法,并把返回的promise对象放到一个数组中

当所有的返回值拿到之后,组合所有的state,初始化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
25
26
27
28
29
30
31
32
33
34
35
36
37
import Router from '@koa/router';
import ReactDOMServer from 'react-dom/server';
import React from 'react';
import App from './router';
import routes from '../components/routes'
import { matchRoutes } from "react-router-config";


const router = new Router();

router.get("/(.*)",async (ctx,next)=>{
const promises:Array<any> = matchRoutes(routes,ctx.request.path).map(({route,match})=> route.loadData?route.loadData(match):Promise.resolve());

const preloadData = await Promise.all(promises);
const __HYDRATE_DATA__ = preloadData.reduce((res,data)=>Object.assign({},res,data) ,{});

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,__HYDRATE_DATA__}}/>)}</div>
<script>
window.__HYDRATE_DATA__ = ${JSON.stringify(__HYDRATE_DATA__)}
</script>
<script src='/index.js'></script>
</body>
</html>
`
})

export default router;

数据脱水和注水

  • 服务端渲染的时候需要把整合之后的store传入到初始化函数中,用于渲染Html字符串

  • 客户端渲染的时候,由于第一次渲染是并没有数据,会覆盖掉服务端渲染的结构,并重新请求后在渲染,这中间的过程就会白屏
    所以会在服务端直接把数据以字符串的方式插入到html界面中,在客户端解析的时候会变成window下的一个store对象,这个过程就叫做数据注水

  • 当客户端初始化时,会尝试查找window下有没有服务端插入的数据,如果有就用这个数据作为初始化数据,从而防止两边状态不统一造成的白屏,这一过程也叫做数据脱水

React v16 源码分析 ① 从入口开始认识React

render 方法

当我们打开 React 的官方文档看到的第一个例子便是 Hello World:

1
ReactDOM.render(<h1>Hello, world!</h1>, document.getElementById("root"));

render函数将h1标签和Hello World文本渲染在了页面的root元素中,但很显然这段代码没有看到的那么简单,js并不会认识html元素,这个标签最终会被转换成 js 对象,然后通过render方法后面的一系列函数调用,最终被插入到页面中。

JSX

刚才看到的 h1 标签,严格说来并不是html,因为他写在 js 语法中,可以把它叫做标签语法,也就是 JSX。

为什么 JSX 的出现好像又让页面开发回到了刀耕火种的jquery时代,jshtml混合在一起,UI(视图)与逻辑耦合。

但实际上 React 认为,渲染逻辑与 UI 的逻辑是耦合的,比如需要在 UI 中绑定事件,数据改变时通知 UI 发生改变,而 React 并没有将这两点分离到不同的文件中,而是通过组件的概念,实现关注点分离,也就是设计原则的分离,每一部分都有自己的关注点。

我们可以在Babel中,看一下 JSX 最终变成了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const CompA = (
<div>
<p>p</p>div
</div>
);

const CompB = () => <a>a</a>;
const App = () => {
return (
<>
<CompA />
<CompB />
</>
);
};

转化为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"use strict";

const CompA = /*#__PURE__*/ React.createElement(
"div",
null,
/*#__PURE__*/ React.createElement("p", null, "p"),
"div"
);

const CompB = () => /*#__PURE__*/ React.createElement("a", null, "a");

const App = () => {
return /*#__PURE__*/ React.createElement(
React.Fragment,
null,
/*#__PURE__*/ React.createElement(CompA, null),
/*#__PURE__*/ React.createElement(CompB, null)
);
};

可以注意到组件被转化为React.createElement方法的第一个参数,这也是为什么组件的第一个字母需要大写的原因,Babel 会通过大小写来区分原生组件和 React 组件

而正因为 JSX 语法被转换成了React.createElement的函数调用,因此在写 JSX 的时候必须要import React from react

而 React 17-RC 以及之后的版本将采用新的 JSX 转换,从而无需在引入 React

Virtual DOM

通过 React.createElement 处理的节点,会被转换成 Virtual DOM 也可以称为虚拟 DOM

Virtual DOM 是一种编程概念,它与真是的 DOM 一一对应,而且他是保存在内存中的,当 DOM 的属性发生改变的时候,React 会在内存中把改变映射成 Virtual DOM,在把最终状态渲染在页面中,从而保证最小的 DOM 操作

实际上 React 中的 Fiber,也是属于 Virtual DOM 概念的一部分,在 React.createElement 创建的对象上添加了,更多的属性,例如优先级,副作用,更新队列等。

React.createElement /react/packages/react/src/React.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
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
export function createElement(type, config, children) {
let propName;

// 用于提取保留字段
const props = {};

let key = null;
let ref = null;
let self = null;
let source = null;

if (config != null) {
if (hasValidRef(config)) {
ref = config.ref;
}
if (hasValidKey(config)) {
// 转换为字符串
key = "" + config.key;
}

self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;

// 拷贝外部传入的props属性(这里的形参是config)到props对象上
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
!RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}

// 子元素的个数
const childrenLength = arguments.length - 2;

// 如果只有一个子元素直接赋值
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
// 如果大于一个则插入到一个数组中
props.children = childArray;
}

// 如果定义了默认属性,则用默认属性覆盖掉,
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
return ReactElement(
type, // 组件类型 可能是元素标签,也可能是类组件或函数组件的引用
key, // 字符串key
ref, // ref对象
self, // 内部属性初始化null
source, // 内部属性初始化null
ReactCurrentOwner.current,// 用于跟踪拥有当前正在被构建组件的组件
props // 属性集合
);
}

const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref,
props: props,
_owner: owner,
};

return element;
};

当type是一个react组件的时候,他会被赋值到type属性上,最终被实例化。现在看一下,这个组件被定义时的样子

1
class App extends React.Component {}

React.Component /react/packages/react/src/ReactBaseClasses.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

// 基本类,用于帮助更新组件的状态
function Component(props, context, updater) {
this.props = props;
this.context = context;
// 如果refs是一个已经过时的string类型,晚一点的时候会重新分配一个不同的对象
this.refs = emptyObject;
// 初始化一个默认updater对象,但真实的对象会在渲染的时候被插入
this.updater = updater || ReactNoopUpdateQueue;
}

Component.prototype.isReactComponent = {};

/**
* 设置一个状态的子集,必须使用这个方法,你应该确保'this.state'是不可变的,也就是不能修改,每次修改必须返回一个新的传入一个新的state
*
* setState没有确保立即更新,所以在调用这个方法后使用数据可能是旧的。也没有确保setState会立即执行,实际上最终可能会一起执行
* 你可以提供一个回调函数,他将在setState真正完成后执行。
*
* 当setState传入一个函数,他将在未来的某一时间执行,并不是同步,它将使用最新的组件参数(state, props, context)被调用
* 这些参数与this对象上的可以是不同的,因为他可能在receiveProps之后但在shouldComponentUpdate之前,这些新的state, props, and context还没来得及合并到this对象
*/
Component.prototype.setState = function(partialState, callback) {
if (
typeof partialState !== 'object' &&
typeof partialState !== 'function' &&
partialState != null
) {
throw new Error(
'setState(...): takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
}

// 更新会被添加到队列,在未来的某一时间更新
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

/**
* 强制更新,它应该只当明确知道我们不在一个DOM事务中才被使用,你可能会想,当你知道一些深层组件的状态已经改变但是setState没有调用的时候去调用它。
* 它将不会触发shouldComponentUpdate,但会触发 `componentWillUpdate` 和 `componentDidUpdate`
*/
Component.prototype.forceUpdate = function(callback) {
this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};

执行流程

现在已经知道了render执行时的大部分必要信息,那render背后的逻辑又是怎样的,这显然是一个复杂的调用过程,但是我们可以通过浏览器的性能分析去查看render函数的调用那个过程

现在你只需要大概了解调用了哪些方法,这些方法会组成后面的调用流程图,后面会详细的描述

初始化事件相关对象

  • registerSimpleEvents 创建对象相关对象
变量名称 变量对象 说明
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
入口

render : ReactDom.render()
createRootImpl : 创建 FiberRootNode 根节点
listenToAllSupportedEvents : 绑定所有原生事件在 root 节点上

render 阶段

unbatchedUpdates : 非批量更新,让用户尽早看见页面内容,如果是 batchedUpdates 会以异步执行
scheduleUpdateOnFiber : 调度 Fiber 节点更新优先级
performUnitOfWork : 以 Fiber 节点为单位,深度优先递归遍历每一个节点
reconcileChildren : 创建对比 Fiber 节点,标记有副作用的节点 (添加,删除,移动,更新)
completeUnitOfWork : 从下至上遍历节点,创建相应的 DOM 节点,并创建 Effects 链表,交给 commit 阶段使用

commit 阶段

commitBeforeMutationEffects: 操作真实节点前执行,会执行getSnapshotBeforeUpdate
commitMutationEffects: 执行节点操作
commitLayoutEffects: 执行副作用函数,包括 componentDidUpdateeffect回调函数

如此复杂的调用栈,是为了解决哪些问题。下一章,让我们感受一下react的设计理念

React原理 执行流程与架构

在 react v15 之前成为 stack 架构,从 v16 开始重构了整个架构,新的架构叫做 fiber 架构,最大的改变就是可以进行时间切片。

  • cpu 瓶颈如果主进程存在大量的计算任务,会阻塞后续的任务,同样也会影响浏览器的绘制任务,从而造成掉帧,用户交互无响应。
    在 v15 的版本当嵌套组件过多时,虚拟 DOM 会递归执行,导致执行时间过长。

  • io 瓶颈,对前端来讲 io 瓶颈主要来自于网络,对于 React 所有的更新都来自于内部状态的变化,所以 react 将触发状态变化的事件分为不同的优先级,统一调度这些任务,挡在更新过程中有更高优先级的任务产生,需要中断当前任务,处理更高优先级的任务。

因此需要 React 实现任务调度算法,可以中断的 dom 更新,任务分级的机制, 以下是 React 升级的关键设计思想:

  • 在 UI 中,不需要每次更新都立即应用;事实上,这样做可能会造成浪费,导致帧丢失并降低用户体验。
  • 不同类型的更新有不同的优先级——动画更新需要比数据存储更新更快地完成。
  • react 使用的拉的模式,他会自己安排更新工作,而不需要让程序员推送任务执行的方式。

fiber 相当于代替了之前版本的调用栈信息, 一个 fiber 相当于一个调用栈帧,生命周期比调用栈长,可以保存在内存中,在需要的时候调用。

为什么在 fiber 架构之前无法中断更新,假设一个列表有 4 个 div 每个 div 中的文字是 2,现在触发一个事件需要将列表中的每个文字更新为 4, stack reconciler 递归的执行每个更新,在更新到第三个元素的时候,如果想要中断了更新那么会让出主进程允许浏览器更新 UI,这就会造成只有三个元素显示了正确的 UI, 造成 UI 和状态不一致。

fiber 架构的第一个阶段是 render/reconciliation, 他会解析当前 fiber 链接起来的树,弄清楚哪些需要更新,这个过程是可以中断的,react 从根节点向下递归,标记那些已经被删除或是更新的节点,并在每个节点处理过后检查是否还剩余处理时间,如果时间不足会中断当前处理让出主线程,当主线程任务结束后会从中断的位置继续处理剩余的节点,所有节点处理完成后,会递归向上回到根节点,将所有需要更新和修改的节点作为一个副作用链表一直连接到 root fiber。

第二个阶段是提交 commit 阶段,它会将识别到的更改应用到元素上,这一过程是不可以中断的。

优先级:

  • Synchronous 同步相当于 stack reconciler
  • task 在下一次事件循环前
  • animation 在下一次渲染之前
  • High 即使处理
  • Low 网络请求等
  • Offscreen

整体执行流程

初始化事件相关对象

  • registerSimpleEvents 创建对象相关对象
变量名称 变量对象 说明
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

入口

render : ReactDom.render()
createRootImpl : 创建 FiberRootNode 根节点
listenToAllSupportedEvents : 绑定所有原生事件在 root 节点上

render 阶段

unbatchedUpdates : 非批量更新,让用户尽早看见页面内容,如果是 batchedUpdates 会以异步执行
scheduleUpdateOnFiber : 调度 Fiber 节点更新优先级
performUnitOfWork : 以 Fiber 节点为单位,深度优先递归遍历每一个节点
reconcileChildren : 创建对比 Fiber 节点,标记有副作用的节点 (添加,删除,移动,更新)
completeUnitOfWork : 从下至上遍历节点,创建相应的 DOM 节点,并创建 Effects 链表,交给 commit 阶段使用

commit 阶段

commitBeforeMutationEffects: 操作真实节点前执行,会执行getSnapshotBeforeUpdate
commitMutationEffects: 执行节点操作
commitLayoutEffects: 执行副作用函数,包括 componentDidUpdateeffect回调函数

JSX

jsx 是 js 语言的扩展,react 通过 babel 词法解析,将 jsx 转换成 React.createElement,React.createElement 方法返回 virtual-dom 对象(内存中用来描述 dom 阶段的对象),所有 jsx 本质上就是 React.createElement 的语法糖,它能声明式的编写我们想要组件呈现出什么样的 ui 效果.

Fiber 双缓存

Fiber 对象上面保存了包括这个节点的属性、类型、dom 等,Fiber 通过 child、sibling、return(指向父节点)来形成 Fiber 树,还保存了更新状态时用于计算 state 的 updateQueue,updateQueue 是一种链表结构,上面可能存在多个未计算的 update,update 也是一种数据结构,上面包含了更新的数据、优先级等,除了这些之外,上面还有和副作用有关的信息。

双缓存是指存在两颗 Fiber 树,current Fiber 树描述了当前呈现的 dom 树,workInProgress Fiber 是正在更新的 Fiber 树,这两颗 Fiber 树都是在内存中运行的,在 workInProgress Fiber 构建完成之后会将它作为 current Fiber 应用到 dom 上

在 mount 时(首次渲染),会根据 jsx 对象(Class Component 或的 render 函数者 Function Component 的返回值),构建 Fiber 对象,形成 Fiber 树,然后这颗 Fiber 树会作为 current Fiber 应用到真实 dom 上,在 update(状态更新时如 setState)的时候,会根据状态变更后的 jsx 对象和 current Fiber 做对比形成新的 workInProgress Fiber,然后 workInProgress Fiber 切换成 current Fiber 应用到真实 dom 就达到了更新的目的,而这一切都是在内存中发生的,从而减少了对 dom 好性能的操作。

Lane 模型

react 之前的版本用 expirationTime 属性代表优先级,该优先级和 IO 不能很好的搭配工作(io 的优先级高于 cpu 的优先级),现在有了更加细粒度的优先级表示方法 Lane,Lane 用二进制位表示优先级,二进制中的 1 表示位置,同一个二进制数可以有多个相同优先级的位,这就可以表示‘批’的概念,而且二进制方便计算。

这好比赛车比赛,在比赛开始的时候会分配一个赛道,比赛开始之后大家都会抢内圈的赛道(react 中就是抢优先级高的 Lane),比赛的尾声,最后一名赛车如果落后了很多,它也会跑到内圈的赛道,最后到达目的地(对应 react 中就是饥饿问题,低优先级的任务如果被高优先级的任务一直打断,到了它的过期时间,它也会变成高优先级)

Lane 的二进制位如下,1 的 bits 越多,优先级越低

1
2
3
4
5
export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;

export const SyncLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncBatchedLane: Lane = /* */ 0b0000000000000000000000000000010;

Scheduler

Scheduler 的作用是调度任务,react15 没有 Scheduler 这部分,所以所有任务没有优先级,也不能中断,只能同步执行。

我们知道了要实现异步可中断的更新,需要浏览器指定一个时间,如果没有时间剩余了就需要暂停任务,requestIdleCallback 貌似是个不错的选择,但是它存在兼容和触发不稳定的原因,react17 中采用 MessageChannel 来实现。

在 Scheduler 中的每的每个任务的优先级使用过期时间表示的,如果一个任务的过期时间离现在很近,说明它马上就要过期了,优先级很高,如果过期时间很长,那它的优先级就低,没有过期的任务存放在 timerQueue 中,过期的任务存放在 taskQueue 中,timerQueue 和 timerQueue 都是小顶堆,所以 peek 取出来的都是离现在时间最近也就是优先级最高的那个任务,然后优先执行它。

reconciler

Reconciler 发生在 render 阶段,render 阶段会分别为节点执行 beginWork 和 completeWork,或者计算 state,对比节点的差异,为节点赋值相应的 effectFlags(对应 dom 节点的增删改)。

协调器是在 render 阶段工作的,简单一句话概括就是 Reconciler 会创建或者更新 Fiber 节点。在 mount 的时候会根据 jsx 生成 Fiber 对象,在 update 的时候会根据最新的 state 形成的 jsx 对象和 current Fiber 树对比构建 workInProgress Fiber 树,这个对比的过程就是 diff 算法。

diff 算法发生在 render 阶段的 reconcileChildFibers 函数中,diff 算法分为单节点的 diff 和多节点的 diff(例如一个节点中包含多个子节点就属于多节点的 diff),单节点会根据节点的 key 和 type,props 等来判断节点是复用还是直接新创建节点,多节点 diff 会涉及节点的增删和节点位置的变化。

reconcile 时会在这些 Fiber 上打上 Flags 标签,在 commit 阶段把这些标签应用到真实 dom 上,这些标签代表节点的增删改,如

1
2
export const Placement = /*             */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;

render 阶段遍历 Fiber 树类似 dfs 的过程,处理发生在 beginWork 函数中,该函数做的主要工作是创建 Fiber 节点,计算 state 和 diff 算法,‘冒泡’阶段发生在 completeWork 中,该函数主要是做一些收尾工作,例如处理节点的 props、和形成一条 effectList 的链表,该链表是被标记了更新的节点形成的链表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function App() {
const [count, setCount] = useState(0);
return (
<>
<h1
onClick={() => {
setCount(() => count + 1);
}}
>
<p title={count}>{count}</p> hello
</h1>
</>
);
}

如果 p 和 h1 节点更新了则 effectList 如下,从 rootFiber->h1->p,,顺便说下 fiberRoot 是整个项目的根节点,只存在一个,rootFiber 是应用的根节点,可能存在多个。

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;
}
}
}
  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信