Logo
Published on

从闭包到 React Hooks

Authors

闭包

闭包是非常基础的前端知识点,我们先复习一下,假如有如下代码:

let foo = 1
function add() {
  foo = foo + 1
  return foo
}

console.log(add())
console.log(add())
console.log(add())
执行后我们可以得到 enter image description here

但是很明显,全局变量foo对于一个公共库来说是很不友好的,即便是业务项目,也很容易被篡改,因此我们可以通过使用闭包来实现上述功能:

function getAdd() {
  let foo = 1

  return function () {
    foo = foo + 1
    return foo
  }
}

const add = getAdd()

console.log(add())
console.log(add())
console.log(add())

执行后,我们同样可以得到一样的输出 enter image description here

但是const add = getAdd()仍有些多余,可以使用IIFE自动声明函数

const add = (function getAdd() {
  let foo = 1

  return function () {
    foo = foo + 1
    return foo
  }
})()

console.log(add())
console.log(add())
console.log(add())

到这里为止,闭包的知识点已经复习完毕,这也是我们对React Hooks建模所需要的全部基础知识,接下来可以实现一个React Hooks的心智模型

useState

首先,我们想一下useState是怎么使用的?接收一个初始值,然后返回一个数组,第一项是一个变量,第二项是改变这个变量的函数,那我们可以这样定义和使用:

function useState(initVal) {
  let _val = initVal
  let state = _val
  const setState = (newVal) => (state = newVal)

  return [state, setState]
}

const [count, setCount] = useState(1)
console.log(count)
setCount(2)
console.log(count)
enter image description here

我们看到这里两次输出的都是1,其实仔细看上述代码,setState已经形成了闭包,state = newVal已经成功了,但由于解构出来的count变量是一个数字(即值,而非引用),因此,在调用setState之后,count也没有改变。

这里可以暂时做一个比较tricky的操作,将useState函数做一个很小的改动:

function useState(initVal) {
  let _val = initVal
  let state = () => _val // 改成函数
  const setState = (newVal) => (_val = newVal)

  return [state, setState]
}

const [count, setCount] = useState(1)
console.log(count()) // 调用方式需要稍微改变一下
setCount(2)
console.log(count())

当然这是一个临时方案,React里面不是这样使用count的,我们也不会想这样使用,因此我们对useState做一次重构:

const React = (function () {
  function useState(initVal) {
    let _val = initVal
    let state = () => _val
    const setState = (newVal) => (_val = newVal)

    return [state, setState]
  }

  function render(Component) {
    const C = Component()
    C.render()
    return C
  }

  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)

  return {
    render: () => console.log(count),
    click: () => setCount(count + 1),
  }
}

var App = React.render(Component)
App.click()
var App = React.render(Component)

在上面重构的代码中,我们先引入了React这个变量,它是一个立即执行函数,我们先将useState函数放进去并且返回给外部。

由于useState都是使用在组件中,我们新增一个函数叫做Component,在这个函数中使用useState。因为现在并没有和DOM相关的内容,所以先返回一个对象,里面模拟render和鼠标点击的click函数,其中render函数直接输出count的值,click函数模拟鼠标点击时修改count的值。

现在我们有了ReactComponent组件,如何告诉React渲染组件呢?我们在React中再增加一个render函数,这个函数接收一个函数(组件)作为参数,在render函数中,直接执行Component参数,在得到Component返回的对象后,执行这个对象中的render函数,最后将执行Component参数得到的对象返回。

最后,我们就可以将ReactComponent结合起来使用。

此时我们会得到这样的内容: enter image description here

一个名为state的函数。这是因为我们在React.useState中,将内部变量state赋值成了函数,为了解决这个问题,现在再对React做一些修改

const React = (function () {
  let _val // 将_val提升到useState外
  function useState(initVal) {
    let state = _val || initVal // 将_val赋值给内部变量state,且用initVal作为默认值
    const setState = (newVal) => (_val = newVal)

    return [state, setState]
  }

  function render(Component) {
    const C = Component()
    C.render()
    return C
  }

  return { useState, render }
})()
这样可以看到: enter image description here

