深度了解vue.js中hooks的相關(guān)知識
背景
最近研究了vue3.0的最新進展,發(fā)現(xiàn)變動很大,總體上看,vue也開始向hooks靠攏,而且vue作者本人也稱vue3.0的特性吸取了很多hooks的靈感。所以趁著vue3.0未正式發(fā)布前,抓緊時間研究一下hooks相關(guān)的東西。
源碼地址:vue-hooks-poc
為什么要用hooks?
首先從class-component/vue-options說起:
- 跨組件代碼難以復(fù)用
- 大組件,維護困難,顆粒度不好控制,細(xì)粒度劃分時,組件嵌套存層次太深-影響性能
- 類組件,this不可控,邏輯分散,不容易理解
- mixins具有副作用,邏輯互相嵌套,數(shù)據(jù)來源不明,且不能互相消費
當(dāng)一個模版依賴了很多mixin的時候,很容易出現(xiàn)數(shù)據(jù)來源不清或者命名沖突的問題,而且開發(fā)mixins的時候,邏輯及邏輯依賴的屬性互相分散且mixin之間不可互相消費。這些都是開發(fā)中令人非常痛苦的點,因此,vue3.0中引入hooks相關(guān)的特性非常明智。
vue-hooks
在探究vue-hooks之前,先粗略的回顧一下vue的響應(yīng)式系統(tǒng):首先,vue組件初始化時會將掛載在data上的屬性響應(yīng)式處理(掛載依賴管理器),然后模版編譯成v-dom的過程中,實例化一個Watcher觀察者觀察整個比對后的vnode,同時也會訪問這些依賴的屬性,觸發(fā)依賴管理器收集依賴(與Watcher觀察者建立關(guān)聯(lián))。當(dāng)依賴的屬性發(fā)生變化時,會通知對應(yīng)的Watcher觀察者重新求值(setter->notify->watcher->run),對應(yīng)到模版中就是重新render(re-render)。
注意:vue內(nèi)部默認(rèn)將re-render過程放入微任務(wù)隊列中,當(dāng)前的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的開發(fā)方式,使用方式如下:
export default withHooks((h)=>{ ... return <span></span> })
不難看出,withHooks依舊是返回一個vue component的配置項options,后續(xù)的hooks相關(guān)的屬性都掛載在本地提供的options上。
首先,先分析一下vue-hooks需要用到的幾個全局變量:
- currentInstance:緩存當(dāng)前的vue實例
- isMounting:render是否為首次渲染
isMounting = !this._vnode
這里的_vnode與$vnode有很大的區(qū)別,$vnode代表父組件(vm._vnode.parent)
_vnode初始化為null,在mounted階段會被賦值為當(dāng)前組件的v-dom
isMounting除了控制內(nèi)部數(shù)據(jù)初始化的階段外,還能防止重復(fù)re-render。
- callIndex:屬性索引,當(dāng)往options上掛載屬性時,使用callIndex作為唯一當(dāng)索引標(biāo)識。
vue options上聲明的幾個本地變量:
- _state:放置響應(yīng)式數(shù)據(jù)
- _refsStore:放置非響應(yīng)式數(shù)據(jù),且返回引用類型
- _effectStore:存放副作用邏輯和清理邏輯
- _computedStore:存放計算屬性
最后,withHooks的回調(diào)函數(shù),傳入了attrs和$props作為入?yún)ⅲ以阡秩就戤?dāng)前組件后,重置全局變量,以備渲染下個組件。
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] }
我們知道,想要響應(yīng)式的監(jiān)聽一個數(shù)據(jù)的變化,在vue中需要經(jīng)過一些處理,且場景比較受限。使用useData聲明變量的同時,也會在內(nèi)部data._state上掛載一個響應(yīng)式數(shù)據(jù)。但缺陷是,它沒有提供更新器,對外返回的數(shù)據(jù)發(fā)生變化時,有可能會丟失響應(yīng)式監(jiān)聽。
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] }
useState是hooks非常核心的API之一,它在內(nèi)部通過閉包提供了一個更新器updater,使用updater可以響應(yīng)式更新數(shù)據(jù),數(shù)據(jù)變更后會觸發(fā)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的時候總是理解不了它的應(yīng)用場景,但真正上手后還是多少有了一些感受。
比如有以下代碼:
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> ) })
點擊按鈕會將數(shù)值+1,同時打印對應(yīng)的變量,輸出結(jié)果為:
0 1 1 2 2 3 3 4 4 5
可以看到,num.current永遠(yuǎn)都是最新的值,而count獲取到的是上一次render的值。
其實,這里將num提升至全局作用域也可以實現(xiàn)相同的效果。
所以可以預(yù)見useRef的使用場景:
- 多次re-render過程中保存最新的值
- 該值不需要響應(yīng)式處理
- 不污染其他作用域
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之一,它負(fù)責(zé)副作用處理和清理邏輯。這里的副作用可以理解為可以根據(jù)依賴選擇性的執(zhí)行的操作,沒必要每次re-render都執(zhí)行,比如dom操作,網(wǎng)絡(luò)請求等。而這些操作可能會導(dǎo)致一些副作用,比如需要清除dom監(jiān)聽器,清空引用等等。
先從執(zhí)行順序上看,初始化時,聲明了清理函數(shù)和副作用函數(shù),并將effect的current指向當(dāng)前的副作用邏輯,在mounted階段調(diào)用一次副作用函數(shù),將返回值當(dāng)成清理邏輯保存。同時根據(jù)依賴來判斷是否在updated階段再次調(diào)用副作用函數(shù)。
非首次渲染時,會根據(jù)deps依賴來判斷是否需要再次調(diào)用副作用函數(shù),需要再次執(zhí)行時,先清除上一次render產(chǎn)生的副作用,并將副作用函數(shù)的current指向最新的副作用邏輯,等待updated階段調(diào)用。
useMounted
useMounted(function(){}) export function useMounted(fn) { useEffect(fn, []) }
useEffect依賴傳[]時,副作用函數(shù)只在mounted階段調(diào)用。
useDestroyed
useDestroyed(function(){}) export function useDestroyed(fn) { useEffect(() => fn, []) }
useEffect依賴傳[]且存在返回函數(shù),返回函數(shù)會被當(dāng)作清理邏輯在destroyed調(diào)用。
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階段各執(zhí)行一次,這里借助useRef聲明一個持久化的變量,來跳過mounted階段。
useWatch
export function useWatch(getter, cb, options) { ensureCurrentInstance() if (isMounting) { currentInstance.$watch(getter, cb, options) } }
使用方式同$watch。這里加了一個是否初次渲染判斷,防止re-render產(chǎn)生多余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首先會計算一次依賴值并緩存,調(diào)用$watch來觀察依賴屬性變化,并更新對應(yīng)的緩存值。
實際上,vue底層對computed對處理要稍微復(fù)雜一些,在初始化computed時,采用lazy:true(異步)的方式來監(jiān)聽依賴變化,即依賴屬性變化時不會立刻求值,而是控制dirty變量變化;并將計算屬性對應(yīng)的key綁定到組件實例上,同時修改為訪問器屬性,等到訪問該計算屬性的時候,再依據(jù)dirty來判斷是否求值。
這里直接調(diào)用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函數(shù),注入_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函數(shù) this.$options.render = function(h) { callIndex = 0 currentInstance = this isMounting = !this._vnode // 默認(rèn)傳入props屬性 const hookProps = hooks(this.$props) // _self指示本身組件實例 Object.assign(this._self, hookProps) const ret = render.call(this, h) currentInstance = null return ret } } } }) }
借助withHooks,我們可以發(fā)揮hooks的作用,但犧牲來很多vue的特性,比如props,attrs,components等。
vue-hooks暴露了一個hooks函數(shù),開發(fā)者在入口Vue.use(hooks)之后,可以將內(nèi)部邏輯混入所有的子組件。這樣,我們就可以在SFC組件中使用hooks啦。
為了便于理解,這里簡單實現(xiàn)了一個功能,將動態(tài)計算元素節(jié)點尺寸封裝成獨立的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函數(shù)很接近了,都是接受props,最后返回依賴的屬性 hooks(props) { const data = useResize(); return { resize: JSON.stringify(data) }; } }; </script> <style> html, body { height: 100%; } </style>
使用效果是,元素尺寸變更時,將變更信息輸出至文檔中,同時在組件銷毀時,注銷resize監(jiān)聽器。
hooks返回的屬性,會合并進組件的自身實例中,這樣模版綁定的變量就可以引用了。
hooks存在什么問題?
在實際應(yīng)用過程中發(fā)現(xiàn),hooks的出現(xiàn)確實能解決mixin帶來的諸多問題,同時也能更加抽象化的開發(fā)組件。但與此同時也帶來了更高的門檻,比如useEffect在使用時一定要對依賴忠誠,否則引起render的死循環(huán)也是分分鐘的事情。
與react-hooks相比,vue可以借鑒函數(shù)抽象及復(fù)用的能力,同時也可以發(fā)揮自身響應(yīng)式追蹤的優(yōu)勢。我們可以看尤在與react-hooks對比中給出的看法:
整體上更符合 JavaScript 的直覺;
不受調(diào)用順序的限制,可以有條件地被調(diào)用;
不會在后續(xù)更新時不斷產(chǎn)生大量的內(nèi)聯(lián)函數(shù)而影響引擎優(yōu)化或是導(dǎo)致 GC 壓力;
不需要總是使用 useCallback 來緩存?zhèn)鹘o子組件的回調(diào)以防止過度更新;
不需要擔(dān)心傳了錯誤的依賴數(shù)組給 useEffect/useMemo/useCallback 從而導(dǎo)致回調(diào)中使用了過期的值 —— Vue 的依賴追蹤是全自動的。
感受
為了能夠在vue3.0發(fā)布后更快的上手新特性,便研讀了一下hooks相關(guān)的源碼,發(fā)現(xiàn)比想象中收獲的要多,而且與新發(fā)布的RFC對比來看,恍然大悟??上Чぷ髟颍_發(fā)項目中很多依賴了vue-property-decorator來做ts適配,看來三版本出來后要大改了。
最后,hooks真香(逃)
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
Vue3監(jiān)聽屬性與Computed的區(qū)別詳解
在 Vue 3 中,watch 和 computed 都是非常重要的概念,它們都可以用于觀察和響應(yīng)數(shù)據(jù)的變化,但在使用場景和原理上存在明顯的區(qū)別,本文將詳細(xì)解析 Vue 3 中監(jiān)聽屬性 (watch) 和計算屬性 (computed) 的區(qū)別,需要的朋友可以參考下2024-02-02Vue.js 2.0和Cordova開發(fā)webApp環(huán)境搭建方法
下面小編就為大家分享一篇Vue.js 2.0和Cordova開發(fā)webApp環(huán)境搭建方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-02-02VUE:vuex 用戶登錄信息的數(shù)據(jù)寫入與獲取方式
今天小編就為大家分享一篇VUE:vuex 用戶登錄信息的數(shù)據(jù)寫入與獲取方式,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2019-11-11