跳到主要内容

底层原理

Fiber架构

React在它的V16版本推出了Fiber架构,在弄清楚什么是Fiber之前,我们应该先了解为什么需要Fiber。

首先,浏览器是多线程的,这些线程包括JS引擎线程(主线程),以及GUI渲染线程,定时器线程,事件线程等工作线程。其中,JS引擎线程和GUI渲染线程是互斥的。又因为绝大多数的浏览器页面的刷新频率取决于显示器的刷新频率,即每16.6毫秒就会通过GUI渲染引擎刷新一次。所以,如果JS引擎线程一次性执行了一个长时间(大于16.6毫秒)的同步任务,就可能出现掉帧的情况,影响用户的体验。

而在旧版本的React中,对于一个庞大的组件,无论是组件的创建还是更新都可能需要较长的时间。而Fiber的思路是将原本耗时较长的同步任务分片为多个任务单元,执行完一个任务单元后可以保存当前的状态,切换到GUI渲染线程去刷新页面,接下来再回到主线程并从上个断点继续执行任务。

我的个人体会,React中的Fiber(纤程)类似或者说就是Coroutine(协程)。ES6的Generator本身也算是协程的一种实现,或者说是状态机,通过它能够得到一个可以暂停的函数任务;而React中的Fiber,将原本耗时很长的同步任务分成多个耗时短的分片,从而实现了浏览器中互斥的主线程与GUI渲染线程之间的调度。

除此之外,对于每一个Fiber的同步任务来说,都拥有一个优先级(总共定义了6种优先级)。

当主线程刚执行完一个任务A的一个分片,若此时出现了一个优先级更高的任务B,React就可能会把任务A废弃掉,待之后重新执行一次任务A。

为什么这里要加一个可能,这是因为对于使用了Fiber的React来说,组件可以分为两个阶段,分别是“Render/Reconciliation phase”和"Commit phase",可以在官方的生命周期图谱看到具体的信息。第一个阶段是没有副作用的,也因此可以被React暂停,废弃又或者重新执行;而第二个阶段会涉及到实际的DOM,是有副作用的,所以无法被React暂停,重新执行。

那么结合上面两段,可以知道处于“Render/Reconciliation phase”的任务A,如果执行时出现了优先级更高的任务B,任务A就会被废弃,之后重新被执行。

举个例子。由于componentWillMount已经要被React废弃了,所以在以上链接中的图谱没有被标出来,它其实也是属于"Render/Reconciliation phase"的。那么当一个组件即将挂载时,就会调用这个生命周期钩子,假如在这之后我们就碰到了优先级更高的任务,那么原本的任务就会被废弃,并在之后被重新调用。导致的结果就是componentWillMount被调用了两次,这是一个值得注意的点。

Diff策略

参考

  1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
  2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  3. 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

针对第一点策略,React只对新老树进行同层的比较(Vue也是如此)。

tree diff

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

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

针对第二点策略,当React遇到不同类的两个组件,它会将旧组件删除,并增加新的组件。

服务端渲染(SSR)

完整代码例子

通常进行客户端渲染时,先从后端获取空页面,通过代码渲染出模板页面,再从后端获取动态数据,之后再渲染出完整页面

那么当进行服务端渲染时,我们希望在服务端获取动态数据,并在服务端渲染出完整页面返回给浏览器

我们可以在后端使用react-dom/server提供的renderToString来生成静态页面,静态页面被返回给浏览器后,我们还需要在前端使用react-dom提供的hydrate来给静态标记附加事件、生命周期等信息。

// server.js 后端
import { renderToString } from 'react-dom/server'

