From Component Class to React Hooks

React 支持两种定义 Component 的方法 —— class 或者 function

所谓的一个 Component,说起来有点像一条变色龙。

  1. 会根据外部环境的不同表现不同的形式
  2. 自身内部的状态变化也会影响表现形式

外部 Props

class Greeting extends React.Component {
  constructor(props){
    super(props)
  }

  render(){
    return <p>Hello, {this.props.name}</button>
  }
}
function Greeting(props){
  return <p>Hello, {propts.name}</p>
}

只依赖外部数据进行显示的情况下,Function 看起来更简单一些。因为问题的实质就是

view = render(props)

内部 State

class Counter extends React.Component {
   constructor(props){
     super(props);
     this.state = {count:0}
   }

   render(){
     return <button click={this.increment}>Increment</click>
   }

   increment(){
     this.setState({count:this.state.count+1})
   }
}
let counterState = {count:0};

function Counter(props){
   let state = counterState;

   function increment(){
     state.count++;
   }
   return <button click={this.increment}>Increment</click>
}

当需要内部状态(State)的时候,class 是传统的封装的方式

  1. 把数据和方法封装在一起
  2. 可以有多个 class instance
  3. 每个 instance 共享方法,但有自己独立的数据
  4. 通过 this 指针找到当前 instance 自己的数据

使用 function 来实现同样的意图的话,

  1. 多个 instance 对应函数的多次调用
  2. 需要通过外部结构保存状态数据
  3. 需要隐式地传入一个类似于 this 指针的参数

根据上述要求,解决方案会类似于下面这样

function Counter(props){
   let state = useState(this)
}

useState() 的需求

从外部结构获取数据就是对外部数据的引用,借助于类似 useState() 这样的方法是很直接的推断。

一个需要能够寻址状态数据的 $this 是一定需要的。如何传递 $this 要麻烦一些。

  1. 参数传递。比如 Counter($this, props)
  2. 绑定this指针 Counter.call($this, ...)
  3. $this 存到一个 useState() 知道的地方。

方法 1 和 2 都会使得 Counter 函数的定义不纯粹:

  1. 绑定 this 不是通常意义上的 pure function。
  2. 参数传递 $this,跟之前的 Counter(props) 的形式不一致。

使用方法 3 类似与下面这样

useState.$this = $this;
Counter(props);
useState.$this = null;   // 不是一定必要

Rules of React Hooks

React Rules of Hooks 列出了两条规则

  1. Only Call Hooks at the Top Level
  2. Only Call Hooks from React Functions

React Hooks 的使用,在形式上类似于上面的方法 3,据此推测,这就是 "Only Call Hooks from React Functions" 这条规则的原因。React Function会在调用函数前后执行 useState() 相关的代码。

至于规则 1,按照上面的例子——把 state 整体一次性导入——实际上是没有什么必要的。

function Counter(props){
   let state = useState()
}

但 React useState 的实际用法类似于

function Counter(props){
   let state = {}
   let state.count = useState()
   let state.total = useState()
}

可以猜想,useState() 调用实际上需要一个隐式地参数来指定 state 的名字。而在没有实际传入这个参数的情况下。这个隐式参数实际上就是 useState() 的调用序数。

规则 2 就是为了保证调用序数的一致。

useState() 的实现

function useState(initValue){
   let seq = useState.$seq++;   // move to next state member
   let store = useState.$store;
   if(seq >= store.length) {
     store.push(initValue);
   }
   let value = store[seq];
   let setValue = function(newValue){
      store[seq] = newValue;
   }
   return [value, setValue]
}

function reactCall(component, props){}
   useState.$store = $store;
   useState.$seq  = 0;
   component(props);
   useState.done()
}

不靠谱的居间人 this

How Are Function Components Different from Classes? 这篇文章里举了一个例子。

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

例子中通过 setTimeout 延迟了一个函数的调用。该函数需要访问 props 中的数据。

假如在函数调用发生之前有另外的事件导致 props 的内容发生了改变,那么该函数调用时所访问到的数据是最新的数据,而不是所期待的之前推迟函数调用时的数据。

背后的原因,简单说,其实就是

  1. 被推迟调用的函数绑定的数据是 this, 这是不会变的。
  2. 但是 this 的成员 props 是可能发生改变的。

这有点类似于 C 语言里的

const char *text = "abc 123";
text[3]='-';

在明白原因的情况下,只要直接绑定当时的 props 数据,就可以解决问题。但文章的意思是因为函数形式的组件定义里没有 this,本身就是直接对 props 的引用,因而天然地避免了上面的问题。

文章的例子以 this.props 开始,但实际上所讨论的问题对于 this.state 也是一样的。

State Destructing

通过 useState() 实现隐式地状态数据绑定和传统的通过 this 来实现看似很不同。实际上在寻址状态数据这个角度来讲,没有质的区别。因为无论如何实现,最终都是实现对状态数据的访问。

而上面的例子里出现的“数据不一致”的问题,实际上是因为数据修改的时序(Timing)的问题。

function ProfilePage(props) {
  let [state, setState] = useState({user:"foo"})

  showMessage = () => {
    alert('Followed ' + state.user);
  };

  changeUser = () => {
    state.user = "bar";
    setState(state);
  }

  handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return <button onClick={this.handleClick}>Follow</button>;
}

如果在 showMessage() 被调用之前,changeUser() 被另外的事件先行触发,那么 showMessage() 执行时读到的也是新的值。这和之前的问题是一样的。

React 文档里的写法没有明确提及,但实际上引导大家写成

function ProfilePage(props) {
  let [user, setUser] = useState("foo")

  showMessage = () => {
    alert('Followed ' + user);
  };

  changeUser = () => {
    user = "bar";
    setUser(newUser);
  }

  handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return <button onClick={this.handleClick}>Follow</button>;
}

Immutable Data

上面讨论的问题实际上都源自状态数据在不同时间点上被不同的事件访问或修改。

如果数据一旦确定,就不能改变,需要改变的时候要求先对数据复制一份,然后再改变这份新复制出来的数据。

用传统 C 语言的术语来说,就是 Copy-On-Write + Deep-Copy.

出自 Facebook 的 Immutable.js 是实现数据 immutable 的一个 JavaScript 库。

与 Deep-Copy 不同的时,实际上我们只需要复制那些被改变的数据,其他部分可以仍然引用之前的数据。如此可以减少复制的开销,并节约存储空间,判断是否相等也有机会可以同过简单判断指针值是否相同来确认相等。