什么是 React?

React

时至今日,React 如日中天,如果有人问“什么是 React?”,多半会被嘲讽无知。

但是如果能坐下来冷静的思考一下,什么是 React?为什么是 React?

索引

React 基本素养

Models and Views

人机交互界面(UI)本质上是把数据以用户能够理解的方式展现出来,同时对交互作出响应。这里的交互可以来自于用户点击、麦克风、加速计甚至是眼神等,也可以是时间、空间、环境的变化。

React 只要做好两件事:

  1. 使用组件描述数据是如何变成界面的。
  2. 向开发者暴露修改数据的接口。

Component = (Data) => <View />

组件通过组合,以及数据的传递,构成了 App 的基本要素。

那么组件要如何“正确”的描述数据与界面之间的关系呢?

让我们来回顾一下 React 创建组件的历史。

初代 React 对象组件

最初的 React 组件是通过向 React.createClass 方法传递一个组件“配置”清单来创建组件的。

初代 React 对象组件
var MyMixin = {
extraMethod: function() {},
}
var Demo = React.createClass({
// 初始 state
getInitialState: function() {
return {
counter: this.props.initialCounter || 0,
}
},
componentDidMount: function() {
console.log('mounted')
},
handleClick: function() {
this.setState(this.state.counter + 1)
},
// 渲染函数
render: function() {
return <div onClick={this.handleClick}>点击计数:{this.state.counter}</div>
},
// 混合复用逻辑
mixins: [MyMixin],
})

看起来有点像 Vue 或小程序,没错这就是 React 走过的弯路,却对其他框架的发展产生深远影响。

在初代 React 组件里,已经能看到一个组件的基本要素:属性、状态、渲染、回调以及生命周期。一个组件会把属性和状态当成数据源,通过渲染函数生成界面,之后监听事件和生命周期进入下一个渲染周期,周而复始的把生成的界面输出给 React,由 React 组合起来最终交由 ReactDOM 输出给浏览器。

注意这个特别的 mixins 字段,它的作用是把另一个对象和当前的配置合并,之所以会出现这样的设计,是因为当年还没有对象展开语法 { ...mixins }Object.assign 又太丑了。值得一提的是这种写法每个方法里的 this 都能正确指向当前组件,包括 mixins 里的方法,这又何尝不是一种优势啊。

这样的写法从 React 15.5.0 开始反对使用,反对的理由并不是这样写有多大的问题,只是因为 React 诞生之初 JavaScript 还没有正式的 class 标准。JavaScript 社区对继承有不同的理解,普遍采用基于原型链的继承方式,这是很多模块作者必须掌握的一项技能,写法多样,难以掌握,在大部分面向对象语言里一支独秀,于是 React 说:“陈独秀你坐下!”。

当年人们眼中的 JavaScript 类
function Foo() {
this.a = 1
}
// 假装我是个类
let obj = new Foo()

二代 React 类组件

随着 ES2015 草案敲定,JavaScript 终于拥有了官方认可的 class,虽然此时还是需要通过 Babel 把 class 编译成原型链继承代码,但对用户来说这一过程是透明的。

二代 React 类组件
class Demo extends React.Component {
constructor(props) {
this.state = {
counter: props.initialCounter || 0,
}
}
componentDidMount() {
console.log('mounted')
}
handleClick = () => {
this.setState(this.state.counter + 1)
}
render() {
return <div onClick={this.handleClick}>点击计数:{this.state.counter}</div>
}
}

一个组件,继承自父类 React.Component,状态变成类的字段,事件和生命周期变成类的方法,面向对象语言几十年的最佳实践都搬过来,几十种设计模式也套过来,一切看起来都是那么美好。

老板让我这个后端 Java 写 React,因为他觉得这两个东西看起来很像。

果真如此吗?

还记得初代组件里的 mixins 吗?如果要复用逻辑,我该如何 mixin 到类组件里呢?或许你首先想到的是继承,没错,确实有人这样做,例如 Taro UI 的 AtComponent。可是 mixins 的值是一个数组,每个组件都可以有选择的复用几套逻辑,JavaScript 可不许继承多个父类,Java 也不能,C++ 可以(搞不懂有多个爸爸是什么感觉)。

caution

这里提及 mixins 并不是在列举它的好处,实际上官方早已列举了 mixins 的种种罪状了。

可我该怎么复用逻辑呢?

官方给出的答案是 HOC(高阶组件),高阶组件是参数为组件,返回值为新组件的函数。

高阶组件 打印生命周期
function withLogger(WrappedComponent) {
return class extends React.Component {
componentDidMount() {
console.log('mounted')
}
componentDidUpdate() {
console.log('updated')
}
componentWillUnmount() {
console.log('unmounted')
}
render() {
return <WrappedComponent {...this.props} />
}
}
}

