精读vue-hooks

2,064 阅读6分钟

原文地址:juejin.cn/post/684490…

原文作者:微笑向暖_Tini

背景

最近研究了vue3.0的最新进展,发现变动很大,总体上看,vue也开始向hooks靠拢,而且vue作者本人也称vue3.0的特性吸取了很多hooks的灵感。所以趁着vue3.0未正式发布前,抓紧时间研究一下hooks相关的东西。

源码地址:vue-hooks-poc

为什么要用hooks?

首先从class-component/vue-options说起:

  • 跨组件代码难以复用
  • 大组件,维护困难,颗粒度不好控制,细粒度划分时,组件嵌套存层次太深-影响性能
  • 类组件,this不可控,逻辑分散,不容易理解
  • mixins具有副作用,逻辑互相嵌套,数据来源不明,且不能互相消费

当一个模版依赖了很多mixin的时候,很容易出现数据来源不清或者命名冲突的问题,而且开发mixins的时候,逻辑及逻辑依赖的属性互相分散且mixin之间不可互相消费。这些都是开发中令人非常痛苦的点,因此,vue3.0中引入hooks相关的特性非常明智。

vue-hooks

image

在探究vue-hooks之前,先粗略的回顾一下vue的响应式系统:首先,vue组件初始化时会将挂载在data上的属性响应式处理(挂载依赖管理器),然后模版编译成v-dom的过程中,实例化一个Watcher观察者观察整个比对后的vnode,同时也会访问这些依赖的属性,触发依赖管理器收集依赖(与Watcher观察者建立关联)。当依赖的属性发生变化时,会通知对应的Watcher观察者重新求值(setter->notify->watcher->run),对应到模版中就是重新render(re-render)。

注意:vue内部默认将re-render过程放入微任务队列中,当前的render会在上一次render flush阶段求值。

withHooks

export function withHooks(render) {
  return {
    data() {
      return {
        _state: {}
      }
    },
    created() {
      this._effectStore = {}
      this._refsStore = {}
      this._computedStore = {}
    },
    render(h) {
      callIndex = 0
      currentInstance = this
      isMounting = !this._vnode
      const ret = render(h, this.$attrs, this.$props)
      currentInstance = null
      return ret
    }
  }
}

withHooks为vue组件提供了hooks+jsx的开发方式,使用方式如下:

export default withHooks((h)=>{
    ...
    return <span></span>
})

不难看出,withHooks依旧是返回一个vue component的配置项options,后续的hooks相关的属性都挂载在本地提供的options上。

首先,先分析一下vue-hooks需要用到的几个全局变量:

  • currentInstance:缓存当前的vue实例
  • isMounting:render是否为首次渲染
isMounting = !this._vnode

这里的_vnode$vnode有很大的区别,$vnode代表父组件(vm._vnode.parent)

_vnode初始化为null,在mounted阶段会被赋值为当前组件的v-dom

isMounting除了控制内部数据初始化的阶段外,还能防止重复re-render。

  • callIndex:属性索引,当往options上挂载属性时,使用callIndex作为唯一当索引标识。

vue options上声明的几个本地变量:

  • _state:放置响应式数据
  • _refsStore:放置非响应式数据,且返回引用类型
  • _effectStore:存放副作用逻辑和清理逻辑
  • _computedStore:存放计算属性

最后,withHooks的回调函数,传入了attrs$props作为入参,且在渲染完当前组件后,重置全局变量,以备渲染下个组件。

useData

const data = useData(initial)
export function useData(initial) {
  const id = ++callIndex
  const state = currentInstance.$data._state
  if (isMounting) {
    currentInstance.$set(state, id, initial)
  }
  return state[id]
}

我们知道,想要响应式的监听一个数据的变化,在vue中需要经过一些处理,且场景比较受限。使用useData声明变量的同时,也会在内部data._state上挂载一个响应式数据。但缺陷是,它没有提供更新器,对外返回的数据发生变化时,有可能会丢失响应式监听。

useState

const [data, setData] = useState(initial)
export function useState(initial) {
  ensureCurrentInstance()
  const id = ++callIndex
  const state = currentInstance.$data._state
  const updater = newValue => {
    state[id] = newValue
  }
  if (isMounting) {
    currentInstance.$set(state, id, initial)
  }
  return [state[id], updater]
}

useStatehooks非常核心的API之一,它在内部通过闭包提供了一个更新器updater,使用updater可以响应式更新数据,数据变更后会触发re-render,下一次的render过程,不会在重新使用$set初始化,而是会取上一次更新后的缓存值。

useRef

