前言
之前写了深入React v16 新特性(一),如果之前没看过的可以先阅读,里面先介绍的 v16 比较简单基础的 API,代码仓库在这篇文章里有。本篇内容有:
- 生命周期函数的改变
- 深入 React v16 的底层 fiber 架构以解释其原因
- 新的
ContextAPI,以及使用其实现简易的react-redux
React v16 底层 fiber
,Facebook 花了近一年的时间,几乎重写了整个 React 的底层架构就是为了引入 fiber。那 fiber 是个啥?在 Google 翻译下就是“纤维”的意思。可以这样理解:fiber 是次于“进程”的一个概念,即更细粒度控制程序。
具体来看:如果你想渲染一个组件,由于 js 是单线程的,必须要全部渲染完毕,如图所示:
最后一直渲染完毕 js 才对其他动作作出响应;如果这个组件树非常大,渲染耗时非常长,那么在这段时间内浏览器就处于假死状态,无法对用户任何反应(点击事件等)作出反应,体验非常差,因为有时候并不需要渲染全部组件出来,于是 fiber 便应运而生。
fiber 会在react渲染时将任务分为几个碎片,每完成一个更新就会将控制权交给 react 协调控制部分,如果此时有优先级更高的任务就会优先完成那一部分:
详情请见程墨老师的这篇文章,对底层有更详细的描述,我们这主要讲对我们开发者的影响。
一个组件渲染(更新)时分为 render 前和 render 后,fiber 的协调控制时机就在 render 时,如果此时有更优先的任务,react 会将此组件已做的计算全部舍弃(对,就是完全不要),完成那个任务后才会从头渲染这个组件。上面三个生命周期会在渲染(更新)前调用,如果是纯函数还好,但如果是有副作用的函数,有可能会被调用两次,这是违反开发者意愿的,对带有副作用的函数处理必须慎之又慎。另外也是为了接下来的异步渲染,现在 React 的做法是完全摈弃这三个函数以免不必要的副作用,用有返回值的函数来代替。
前面讲到的 StrictMode 就是故意调用两次即将废弃的方法来检测副作用的。
生命周期变化(v16.3)
从 v16.3 开始,原来的三个生命周期 API componentWillMount、componentWillUpdate、componentWillReceiveProps 将被废弃,取而代之的是两个全新的生命周期:
- static getDerivedStateFromProps
- getSnapshotBeforeUpdate
static getDerivedStateFromProps用法
直译过来很好理解:“从 props 中获取 state”,通俗易懂。该方法接收两个参数:nextProps、prevState,返回值为表示更新的 state,可以返回 null 表示没有更新(React 新功能,setState(null) 表示没有更新)。此方法会在 props 改变时(包括第一次渲染和 props 改变)就被调用,返回 state 合并在当前 state 中。
与其他生命周期函数显著不同的一点是,这是一个静态方法,这意味着你不能通过 this 访问组件实例,也就是你不能直接访问到this.state、this.props等一系列方法。如果一定要在里面用this,你也只能取到 Component 而非实例。打开代码仓库,在src/GetDerivedStateFromProps下有相关例子,现有原来的实现方法和现在的方法,放一起做比对:
Old_Consumer.jsx:
export default class Consumer extends React.Component {
state = {
// 从 props 获取默认 state
result: this.getResult(this.props.value)
}
componentWillReceiveProps(nextProps) {
// 常用范式
if (nextProps.value !== this.props.value) {
this.setState({
// 更新 state
result: this.getResult(nextProps.value)
})
}
}
// props 到 state 的数据映射
getResult = value => value * value
handleChange = (e) => {
this.props.eraseResult()
this.setState({
result: e.target.value
})
}
render() {
return (
<input type='text'
onChange={ this.handleChange }
value={ this.state.result }></input>
)
}
}
New_Consumer.jsx:
export default class Consumer extends React.Component {
state = {
result: 0,
// 必须存储 props.value 到 state 的副本,以便 getDerivedStateFromProps 取到
value: 0
}
// 新的方法,接收 nextProps 和 prevState
static getDerivedStateFromProps(nextProps, prevState) {
// prevState.value 相当于当前组件的 this.props.value,是存在 state 的副本
if (prevState.value !== nextProps.value) {
// 返回新的state(只需返回更新的部分,与 `setState` 相同)
return {
// 相当于上面的 getResult,但只有一处
result: nextProps.value * nextProps.value,
// 又一次保存副本
value: nextProps.value
}
}
// 返回 null 表示不更新,此函数最后一定需要返回值
return null
}
// 以下都相同
handleChange = e => {
this.props.eraseResult()
this.setState({
result: e.target.value
})
}
render() {
return (
<input type="text"
onChange={this.handleChange}
value={this.state.result}></input>
)
}
}
getDerivedStateFromProps 会在组件第一次渲染的时候更新,因此只用在默认 state 中指定数据结构便于阅读就行了,也正因为如此,从 props 到 state 的数据映射过程只有一处,写在新生命周期方法中,而不像之前版本的中有个 getResult。
这次改变非常大胆,彻底贯彻了 v = f(s) (v = view,s = state) 的 React 设计理念。之前我们可以通过 componentWillReveiveProps 来监听 props 改变从而改变 state 来实现更新,算是伪 props 更新,更像是 v = f(s, 0.5p) (p = props)。当然 state 非常有必要根据 props 不同而不同,但是这样实现不够纯粹,更纯粹的方法就是新的方法,即 v = f(s(p)),即如果开发者足够关心 props 的某一个属性,必须将其存入 state 中。但如此设计是不是过于冒进?这样会使 state 多了冗余数据,反倒使 state 不够纯了;如果新 API 设计为实例方法,能取到 this.props 就跟原来没两样了,使设计理念贯彻不够彻底;我也不知道答案,既然作为新的 API,React 团队已经给了我们答案,先按他们的做吧。
新生命周期实现 forwardRef 效果
上一期在 forwardRef 小节末尾留了个坑,代码仓库中 forwardRef 下已有相关的 MockWithoutForwardRef.jsx 组件,实现了相同的功能(高阶函数等),不同的是本可以放在实例属性上而不是 state 中的属性改写后必须放在 state 上,不过语义来看无可厚非,确实该这么做。理解上面例子后,这个例子也是一样的,不做赘述。
getStapshotBeforeUpdate用法
这是一个实例方法,直译过来就是 “更新前获取快照”,可以认为是替代 componentWillUpdate 的作用。此函数会在 render 后和提交给 DOM 前调用,接收两个参数 prevProps, prevState,此时你可以获取到之前的所有状态和更新后的所有状态;函数的任何返回值都会作为第三个参数传递给 componentDidUpdate。
说实话,实际开发中需要用到 componentWillUpdate 和 componentDidUpdate 的时候真不多,因为在每次更新的时候,作为开发者,都会知道是哪里的 state 改变而更新的,常常将本应写在这两个函数的代码写在逻辑上的 setState 前后了,这样也无可厚非。由于例子极少,我便直接参照官网例子写个小 demo ,代码在原仓库下。现在贴出主要部分代码:
// list` 条目增加,渲染的 `li` 也增加。但是滚动条位置不变
export default class List extends React.Component {
listRef = null
// 新的生命周期
getSnapshotBeforeUpdate(prevProps, prevState) {
// 如果 `props.list` 增加,将原来的 scrollHeight 存入 listRef
if (prevProps.list.length < this.props.list.length) {
return this.listRef.scrollHeight
}
return null
}
// snapshot 就是 `getSnapshotBeforeUpdate` 的返回值
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
// scrollTop 增加新的 scrollHeight 和原来 scrollHeight 的差值,以保持滚动条位置不变
this.listRef.scrollTop += this.listRef.scrollHeight - snapshot
}
}
setListRef = ref => (this.listRef = ref)
render() {
return (
<ul ref={this.setListRef} style={{ height: 200, overflowY: 'scroll' }}>
{this.props.list.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
)
}
}
加上注释很明白了,其实用法也很简单;源码仓库下统一目录还有两种写法,分别是用 componentWillUpdate 的方法(A)和不使用 componentWillUpdate 和 getSnapshotBeforeUpdate 的方法(B)。不得不说,方法(B)的实现真的丑陋,更难读,还是用其他两种方法比较优雅。代码就不贴了,感兴趣的可以看看。
新的 Context API
用法
直接上代码:
// React.createContext 是新的 API,接收一个任意值作为默认值,并返回一个 Context 对象
// Context 对象有两个属性:Provider 和 Consumer,均为 React 的 Component
const ColorContext = React.createContext('red')
const { Provider, Consumer } = ColorContext
// 将 Context.Provider 包裹在你想应用 Context 的组件上,可指定 `value` 值,否则将使用默认值
const Wrapper = () => (
<Provider value='red'>
<Text></Text>
</Provider>
)
// 当要用到 Context 里的值时,用 Context.Consumer 包裹
// 里面的 chlidren 只能是一个render 函数,并且函数的第一个参数就是 Context 的值
const Text = () => (
<Consumer>
{color => (
<p style={{ color }}>This is Red!</p>
)}
</Consumer>
)
其实用法也挺简单的,次序如下:
- 新建 Context 对象,并指定默认值
- 在要设置 Context 值的地方用
Context.Provider包裹,并通过props.value设置值 - 在用到的地方使用
Context.Consumer包裹,并且里面通过函数渲染,函数的第一个参数就是设置的 Context 值
需要注意的是在 Context.Consumer 中,children 必须为函数,这样才能将 Context 值显式传递下来,用法比较简单,还支持组合(嵌套)。自从有了这个 API,React 官方也不再不推荐使用 context 了。
实现 react-redux
要在 React 中使用 redux,想必大家都用的是react-redux 库。最重要,也是最常用的的 API 就是 Provider 和 connect 了,现在我们一起用新 API 实现一个拥有核心功能的 react-redux 库。所有示例代码都在原仓库,如果你还不会 redux,可以跳过。
实现目标
由于我们最常用的就是 Provider 和 connect 了,不用太复杂,实现这两个核心功能,一个简单的 react-redux 应用就跑起来了。
API 回顾
Provider 用于最顶层组件,只需传入一个 redux 的 store,用于给所有子组件传递 context。store 中有几个我们比较关心的方法:
getState:getState()返回整个 state 内容;dispatch:dispatch 一个 action 可以修改 store 中的 state;subscribe:订阅 state 的变化,返回一个可以取消订阅的函数。
connect 接收多个参数,我们这里只关心主要的两个:
mapStateToProps:一个函数,传入整个 state,返回其中关心的 state;mapDispatchToProps:也是函数,用于将 action 与 dispatch 连接起来,并转为组件的 props。
目标达成例子
由于 redux 样板代码太多,这里不一一贴出,只贴目标代码:
const App = () => (
<Provider store={store}>
<Text></Text>
</Provider>
)
// 装饰器语法,开发中连接的组件往往是 class
class Text extends React.Component {
render() {
// incCount 是 action,count 是 state 上的值
return (
<button onClick={this.props.incCount}>{this.props.count}</button>
)
}
}
开始
我们发现一个共同点:Context 和 react-redux 都有 Provider,那至少有这一部分:
// mock-react-redux.jsx
import React from 'react'
// 本例不需要默认值
const {Provider: ContextProvider, Consumer: ContextCosumer} = React.createContext()
class Provider extends React.Component {
render() {
// 将 store 放入 context 中
return (
<ContextProvider value={this.props.store}>
{React.Children.only(this.props.children)}
</ContextProvider>
)
}
}
// 连接时就是在 Consumer 中,并取出 store
const connect = (mapStateToProps = state => state, mapDispatchToProps = () => {}) => Component => props => (
<ContextConsumer>
{store => (
<Component
{...(mapStateToProps(store.getState()))}
{...mapDispatchToProps(store.dispatch)}
{...props}>
</Component>
)}
</ContextConsumer>
)
如果对高阶组件足够熟悉,这是第一感觉。完成后我们确实发现能正常渲染,但是当我们点击时,发现视图上并不能及时更新。如果在 redux 中打断点发现 store 确实会改变,然而 React 并没有更新。这是因为我们并没有 setState 通知 React 更新,因此这里有两种方法:
- 保存一个 state,更新时
setState通知 React; - 直接通过
this.forceUpdate()强制更新。
forceUpdate 总有一种怪怪的味道,在能使用 state 的情况下就最好不要用这个 API,因此我们使用 setState,将 store 里的 state 作为 Provider 的 state;监听 redux 的 store,一旦发生改变就通知 Provider。
// 修改 Provider
class Provider extends React.Component {
state = this.props.store.getState()
listener = null
componentDidMount() {
const { subscribe, getState } = this.props.store
this.listener = subscribe(() => {
// setState 通知 React 更新视图
this.setState(getState())
})
}
componentWillUnmount() {
// 别忘了解除监听,否则可能引起内存泄露
this.listener()
}
render() {
// 这里要做相应修改,Consumer 内只关心 state 和 dispatch
return (
<ContextProvider value={{ state: this.state, dispatch: this.props.store.dispatch }}>
{React.Children.only(this.props.children)}
</ContextProvider>
)
}
}
// connect 相应修改
const connect = (mapStateToProps = state => state, mapDispatchToProps = () => {}) => Component => props => (
<ContextConsumer>
{({ state, dispatch }) => (
<Component
{...(mapStateToProps(state))}
{...mapDispatchToProps(dispatch)}
{...props}>
</Component>
)}
</ContextConsumer>
)
这样就完成了一个简单的 react-redux。可以直接放在已有应用并替换 react-redux 包,可以正常工作。当然有很多不足,比如 Provider 没有优化性能,应该写个 shouldComponentUpdate,connect 高阶组件没有命名等,但是并不影响核心功能。