Vue 進階必學之高階元件 HOC(保姆式教學,衝擊20k必備)

晨曦時夢見兮發表於2020-04-07

高階元件這個概念在 React 中一度非常流行,但是在 Vue 的社群裡討論的不多,掘金裡很多關於 Vue 進階技巧的帖子我也都看過,大部分無外乎講的是什麼 deep watchVue.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>
複製程式碼

一般我們都這樣寫,平常也沒感覺有啥問題,但是其實我們每次在寫非同步請求的時候都要有 loadingerror 狀態,都需要有 取資料 的邏輯,並且要管理這些狀態。

那麼想個辦法抽象它?好像特別好的辦法也不多,React 社群在 Hook 流行之前,經常用 HOC(high order component) 也就是高階元件來處理這樣的抽象。

高階元件是什麼?

說到這裡,我們就要思考一下高階元件到底是什麼概念,其實說到底,高階元件就是:

一個函式接受一個元件為引數,返回一個包裝後的元件

在 React 中

在 React 裡,元件是 Class,所以高階元件有時候會用 裝飾器 語法來實現,因為 裝飾器 的本質也是接受一個 Class 返回一個新的 Class

在 React 的世界裡,高階元件就是 f(Class) -> 新的Class

在 Vue 中

在 Vue 的世界裡,元件是一個物件,所以高階元件就是一個函式接受一個物件,返回一個新的包裝好的物件。

類比到 Vue 的世界裡,高階元件就是 f(object) -> 新的object

實現

有了這個思路,我們就開始嘗試實現。

首先上文提到了,HOC 是個函式,本次我們的需求是實現請求管理的 HOC,那麼先定義它接受兩個引數

  1. wrapped 也就是需要被包裹的元件物件。
  2. promiseFunc 也就是請求對應的函式,需要返回一個 Promise

並且 loadingerror 等狀態,還有 載入中載入錯誤 等對應的檢視,我們都要在 新返回的包裝元件 中定義好。

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 裡也可以直接預覽原始碼。

完善

到此為止的高階元件雖然可以演示,但是並不是完整的,它還缺少一些功能,比如

  1. 要拿到子元件上定義的引數,作為初始化傳送請求的引數。
  2. 要監聽子元件中請求引數的變化,並且重新傳送請求。
  3. 外部元件傳遞給 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

當時的我還不能看懂這篇文章中涉及到的原始碼問題和修復方案,然後改用了另一種方式實現了業務,但是這篇文章裡提到的東西一直在我的心頭縈繞,我在忙碌的工作之餘努力學習原始碼,期望有朝一日能徹底看懂這篇文章。

時至今日我終於能理解文章中說到的 $vnodecontext 代表什麼含義,但是這個 bug 在 Vue 2.6 版本由於 slot 的實現方式被重寫,也順帶修復掉了,現在在 Vue 中使用最新的 slot 語法配合高階函式,已經不會遇到這篇文章中提到的 bug 了。

相關文章