Vue之高階組件實戰


歡迎關注前端早茶,與廣東靚仔攜手共同進階

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~

前言

高階組件這個概念在 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組件,是不是很形象。

實現

具體到上面這個例子中(如果你忘了,趕緊回去看看,哈哈),我們的思路是這樣的,

  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 函數,其實就是不斷的把函數給高階化,返回一個新的函數。

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)

 

  1. 首先在 reduce 的第一次循環里,a 是 withAb 是 withB,然后 return 了:

    (...args) => withA(withB(...args))

     

這個 return 的值就會作為 reduce 中下次循環的 a

  1. 下一次循環,那么此時的b 是我們假設的另一個高階組件 withC,那么就 return 了

    (...args2) => (...args) => withA(withB(...args))(withC(...args2))
              ↑ 這里是a                          ↑這里是(b(args))

     

  2. 此時我們如果外部傳入了 view,上一步中的 args2 就會被消除,這個函數會先歸約成這樣:
(...args) => withA(withB(...args))(withC(view))

 

此時 withC(view) 又進一步的作為...args去執行這個函數,進一步歸約:

withA(withB(withC(view)))

可以看到,compose 函數不斷的把函數高階包裹,在執行的時候又一層一層的解包,非常巧妙的構思。

 

歡迎關注前端早茶,與廣東靚仔攜手共同進階

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM