【React】1260- 聊聊我眼中的 React Hooks

前端自习课

共 10000字,需浏览 20分钟

 ·

2022-03-16 02:22

时至 2022 年年初,React Hooks 已在 React 生态中大放异彩,席卷了几乎所有的 React 应用。而其又与 Function Component 以及 Fiber 架构几近天作之合,在当下,我们好像毫无拒绝它的道理。

诚然,Hooks 解决了 React Mixins 这个老大难的问题,但从它各种奇怪的使用体验上来说,我认为现阶段的 Hooks 并不是一个好的抽象。

红脸太常见,也来唱个黑脸,本文将站在一个「挑刺儿」的视角,聊聊我眼中的 React Hooks ~

「奇怪的」规矩

React 官方制定了一些 Hooks 书写规范用来规避 Bug,但这也恰恰暴露了它存在的问题。

命名

Hooks 并非普通函数,我们一般用use开头命名,以便与其他函数区分。

但相应地,这也破坏了函数命名的语义。固定的use前缀使 Hooks 很难命名,你既为useGetState这样的命名感到困惑,也无法理解useTitle到底是怎么个use法儿。

相比较而言,以_开头的私有成员变量和$尾缀的流,则没有类似的困扰。当然,这只是使用习惯上的差异,并不是什么大问题。

调用时序

在使用useState的时候,你有没有过这样的疑惑:useState虽然每次render()都会调用,但却可以为我保持住 State,如果我写了很多个,那它怎么知道我想要的是什么 State 呢?

const [name, setName] = useState('xiaoming')
console.log('some sentences')
const [age, setAge] = useState(18)

两次useState只有参数上的区别,而且也没有语义上的区分(我们仅仅是给返回值赋予了语义),站在 useState的视角,React 怎么知道我什么时候想要name而什么时候又想要age的呢?

以上面的示例代码来看,为什么第 1 行的useState会返回字符串name,而第 3 行会返回数字age呢? 毕竟看起来,我们只是「平平无奇」地调用了两次useState而已。答案是「时序」。useState的调用时序决定了结果,也就是,第一次的useState「保存」了 name的状态,而第二次「保存」了age的状态。

// Class Component 中通过字面量声明与更新 State,无一致性问题
this.setState({
  name: 'xiaoming',  // State 字面量 `name`,`age`
  age: 18,
})

React 简单粗暴地用「时序」决定了这一切(背后的数据结构是链表),这也导致 Hooks 对调用时序的严格要求。也就是要避免所有的分支结构,不能让 Hooks 「时有时无」。

// ❌ 典型错误
if (some) {
  const [name, setName] = useState('xiaoming')
}

这种要求完全依赖开发者的经验抑或是 Lint,而站在一般第三方 Lib 的角度看,这种要求调用时序的 API 设计是极为罕见的,非常反直觉。

最理想的 API 封装应当是给开发者认知负担最小的。好比封装一个纯函数add(),不论开发者是在什么环境调用、在多么深的层级调用、用什么样的调用时序,只要传入的参数符合要求,它就可以正常运作,简单而纯粹。

function add(a: number, b: number) {
  return a + b
}

function outer() {
  const m = 123;
  setTimeout(() => {
    request('xx').then((n) => {
      const result = add(m, n)         // 符合直觉的调用:无环境要求
    })
  }, 1e3)
}

可以说「React 确实没办法让 Hooks 不要求环境」,但也不能否认这种方式的怪异。

类似的情况在redux-saga里也有,开发者很容易写出下面这种「符合直觉」的代码,而且怎么也「看」不出有问题。

import { call } from 'redux-saga/effects'

functionfetch() {
  setTimeout(function* () {
    const user = yield call(fetchUser) 
    console.log('hi', user)                  // 不会执行到这儿
  }, 1e3)                     
}

yield call()在 Generator 里调用,看起来真的很「合理」。但实际上,function*需要 Generator 执行环境,而call也需要redux-saga的执行环境。双重要求之下,实例代码自然无法正常运行。

useRef 的「排除万难」

从本义上来说,useRef其实是 Class Component 时代React.createRef()的等价替代。

官方文档中最开始的示例代码可以佐证这一点(如下所示,有删减):

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  return (
    type="text" />
  );
}

