歡迎關注前端早茶,與廣東靚仔攜手共同進階
前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~
前言
高階組件這個概念在 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 函數不斷的把函數高階包裹,在執行的時候又一層一層的解包,非常巧妙的構思。
歡迎關注前端早茶,與廣東靚仔攜手共同進階
前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~

