底层原理
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策略
- Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
- 对于同一层级的一组子节点,它们可以通过唯一 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-dom
和redux
,要注意后端需要使用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中添加这段信息,就像我们之前手动添加CONTENT
和STATE
一样。
为了实现该目的,我们首先需要安装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模块
。
另外,有的教程使用StaticRouter
的context
来实现服务端中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 Module
和css-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 start
、npm build
或npm test
时,NODE_ENV
的值分别为development
、production
、test
。另外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
这样的库来拓展配置信息。