唔~ 我想复用逻辑,结果现在我有两个组件了。每多一次复用,就要再包一层,我自己的组件就这样被包成了粽子。端午节快乐! 甚至还有一种帮你包粽子的工具函数 compose,例如 lodash.flowRight,把原本难看的 C(B(A(Comp))) 改写成 compose(A, B, C)(Comp)。把“高阶”玩出花样的要数 Redux,它的 connect 函数是一个返回高阶组件的高阶函数!

这还不算完,因为高阶组件也是组件,最好指定名称 (displayName),否则在 React Developer Tools 里无法区分。就好像光看外皮你分不清粽子是什么馅的。如果原组件有静态方法,高阶组件要一并复制过来,否则你的行为和内部组件不符,就露馅了。

还能不能 Happy coding 啦?

社区稍候给出另一个答案 Render Props,术语 “render prop” 是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术。

既然伪装一个组件这么麻烦,索性来明的吧,我把数据传给你,怎么用是你的事情。

Render Props 切换值
class Toggle extends React.Component {
state = { value: false }
toggle = () => {
this.setState(!this.state.value)
}
render() {
return this.props.children({ ...this.state, toggle: this.toggle })
}
}
class Demo extends React.Component {
render() {
return (
<Toggle>
{({ value, toggle }) => {
return <div onClick={toggle}>当前状态:{value ? '开' : '关'}</div>
}}
</Toggle>
)
}
}

这种做法比 HOC 好那么一点,但对使用者不再透明,你必须显示的“接收”这些传入的值,再继续向下传递。那一天,人类终于回想起了曾一度被 回调地狱 所支配的恐怖。进而催生出 react-adopt 这个项目,像 compose 组合 HOC 一样去组合 Render Props。。。

Render Props

逻辑复用是 React class 组件永远的痛。

现代 React 函数式组件

important

本节代码不保证正常运行,主要是为了说明原理。

React 曾经在很长一段时间里把函数写成的组件叫做“无状态组件 (StatelessComponent 或 SFC)”,原因不言自明,因为没有状态,不是不想,是不能。脑补“臣妾做不到哇”时的表情。

让我们看一下最初是如何用函数写组件的。

无状态组件
const FuncComp = ({ name }) => <div>{name}</div>

这么简单的一行代码的组件,我们实在不能对它奢求太多,毕竟配合 React.memo(FuncComp),会瞬间变成比 React.PureComponent 还纯的组件,属性不变时直接跳过渲染。

有什么办法可以让函数插上翅膀吗?

接下来让我们依次赋予它一些超能力,看看一个简单的函数是如何一步一步变成火箭的。

Rocket

变量保持

组件的价值在于可重用,React 在处理 class 组件时,会在组件树中对应位置初始化 class 实例,需要时调用组件的 render 方法获取新的界面。

类组件用属性字段接收 ref
class ClassComponent extends React.Component {
ref = React.createRef()
render() {
return <div ref={this.ref} />
}
}

如果换成 function 组件,函数本身扮演了 render 方法,函数内部的局部变量在函数运行之前还没有初始化,运行之后会被回收,也就是说 function 只能通过参数调整输出结果。

如果想让函数组件满足最基本的需求“变量保持”,需要在函数外部存放变量,这个值发生变化不会触发组件重新渲染,因为是引用,读取到的值永远是最新的。

函数组件外部变量
const ref = { current: undefined }
const Fun = () => <div ref={ref} />

似乎可行,前提是 Fun 组件只用一次。造火箭是一个持续迭代的过程,我们先保留这个简单的例子。

Live Editor
Result
Sat Jul 18 2020 03:35:10 GMT+0800 (中国标准时间)

状态保持

class 组件的另一个能力是保存组件的当前状态,并且在状态发生变化后更新组件,当然我们要遵守 React 的约定,状态字段名必须是 state,修改状态必须通过 this.setState,React 也会信守承诺,做好组件更新工作。

类组件处理 state 的方式
class ClassComponent extends React.Component {
state = { value: 1 }
render() {
const { value } = this.state
return <div onClick={() => this.setState(value + 1)}>{value}</div>
}
}

状态保持可以看作是变量保持的增强版,同样需要在函数外部存放 state 值。

函数组件外部变量
let value = 1
const setValue = nextValue => {
value = nextValue
}
const Fun = () => <div onClick={() => setValue(value + 1)}>{value}</div>

状态可以修改了,但是还缺少一种更新组件的手段,组件一般是被动更新的,所以我们先借用一下 React 的更新能力。

caution

下面示例中的 caller 是函数的废弃属性,不要用在真实项目上。

Live Editor
Result

生命周期

所谓生命周期,是 React 在渲染过程中与组件之间的协调过程,组件的渲染是用户代码和 React 之间彼此配合的结果,这中间会发生若干次控制权的交接。

