高阶组件这个概念在 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> 复制代码
通常咱们都这样写,日常也没感受有啥问题,可是其实咱们每次在写异步请求的时候都要有 loading
、 error
状态,都须要有 取数据
的逻辑,而且要管理这些状态。github
那么想个办法抽象它?好像特别好的办法也很少,React 社区在 Hook 流行以前,常常用 HOC
(high order component) 也就是高阶组件来处理这样的抽象。vue-router
说到这里,咱们就要思考一下高阶组件究竟是什么概念,其实说到底,高阶组件就是:redux
一个函数接受一个组件为参数,返回一个包装后的组件
。axios
在 React 里,组件是 Class
,因此高阶组件有时候会用 装饰器
语法来实现,由于 装饰器
的本质也是接受一个 Class
返回一个新的 Class
。
在 React 的世界里,高阶组件就是 f(Class) -> 新的Class
。
在 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)))
这个函数对于第一次接触的同窗来讲可能须要很长时间来理解,由于它确实很是复杂,可是一旦理解了,你的函数式思想又更上一层楼了。
我再 github
里对一个多参数的 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
高阶函数了,由于仔细观察这个 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
当时的我还不能看懂这篇文章中涉及到的源码问题和修复方案,而后改用了另外一种方式实现了业务,可是这篇文章里提到的东西一直在个人心头萦绕,我在忙碌的工做之余努力学习源码,指望有朝一日能完全看懂这篇文章。
时至今日我终于能理解文章中说到的 $vnode
和 context
表明什么含义,可是这个 bug 在 Vue 2.6 版本因为 slot
的实现方式被重写,也顺带修复掉了,如今在 Vue 中使用最新的 slot
语法配合高阶函数,已经不会遇到这篇文章中提到的 bug 了。
1.若是本文对你有帮助,就点个赞支持下吧,你的「赞」是我创做的动力。
2.关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿共同交流和进步。