03|实现 useReducer 和 useState

1,102 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第8天,点击查看活动详情

这一节我们实现 useReducer 和 useState,让函数组件支持数据更新。

👉 仓库地址,跪求您帮忙点个 🌟🌟,谢谢啦~ PS:本节代码在 v0.0.3 分支

使用 useReducer

在此之前,需要在demo/which-react.js中将useReducer从react中引入,并将ReactDOM更换为从react-dom引入

import { Component, Fragment, useReducer } from 'react';
import ReactDOM from 'react-dom/client';

// import { Component, Fragment } from '../src/react';
// import ReactDOM from '../src/react-dom';

export {
    Component,
    Fragment,
    useReducer,
    ReactDOM
}

demo/src/main.jsx中的 FunctionComponent 中添加useReducer

function FunctionComponent(props) {

  const [state, dispatch] = useReducer(x => x + 1, 0)

  return (
    <div className='function'>
      <p>{props.name}</p>
      <div>{state}</div>
      <button onClick={dispatch} >+1</button>
    </div>
  )
}

那么每次点击button都会将state + 1,有了我们想要的效果,接下来我们去实现它

实现 useReducer

将 React 的引入重新设定为自己写的(修改which-react.js),创建src/ReactFiberHooks.ts

export function useReducer(reducer, initalState) {

    const dispatch = () => {
            console.log('useReducer dispatch log')
    }
    // 暂时直接返回
    return [initalState, dispatch]
}

接着会发现点击 button 没有触发 dispatch 里的 console,这是因为我们还没有实现 React 事件,暂时只是将事件作为属性挂在dom上

// src/utils.ts
export function updateNode(node, nextVal) {
  Object.keys(nextVal).forEach(key => {
    if (key === 'children') {
      if (isStringOrNumber(nextVal[key])) {
        node.textContent = nextVal[key]
      }
    } else {
      node[key] = nextVal[key] // 这里直接当作属性放到 dom 上了
    }
  })
}

这里我们先简单处理一下,能让事件能响应(注意这里并不是真正的 React 事件实现方式)

export function updateNode(node: HTMLElement, nextVal) {
  Object.keys(nextVal).forEach(key => {
    if (key === 'children') {
      if (isStringOrNumber(nextVal[key])) {
        node.textContent = nextVal[key]
      }
    } else if (key.slice(0, 2) === 'on') {
      // 简单处理一下事件响应(并不是真正的React 事件)
      const eventName = key.slice(2).toLocaleLowerCase()
      node.addEventListener(eventName, nextVal[key])
    } else {
      node[key] = nextVal[key]
    }
  })
}

点击按钮能让console.log('useReducer dispatch log')执行出来了

实现 mount 时的 useReducer

import { Fiber } from "./ReactFiber"

interface Hook {
    memoizedState: any, // state
    next: null | Hook // 下一个 hook
}

// 当前正在渲染的 fiber
let currentlyRenderingFiber: Fiber | null = null

// 没什么特别的意义,就是想返回一个 Fiber 类型 不让 ts 报错
function getCurrentlyRenderingFiber() {
    return currentlyRenderingFiber as Fiber
}

// 当前正在处理的 hook
let workInProgressHook: Hook | null = null

export function renderWithHooks(workInProgress: Fiber) {
    currentlyRenderingFiber = workInProgress
    currentlyRenderingFiber.memoizedState = null
    workInProgressHook = null
}

function updateWorkInProgressHook() {
    currentlyRenderingFiber = getCurrentlyRenderingFiber()
    let hook

    const current = currentlyRenderingFiber.alternate
    // current 存在说明是 update,否则就是 mount
    if (current) {
        // 复用之前的 hook
        currentlyRenderingFiber.memoizedState = current.memoizedState
        // 看是否是第一个 hook
        if (workInProgressHook) {
            // 不是,则拿到下一个 hook,同时更新 workInProgressHook
            workInProgressHook = hook = workInProgressHook.next
        } else {
            // 是第一个 hook ,拿到第一个hook
            workInProgressHook = hook = currentlyRenderingFiber.memoizedState
        }
    } else {
        // mount 时需要新建hook
        hook = {
            memoizedState: null, // state
            next: null // 下一个 hook
        }
        if (workInProgressHook) {
            workInProgressHook = workInProgressHook.next = hook
        } else {
            // 第一个 hook,将 hook 放到 fiber 的 state 上,同时更新 workInProgressHook
            workInProgressHook = currentlyRenderingFiber.memoizedState = hook
        }
    }
    // 最终返回 hook(也就是 workInProgressHook)
    return hook
}

export function useReducer(reducer, initalState) {
    currentlyRenderingFiber = getCurrentlyRenderingFiber()

    const hook = updateWorkInProgressHook()

    if (!currentlyRenderingFiber.alternate) {
        // 初次渲染时将默认数据放到 hook.memoizedState 上即可
        hook.memoizedState = initalState
    }

    const dispatch = () => {
        console.log('useReducer dispatch log')
    }

  // 返回 state
    return [hook.memoizedState, dispatch]
}