const content = renderToString(<App />)
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React App</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/client.js"></script>
</body>
</html>
`
// client.js 前端
import { hydrate } from 'react-dom'

hydrate(<App />, document.querySelector('#root'))

同构

所谓同构,指的是一份代码可以分别在前端和后端运行。

比如上述代码例子中的App组件,分别在前后端运行了一次。值得注意的是,只有在前端使用hydrate后才会触发对应的生命周期或事件,而仅在后端使用renderToString时并不会触发组件的生命周期,所以我们无法通过生命周期在后端获取动态数据。

服务端加载数据

通常使用服务端渲染都会用到react-router-domredux ,要注意后端需要使用StaticRouter而不是BrowserRouter

我们在服务端创建一个store,再调用指定的方法来获取动态数据,并更新store的值。在这之后再把store作为Provider的值,使用renderToString渲染完整的页面。

浏览器收到静态页面后,使用hydrate给静态页面绑定事件时我们也需要给Provider指定store。所以在之前渲染完整页面时可以在页面插入一段window.__STATE__ = XXX,从而进行store在前后端的传递。

export const loadData = () => (dispatch) => {
dispatch(setFetching(true))
return axios.get('http://localhost:3000/getData')
.then(res => {
dispatch(setLists(res.data.lists))
})
.catch(err => console.log(err))
}


export const load = (store) => {
return store.dispatch(loadData())
}

export const routes = [
{
path: '/',
key: 'home',
component: Contain,
loadData: load, // 在这里加载数据
exact: true,
},
{
path: '/signup',
key: 'signup',
component: Signup
},
]
// server.js 后端
app.use(async (ctx) => {
const store = configureStore(initialState) // 创建store
const promiseArr = []
routes.forEach(route => {
if (route.loadData) {
promiseArr.push(route.loadData(store)) // 加载数据
}
})
await Promise.all(promiseArr) // 需要等所有数据都加载完毕
const content = await renderToHTML(ctx.url, store) // 生成完整页面
ctx.body = content
})

export default async function renderToHTML(url, store) {
const template = await fs.readFileAsync((`./template/index.html`), 'utf8')

const content = renderToString(
<Provider store={store}> // 此时store已经是最新值
<StaticRouter location={url}>
<App />
</StaticRouter>
</Provider>
)

// 后端把store放在window里,从而向前端传递
const state = `
<script>
window.__STATE__ = ${JSON.stringify(store.getState())}
</script>
`
return template
.replace(`<!-- CONTENT -->`, content)
.replace(`<!-- STATE -->`, state)
}
// client.js 前端
export default function App() {
return (
<Switch>
{
routes.map(route => {
return <Route {...route}></Route>
})
}
</Switch>
)
}

const state = window.__STATE__ // 获取后端传过来的store

delete window.__STATE__

const store = configureStore(state)

hydrate(
<Provider store={store}>
<Router>
<App />
</Router>
</Provider>,
document.querySelector('#root')
)

服务端加载CSS

App组件中需要使用import 'index.css'来加载样式,通过style-loader来调用document.createElement('style')创建标签,又因为Node环境中并不存在document变量,所以这种思路是行不通的。

那么我们切换一下思路,与其引入index.css时自动创建标签,我们实际上可以在引入index.css后,获取对应的样式信息,再手动的在模板HTML中添加这段信息,就像我们之前手动添加CONTENTSTATE一样。

为了实现该目的,我们首先需要安装isomorphic-style-loader,并在webpack配置中替换掉原本用来转换CSS的style-loader。此时,我们引入样式文件时不会自动创建标签。

// webpack.config.js
{
test: /\.module\.css$/,
use: ["isomorphic-style-loader", {
loader: "css-loader",
options: {
importLoaders: 1,
esModule: false, // 注意这里
}
},
"postcss-loader"
],

值得注意的是,与官网文档上的例子不同,我这里使用了esModule: false。这主要是css-loader版本的差异导致的,在一些老的教程和文档中可能使用的是css-loader@3.x,而最新的版本已经是css-loader@5.x了。在最新的情况下css-loader会生成一个es模块,而isomorphic-style-loader需要一个commonjs模块

另外,有的教程使用StaticRoutercontext来实现服务端中CSS的加载,而在isomorphic-style-loader的官网中,使用的是它自带的一些工具函数来实现,我可能更倾向于后者吧。

总之,在配置好后,我们可以跟着官网的教程来实现服务端加载CSS了。

// server.js 后端
const css = new Set() // CSS for all rendered React components
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const body = ReactDOM.renderToString(
<StyleContext.Provider value={{ insertCss }}>
<App />
</StyleContext.Provider>
)
const html = `<!doctype html>
<html>
<head>
<script src="client.js" defer></script>
<style>${[...css].join('')}</style>
</head>
<body>
<div id="root">${body}</div>
</body>
</html>
`
// client.js 前端

import React from 'react'
import withStyles from 'isomorphic-style-loader/withStyles'
import s from './App.scss'

function App(props, context) {
return (
<div className={s.root}>
<h1 className={s.title}>Hello, world!</h1>
</div>
)
}

export default withStyles(s)(App) // <--

const insertCss = (...styles) => {
// 不过,这两行代码有必要么
const removeCss = styles.map(style => style._insertCss())
return () => removeCss.forEach(dispose => dispose())
}

ReactDOM.hydrate(
<StyleContext.Provider value={{ insertCss }}>
<App />
</StyleContext.Provider>,
document.getElementById('root')
)

Next.js

next.js是一个React的服务端渲染框架,它有以下特点:

  • 对项目的目录结构进行了约束,并提供了许多有用的内部组件,从而提供快速的开发能力
  • 自带对CSS Modulecss-in-js的支持
  • 通过目录结构划分前端路由,并支持动态路由等功能
  • 支持两种预渲染方案:①静态生成的页面,即在项目构建时生成静态页面;②服务端渲染,即每次收到请求时构建一次页面。

总的来说,我觉得是个很优秀的框架,拿来搭建博客也是个非常不错的选择,部署在它家的Vercel平台也十分方便。

Create-React-App

环境变量

通常项目都会存在测试环境和正式环境,不同环境下接口请求的路径也是不同的。而CRA提供了process.env让我们在前端读取环境变量,从而可以根据环境的不同设置不同的接口参数。

// process.env 默认值
{
NODE_ENV: "development" | "production" | "test"
PUBLIC_URL: ""
WDS_SOCKET_HOST: undefined
WDS_SOCKET_PATH: undefined
WDS_SOCKET_PORT: undefined
}

我们用的最多的是NODE_ENV这个环境变量,通常当我们使用npm startnpm buildnpm test时,NODE_ENV的值分别为developmentproductiontest。另外PUBLIC_URL也可以在模板HTML中看到它的使用方式。

我们也可以自己设置环境变量,不过需要注意的是我们设置的环境变量必须以REACT_APP_开头才能被process.env读取到,比如可以这么写:

{
"dev": "cross-env REACT_APP_MY_ENV=development react-scripts start"
}

rewired

使用create-react-app创建的项目,其webpack配置等信息对我们是不可见的,也是不可直接修改的。

然而在有些场合我们还是希望能适度修改配置,除了eject我们也可以使用像react-app-rewired这样的库来拓展配置信息。