Vue 高级组件(HOC)实现原理
在实际业务中, 若想简化异步状态管理, 可以使用基于slot-scopes的开源库vue-promised.
本文主是强调实际此类高阶段组件的思想, 如果要将此使用到生产环境, 建议使用开源库vue-promised
举个例子
在平常开发中, 最常见的需求是: 异常请求数据, 并做出相应的处理:
- 数据请求中, 给出提示, 如: ‘loading’
- 数据请求出错时, 给出错误提示, 如: ‘failed to load data’
例如:
<template>
<div>
<div v-if="error">failed to load data!</div>
<div v-else-if="loading">loading...</div>
<div v-else>result: {{ result.status }}</div>
</div>
</template>
<script>
/* eslint-disable prettier/prettier */
export default {
data () {
return {
result: {
status: 200
},
loading: false,
error: false
}
},
async created () {
try {
this.loading = true
const data = await this.$service.get('/api/list')
this.result = data
} catch (e) {
this.error = true
} finally {
this.loading = false
}
}
}
</script>
<style lang="less" scoped></style>
复制代码
通常情况下, 我们可能会这么写. 但这样有一个问题, 每次使用异步请求时, 都需要去管理loading, error状态, 都需要处理和管理数据.
有没有办法抽象出来呢? 这里, 高阶组件就可能是一种选择了.
什么是高阶(HOC)组件?
高阶组件, 其实是一个函数接受一个组件为参数, 返回一个包装后的组件.
在Vue中, 组件是一个对象, 因此高阶组件就是一个函数接受一个对象, 返回一个包装好的对象, 即:
高阶组件是: fn(object) => newObject
复制代码
初步实现
基于这个思路, 我们就可以开始尝试了
高阶组件其实是一个函数, 因此我们需要实现请求管理的高阶函数, 先定义它的两个入参:
- component, 需要被包裹的组件对象
- promiseFunc, 异步请求函数, 必须是一个promise
loading, error等状态, 对应的视图, 我们就在高阶函数中处理好, 返回一个包装后的新组件.
export const wrapperPromise = (component, promiseFn) => {
return {
name: 'promise-component',
data() {
return {
loading: false,
error: false,
result: null
}
},
async mounted() {
this.loading = true
const result = await promiseFn().finally(() => {
this.loading = false
})
this.result = result
},
render(h) {
return h(component, {
props: {
result: this.result,
loading: this.loading
}
})
}
}
}
复制代码
至此, 算是差不多实现了一个初级版本. 我们添加一个示例组件:
View.vue
<template>
<div>
{{ result.status }}
</div>
</template>
<script>
export default {
props: {
result: {
type: Object,
default: () => { }
},
loading: {
type: Boolean,
default: false
}
}
}
</script>
复制代码
此时, 如果我们使用wrapperPromise
包裹这个View.vue
组件
const request = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({status: 200})
}, 500)
})
}
const hoc = wrapperPromise(View, request)
复制代码
并在父组件(Parent.vue)中使用它渲染:
<template>
<div>
<hoc />
</div>
</template>
<script>
import View from './View'
import {wrapperPromise} from './utils'
const request = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve({status: 200})
}, 500)
})
}
const hoc = wrapperPromise(View, request)
export default {
components: {
hoc
}
}
</script>
复制代码
此时, 组件在空白500ms后, 渲染出了200
, 代码运行成功, 异步数据流run通了.
进一步优化高阶组件, 增加”loading”和”error”视图, 在交互体现上更加友好一些.
/* eslint-disable max-lines-per-function */
export const wrapperPromise = (component, promiseFn) => {
return {
name: 'promise-component',
data() {
return {
loading: false,
error: false,
result: null
}
},
async mounted() {
this.loading = true
const result = await promiseFn().finally(() => {
this.loading = false
})
this.result = result
},
render(h) {
const conf = {
props: {
result: this.result,
loading: this.loading
}
}
const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
return wrapper
}
}
}
复制代码
再完善
到目前为止, 高阶组件虽然可以使用了, 但还不够好, 仍缺少一些功能, 如:
- 从子组件上取得参数, 用于发送异步请求的参数
- 监听子组件中请求参数的变化, 并重新发送请求
- 外部组件传递给hoc组件的参数, 没有传递下去.例如, 我们在最外层使用hoc组件时, 希望能些额外的props或attrs(或者slot等)给最内层的被包装的组件. 此时, 就需要hoc组件将这些信息传递下去.
为实现第1点, 需要在View.vue
中添加一个特定的字段, 作为请求参数, 如: requestParams
<template>
<div>
{{ result.status + ' => ' + result.name }}
</div>
</template>
<script>
export default {
props: {
result: {
type: Object,
default: () => { }
},
loading: {
type: Boolean,
default: false
}
},
data () {
return {
requestParams: {
name: 'http'
}
}
}
}
</script>
<style lang="scss" scoped></style>
复制代码
同时改写下request函数, 让它接受请求参数. 这里我们不做什么处理, 原样返回.
const request = params => {
return new Promise(resolve => {
setTimeout(() => {
resolve({...params, status: 200})
}, 500)
})
}
复制代码
有一个问题是, 我们如何能够拿到View.vue
组件中的值的呢?
可以考虑通过ref
来获取, 例如:
export const wrapperPromise = (component, promiseFn) => {
return {
name: 'promise-component',
data() {
return {
loading: false,
error: false,
result: null
}
},
async mounted() {
this.loading = true
// 获取包裹组件的请求参数
const {requestParams} = this.$refs.wrapper
const result = await promiseFn(requestParams).finally(() => {
this.loading = false
})
this.result = result
},
render(h) {
const conf = {
props: {
result: this.result,
loading: this.loading
},
ref: 'wrapper'
}
const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
return wrapper
}
}
}
复制代码
第2点, 子组件请求参数发生变化时, 父组件要同步更新请求参数, 并重新发送请求, 然后把新数据传递给子组件.
/* eslint-disable max-lines-per-function */
export const wrapperPromise = (component, promiseFn) => {
return {
name: 'promise-component',
data() {
return {
loading: false,
error: false,
result: null
}
},
methods: {
async request() {
this.loading = true
const {requestParams} = this.$refs.wrapper
const result = await promiseFn(requestParams).finally(() => {
this.loading = false
})
this.result = result
}
},
async mounted() {
this.$refs.wrapper.$watch('requestParams', this.request.bind(this), {
immediate: true
})
},
render(h) {
const conf = {
props: {
result: this.result,
loading: this.loading
},
ref: 'wrapper'
}
const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
return wrapper
}
}
}
复制代码
第2个问题, 我们只在渲染子组件时, 把$attrs
, $listeners
, $scopedSlots
传递下去即可.
export const wrapperPromise = (component, promiseFn) => {
return {
name: 'promise-component',
data() {
return {
loading: false,
error: false,
result: {}
}
},
methods: {
async request() {
this.loading = true
const {requestParams} = this.$refs.wrapper
const result = await promiseFn(requestParams).finally(() => {
this.loading = false
})
this.result = result
}
},
async mounted() {
this.$refs.wrapper.$watch('requestParams', this.request.bind(this), {
immediate: true,
deep: true
})
},
render(h) {
const conf = {
props: {
// 混入 $attrs
...this.$attrs,
result: this.result,
loading: this.loading
},
// 传递事件
on: this.$listeners,
// 传递 $scopedSlots
scopedSlots: this.$scopedSlots,
ref: 'wrapper'
}
const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
return wrapper
}
}
}
复制代码
至此, 完整代码
Parent.vue
<template>
<div>
<hoc />
</div>
</template>
<script>
import View from './View'
import {wrapperPromise} from './utils'
const request = params => {
return new Promise(resolve => {
setTimeout(() => {
resolve({...params, status: 200})
}, 500)
})
}
const hoc = wrapperPromise(View, request)
export default {
components: {
hoc
}
}
</script>
复制代码
utils.js
export const wrapperPromise = (component, promiseFn) => {
return {
name: 'promise-component',
data() {
return {
loading: false,
error: false,
result: {}
}
},
methods: {
async request() {
this.loading = true
const {requestParams} = this.$refs.wrapper
const result = await promiseFn(requestParams).finally(() => {
this.loading = false
})
this.result = result
}
},
async mounted() {
this.$refs.wrapper.$watch('requestParams', this.request.bind(this), {
immediate: true,
deep: true
})
},
render(h) {
const conf = {
props: {
// 混入 $attrs
...this.$attrs,
result: this.result,
loading: this.loading
},
// 传递事件
on: this.$listeners,
// 传递 $scopedSlots
scopedSlots: this.$scopedSlots,
ref: 'wrapper'
}
const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
return wrapper
}
}
}
复制代码
View.vue
<template>
<div>
{{ result.status + ' => ' + result.name }}
</div>
</template>
<script>
export default {
props: {
result: {
type: Object,
default: () => { }
},
loading: {
type: Boolean,
default: false
}
},
data () {
return {
requestParams: {
name: 'http'
}
}
}
}
</script>
复制代码
扩展
假如, 业务上需要在某些组件的mounted的钩子函数中帮忙打印日志
const wrapperLog = (component) => {
return {
mounted(){
window.console.log("I am mounted!!!")
},
render(h) {
return h(component, {
on: this.$listeners,
attr: this.$attrs,
scopedSlots: this.$scopedSlots,
})
}
}
}
复制代码
思考
此时, 若结合前文中实现的高阶组件, 如果两个一起使用呢?
const hoc = wrapperLog(wrapperPromise(View, request))
复制代码
这样的写法, 看起来会有些困难, 若学过React的同学, 可以考虑把Redux中的compose函数拿来使用.
compose
在了解redux compose函数前, 了解下函数式编程中纯函数的定义.
:::tip 纯函数
纯函数, 指相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
:::
export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
复制代码
其实compose函数, 是将var a = fn1(fn2(fn3(fn4(x))))
这种难以阅读的嵌套调用方式改写为: var a = compose(fn1, fn2, fn3, fn4)(x)
的方式来调用.
redux的compose实现很简单, 使用数组的reduce
方法来实现, 核心代码只有一句:
return funcs.reduce((a,b) => (..args) => a(b(...args)))
复制代码
虽然写了多年的前端代码, 与使用过reduce
函数, 但是看到这句代码还是比较懵的.
举例说明
因此,在这里举一个例子来看看这个函数是执行的.
import {compose} from 'redux'
let a = 10
function fn1(s) {return s + 1}
function fn2(s) {return s + 2}
function fn3(s) {return s + 3}
function fn4(s) {return s + 4}
let res = fn1(fn2(fn3(fn4(a)))) // 即: 10 + 4 + 3 + 2 + 1
// 根据compose的功能, 可以上面的代码改写为:
let composeFn = compose(fn1, fn2, fn3, fn4)
let result = composeFn(a) // 20
复制代码
代码解释
根据compose
的源码来看, composeFn
其执行等价于:
[fn1, fn2, fn3, fn4].reduce((a, b) => (...args) => a(b(...args)))
复制代码
循环 | a的值 | b的值 | 返回值 |
---|---|---|---|
第1轮 | fn1 | fn2 | (…args) => fn1(fn2(…args)) |
第2轮 | (…args) => fn1(fn2(…args)) | fn3 | (…args) => fn1(fn2(fn3(…args))) |
第3轮 | (…args) => fn1(fn2(fn3(…args))) | fn4 | (…args) => fn1(fn2(fn3(fn4(…args)))) |
循环到最后的返回值: (...args) => fn1(fn2(fn3(fn4(...args))))
.
经过compose处理后, 函数变成了我们想要的格式.
代码优化
按这个思路, 我们改造下wrapperPromise
函数, 让它只接受一个被包裹的参数, 即进一步高阶化它.
const wrapperPromise = (promiseFn) => {
return function(wrapper){
return {
mounted() {},
render() {}
}
}
}
复制代码
有了这个后, 就可以更加优雅的组件高阶组件了.
const composed = compose(wrapperPromise(request), wrapperLog)
const hoc = composed(View)
复制代码
小结
compose
函数其实在函数式编程中也比较常见. redux
中对compose
的实现也很简单, 理解起来应该还好.
主要是对Array.prototype.reduce
函数使用并不是很熟练, 再加上使用函数返回函数的写法, 并配上几个连续的=>
(箭头函数), 基本上就晕了.
::: tip warning
对于第一次接触此类函数(compose
)的同学, 可能比较难以理解, 但一旦理解了, 你的函数式编程思想就又升华了.
:::