到这里,useState基本上可以运行起来了,但接下来又有了新的问题,如果我有多个useState呢?

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('PCG')

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (newText) => setText(newText),
  }
}

var App = React.render(Component)
App.click()
var App = React.render(Component)
App.type('WXG')
var App = React.render(Component)
enter image description here

可以很清楚地看到,每次调用setXX函数是,会将所有的变量都置为同一个值,这是因为他们共用了React模块中的_val这个私有变量,要解决这个问题,我们需要将_val扩充:

const React = (function () {
  let hooks = []
  let idx = 0
  function useState(initVal) {
    let state = hooks[idx] || initVal
    /**
     * 由于setState是异步执行
     * 每次执行时,idx已经被重置为0了
     * 因此需要拿到idx的瞬时值
     * 否则,每次调用render后,输出的结果都是一样的
     */
    const _idx = idx
    const setState = (newVal) => {
      hooks[_idx] = newVal
    }

    idx++ // 在每次执行完hook之后,idx自增,这样下一个hook就可以正确执行
    return [state, setState]
  }

  function render(Component) {
    idx = 0 // 每次渲染重置idx
    const C = Component()
    C.render()
    return C
  }

  return { useState, render }
})()
enter image description here

可以看到此时的输出结果已经正常了,我们可以分别管理counttext的值

同时也可以看出来,我们为什么不能在条件语句中使用hooks,例如:

if (Math.random() > 0.5) {
  const [count, setCount] = React.useState(1)
}

const [text, setText] = React.useState('PCG')

此时,第一个useState有50%的可能不会执行,在是否执行的两种情况下,内部变量idx的值的变化时不同的,这会导致内部变量hooks数组中保存的值的变化也变得不可预知。因此,千万不要在条件语句中使用hooks

useEffect

在有了useState之后,我们当然想要useEffect,同样根据useEffect的使用方式,我们来对其建模:

const React = (function () {
  let hooks = []
  let idx = 0
  function useState(initVal) {
    let state = hooks[idx] || initVal
    const _idx = idx
    const setState = (newVal) => {
      hooks[_idx] = newVal
    }

    idx++
    return [state, setState]
  }

  function useEffect(cb, deps) {
    const oldDep = hooks[idx] // 临时保存依赖的旧值
    let hasChanged = true // flag,判断依赖是否改变
    if (oldDep) {
      /**
       * 如果旧依赖存在
       * 在传入的依赖列表中
       * 找到对应的旧值并判断是否相等
       * 如果不相等,即为依赖发生了变化
       */
      hasChanged = deps.some((dep, i) => !Object.is(dep, oldDep[i]))
    }

    if (hasChanged) cb() // 依赖变化,执行回调函数
    hooks[idx] = deps // 将传入的依赖的新值保存
    idx++ // 每次hooks执行后都需要自增idx
  }

  function render(Component) {
    idx = 0
    const C = Component()
    C.render()
    return C
  }

  return { useState, render, useEffect }
})()

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('PCG')

  React.useEffect(() => {
    console.log('Tencent!!!!')
  }, []) // 可以自行增加依赖项或去掉第二个参数查看效果

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (newText) => setText(newText),
  }
}

var App = React.render(Component)
App.click()
var App = React.render(Component)
App.type('WXG')
var App = React.render(Component)
enter image description here

可以看到在最开始执行了一次useEffect,证明我们的useEffect是成功的。

到这里为止,一个简易的React模型就已经完成了。虽然暂时没有和DOM联系起来,但所需要的也仅仅是JSX解析函数和一个更完善的render函数,这两块可以留到下次分享。

最后,虽然上述的React模块看上去很像真正的React,但其实它并不是,它只能算是一个React的模型,具体实现中需要考虑更多的因素。Anyway,我们可以从这样模型出发,再去读React的源码,也许会对React有另外的认识。

参考资料:JSCONF Asia 2019