但因为其实现特殊,也常作他用。

React Hooks 源码中,useRef仅在 Mount 时期初始化对象,而 Update 时期返回 Mount 时期的结果(memoizedState)。这意味着一次完整的生命周期中,useRef保留的引用始终不会改变。

而这一特点却让它成为了 Hooks 闭包救星。

「遇事不决,useRef !」(useRef存在许多滥用的情况,本文不多赘述)

每一个 Function 的执行都有与之相应的 Scope,对于面向对象来说,this引用即是连接了所有 Scope 的 Context(当然前提是在同一个 Class 下)。

class Runner {
  runCount = 0

  run() {
    console.log('run')
    this.runCount += 1
  }

  xrun() {
    this.run()
    this.run()
    this.run()
  }

  output() {
    this.xrun()
    // 即便是「间接调用」`run`,这里「仍然」能获取 `run` 的执行信息
    console.log(this.runCount) // 3
  } 
}

在 React Hooks 中,每一次的 Render 由彼时的 State 决定,Render 完成 Context 即刷新。优雅的 UI 渲染,干净而利落。

但useRef多少违背了设计者的初衷, useRef可以横跨多次 Render 生成的 Scope,它能保留下已执行的渲染逻辑,却也能使已渲染的 Context 得不到释放,威力无穷却也作恶多端。

而如果说 this引用是面向对象中最主要的副作用,那么 useRef亦同。从这一点来说,拥有 useRef写法的 Function Component 注定难以达成「函数式」。

有缺陷的生命周期

构造时

Class Component 和 Function Component 之间还有一个很大的「Bug」,Class Component 仅实例化一次后续仅执行render() ,而 Function Component 却是在不断执行自身。

这导致 Function Component 相较 Class Component 实际缺失了对应的constructor构造时。当然如果你有办法只让 Function 里的某段逻辑只执行一遍,倒是也可以模拟出constructor。

// 比如使用 useRef 来构造
function useConstructor(callback) {
  const init = useRef(true)
  if (init.current) {
    callback()
    init.current = false
  }
}

生命周期而言, constructor 不能类同 useEffect ,如果实际节点渲染时长较长,二者会有很大时差。

也就是说,Class Component 和 Function Component 的生命周期 API 并不能完全一一对应,这是一个很引发错误的地方。

设计混乱的 useEffect

在了解useEffect的基本用法后,加上对其字面意思的理解(监听副作用),你会误以为它等同于 Watcher。

useEffect(() => {
  // watch 到 `a` 的变化
  doSomething4A()
}, [a])

但很快你就会发现不对劲,如果变量a未能触发 re-render,监听并不会生效。也就是说,实际还是应该用于监听 State 的变化,即useStateEffect。但参数deps却并未限制仅输入 State。如果不是为了某些特殊动作,很难不让人认为是设计缺陷。

const [a] = useState(0)
const [b] = useState(0)

useEffect(() => {
    // 假定此处为 `a` 的监听
}, [a])

useEffect(() => {
    // 假定此处为 `b` 的监听
  // 实际即便 `b` 未变化也并未监听 `a`,但此处仍然因为会因为 `a` 变化而执行
}, [b, Date.now()])        // 因为 Date.now() 每次都是新的值

useStateEffect的理解也并不到位,因为useEffect实际还负责了 Mount 的监听,你需要用「空依赖」来区分 Mount 和 Update。

useEffect(onMount, [])

单一 API 支持的能力越多,也意味着其设计越混乱。复杂的功能不仅考验开发者的记忆,也难于理解,更容易因错误理解而引发故障。

useCallback

性能问题?

在 Class Component 中我们常常把函数绑在this上,保持其的唯一引用,以减少子组件不必要的重渲染。

class App {
  constructor() {
    // 方法一
    this.onClick = this.onClick.bind(this)
  }
  onClick() {
    console.log('I am `onClick`')
  }

  // 方法二
  onChange = () => {}

  render() {
    return (
      
    )
  }
}

在 Function Component 中对应的方案即useCallback :

// ✅ 有效优化
function App() {
  const onClick = useCallback(() => {
    console.log('I am `onClick`')
  }, [])

  return ()
}