const data = useRef(initial) // data = {current: initial}
export function useRef(initial) {
  ensureCurrentInstance()
  const id = ++callIndex
  const { _refsStore: refs } = currentInstance
  return isMounting ? (refs[id] = { current: initial }) : refs[id]
}

使用useRef初始化会返回一个携带current的引用,current指向初始化的值。我在初次使用useRef的时候总是理解不了它的应用场景,但真正上手后还是多少有了一些感受。

比如有以下代码:

export default withHooks(h => {
  const [count, setCount] = useState(0)
  const num = useRef(count)
  const log = () => {
    let sum = count + 1
    setCount(sum)
    num.current = sum
    console.log(count, num.current);
  }
  return (
    <Button onClick={log}>{count}{num.current}</Button>
  )
})

点击按钮会将数值+1,同时打印对应的变量,输出结果为:

0 1
1 2
2 3
3 4
4 5

可以看到,num.current永远都是最新的值,而count获取到的是上一次render的值。 其实,这里将num提升至全局作用域也可以实现相同的效果。 所以可以预见useRef的使用场景:

  • 多次re-render过程中保存最新的值
  • 该值不需要响应式处理
  • 不污染其他作用域

useEffect

useEffect(function ()=>{
    // 副作用逻辑
    return ()=> {
        // 清理逻辑
    }
}, [deps])
export function useEffect(rawEffect, deps) {
  ensureCurrentInstance()
  const id = ++callIndex
  if (isMounting) {
    const cleanup = () => {
      const { current } = cleanup
      if (current) {
        current()
        cleanup.current = null
      }
    }
    const effect = function() {
      const { current } = effect
      if (current) {
        cleanup.current = current.call(this)
        effect.current = null
      }
    }
    effect.current = rawEffect

    currentInstance._effectStore[id] = {
      effect,
      cleanup,
      deps
    }

    currentInstance.$on('hook:mounted', effect)
    currentInstance.$on('hook:destroyed', cleanup)
    if (!deps || deps.length > 0) {
      currentInstance.$on('hook:updated', effect)
    }
  } else {
    const record = currentInstance._effectStore[id]
    const { effect, cleanup, deps: prevDeps = [] } = record
    record.deps = deps
    if (!deps || deps.some((d, i) => d !== prevDeps[i])) {
      cleanup()
      effect.current = rawEffect
    }
  }
}

useEffect同样是hooks中非常重要的API之一,它负责副作用处理和清理逻辑。这里的副作用可以理解为可以根据依赖选择性的执行的操作,没必要每次re-render都执行,比如dom操作,网络请求等。而这些操作可能会导致一些副作用,比如需要清除dom监听器,清空引用等等。

先从执行顺序上看,初始化时,声明了清理函数和副作用函数,并将effect的current指向当前的副作用逻辑,在mounted阶段调用一次副作用函数,将返回值当成清理逻辑保存。同时根据依赖来判断是否在updated阶段再次调用副作用函数。

非首次渲染时,会根据deps依赖来判断是否需要再次调用副作用函数,需要再次执行时,先清除上一次render产生的副作用,并将副作用函数的current指向最新的副作用逻辑,等待updated阶段调用。

useMounted

useMounted(function(){})
export function useMounted(fn) {
  useEffect(fn, [])
}

useEffect依赖传[]时,副作用函数只在mounted阶段调用。

useDestroyed

useDestroyed(function(){})
export function useDestroyed(fn) {
  useEffect(() => fn, [])
}

useEffect依赖传[]且存在返回函数,返回函数会被当作清理逻辑在destroyed调用。

useUpdated

useUpdated(fn, deps)
export function useUpdated(fn, deps) {
  const isMount = useRef(true)
  useEffect(() => {
    if (isMount.current) {
      isMount.current = false
    } else {
      return fn()
    }
  }, deps)
}

如果deps固定不变,传入的useEffect会在mounted和updated阶段各执行一次,这里借助useRef声明一个持久化的变量,来跳过mounted阶段。

useWatch

export function useWatch(getter, cb, options) {
  ensureCurrentInstance()
  if (isMounting) {
    currentInstance.$watch(getter, cb, options)
  }
}

使用方式同$watch。这里加了一个是否初次渲染判断,防止re-render产生多余Watcher观察者。

useComputed

const data = useData({count:1})
const getCount = useComputed(()=>data.count)
export function useComputed(getter) {
  ensureCurrentInstance()
  const id = ++callIndex
  const store = currentInstance._computedStore
  if (isMounting) {
    store[id] = getter()
    currentInstance.$watch(getter, val => {
      store[id] = val
    }, { sync: true })
  }
  return store[id]
}