在处理 FunctionComponent 时更新正在处理的 fiber(也就是当前的这个函数)

// ReactFiberReconciler.ts
export function updateFunctionComponent(workInProgress: Fiber) {
    // 更新正在处理的 fiber
    renderWithHooks(workInProgress)
    const { type, props } = workInProgress
    const children = type(props)
    reconcileChildren(workInProgress, children)
}

实现 update 时的 useReducer

现在页面在浏览器中渲染正常,没有报错,点击按钮还只是console,接下来我们处理 update 时。 update 时需要让数据更新(页面更新),之前是通过调scheduleUpdateOnFiber进行更新,这里也需要使用

export function useReducer(reducer, initalState) {

    const hook = updateWorkInProgressHook()

    if (!currentlyRenderingFiber?.alternate) {
        // 初次渲染
        hook.memoizedState = initalState
    }

    const dispatch = () => {
        // 修改状态值(将旧的state传给使用者,然后返回新的state给 hook)
        hook.memoizedState = reducer(hook.memoizedState); // 后面有圆括号,需要加分号

        // 更新之前将 currentlyRenderingFiber 设置为自己的 alternate 
        (currentlyRenderingFiber as Fiber).alternate = { ...currentlyRenderingFiber as Fiber }
        // 更新
        scheduleUpdateOnFiber(currentlyRenderingFiber as Fiber)
        console.log('useReducer dispatch log')
    }

    return [hook.memoizedState, dispatch]
}

因为我们写的 FragmentComponent 也是 FunctionComponent所以在 mount 完以后   currentlyReeringFiber 就指向了这个 FragmentComponent,这里先保证写的最后一个 FunctionComponent 是包含有刚刚写的 useReducer 的组件,将其他组件都注视掉,有其他问题后面再处理。

刷新页面,点击以后页面从这个 FunctionComponent 往后的组件都会再出现一遍,同时 state 是最新的值,这是因为我们在 reconcileChildren 时创建的Fiber的 flags 是 Placement,每次都会重新创建新的 dom

实现节点的复用

因为diff比较复杂,我们这节的重点是实现useReducer,所以只会实现一个简单的diff(sameNode 函数判断能否复用)


function reconcileChildren(workInProgress: Fiber, children) {
    if (isStringOrNumber(children)) {

            return
    }
    // 这里先将子节点都当作数组来处理
    const newChildren: any[] = isArray(children) ? children : [children]

    // oldFiber 的头节点
    let oldFiber = workInProgress.alternate?.child
    // 用于保存上个 fiber 节点
    let previousNewFiber: Fiber | null = null
    for (let i = 0; i < newChildren.length; i++) {
        const newChild = newChildren[i]
        if (newChild === null) {
            // 会遇到 null 的节点,直接忽略即可
            continue
        }
        const newFiber = createFiber(newChild, workInProgress)
        // 能否复用
        const same = sameNode(newFiber, oldFiber)

        if (same) {
            // 能复用
            Object.assign(newFiber, {
                stateNode: (oldFiber as Fiber).stateNode,
                alternate: oldFiber as Fiber,
                flags: Update
            })
        }

        if (oldFiber) {
            // 处于for 中,oldFiber 也需要更新到下一个 fiber	
            oldFiber = oldFiber.sibling
        }

        if (previousNewFiber === null) {
            // 第一个子节点直接保存到 workInProgress 上
            workInProgress.child = newFiber
        } else {
            // 后续都保存到上一个节点的 sibling 上
            previousNewFiber.sibling = newFiber
        }
        // 更新
        previousNewFiber = newFiber
    }
}

// 节点复用条件
// 1. 同层级
// 2. type 相同
// 3. key 相同
function sameNode(a, b) {
    return a && b && a.type === b.type && a.key === b.key
}

接着在 commit 时需要处理节点更新时的情况

// ReactFIberWorkLoop.ts
function commitWorker(workInProgress: Fiber | null) {
    // ......
    if (flags & Update && stateNode) {
        // 更新属性
        updateNode(
            stateNode, 
            (workInProgress.alternate as Fiber).props, 
            workInProgress.props
        )
    }

  // ......
}

updateNode函数中需要将旧的属性和原本监听的事件都移除掉,重新赋值新的属性和监听事件

// src/utils.ts
export function updateNode(node: HTMLElement, prevVal, nextVal) {
  // 遍历老的props,将原本的事件以及新props不存在的属性移除
  Object.keys(prevVal)
    .forEach((key) => {
      if (key === "children") {
        // 有可能是文本,直接将其清除
        if (isStringOrNumber(prevVal[key])) {
          node.textContent = "";
        }
      } else if (key.slice(0, 2) === "on") {
        // 事件需要移除掉
        const eventName = key.slice(2).toLocaleLowerCase();
        node.removeEventListener(eventName, prevVal[key]);
      } else {
        // 对于老的key存在oldProps,新的上面不存在的需要将其处理掉(remove)
        if (!(key in nextVal)) {
          node[key] = "";
        }
      }
    });

  // 更新 props
  Object.keys(nextVal)
    .forEach((key) => {
      if (key === "children") {
        // 有可能是文本
        if (isStringOrNumber(nextVal[key])) {
          node.textContent = nextVal[key] + "";
        }
      } else if (key.slice(0, 2) === "on") {
        const eventName = key.slice(2).toLocaleLowerCase();
        node.addEventListener(eventName, nextVal[key]);
      } else {
        node[key] = nextVal[key];
      }
    });
}

