歡迎關注前端早茶,與廣東靚仔攜手共同進階
前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~
前言
高階組件這個概念在 React 中一度非常流行,但是在 Vue 的社區里討論的不多,本篇文章就真正的帶你來玩一個進階的騷操作。
先和大家說好,本篇文章的核心是學會這樣的思想,也就是 智能組件
和 木偶組件
的解耦合,沒聽過這個概念沒關系,下面會詳細說明。
這可以有很多方式,比如 slot-scopes
,比如未來的composition-api
。本篇所寫的代碼也不推薦用到生產環境,生產環境有更成熟的庫去使用,這篇強調的是 思想
,順便把 React 社區的玩法移植過來皮一下。
不要噴我,不要噴我,不要噴我!!,此篇只為演示高階組件的思路,如果實際業務中想要簡化文中所提到的異步狀態管理,請使用基於 slot-scopes
的開源庫 vue-promised
例子
本文就以平常開發中最常見的需求,也就是異步數據的請求
為例,先來個普通玩家的寫法:
<template> <div v-if="error">failed to load</div> <div v-else-if="loading">loading...</div> <div v-else>hello {{result.name}}!</div> </template> <script> export default { data() { return { result: { name: '', }, loading: false, error: false, }, }, async created() { try { // 管理loading this.loading = true // 取數據 const data = await this.$axios('/api/user') this.data = data } catch (e) { // 管理error this.error = true } finally { // 管理loading this.loading = false } }, } </script>
一般我們都這樣寫,平常也沒感覺有啥問題,但是其實我們每次在寫異步請求的時候都要有 loading
、 error
狀態,都需要有 取數據
的邏輯,並且要管理這些狀態。
那么想個辦法抽象它?好像特別好的辦法也不多,React 社區在 Hook 流行之前,經常用 HOC
(high order component) 也就是高階組件來處理這樣的抽象。
高階組件是什么?
說到這里,我們就要思考一下高階組件到底是什么概念,其實說到底,高階組件就是:
一個函數接受一個組件為參數,返回一個包裝后的組件
。
在 React 中
在 React 里,組件是 Class
,所以高階組件有時候會用 裝飾器
語法來實現,因為 裝飾器
的本質也是接受一個 Class
返回一個新的 Class
。
在 React 的世界里,高階組件就是 f(Class) -> 新的Class
。
在 Vue 中
在 Vue 的世界里,組件是一個對象,所以高階組件就是一個函數接受一個對象,返回一個新的包裝好的對象。
類比到 Vue 的世界里,高階組件就是 f(object) -> 新的object
。
智能組件和木偶組件
如果你還不知道 木偶
組件和 智能
組件的概念,我來給你簡單的講一下,這是 React 社區里一個很成熟的概念了。
木偶
組件: 就像一個牽線木偶一樣,只根據外部傳入的 props
去渲染相應的視圖,而不管這個數據是從哪里來的。
智能
組件: 一般包在 木偶
組件的外部,通過請求等方式獲取到數據,傳入給 木偶
組件,控制它的渲染。
一般來說,它們的結構關系是這樣的:
<智能組件> <木偶組件 /> </智能組件>
它們還有另一個別名,就是 容器組件
和 ui組件
,是不是很形象。
實現
具體到上面這個例子中(如果你忘了,趕緊回去看看,哈哈),我們的思路是這樣的,
- 高階組件接受
木偶組件
和請求的方法
作為參數 - 在
mounted
生命周期中請求到數據 - 把請求的數據通過
props
傳遞給木偶組件
。
接下來就實現這個思路,首先上文提到了,HOC
是個函數,本次我們的需求是實現請求管理的 HOC
,那么先定義它接受兩個參數,我們把這個 HOC
叫做 withPromise
。
並且 loading
、error
等狀態,還有 加載中
、加載錯誤
等對應的視圖,我們都要在 新返回的包裝組件
,也就是下面的函數中 return 的那個新的對象
中定義好。
const withPromise = (wrapped, promiseFn) => { return { name: "with-promise", data() { return { loading: false, error: false, result: null, }; }, async mounted() { this.loading = true; const result = await promiseFn().finally(() => { this.loading = false; }); this.result = result; }, }; };
在參數中:
wrapped
也就是需要被包裹的組件對象。promiseFunc
也就是請求對應的函數,需要返回一個 Promise
看起來不錯了,但是函數里我們好像不能像在 .vue
單文件里去書寫 template
那樣書寫模板了,
但是我們又知道模板最終還是被編譯成組件對象上的 render
函數,那我們就直接寫這個 render
函數。(注意,本例子是因為便於演示才使用的原始語法,腳手架創建的項目可以直接用 jsx
語法。)
在這個 render
函數中,我們把傳入的 wrapped
也就是木偶組件給包裹起來。
這樣就形成了 智能組件獲取數據
-> 木偶組件消費數據
,這樣的數據流動了。
const withPromise = (wrapped, promiseFn) => { return { data() { ... }, async mounted() { ... }, render(h) { return h(wrapped, { props: { result: this.result, loading: this.loading, }, }); }, }; };
到了這一步,已經是一個勉強可用的雛形了,我們來聲明一下 木偶
組件。
這其實是 邏輯和視圖分離
的一種思路。
const view = { template: ` <span> <span>{{result?.name}}</span> </span> `, props: ["result", "loading"], };
注意這里的組件就可以是任意 .vue
文件了,我這里只是為了簡化而采用這種寫法。
然后用神奇的事情發生了,別眨眼,我們用 withPromise
包裹這個 view
組件。
// 假裝這是一個 axios 請求函數 const request = () => { return new Promise((resolve) => { setTimeout(() => { resolve({ name: "ssh" }); }, 1000); }); }; const hoc = withPromise(view, request)
然后在父組件中渲染它:
<div id="app"> <hoc /> </div> <script> const hoc = withPromise(view, request) new Vue({ el: 'app', components: { hoc } }) </script>
此時,組件在空白了一秒后,渲染出了我的大名 ssh
,整個異步數據流就跑通了。
現在在加上 加載中
和 加載失敗
視圖,讓交互更友好點。
const withPromise = (wrapped, promiseFn) => { return { data() { ... }, async mounted() { ... }, render(h) { const args = { props: { result: this.result, loading: this.loading, }, }; const wrapper = h("div", [ h(wrapped, args), this.loading ? h("span", ["加載中……"]) : null, this.error ? h("span", ["加載錯誤"]) : null, ]); return wrapper; }, }; };
到此為止的代碼可以在 效果預覽 里查看,控制台的 source 里也可以直接預覽源代碼。
完善
到此為止的高階組件雖然可以演示,但是並不是完整的,它還缺少一些功能,比如
- 要拿到子組件上定義的參數,作為初始化發送請求的參數。
- 要監聽子組件中請求參數的變化,並且重新發送請求。
- 外部組件傳遞給
hoc
組件的參數現在沒有透傳下去。
第一點很好理解,我們請求的場景的參數是很靈活的。
第二點也是實際場景中常見的一個需求。
第三點為了避免有的同學不理解,這里再啰嗦下,比如我們在最外層使用 hoc
組件的時候,可能希望傳遞一些 額外的props
或者 attrs
甚至是 插槽slot
給最內層的 木偶
組件。那么 hoc
組件作為橋梁,就要承擔起將它透傳下去的責任。
為了實現第一點,我們約定好 view
組件上需要掛載某個特定 key
的字段作為請求參數,比如這里我們約定它叫做 requestParams
。
const view = { template: ` <span> <span>{{result?.name}}</span> </span> `, data() { // 發送請求的時候要帶上它 requestParams: { name: 'ssh' } }, props: ["result", "loading"], };
改寫下我們的 request
函數,讓它為接受參數做好准備,
並且讓它的 響應數據
原樣返回 請求參數
。
// 假裝這是一個 axios 請求函數 const request = (params) => { return new Promise((resolve) => { setTimeout(() => { resolve(params); }, 1000); }); };
那么問題現在就在於我們如何在 hoc
組件中拿到 view
組件的值了,
平常我們怎么拿子組件實例的? 沒錯就是 ref
,這里也用它:
const withPromise = (wrapped, promiseFn) => { return { data() { ... }, async mounted() { this.loading = true; // 從子組件實例里拿到數據 const { requestParams } = this.$refs.wrapped // 傳遞給請求函數 const result = await promiseFn(requestParams).finally(() => { this.loading = false; }); this.result = result; }, render(h) { const args = { props: { result: this.result, loading: this.loading, }, // 這里傳個 ref,就能拿到子組件實例了,和平常模板中的用法一樣。 ref: 'wrapped' }; const wrapper = h("div", [ this.loading ? h("span", ["加載中……"]) : null, this.error ? h("span", ["加載錯誤"]) : null, h(wrapped, args), ]); return wrapper; }, }; };
再來完成第二點,子組件的請求參數發生變化時,父組件也要響應式
的重新發送請求,並且把新數據帶給子組件。
const withPromise = (wrapped, promiseFn) => { return { data() { ... }, methods: { // 請求抽象成方法 async request() { this.loading = true; // 從子組件實例里拿到數據 const { requestParams } = this.$refs.wrapped; // 傳遞給請求函數 const result = await promiseFn(requestParams).finally(() => { this.loading = false; }); this.result = result; }, }, async mounted() { // 立刻發送請求,並且監聽參數變化重新請求 this.$refs.wrapped.$watch("requestParams", this.request.bind(this), { immediate: true, }); }, render(h) { ... }, }; };
第二個問題,我們只要在渲染子組件的時候把 $attrs
、$listeners
、$scopedSlots
傳遞下去即可,
此處的 $attrs
就是外部模板上聲明的屬性,$listeners
就是外部模板上聲明的監聽函數,
以這個例子來說:
<my-input value="ssh" @change="onChange" />
組件內部就能拿到這樣的結構:
{ $attrs: { value: 'ssh' }, $listeners: { change: onChange } }
注意,傳遞 $attrs
、$listeners
的需求不僅發生在高階組件中,平常我們假如要對 el-input
這種組件封裝一層變成 my-input
的話,如果要一個個聲明 el-input
接受的 props
,那得累死,直接透傳 $attrs
、$listeners
即可,這樣 el-input
內部還是可以照樣處理傳進去的所有參數。
// my-input 內部 <template> <el-input v-bind="$attrs" v-on="$listeners" /> </template>
那么在 render
函數中,可以這樣透傳:
const withPromise = (wrapped, promiseFn) => { return { ..., render(h) { const args = { props: { // 混入 $attrs ...this.$attrs, result: this.result, loading: this.loading, }, // 傳遞事件 on: this.$listeners, // 傳遞 $scopedSlots scopedSlots: this.$scopedSlots, ref: "wrapped", }; const wrapper = h("div", [ this.loading ? h("span", ["加載中……"]) : null, this.error ? h("span", ["加載錯誤"]) : null, h(wrapped, args), ]); return wrapper; }, }; };
至此為止,完整的代碼也就實現了:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>hoc-promise</title> </head> <body> <div id="app"> <hoc msg="msg" @change="onChange"> <template> <div>I am slot</div> </template> <template v-slot:named> <div>I am named slot</div> </template> </hoc> </div> <script src="./vue.js"></script> <script> var view = { props: ["result"], data() { return { requestParams: { name: "ssh", }, }; }, methods: { reload() { this.requestParams = { name: "changed!!", }; }, }, template: ` <span> <span>{{result?.name}}</span> <slot></slot> <slot name="named"></slot> <button @click="reload">重新加載數據</button> </span> `, }; const withPromise = (wrapped, promiseFn) => { return { data() { return { loading: false, error: false, result: null, }; }, methods: { async request() { this.loading = true; // 從子組件實例里拿到數據 const { requestParams } = this.$refs.wrapped; // 傳遞給請求函數 const result = await promiseFn(requestParams).finally(() => { this.loading = false; }); this.result = result; }, }, async mounted() { // 立刻發送請求,並且監聽參數變化重新請求 this.$refs.wrapped.$watch( "requestParams", this.request.bind(this), { immediate: true, } ); }, render(h) { const args = { props: { // 混入 $attrs ...this.$attrs, result: this.result, loading: this.loading, }, // 傳遞事件 on: this.$listeners, // 傳遞 $scopedSlots scopedSlots: this.$scopedSlots, ref: "wrapped", }; const wrapper = h("div", [ this.loading ? h("span", ["加載中……"]) : null, this.error ? h("span", ["加載錯誤"]) : null, h(wrapped, args), ]); return wrapper; }, }; }; const request = (data) => { return new Promise((r) => { setTimeout(() => { r(data); }, 1000); }); }; var hoc = withPromise(view, request); new Vue({ el: "#app", components: { hoc, }, methods: { onChange() {}, }, }); </script> </body> </html>
可以在 這里 預覽代碼效果。
我們開發新的組件,只要拿 hoc
過來復用即可,它的業務價值就體現出來了,代碼被精簡到不敢想象。
import { getListData } from 'api' import { withPromise } from 'hoc' const listView = { props: ["result"], template: ` <ul v-if="result> <li v-for="item in result"> {{ item }} </li> </ul> `, }; export default withPromise(listView, getListData)
一切變得簡潔而又優雅。
組合
注意,這一章節對於沒有接觸過 React 開發的同學可能很困難,可以先適當看一下或者跳過。
有一天,我們突然又很開心,寫了個高階組件叫 withLog
,它很簡單,就是在 mounted
聲明周期幫忙打印一下日志。
const withLog = (wrapped) => { return { mounted() { console.log("I am mounted!") }, render(h) { return h(wrapped) }, } }
這里我們發現,又要把on
、scopedSlots
等屬性提取並且透傳下去,其實挺麻煩的,我們封裝一個從 this
上整合需要透傳屬性的函數:
function normalizeProps(vm) { return { on: vm.$listeners, attr: vm.$attrs, // 傳遞 $scopedSlots scopedSlots: vm.$scopedSlots, } }
然后在 h
的第二個參數提取並傳遞即可。
const withLog = (wrapped) => { return { mounted() { console.log("I am mounted!") }, render(h) { return h(wrapped, normalizeProps(this)) }, } }
然后再包在剛剛的 hoc
之外:
var hoc = withLog(withPromise(view, request));
可以看出,這樣的嵌套是比較讓人頭疼的,我們把 redux
這個庫里的 compose
函數給搬過來,這個 compose
函數,其實就是不斷的把函數給高階化,返回一個新的函數。
function compose(...funcs) { return funcs.reduce((a, b) => (...args) => a(b(...args))) }
compose(a, b, c)
返回的是一個新的函數,這個函數會把傳入的幾個函數 嵌套執行
返回的函數簽名:(...args) => a(b(c(...args)))
這個函數對於第一次接觸的同學來說可能需要很長時間來理解,因為它確實非常復雜,但是一旦理解了,你的函數式思想又更上一層樓了。
但是這也說明我們要改造 withPromise
高階函數了,因為仔細觀察這個 compose
,它會包裝函數,讓它接受一個參數,並且把第一個函數的返回值
傳遞給下一個函數作為參數。
比如 compose(a, b)
來說,b(arg)
返回的值就會作為 a
的參數,進一步調用 a(b(args))
這需要保證參數只有一個。
那么按照這個思路,我們改造 withPromise
,其實就是要進一步高階化它,讓它返回一個只接受一個參數的函數:
const withPromise = (promiseFn) => { // 返回的這一層函數 wrap,就符合我們的要求,只接受一個參數 return function wrap(wrapped) { // 再往里一層 才返回組件 return { mounted() {}, render() {}, } } }
有了它以后,就可以更優雅的組合高階組件了:
const compsosed = compose(
withPromise(request),
withLog,
)
const hoc = compsosed(view)
以上 compose
章節的完整代碼 在這。
注意,這一節如果第一次接觸這些概念看不懂很正常,這些在 React 社區里很流行,但是在 Vue 社區里很少有人討論!關於這個 compose
函數,第一次在 React 社區接觸到它的時候我完全看不懂,先知道它的用法,慢慢理解也不遲。
真實業務場景
可能很多人覺得上面的代碼實用價值不大,但是 vue-router
的 高級用法文檔 里就真實的出現了一個用高階組件去解決問題的場景。
先簡單的描述下場景,我們知道 vue-router
可以配置異步路由,但是在網速很慢的情況下,這個異步路由對應的 chunk
也就是組件代碼,要等到下載完成后才會進行跳轉。
這段下載異步組件
的時間我們想讓頁面展示一個 Loading
組件,讓交互更加友好。
在 Vue 文檔-異步組件 這一章節,可以明確的看出 Vue 是支持異步組件聲明 loading
對應的渲染組件的:
const AsyncComponent = () => ({ // 需要加載的組件 (應該是一個 `Promise` 對象) component: import('./MyComponent.vue'), // 異步組件加載時使用的組件 loading: LoadingComponent, // 加載失敗時使用的組件 error: ErrorComponent, // 展示加載時組件的延時時間。默認值是 200 (毫秒) delay: 200, // 如果提供了超時時間且組件加載也超時了, // 則使用加載失敗時使用的組件。默認值是:`Infinity` timeout: 3000 })
我們試着把這段代碼寫到 vue-router
里,改寫原先的異步路由:
new VueRouter({ routes: [{ path: '/', - component: () => import('./MyComponent.vue') + component: AsyncComponent }] })
會發現根本不支持,深入調試了一下 vue-router
的源碼發現,vue-router
內部對於異步組件的解析和 vue
的處理完全是兩套不同的邏輯,在 vue-router
的實現中不會去幫你渲染 Loading
組件。
這個肯定難不倒機智的社區大佬們,我們轉變一個思路,讓 vue-router
先跳轉到一個 容器組件
,這個 容器組件
幫我們利用 Vue 內部的渲染機制去渲染 AsyncComponent
,不就可以渲染出 loading
狀態了?具體代碼如下:
由於 vue-router 的 component
字段接受一個 Promise
,因此我們把組件用 Promise.resolve
包裹一層。
function lazyLoadView (AsyncView) { const AsyncHandler = () => ({ component: AsyncView, loading: require('./Loading.vue').default, error: require('./Timeout.vue').default, delay: 400, timeout: 10000 }) return Promise.resolve({ functional: true, render (h, { data, children }) { // 這里用 vue 內部的渲染機制去渲染真正的異步組件 return h(AsyncHandler, data, children) } }) } const router = new VueRouter({ routes: [ { path: '/foo', component: () => lazyLoadView(import('./Foo.vue')) } ] })
這樣,在跳轉的時候下載代碼的間隙,一個漂亮的 Loading
組件就渲染在頁面上了。
compose 拆解原理
這一章來一步步拆解 compose
函數,看看它到底做了什么樣的事情,比較腦殼痛。
第一次接觸這個函數的小伙伴還是酌情跳過吧。
假設現在是三個高階組件的組合:
const compsosed = compose(
withA,
withB,
withC
)
const hoc = compsosed(view)
-
首先在
reduce
的第一次循環里,a
是withA
,b
是withB
,然后 return 了:(...args) => withA(withB(...args))
這個 return 的值就會作為 reduce
中下次循環的 a
-
下一次循環,那么此時的
b
是我們假設的另一個高階組件withC
,那么就 return 了(...args2) => (...args) => withA(withB(...args))(withC(...args2)) ↑ 這里是a ↑這里是(b(args))
- 此時我們如果外部傳入了
view
,上一步中的args2
就會被消除,這個函數會先歸約成這樣:
(...args) => withA(withB(...args))(withC(view))
此時 withC(view)
又進一步的作為...args
去執行這個函數,進一步歸約:
withA(withB(withC(view)))
可以看到,compose
函數不斷的把函數高階包裹,在執行的時候又一層一層的解包,非常巧妙的構思。
歡迎關注前端早茶,與廣東靚仔攜手共同進階
前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~