// ❌ 错误示范,`onClick` 在每次 Render 中都是全新的, 会因此重渲染
function App() {
  // ... some states
  const onClick = () => {
    console.log('I am `onClick`')
  }

  return ()
}

useCallback可以在多次重渲染中仍然保持函数的引用, 第2行的onClick也始终是同一个,从而避免了子组件的重渲染。

useCallback源码其实也很简单:

Mount 时期仅保存 callback 及其依赖数组

Update 时期判断如果依赖数组一致,则返回上次的 callback

顺便再看看useMemo的实现,其实它与 useCallback 的区别仅仅是多一步 Invoke:

无限套娃✓

相比较未使用useCallback带来的性能问题,真正麻烦的是useCallback带来的引用依赖问题。

  // 当你决定引入 `useCallback` 来解决重复渲染问题
function App() {
  // 请求 A 所需要的参数
  const [a1, setA1] = useState('')
  const [a2, setA2] = useState('')
  // 请求 B 所需要的参数
  const [b1, setB1] = useState('')
  const [b2, setB2] = useState('')

  // 请求 A,并处理返回结果
  const reqA = useCallback(() => {
    requestA(a1, a2)
  }, [a1, a2])

  // 请求 A、B,并处理返回结果
  const reqB = useCallback(() => {
    reqA()                                           // `reqA`的引用始终是最开始的那个,
    requestB(b1, b2)                       // 当`a1`,`a2`变化后`reqB`中的`reqA`其实是过时的。
  }, [b1, b2])                   // 当然,把`reqA`加到`reqB`的依赖数组里不就好了?
                                                             // 但你在调用`reqA`这个函数的时候,
                                                                 // 你怎么知道「应该」要加到依赖数组里呢?
  return (
    <>
      
      
    
  )
}

从上面示例可以看到,当useCallback之前存在依赖关系时,它们的引用维护也变得复杂。调用某个函数时要小心翼翼,你需要考虑它有没有引用过时的问题,如有遗漏又没有将其加入依赖数组,就会产生 Bug。

Use-Universal

Hooks 百花齐放的时期诞生了许多工具库,仅ahooks就有 62 个自定义 Hooks,真可谓「万物皆可 use」~ 真的有必要封装这么多 Hooks 吗?又或者说我们真的需要这么多 Hooks 吗?

合理封装?

尽管在 React 文档中,官方也建议封装自定义 Hooks 提高逻辑的复用性。但我觉得这也要看情况,并不是所有的生命周期都有必要封装成 Hooks。

// 1. 封装前
function App() {
  useEffect(() => {           // `useEffect` 参数不能是 async function
    (async () => {
      await Promise.all([fetchA(), fetchB()])
      await postC()
    })()
  }, [])
  return (
123
)
}
// --------------------------------------------------


// 2. 自定义 Hooks
function App() {
  useABC()
  return (
123
)
}

function useABC() {
  useEffect(() => {
    (async () => {
      await Promise.all([fetchA(), fetchB()])
      await postC()
    })()
  }, [])
}
// --------------------------------------------------

// 3. 传统封装
function App() {
  useEffect(() => {
    requestABC()
  }, [])
  return (
123
)
}

async function requestABC() {
  await Promise.all([fetchA(), fetchB()])
  await postC()
}

在上面的代码中,对生命周期中的逻辑封装为 HookuseABC反而使其耦合了生命周期回调,降低了复用性。即便我们的封装中不包含任何 Hooks,在调用时也仅仅是包一层useEffect而已,不算费事,而且让这段逻辑也可以在 Hooks 以外的地方使用。

如果自定义 Hooks 中使用到的useEffect和useState总次数不超过 2 次,真的应该想一想这个 Hook 的必要性了,是否可以不封装。

简单来说,Hook 要么「挂靠生命周期」要么「处理 State」,否则就没必要。

重复调用

Hook 调用很「反直觉」的就是它会随重渲染而不停调用,这要求 Hook 开发者要对这种反复调用有一定预期。

正如上文示例,对请求的封装,很容易依赖useEffect,毕竟挂靠了生命周期就能确定请求不会反复调。

function useFetchUser(userInfo) {
  const [user, setUser] = useState(null)
  useEffect(() => {
    fetch(userInfo).then(setUser)
  }, [])

  return user
}

