高階元件這個概念在 React 中一度非常流行,但是在 Vue 的社群裡討論的不多,掘金裡很多關於 Vue 進階技巧的帖子我也都看過,大部分無外乎講的是什麼 deep watch
、 Vue.extends
等爛大街的技巧,本篇文章就真正的帶你來玩一個進階的騷操作。
先和大家說好,本篇文章的核心是學會這樣的思想,也就是 容器
和 木偶
元件的解耦合,這可以有很多方式,比如 slot-scopes
,比如未來的composition-api
。本篇所寫的程式碼也不推薦用到生產環境,生產環境有更成熟的庫去使用,這篇強調的是 思想
,順便把 React 社群的玩法移植過來皮一下。
不要噴我,不要噴我,不要噴我,此篇只為演示高階元件的思路,如果實際業務中想要簡化文中所提到的非同步狀態管理,請使用基於 slot-scopes
的開源庫 vue-promised
另外標題中提到的 20k
其實有點標題黨,我更多的想表達的是我們要有這樣的精神,只會這一個技巧肯定不能讓你達到 20k
。但我相信只要大家有這樣鑽研高階用法,不斷優化業務程式碼,不斷提效的的精神,我們總會達到的,而且這一天不會很遠。
例子
本文就以平常開發中最常見的需求,也就是非同步資料的請求
為例,先來個普通玩家的寫法:
<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
。
實現
有了這個思路,我們就開始嘗試實現。
首先上文提到了,HOC
是個函式,本次我們的需求是實現請求管理的 HOC
,那麼先定義它接受兩個引數
wrapped
也就是需要被包裹的元件物件。promiseFunc
也就是請求對應的函式,需要返回一個 Promise
並且 loading
、error
等狀態,還有 載入中
、載入錯誤
等對應的檢視,我們都要在 新返回的包裝元件
中定義好。
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;
},
};
};
複製程式碼
看起來不錯了,但是函式裡我們好像不能像在 .vue
單檔案裡去書寫 template
那樣書寫模板了,
但是我們又知道模板最終還是被編譯成元件物件上的 render
函式,那我們就直接寫這個 render
函式。(注意,本例子是因為便於演示才使用的原始語法,腳手架建立的專案可以直接用 jsx
語法。)
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)
複製程式碼
一切變得簡潔而又優雅。
總結
本篇文章的所有程式碼都儲存在 Github倉庫 中,並且提供預覽。
謹以此文獻給在我原始碼學習道路上給了我很大幫助的 《Vue技術內幕》 作者 hcysun
大佬,雖然我還沒和他說過話,但是在我還是一個工作幾個月的小白的時候,一次業務需求的思考就讓我找到了這篇文章:探索Vue高階元件 | HcySunYang
當時的我還不能看懂這篇文章中涉及到的原始碼問題和修復方案,然後改用了另一種方式實現了業務,但是這篇文章裡提到的東西一直在我的心頭縈繞,我在忙碌的工作之餘努力學習原始碼,期望有朝一日能徹底看懂這篇文章。
時至今日我終於能理解文章中說到的 $vnode
和 context
代表什麼含義,但是這個 bug 在 Vue 2.6 版本由於 slot
的實現方式被重寫,也順帶修復掉了,現在在 Vue 中使用最新的 slot
語法配合高階函式,已經不會遇到這篇文章中提到的 bug 了。