高階組件這個概念在 React 中一度很是流行,可是在 Vue 的社區裏討論的很少,本篇文章就真正的帶你來玩一個進階的騷操做。html
先和你們說好,本篇文章的核心是學會這樣的思想,也就是 智能組件
和 木偶組件
的解耦合,沒聽過這個概念不要緊,下面會詳細說明。前端
這能夠有不少方式,好比 slot-scopes
,好比將來的composition-api
。本篇所寫的代碼也不推薦用到生產環境,生產環境有更成熟的庫去使用,這篇強調的是 思想
,順便把 React 社區的玩法移植過來皮一下。vue
不要噴我,不要噴我,不要噴我!! 此篇只爲演示高階組件的思路,若是實際業務中想要簡化文中所提到的異步狀態管理,請使用基於 slot-scopes
的開源庫 vue-promisednode
另外標題中提到的 20k
其實有點標題黨,我更多的想表達的是咱們要有這樣的精神,只會這一個技巧確定不能讓你達到 20k
。但我相信只要你們有這樣鑽研高級用法,不斷優化業務代碼,不斷提效的的精神,咱們總會達到的,並且這一天不會很遠。ios
本文就以日常開發中最多見的需求,也就是異步數據的請求
爲例,先來個普通玩家的寫法:git
<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
狀態,都須要有 取數據
的邏輯,而且要管理這些狀態。github
那麼想個辦法抽象它?好像特別好的辦法也很少,React 社區在 Hook 流行以前,常常用 HOC
(high order component) 也就是高階組件來處理這樣的抽象。vue-router
說到這裏,咱們就要思考一下高階組件究竟是什麼概念,其實說到底,高階組件就是:redux
一個函數接受一個組件爲參數,返回一個包裝後的組件
。axios
在 React 裏,組件是 Class
,因此高階組件有時候會用 裝飾器
語法來實現,由於 裝飾器
的本質也是接受一個 Class
返回一個新的 Class
。
在 React 的世界裏,高階組件就是 f(Class) -> 新的Class
。
在 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)))
這個函數對於第一次接觸的同窗來講可能須要很長時間來理解,由於它確實很是複雜,可是一旦理解了,你的函數式思想又更上一層樓了。
我再 github
裏對一個多參數的 compose
例子作了一個逐步拆解的分析,有興趣的話能夠看看 compose拆解原理
若是你不理解這種 函數式
的 compose
寫法,那咱們用普通的循環來寫,就是返回一個函數,把傳入的函數數組從右往左的執行,而且上一個函數的返回值會做爲下一個函數執行的參數。
正常思路寫出來的 compose
函數是這樣的:
function compose(...args) { return function(arg) { let i = args.length - 1 let res = arg while(i >= 0) { let func = args[i] res = func(res) i-- } return res } } 複製代碼
可是這也說明咱們要改造 withPromise
高階函數了,由於仔細觀察這個 compose
,它會包裝函數,讓它接受一個參數,而且把第一個函數的返回值
傳遞給下一個函數做爲參數。
好比 compose(a, b)
來講,b(arg)
返回的值就會做爲 a
的參數,進一步調用 a(b(args))
這須要保證 compose 裏接受的函數,每一項的參數都只有一個。
那麼按照這個思路,咱們改造 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
組件就渲染在頁面上了。
本篇文章的全部代碼都保存在 Github倉庫 中,而且提供預覽。
謹以此文獻給在我源碼學習道路上給了我很大幫助的 《Vue技術內幕》 做者 hcysun
大佬,雖然我還沒和他說過話,可是在我仍是一個工做幾個月的小白的時候,一次業務需求的思考就讓我找到了這篇文章:探索Vue高階組件 | HcySunYang
當時的我還不能看懂這篇文章中涉及到的源碼問題和修復方案,而後改用了另外一種方式實現了業務,可是這篇文章裏提到的東西一直在個人心頭縈繞,我在忙碌的工做之餘努力學習源碼,指望有朝一日能完全看懂這篇文章。
時至今日我終於能理解文章中說到的 $vnode
和 context
表明什麼含義,可是這個 bug 在 Vue 2.6 版本因爲 slot
的實現方式被重寫,也順帶修復掉了,如今在 Vue 中使用最新的 slot
語法配合高階函數,已經不會遇到這篇文章中提到的 bug 了。
1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。
2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。