Vue 進階必學之高階組件 HOC

前言

高階組件這個概念在 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>
複製代碼

通常咱們都這樣寫,日常也沒感受有啥問題,可是其實咱們每次在寫異步請求的時候都要有 loadingerror 狀態,都須要有 取數據 的邏輯,而且要管理這些狀態。github

那麼想個辦法抽象它?好像特別好的辦法也很少,React 社區在 Hook 流行以前,常常用 HOC(high order component) 也就是高階組件來處理這樣的抽象。vue-router

高階組件是什麼?

說到這裏,咱們就要思考一下高階組件究竟是什麼概念,其實說到底,高階組件就是:redux

一個函數接受一個組件爲參數,返回一個包裝後的組件axios

在 React 中

在 React 裏,組件是 Class,因此高階組件有時候會用 裝飾器 語法來實現,由於 裝飾器 的本質也是接受一個 Class 返回一個新的 Class

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

在 Vue 中

在 Vue 的世界裏,組件是一個對象,因此高階組件就是一個函數接受一個對象,返回一個新的包裝好的對象。

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

智能組件和木偶組件

若是你還不知道 木偶 組件和 智能 組件的概念,我來給你簡單的講一下,這是 React 社區裏一個很成熟的概念了。

木偶 組件: 就像一個牽線木偶同樣,只根據外部傳入的 props 去渲染相應的視圖,而無論這個數據是從哪裏來的。

智能 組件: 通常包在 木偶 組件的外部,經過請求等方式獲取到數據,傳入給 木偶 組件,控制它的渲染。

通常來講,它們的結構關係是這樣的:

<智能組件>
  <木偶組件 />
</智能組件>
複製代碼

它們還有另外一個別名,就是 容器組件ui組件,是否是很形象。

實現

具體到上面這個例子中(若是你忘了,趕忙回去看看,哈哈),咱們的思路是這樣的,

  1. 高階組件接受 木偶組件請求的方法 做爲參數
  2. mounted 生命週期中請求到數據
  3. 把請求的數據經過 props 傳遞給 木偶組件

接下來就實現這個思路,首先上文提到了,HOC 是個函數,本次咱們的需求是實現請求管理的 HOC,那麼先定義它接受兩個參數,咱們把這個 HOC 叫作 withPromise

而且 loadingerror 等狀態,還有 加載中加載錯誤 等對應的視圖,咱們都要在 新返回的包裝組件 ,也就是下面的函數中 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;
    },
  };
};
複製代碼

在參數中:

  1. wrapped 也就是須要被包裹的組件對象。
  2. 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 裏也能夠直接預覽源代碼。

完善

到此爲止的高階組件雖然能夠演示,可是並非完整的,它還缺乏一些功能,好比

  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)
複製代碼

一切變得簡潔而又優雅。

組合

注意,這一章節對於沒有接觸過 React 開發的同窗可能很困難,能夠先適當看一下或者跳過。

有一天,咱們忽然又很開心,寫了個高階組件叫 withLog,它很簡單,就是在 mounted 聲明週期幫忙打印一下日誌。

const withLog = (wrapped) => {
  return {
    mounted() {
      console.log("I am mounted!")
    },
    render(h) {
      return h(wrapped)
    },
  }
}
複製代碼

這裏咱們發現,又要把onscopedSlots 等屬性提取而且透傳下去,其實挺麻煩的,咱們封裝一個從 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 函數,其實就是不斷的把函數給高階化,返回一個新的函數。

函數式 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 寫法,那咱們用普通的循環來寫,就是返回一個函數,把傳入的函數數組從右往左的執行,而且上一個函數的返回值會做爲下一個函數執行的參數。

正常思路寫出來的 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

可是這也說明咱們要改造 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

當時的我還不能看懂這篇文章中涉及到的源碼問題和修復方案,而後改用了另外一種方式實現了業務,可是這篇文章裏提到的東西一直在個人心頭縈繞,我在忙碌的工做之餘努力學習源碼,指望有朝一日能完全看懂這篇文章。

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

❤️感謝你們

1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。

2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索