类组件生命周期
class ClassComponent extends React.Component {
componentDidMount() {
console.log('mounted')
}
componentDidUpdate() {
console.log('updated')
}
render() {
return null
}
}

函数组件要想实现这样的效果,需要一个伴随组件渲染的函数。

函数组件伴生函数
const onDidUpdate = callback => {
callback()
}
const Fun = () => {
onDidUpdate(() => console.log('updated'))
return <div />
}

这似乎没什么用,callback 只是换了一个地方执行,和直接写在组件里是一样的效果,让我们改造一下对运行条件施加一些限制。

函数组件伴生函数受依赖值控制
let oldDeps = []
const useEffect = (callback, deps) => {
// 没有依赖或依赖发生变化时调用回调函数
if (!deps || oldDeps ? !deps.every((d, i) => d === oldDeps[i]) : true) {
callback()
oldDeps = deps
}
}
const Fun = () => {
useEffect(() => console.log('mounted'), [])
return <div />
}

为了模拟生命周期,我们在函数组件里放置一个 useEffect 函数,并且指定只有依赖值发生变化时才执行回调,这样一个有条件执行的 useEffect 兼具 componentDidMount 和 componentDidUpdate 特色。

让我们把 useEffect 和上一节提到的 useState 放在一起看下会产生什么样的效果。

Live Editor
Result

一个比较粗糙的火箭造好了,我点火试了几次都能发射,希望你点火的时候别出什么意外。

或许你觉得这跟真正的 React 差远了,这些组件和 useXXX 函数都是单例的。没错,我在造火箭,一次性的火箭,我知道马斯克的火箭是可回收的,只要有时间继续深入,我们的火箭也能实现重复利用,而且是三级火箭在回收的过程中实现空中对接整体返回。

tip

以上介绍的是 React 最基础的 3 个 Hooks API,真实的 API 并不完全是这样运作的,这些例子只是向你展示 JavaScript 的另一面,函数式编程的魅力,即:函数是一等公民。

React Hooks 的意义

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

回顾 class 组件,逻辑复用是 class 组件永远的痛。不仅如此,单单是 JavaScript 中的 class,也一直饱受非议,原因很简单,class 诞生的时间太晚,还只是个襁褓中的婴儿,同时它也是标准委员会硬塞给 JavaScript 的半成品。JavaScript 曾经自诩是函数式语言(虽然真正的函数式语言认为它并不纯粹),现在有了 class,就再也解释不清了。

React 批评 JavaScript class 有以下几个问题:

  1. 难以理解的 this,与其他语言存在巨大差异。
  2. 没有稳定的语法提案你今天写的代码,未来可能会有别的含义。
  3. class 组件会无意中鼓励开发者使用一些让优化措施无效的方案。

React 组件一直更像是函数,而 Hook 则拥抱了函数。

每一个 Hook 代表一小块“逻辑”,Hook 可以包装,相关的 Hooks 可以组合,只要在外面再包裹一层函数即可,逻辑复用变得和组件复用一样自然。甚至在编辑器里选中几行 Hooks 点击重构就能生成一个新的自定义 Hook。

模仿 componentDidMount componentDidUpdate
function useDidMount(fn) {
useEffect(() => {
fn()
}, [])
}
function useDidUpdate(fn) {
const first = useRef(true)
useEffect(() => {
if (first.current) {
first.current = false
return
}
fn()
}, [])
}
实现数据加载
function useFetch(url) {
const [loading, setLoading] = useState(false)
const result = useRef()
const error = useRef()
useEffect(() => {
setLoading(true)
fetch(url)
.then(res => {
result.current = res
})
.catch(err => {
error.current = err
})
.finally(() => setLoading(false))
}, [url])
return { loading, result: result.current, error: error.current }
}

类似的例子不胜枚举,请参考 react-use,它的源码是你提高对 Hooks 认知的捷径。

什么是 React?

React

了解了 React 的发展过程,分析了 React 极力推荐的 Hooks API,让我们回到最初的问题:“什么是 React?”

主流的前端框架把屏幕当成电子纸,可以反复重绘。之前的前端框架则误以为屏幕是一块画布,一手拿着画笔,一手拿着涂改液。

回到这个经典的公式:

Component = (Data) => <View />

  • Component 重复使用。
  • Data 因时而变。
  • View 因数而变。

现在你应该明白,为什么 Facebook 说:“React 组件一直更像是函数。”

组件只不过是把传入的参数当作数据,再返回一个恰当的界面罢了。

React 只不过是在正确的时间做了正确的事情。

刚一推出就坚持不用 HTML 模板,而是采用 React.createElement 创建节点,JSX 可以看成是妥协的产物,本质上还是 JavaScript。

中间经历了 class 组件一波三折,终于回归本源。

一个函数,几个钩子。

一桌、一椅、一扇、一抚尺而已。