函数组件的 useReducer 就实现了更新。但还有一些需要解决的bug。

我们发现,如果存在多个FunctionComponent时,会因为 currntkyRendingFiber 是一个全局变量,导致最终指向的是最后一个 FunctionComponent,使得我们的 state,无法被更新,反而出现最后一个组件重新渲染一次问题。

所以我们需要保证在使用 dispatch 时内部的 fiber 是当前组件的 fiber。那么我们可以利用 bind 可以存储住参数的特性,将 currentlyRenderingFiber 作为 bind 时的预制参数,使得我们在调用 dispatch 时拿到的 fiber 是使用这个 hook 的 fiber。

// src/ReactFiberHooks.ts

export function useReducer(reducer, initalState) {

    const hook = updateWorkInProgressHook()

    if (!(currentlyRenderingFiber as Fiber).alternate) {
        // 初次渲染
        hook.memoizedState = initalState
    }

    // 因为 currentlyRenderingFiber 是全局变量,可能会导致存储的 fiber 不是需要更新 state 的 fiber
    // 所以需要通过 bind 在 dispatchReducerAction 这个函数内存储住相关信息(fiber 等),在调用时能获取需要更新的 fiber
    const dispatch = dispatchReducerAction.bind(
        null,
        currentlyRenderingFiber,
        hook,
        reducer
    )

    return [hook.memoizedState, dispatch]
}

function dispatchReducerAction(fiber: Fiber, hook: Hook, reducer, action) {
    hook.memoizedState = reducer(hook.memoizedState)
    // 更新之前将 currentlyRenderingFiber 设置为自己的 alternate 
    fiber.alternate = { ...fiber }

    // 更新
    scheduleUpdateOnFiber(fiber)
}

state 不更新的问题解决了,还有组件重新渲染的问题。这个其实也很简单。因为我们只需要当前组件更新,其他组件不需要动,所以我们在 scheduleUpdateOnFiber 时只需要提交当前 fiber 即可


function dispatchReducerAction(fiber: Fiber, hook: Hook, reducer, action) {
    hook.memoizedState = reducer(hook.memoizedState)
    fiber.alternate = { ...fiber }
    // 因为我们只更新这一个 fiber 组件,不能影响其他组件,所以需要将 sibling 设置为 null,避免后续组件被重复渲染
    fiber.sibling = null
    // 更新当前 fiber
    scheduleUpdateOnFiber(fiber)
}

至此,我们完成了 useReducer 的功能了。在这个功能中,我们实现了 fiber、dom 的复用和更新

实现 useState

实现完 useReducer,会发现 useState 的实现是差不多的,区别就在于 useState 没有 reducer 参数,并在使用 dispatch 时会将新的值传递过来,我们对 useReducer 稍作修改后兼容实现 useState

export function useState(initalState) {
    // 因为我们没有 reduce ,所以传 null
    return useReducer(null, initalState)
}

function dispatchReducerAction(fiber: Fiber, hook: Hook, reducer, action) {
    // 通过判断是否有 reducer,来决定 state 的值是 reducer 执行的结果,来还是根据传进来的参数
    // 其实就是判断是 useReducer 还是 useState
    hook.memoizedState = reducer ? reducer(hook.memoizedState) : action
    fiber.alternate = { ...fiber }
    fiber.sibling = null
    scheduleUpdateOnFiber(fiber)
}

优化 useReducer

在我们实际使用 useReducer 时,可能是下面这样使用的,在调用 dispatch 时也需要支持参数。

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

function FunctionComponent(props) {

  const [data, setData] = useReducer(reducer, { count: 0 })

  return (
    <div className='function'>
      <div>{ data.count }</div>
      <button onClick={ () => setData({ type: 'increment' }) }>data.count + 1</button>
      <button onClick={ () => setData({ type: 'decrement' }) }>data.count - 1</button>
    </div>
  )
}

我们只需要将 dispatch 中的 action 传入到reducer中即可

function dispatchReducerAction(fiber: Fiber, hook: Hook, reducer, action) {
    // 通过判断是否有 reducer,来决定 state 的值是 reducer 执行的结果,来还是根据传进来的参数
    // 其实就是判断是 useReducer 还是 useState
    hook.memoizedState = reducer ? reducer(hook.memoizedState, action) : action
    fiber.alternate = { ...fiber }
    fiber.sibling = null
    scheduleUpdateOnFiber(fiber)
}

至此,我们实现了useReduceruseState,提供了更新页面数据的能力。

👉 仓库地址,跪求您帮忙点个 🌟🌟,谢谢啦~ PS:本节代码在 v0.0.3 分支