但,useEffect真的合适吗?这个时机如果是DidMount,那执行的时机还是比较晚的,毕竟如果渲染结构复杂、层级过深,DidMount就会很迟。

比如,ul中渲染了 2000 个 li:

function App() {
  const start = Date.now()

  useEffect(() => {
    console.log('elapsed:', Date.now() - start, 'ms')
  }, [])

  return (
    

          {Array.from({ length: 2e3 }).map((_, i) => ({i}))}
        

  )
}

// output
// elapsed: 242 ms

那不挂靠生命周期,而使用状态驱动呢?似乎是个好主意,如果状态有变更,就重新获取数据,好像很合理。

useEffect(() => {
  fetch(userInfo).then(setUser)
}, [userInfo])                   // 请求参数变化时,重新获取数据

但初次执行时机仍然不理想,还是在DidMount。

let start = 0
let f = false

function App() {
  const [id, setId] = useState('123')
  const renderStart = Date.now()

  useEffect(() => {
    const now = Date.now()
    console.log('elapsed from start:', now - start, 'ms')
    console.log('elapsed from render:', now - renderStart, 'ms')
  }, [id])                       // 此处监听 `id` 的变化

  if (!f) {
    f = true
    start = Date.now()
    setTimeout(() => {
      setId('456')
    }, 10)
  }

  return null
}

// output
// elapsed from start: 57 ms
// elapsed from render: 57 ms
// elapsed from start: 67 ms
// elapsed from render: 1 ms

这也是上文为什么说useEffect设计混乱,你把它当做 State Watcher 的时候,其实它还暗含了「初次执行在DidMount」的逻辑。从字面意思Effect来看,这个逻辑才是副作用吧。。。

状态驱动的封装除了调用时机以外,其实还有别的问题:

function App() {
  const user = useFetchUser({          // 乍一看似乎没什么问题
    name: 'zhang',               
    age: 20,                       
  })

  return (
{user?.name}
)
}

实际上,组件重渲染会导致请求入参重新计算 -> 字面量声明的对象每次都是全新的 -> useFetchUser因此不停请求 -> 请求变更了 Hook 内的 State user -> 外层组件重渲染。

这是一个死循环!

当然,你可以用Immutable来解决同一参数重复请求的问题。

useEffect(() => {
  // xxxx
}, [ Immutable.Map(userInfo) ])

但总的看来,封装 Hooks 远远不止是变更了你代码的组织形式而已。比如做数据请求,你可能因此而走上状态驱动的道路,同时,你也要解决状态驱动随之带来的新麻烦。

为了 Mixin ?

其实,Mixin 的能力也并非 Hooks 一家独占,我们完全可以使用 Decorator 封装一套 Mixin 机制。也就是说, Hooks 不能依仗 Mixin 能力去力排众议。

const HelloMixin = {
  componentDidMount() {
    console.log('Hello,')
  }
}

function mixin(Mixin) {
  return function (constructor) {
    return class extends constructor {
      componentDidMount() {
        Mixin.componentDidMount()
        super.componentDidMount()
      }
    }
  }
}

@mixin(HelloMixin)
class Test extends React.PureComponent {
  componentDidMount() {
    console.log('I am Test')
  }

  render() {
    return null
  }
}

render() // output: Hello, \n I am Test

不过 Hooks 的组装能力更强一些,也容易嵌套使用。但需要警惕层数较深的 Hooks,很可能在某个你不知道的角落就潜伏着一个有隐患的useEffect。

小结

  • 本文没有鼓吹 Class Component 拒绝使用 React Hooks 的意思,反而是希望通过细致地比对二者,从而更深入理解 Hooks。
  • React Hooks 的各种奇怪之处,也正是潜在症结之所在。
  • 在 Hooks 之前,Function Component 都是 Stateless 的,小巧、可靠但功能有限。Hooks 为 Function Component 赋予了 State 能力并提供了生命周期,使 Function Component 的大规模使用成为了可能。
  • Hooks 的「优雅」来自向函数式的致敬,但useRef的滥用让 Hooks 离「优雅」相去甚远。
  • 大规模实践 React Hooks 仍然有诸多问题,无论是从语义理解抑或是封装的必要性。
  • 创新不易,期待 React 官方之后会有更好的设计吧。



浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报