複用
複用,在前端 vue 層面有多種形式:指令、filters(vue3 廢棄)、minx(vue 3 廢棄)、hook,計算屬性等。
這些不同的概念,是對不同場景和需求下框架層面的一種抽象,其中對使用者出錯的頻率 filter < 指令 < 計算屬性 < hook < mixin
最前面兩種是純函數,輸入輸出確定,返回結果就能確定,調試和理解成本都非常低。計算屬性是帶緩存的函數,後面兩種分別是帶副作用的函數。
雖然在 vue 中 hook 是 mixin 的一種更優替代,但其危險程度,對於沒有熟練運用的人一樣非常高。
鑑於官方文檔並沒有深刻、詳細介紹最佳實踐,且例子對初學者隱藏了很多設計背後的東西,導致被不少同學奉這種新 api 為聖經且無法正確的運用。在這裏深入淺出的剖析達下背後設計理念。以下介紹同樣適用於 react,其本質內核不變。
一個 vue 組件本質上其內核是面向對象的,對象對外隱藏了內部實現,且包括自身狀態維護。比如組件對象包括的屬性有組件名、組件方法、組件 data、組件 render 函數(模板)。
【複用:example-1】
class Components {
data: {
a:1,
b:2
}
name: 'helloWord',
methods: {
say: () => {
}
},
render () {
}
}
如上,是 vue 經典的選項式寫法,語法表達的形式和傳統面向對象更接近。在選項式寫法中,我們如何複用邏輯呢? 剛剛説前端層面的複用有很多形式,最簡單且安全的就是純函數複用。
【複用:example-2】
class Components {
...
methods: {
say: (id) => {
const user = getUserInfo(id)
}
},
}
class Components {
...
methods: {
hello: (id) => {
const user = getUserInfo(id)
}
},
}
getUserInfo 其內部可能有很多細節,且都是獨立無關組件的,我們只需要簡單把它封裝成函數。這裏的 getUserInfo 只要輸入 id 不變,返回的結果也必定不變,被影響的因素只有 id 這個參數,任何人調試這段代碼心裏壓力都非常小。
複用既然如此簡單,一個函數搞定,為啥還有 mixin? 我們看這個例子: 【複用:example-3】
class Components {
data: {
id1: 111,
id2: 333,
magic: 'hello'
}
methods: {
helloWord: (id) => {
const someMagic = this.id + this.id2 * id1;
aler(someMagic + magic);
}
},
}
class Components2 {
// 我該如何複用?
}
helloWord 函數內部的邏輯不僅僅依賴一個參數 id,還依賴另外兩個當前對象的狀態,id1、id2。這個兩個數據沒法抽象到純函數之中。
什麼是副作用?
副作用是面向對象編程當前方法會受到上下狀態的影響,這是面向對象一直以來不可避免的的問題。在大量的業務邏輯中,我們不可避免出現依賴狀態的場景,函數式編程嘗試解決它。
function add() {
id1 = 1;
id2 = 3
return function (id) {
return id1 + id2 * id;
}
}
add()(1) // 4
add()(2) // 7
如上,函數式編程把狀態隱藏在函數閉包之中,這樣一來,程序不能直接修改 id1 和 id2 的值,每次調用無副作用。 理論上證明了任何帶狀態的面向對象邏輯,都能轉換成函數調用,但計算代價非常昂貴,狀態本質上是內存換計算。函數式編程目前只是特定場景結合使用,沒有成為主流。
解決方案
面向對象的副作用,導致我們無法用純函數解決對象之間帶狀態邏輯的複用——橫切關注點。在傳統編程語言中,比如、C++ 、java 等,我們使用繼承的方案,解決這種對象複用問題。前端內一個組件可能需要從多個不同的組件公用方法這很常見,這不可避免要多繼承,而傳統多繼承有的,菱形繼承問題等不可避免。
前端靈活的特性,主流生態圈一直是排斥傳統編程,除了前端大型軟件,完全傳統的純面向對象編程方案非常少被運用。
hook 出來之前,react 使用了 HOC、mixin 等方案。高階組件本質上是利用的父子組件可嵌套的特性,把帶狀態的複用邏輯提升到父組件中,通過組件進行傳遞,在 vue 中一樣能做到,但 jsx 在 vue 中使用不頻繁,HOC 也沒有這麼方便, 於是 mixin 是作為組件狀態邏輯複用的最早方案(因為狀態邏輯複用是危險,大部分前端場景可以通過設計規避掉,我們會簡單介紹)。
mixin 的問題非常多,這些命名衝突和多繼承一樣,參考另外一篇詳細分析 mixin)。
hook
react 團隊難以忍受高階組件帶來的深層嵌套,創造了一種新概念,叫 hook,發佈在 react 16 版本中,隨後很快流行起來。vue 3 以組合式 api 的形式提供了類似的思想,以下我們統稱 hook。
hook 函數不是“真”函數。瞭解編程基礎的人都知道,函數就是子程序,可以實現固定運算功能的同時,還帶有一個入口和一個出口,函數定義在任何編程語言中都是相通的。hook 則是前端專有,其內核就是把多繼承包裝成函數的寫法,解決了傳統多繼承的一些問題。
【複用:example-4】
function hello() {
const state = reactive({
loading: true,
});
loadData = () => {
setTimeout(() => {
state.loading = fasle;
}, 1000);
};
loadData();
return state.loading;
}
不清楚 hook 背景的開發同學看這個函數,會有一個疑惑,這個方法只要調用就會返回一個布爾值,中間的 loadData 過 1 秒中改變了值的狀態,函數都已經調用結束了,寫在這裏有什麼意義呢?
這正是突破傳統語言的一種方案,正常函數只能被程序主動調用,而前端 hook 函數則嘗試監聽(reactive)函數中的某些數據,觀測到數據變化,則重新把值返回回去。使用任意傳統語言也能實現這樣的監聽、派發新值。前端的優勢是藉助靈活特點,包裝到函數中去了,看起來幾乎和普通函數一模一樣。
這個函數內部同時帶有狀態和行為,行為等同於對象中的方法,狀態則是對象中上下數據。函數內部數據被修改後,最後函數的值會重新自動計算並返回給調用方。
app () {
let loading = hello() // 這個 hello 不是普通函數,會自動返回最新的值,而這個值正式當前上下文依賴的狀態。
render () {
// 藉助框架機制, 會檢測到 loading 變化,自動更新
{{ loading }}
}
}
這確實是一個精妙的設計,最早想到這個 idea 的人是個鬼才,軟化行業沒有什麼問題不能夠多加一層解決,把面向對象的橫切面複用問題,通過 DSL 包裝到普通函數中去,讓複用模塊能像函數一樣方便的調用。
hook 和反應式編程(RX)的區別
反應式編程仍然是屬於函數式,即每一個函數和操作都是無副作用的純函數,通過顯示的事件訂閲對消息進行傳遞。
而 hook,我們從上面的例子看到,是帶副作用的“偽”函數,它對外暴露了特定語法,數據被函數外部的外觀察者監聽到,再重新傳遞給調用方。
綜上,我們看到了 hook 的本質是帶副作用的反應式函數,而這個副作用就是面向對象的狀態。
方案對比
上面例子我們 example-3 中有個問題沒有解決,如何編寫代碼,讓帶狀態的數據和方法邏輯被複用。我們稍微改造成更復雜,但很常見的案例:即組件需要共用多個不同對象的數據和行為,先用 hook 以外的幾種方案。
繼承:
class common1 {
data: {
id1: 111,
id2: 333
}
helloWord: (id) => {
const someMagic = this.id + this.id2 * id1;
aler(someMagic + magic);
}
}
class common2{
data: {
magic: 'hello'
}
magic: (id) => {
this.data.someMagic = id + 'magic'
}
}
class Components2 extend common1, common2 {
render () {
this.helloWord();
this.magic();
}
...
}
多繼承是傳統編程中常用的方法,優點很明顯,結構清晰,但很前端不管是 es6、還是 typescript 原生均不支持。
mixin
let common1Mixin = {
data: {
id1: 111,
id2: 333,
}
helloWord: (id) => {
const someMagic = this.id + this.id2 * id1;
aler(someMagic + magic);
}
}
let common2Mixin = {
data: {
magic: 'hello'
}
magic: (id) => {
this.data.someMagic = id + 'magic'
}
}
class Components2 {
mixin: [ common1Mixin, common2Mixin]
render () {
this.helloWord();
this.magic();
}
...
}
因為前端只能嚴格單繼承,mixin 混合其實是一種用函數模擬多繼承的方法,把其他對象的數據和方法動態合併到當前對象,非常靈活和輕量。
混合的缺點我們之前分析過,這裏再講下對比真正多繼承的另一個重要缺點,混合是一種動態函數,沒法享受類型自動導入,編輯器跳轉等傳統多繼承的優點,混合的對象非常之多的時候,基本靠全局搜索,可維護性簡直噩夢。
hooks
輪到我們 hooks 上場了。
let hook1 = () => {
let data = {
id1: 111,
id2: 333,
}
let helloWord = (id) => {
const someMagic = this.id + this.id2 * id1;
aler(someMagic + magic);
}
return { data, helloWord }
}
let hook2 = () => {
let data = {
magic: 'hello'
}
let magic = (id) => {
this.data.someMagic = id + 'magic'
}
return { data, magic }
}
class Components2 {
setup () {
const { helloWord, data } = hook1();
const { magic, data: data2 } = hook2();
render () => {
helloWord();
magic();
console.log(data2.magic);
}
}
}
我們看下,hook 如何解決傳統多繼承,及混合的問題。
- 隱式定義
如上,helloWord、magic 這幾個變量如果採用 mixin 的方式會無法直接推斷來自哪個對象,而 hook 通過函數返回值顯示的進行定義。編輯器也能根據函數定義,自動推導類型。
- 命名衝突
傳統多繼承的形式無法避免這個問題,有些編程語言靜態檢測到衝突後編譯拋錯(c ++),有些採用設定優先級方案(python)。而 hook 中兩個相同函數和變量可以被 es6 結構賦值重寫,上面的 data 變量在 hook1 和 hook2 中同時存在。正常導出編輯器會提示錯誤,我們能重新解構賦值給一個變量別名,無需改變原函數。
hook 的問題及最佳實踐
最佳實踐
以函數的形式表達狀態及行為,可以享受函數調用的便捷,但也會受制於函數的使用場景。
- 要符合函數的最佳實踐
hook 被錯誤使用最多的地方在狀態和行為過多,理論上面向對象中一個幾百上千行的父類,都能通過 hook 表達出來,但最終效果是這樣的:
let hook1 = () => {
let data1 = "xxx";
let data2 = "xxx2";
// ...此處幾十個狀態
let helloWord = (id) => {
// someMagic
};
let helloWord2 = (id) => {
// someMagic
};
// ...此處幾十個方法
return {
data1,
data1,
... // 此處幾十上百的變量
};
};
class Components2 {
setup () {
// 這裏導出幾十上百的變量
const { data, ... } = hook1();
render () => {
helloWord();
magic();
console.log(data.magic);
}
}
}
很明顯,這樣一個函數在閲讀性和可理解性上非常差。函數的只專注於一件精簡的事情,其參數也不宜過長,這是函數可讀的基本要求。
hook 的運用也要符合函數整潔之道,保持共享狀態的精簡、小巧,最理想的是一個 hook 只處理一個狀態, 參考上面 example-4。關於函數的其他最佳實踐有哪些,大家參考 【Robert C.Martin】的《代碼整潔之道》。關鍵字:'短小'、'只做一件事'。
- 集中管理副作用
hook 和純函數的區別在副作用上,我們不可避免會去修改內部狀態,這些行為都會導致 bug 的地方。編寫 vue 的自定義組合式 api 把修改數據的方法集合到一個代碼塊,可讀性會高更多(react 同理)。
// bad code
function hello() {
const state = reactive({
loading: true,
});
// ... 更多東西
const loadData1 = () => {
setTimeout(() => {
state.loading = fasle;
}, 1000);
};
// ... 更多東西
const state3 = reactive({
loading2: true,
});
// ... 更多東西
const loadData2 = () => {
setTimeout(() => {
state3.loading = fasle;
}, 1000);
};
mounted(() => {
loadData1();
loadData2();
});
// ... 更多東西
loadData();
return state;
}
// good code
function hello() {
const state = reactive({
loading: true,
loading2: true,
});
//!!!下面的代碼會有副作用,易引發 bug !!!
loadData1 = () => {
setTimeout(() => {
state.loading = fasle;
}, 1000);
};
loadData2 = () => {
setTimeout(() => {
state.loading2 = fasle;
}, 1000);
};
//!!!上面的代碼會有副作用,易引發 bug !!!
mounted(() => {
loadData1();
loadData2();
});
return state;
}
- 小心 hook 嵌套調用
我們已經知道了,hook 非純函數,每一個 hook 內部的狀態都會增加一層複雜度,hook 內部如何再次引用其他 hook,相當於一個組件向上繼承了多個狀態。
一個組件,如果向上複用了更多的狀態,只要中間任意一個狀態被意外修改,組件產生的疑難雜症非常難定為,這和多繼承的隱患相似。我們應該儘量在架構設計層面規避這種場景發生,同時小巧的 hook 又會降低這種風險。
- 關注分離結合點
hook 設計原則應該和傳統 class 類相似,高度內聚。組件中哪些行為和狀態是需要分離出去成為獨立的模塊,有一個比較基本的判斷標準,分離出去的模塊對原組件零依賴,和組件交互通過純粹的函數接口進行通信即可。
前端靈活的特性,很多低可維護性的 hook 內部和原組件之間有大量回調,原組件的一些方法用參數傳到 hook,hook 的方法又在組件中,出現太多這種雙向調用,説明 hook 設計上沒有明顯和原組件分離,或者壓根不需要分離。
問題
類,經過這麼多年的潛移默化的影響,大部分開發熟知各種教條式的規則,傳統的編程語言在語法上比較限嚴格,創建一個類,書寫其業務代碼通常問題下限比較高。
函數,任何編程語言都有的概念,調用非常靈活。而函數式編程語言,又允許把函數本身當成參數賦值,靈活性更高。但好在通常的純函數是無狀態、沒有副作用的,再怎麼寫,純函數還是能追隨 bug 源頭,問題下限偏中吧。
hook 是函數和類的結合體,在設計上要同時符合兩者的思維,我們要用函數解決小規模類的缺點,但函數非常靈活,傳統類 “死板” 的各種限制被消失了,稍微不小心副作用被函數調用帶到漫天飛舞。所以 hook,下限低,上限高。
使用場景
現在社區有種把一切項目都用 hook 去完成的衝動和勇氣,甚至要完全替代傳統 class (包括選項式)的表達方式。這種想法和當年要用函數式編程替代傳統的類一樣,非常危險。從語法完備上,它們都能做相同的事情,純函數式杜絕狀態,要通過疊加計算來模擬狀態的效果。而 hook 則做出妥協,在函數中包裝狀態,達到和類同等的效果。
狀態——始終是 hook 可維護性的源頭,函數內篡改引用數值、函數賦值、回調等靈活特性難以被收斂。通常,熟練的開發者,可以避開這些坑,並且減少心智負擔,達到比傳統類更靈活、強大的武器。對於團隊,函數式的 hook 失去了傳統類的基本約束,每個人擁有任意修改狀態的至高的權限,hook 的使用要評估團隊成員的能力,放任低水平的 hook 代價是極大拉高代碼維護成本。
- 不是必要條件,而是可選
我們從本篇知道了,hook 的設計初衷,對比傳統類的創新思維,但不管是 react、還是 vue 都是把它作為可選的嘗試。而更偏向大型應用開發的 angular 更不會往這種靈活的方向發展。
- 解決狀態邏輯問題
我們從設計 hook 的背景,已經知道帶狀態邏輯複用確實是前端(僅 vue/react)痛點,hook 非常適合小規模狀態提取出來取代傳統小類。 除此以外,hook 天生適合和與 redux/vuex 中小函數相互結合,Pinia 正是是這樣一種改進。
- 大規模狀態和行為是噩夢
封裝大規模狀態和行為首先不符合我們函數的最佳實踐。其次,前端組件設計層面,我們從來不推薦有大組件、更不會有大的被複用模塊,在 react、vue 均提倡拆分,組合的思想,遇到這種場景,只能説你拆分設計不夠細。相反,如果你的應用規模對象足夠多且複雜,使用類似 angular 用完全的面向對象思維和語法去構建業務更合適。常見的前端頁面,特別是中後台等側重 B-S 業務交互的場景並不具備這種大規模面相對象的特點。
- 狀態複用應該優先在設計上避免
在 hook 出來之前,我使用 vue 和 react 這麼多年,無論構建規模多大的業務,涉及到狀態模塊複用的場景(其實就是多繼承)都能被合理的組件拆分及其他方案規避掉。不少同學,把簡單的純函數調用,換成 hook,簡直是引入額外副作用來增加風險。
// 下面偽代碼
class Components2 {
setup () {
// hook someData 自動更新,這只是一個簡單場景,實際會有更對類似需要追蹤的調用。
const { someData, getData } = getDataHook(render);
getData();
// 模擬框架的自動 render
const = render () => {
console.log(someData);
}
}
}
class Components2 {
setup () {
someData = ref([]);
// 主動賦值
const getData async () {
someData = await getDataPrue();
}
getData();
// 模擬框架的自動 render
const = render () => {
console.log(someData);
}
}
}
我們看上面第一個,getData 只是一個獲取數據的異步方法而已,hook 會響應式自動更新 someData 數據。而人的思維天生對同步編程方式理解成本更低。響應式只應該出現在人不該關心的地方,人關心的地方,應該是讓開發者手動去調用,優先使用同步的思維,否則,在大量響應式表達下,變量來源難以追蹤。 這種只是小例子,大部分情況下不要為了少寫一行代碼,而增加理解成本,複用的目的從來不是少碼字,而是為了提高可讀、易拓展,看未來的自己,讓其他同事看起來容易理解。
結
只要是狀態,不管通過類,還是 hook 等方式表達,都比純函數危險都更高,異步比同步危險。而 99% 的場景可以設計成不需要狀態共用,讓橫切關注點降低。而跨組件共享的狀態,大部分又能通過專門的狀態管理解,但正如 redux 狀態管理的作者也説過“ 99% 的前端場景不需要使用狀態管理”。
所以,請不要濫用 hook 及其他複用模式。
——————
文檔信息
標題:關於前端複用的幾點思考和建議——hook
發表時間:2022年9月4日
筆名:混沌福王
原鏈接:https://imwangfu.com/2022/09/...
版權聲明:如需轉載,請郵件知會 imwangfu@gmail.com,並保留此文檔信息申明
——————