useComputed首先会计算一次依赖值并缓存,调用$watch来观察依赖属性变化,并更新对应的缓存值。

实际上,vue底层对computed对处理要稍微复杂一些,在初始化computed时,采用lazy:true(异步)的方式来监听依赖变化,即依赖属性变化时不会立刻求值,而是控制dirty变量变化;并将计算属性对应的key绑定到组件实例上,同时修改为访问器属性,等到访问该计算属性的时候,再依据dirty来判断是否求值。

这里直接调用watch并同步来监听依赖属性变化,虽然会增加计算开销,但这样可以保证watch会在属性变化时,立即获取最新值,而不是等到render flush阶段去求值。

hooks

export function hooks (Vue) {
  Vue.mixin({
    beforeCreate() {
      const { hooks, data } = this.$options
      if (hooks) {
        this._effectStore = {}
        this._refsStore = {}
        this._computedStore = {}
        // 改写data函数,注入_state属性
        this.$options.data = function () {
          const ret = data ? data.call(this) : {}
          ret._state = {}
          return ret
        }
      }
    },
    beforeMount() {
      const { hooks, render } = this.$options
      if (hooks && render) {
        // 改写组件的render函数
        this.$options.render = function(h) {
          callIndex = 0
          currentInstance = this
          isMounting = !this._vnode
          // 默认传入props属性
          const hookProps = hooks(this.$props)
          // _self指示本身组件实例
          Object.assign(this._self, hookProps)
          const ret = render.call(this, h)
          currentInstance = null
          return ret
        }
      }
    }
  })
}

借助withHooks,我们可以发挥hooks的作用,但牺牲来很多vue的特性,比如props,attrs,components等。

vue-hooks暴露了一个hooks函数,开发者在入口Vue.use(hooks)之后,可以将内部逻辑混入所有的子组件。这样,我们就可以在SFC组件中使用hooks啦。

为了便于理解,这里简单实现了一个功能,将动态计算元素节点尺寸封装成独立的hooks:

<template>
  <section class="demo">
    <p>{{resize}}</p>
  </section>
</template>
<script>
import { hooks, useRef, useData, useState, useEffect, useMounted, useWatch } from '../hooks';

function useResize(el) {
  const node = useRef(null);
  const [resize, setResize] = useState({});

  useEffect(
    function() {
      if (el) {
        node.currnet = el instanceof Element ? el : document.querySelector(el);
      } else {
        node.currnet = document.body;
      }
      const Observer = new ResizeObserver(entries => {
        entries.forEach(({ contentRect }) => {
          setResize(contentRect);
        });
      });
      Observer.observe(node.currnet);
      return () => {
        Observer.unobserve(node.currnet);
        Observer.disconnect();
      };
    },
    []
  );
  return resize;
}

export default {
  props: {
    msg: String
  },
  // 这里和setup函数很接近了,都是接受props,最后返回依赖的属性
  hooks(props) {
    const data = useResize();
    return {
      resize: JSON.stringify(data)
    };
  }
};
</script>
<style>
html,
body {
  height: 100%;
}
</style>

使用效果是,元素尺寸变更时,将变更信息输出至文档中,同时在组件销毁时,注销resize监听器。

hooks返回的属性,会合并进组件的自身实例中,这样模版绑定的变量就可以引用了。

hooks存在什么问题?

在实际应用过程中发现,hooks的出现确实能解决mixin带来的诸多问题,同时也能更加抽象化的开发组件。但与此同时也带来了更高的门槛,比如useEffect在使用时一定要对依赖忠诚,否则引起render的死循环也是分分钟的事情。

react-hooks相比,vue可以借鉴函数抽象及复用的能力,同时也可以发挥自身响应式追踪的优势。我们可以看尤在与react-hooks对比中给出的看法:

  • 整体上更符合 JavaScript 的直觉;
  • 不受调用顺序的限制,可以有条件地被调用;
  • 不会在后续更新时不断产生大量的内联函数而影响引擎优化或是导致 GC 压力;
  • 不需要总是使用 useCallback 来缓存传给子组件的回调以防止过度更新;
  • 不需要担心传了错误的依赖数组给 useEffect/useMemo/useCallback 从而导致回调中使用了过期的值 —— Vue 的依赖追踪是全自动的。

感受

为了能够在vue3.0发布后更快的上手新特性,便研读了一下hooks相关的源码,发现比想象中收获的要多,而且与新发布的RFC对比来看,恍然大悟。可惜工作原因,开发项目中很多依赖了vue-property-decorator来做ts适配,看来三版本出来后要大改了。

最后,hooks真香(逃)

参考文章