博客 / 詳情

返回

關於前端複用的幾點思考和建議——hook

複用

複用,在前端 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,並保留此文檔信息申明
——————

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.