Vue中HOC的应用

今天我们聊一聊在Vue中如何应用HOC,使用他能帮助我们解决什么样的问题,以及对以后参与和维护的人员带来多大的帮助。对于这篇文章我也希望大家能从中学到一些技术点,如果有不对,或者有毛病的地方,还请大家多多指出,共同进步。

1.探讨一下在Vue开发中创建组件的方式

<template>
    <div>经典组件写法</div>
</template>
<script>
    export default {
        name: 'index',
        props: {
            text: String
        },
        methods: {}
        // 其他钩子或成员属性
    }
</script>
<style lang="scss"></style>
  • 上面是一个经典的Vue组件写法
  • 比如有一个系统上经常能看到的一个功能(一个组件)

  • icon, textbox, button组成的一个复合组件
  • 点击search先校验搜索内容不为空, 才开始搜索

2.接下来看一下上述需求在经典组件中实现的解决方案

  • Mixins
  • 外部组件包裹内部组件
  • Slot / 动态组件<component v-bind:is=“view” />
  • 代码设计及主要实现
    • 对 Textbox直接依赖
    • 在template留下component
    • 校验方法由button持有
<template>
    <!-- 或者直接引入,都可以 -->
    <component :is="icon"></component>
    <Textbox />
    <!-- 或者直接引入,都可以 -->
    <component :is="button"></component>
</template>
<script>
    import Textbox from 'Textbox'
    import validate from 'Validate'

    export default {
        props: ['icon', 'button'],
        mixins: [ validate ],
        components: {
            Textbox
        }
    }
</script>
<style lang="scss"></style>

3.现在需求变了(产品一直都在改变, emm…..)

  • 文本框前面的不再是Icon, 而是一个复选框
  • 文本框后面的不再是Search按钮, 而是确定按钮
  • 添加蓝色的边框
  • 代码实现和对比
<!-- icon变search -->
<template>
    <!-- 或者直接引入,都可以 -->
    <component :is="checkbox"></component>
    <Textbox />
    <!-- 或者直接引入,都可以 -->
    <component :is="button"></component>
</template>
<script>
    import Textbox from 'Textbox'
    import validate from 'Validate'

    export default {
        props: ['checkbox', 'button'],
        mixins: [ validate ],
        components: {
            Textbox
        }
    }
</script>
<style lang="scss"></style>


<!-- 整体组件添加蓝色边框 -->
<template>
    <div class="border color">
        <component :is="innerComponent"></component>
    </div>
</template>
<script>
    export default {
        props: ['innerComponent']
    }
</script>
<style lang="scss"></style>

4.发现了什么问题?

  • 组件对象直接持有了mixin对象, 组件对象需要做出配合
  • 组件套组件时, 外部组件完全代理内部组件的属性, 导致外部组件无法复用
  • Slot/Component需要预先在组件中预留位置
  • 模板语法一旦有错, 无法容错

5.实际上我们需要解决的问题

  • 如何优雅的改变组件之间的依赖关系?
  • 如何优雅的监视一个组件事件执行?
  • 如何优雅的处理渲染错误?
  • 如何优雅的扩展一个组件的外观?

6.方案

  • 为什么不试试JSX(在官网的一个小角落里)
  • JSX主要实现
import Textbox from 'Textbox'

export default {
    name: 'search',
    props: {
        prefix: Object,
        suffix: Object
    },
    render(h) {
        return (
            <div class='search'>
                // 前面的icon 或者
                { this.prefix }
                <Textbox />
                { this.suffix }
            </div>
        )
    }
}
  • 看上去还不错
    • 对Textbox直接依赖
    • 需要预留位置
    • 校验方法由button持有

7.非常遗憾, 这并没有解决任何问题

  • 我们还需要引入面向对象的概念
  • vue-class-component,在cli3.0选择typescript语言自动会安装依赖
import Component from 'vue-class-component'

@Component
export default class Button {
    render() {
        return (
            <div class='button'>
                我是一个按钮
            </div>
        )
    }
}
  • 关于vue-class-component
    • ES/TS的装饰器来提供类风格的Vue组件
    • methods可以作为类的成员方法直接定义
    • computed可以作为类的属性访问器直接定义
    • data可以作为类的成员字段直接定义, 但不能通过类的实例直接调用
    • render, mounted等生命周期的钩子函数也可以作为成员方法直接定义, 但不能通过类的实例调用它们
    • 对于其他配置(如组件的显示名称name)等options, 作为装饰器函数的参数传入

8.来尝尝vue-class-component,恩~真香!

import Vue from 'vue'
import Component from 'vue-class-component'

@Component(
    {
        props: {
            text: String
        }
    }
)
class Demo extends Vue {
    name = 'Demo'

    set name(newName) {
        this.name = newName
    }

    get name() {
        return this.name
    }

    doSomeThing() {
        return `Hello ${ this.name }`
    }

    render() {
        return (
            <div>
                我是一个小Demo
            </div>
        )
    }
}

export default Demo
// 引入上面的demo
import Demo from 'demo'
import Component from 'vue-class-component'

class DemoExtend extends Demo {
    // 重写demo中的dosomething
    doSomeThing() {
        const superValue = Demo.options.methods.doSomeThing.call(this)
        return `${ superValue } was extended`
    }

    // 重写render方法
    render() {
        // 这里不支持super
        const view = Demo.options.render.call(this, this.$createElement)
        return (
            <div class='border' style={{ border: '2px solid blue' }}>
                { view }
            </div>
        )
    }
}
  • 有什么好处?
    • 通过继承增强对render的控制力度
    • 可以抽象公用一些逻辑代码
    • 拥抱Javascript
    • 代码更灵活, 更易扩展

9.面向对象的实现方式

import Textbox from 'Textbox'
import Component from 'vue-class-component'

@Component(
    {
        props: {
            prefix: HTMLElement,
            suffix: HTMLElement
        }
    }
)
export default class Search {
    render() {
        return (
            <div class='search'>
                { this.prefix }
                <Textbox />
                { this.suffix }
            </div>
        )
    }
}
// 引入上面的search文件
import Search from 'search'
import Component from 'vue-class-component'

@Component(
    {
        props: Search.options.props
    }
)
export default class SearchTextBox extends Search {
    render() {
        try {
            return Search.options.render.call(this, this.$createElement)
        } catch (e) {
            return <div>对不起,模板错误了</div>
        }
    }
}

10.实际上, 有更优雅的方式

  • 什么是组件?
    • 组件(Component)是对数据和方法的简单封装 –百度百科
  • 什么是高阶组件?
    • 所谓高阶组件, 就是一个参数是组件, 返回值也是组件的函数 –React Docs
  • 定义高阶组件
    • HOC即High-Ordered Component
const HOC = Component => Component

11.一个高阶组件, 用于向组件注入其依赖的组件

  • InjectController
import Vue from 'vue'
import Component from 'vue-class-component'

export default const Inject = (
    InjectedIcon,
    iconProps,
    InjectedButton,
    buttonProps) => (WrappedComponent) =>
    @Component
    class InjectController extends WrappedComponent {
        render() {
            const vnode = WrappedComponent.options.render.call(this, this.$createElement)
            const children = vnode.children

            children.splice(0, 0, <InjectedIcon { ...{ props: iconProps } } />)
            children.push(<InjectedButton { ...{ props: buttonProps } } />)

            return vnode
        }
    }
  • 接下来使用以下,尝一尝
import Inject from './HOC/inject'
import Textbox from './textbox'
import Button from './button'
import Icon from './icon'

// 使用高阶组件
export default Inject(
    Inject,
    {
        type: 'serach'
    },
    Button,
    {
        text: 'Search',
        click() {
            alert('searching')
        }
    }
)(Textbox)

12.我们换个姿势在在尝试以下

// 引入的东西不在引入
@Inject(
    Icon,
    {
        type: 'search'
    },
    Button,
    {
        text: 'Search',
        click() {
            alert('点击了按钮')
        }
    }
)
@Component
export class Search extends Textbox {}
  • 接下来我们实现以下之前说的新的需求,将icon换成复选框
@Inject(
    CheckBox,
    {
        defaultChecked: true
    },
    Button,
    {
        text: '提交',
        click() {
            alert('点击了按钮')
        }
    }
)
@Component
export class Search extends Textbox {}

13.实际上我们需要解决的问题

  • 如何优雅的改变组件之间的依赖关系?
  • 如何优雅的监视一个组件事件执行?
  • 如何优雅的处理渲染错误?
  • 如何优雅的扩展一个组件的外观?

14.监视一个组件事件执行

// 高阶组件,用于代理事件
export default const proxyClick = (
    beforeHandler,
    afterHandler) => (WrappedComponent) =>
    @Component
    class ProxyClickController {
        handleClick(...args) {
            beforeHandler && beforeHandler(...args)

            const { click } = this
            click && click(...args)

            afterHandler && afterHandler()
        }

        render() {
            const oldProps = this.$props
            const newProps = {
                props: {
                    ...oldProps,
                    click: this.handleClick
                }
            }

            return <WrappedComponent { ...newProps } />
        }
    }
// 现在我们加入事件代理

// 需要引入上面的proxyClick,这里不在引入
const searchButton = proxyClick(
    e => alert('事件之前: before'),
    e => alert('事件之后: after')
)

// 注入的高阶组件和相关的icon,search等都需要引入,这里不在引入
export default Inject(
    Icon,
    {
        type: 'search'
    },
    searchButton,
    {
        text: 'Search',
        click() {
            alert('点击了按钮')
        }
    }
)(Textbox)
  • 接下来我们用装饰器来操作事件代理的高阶函数
@ProxyClick(
    e => alert('事件之前: before'),
    e => alert('事件之后: after')
)

// 这里的button也需要引入,这里不在引入
@Component(
    {
        props: {
            ...Button.options.props
        }
    }
)
class SearchButton extends Button {}

15.实际上我们需要解决的问题

  • 如何优雅的改变组件之间的依赖关系?
  • 如何优雅的监视一个组件事件执行?
  • 如何优雅的处理渲染错误?
  • 如何优雅的扩展一个组件的外观?

16.接下来我们看一下处理渲染错误

import Vue from 'vue'
import Component from 'vue-class-component'

export default const catchError = (errorMessage) => (WrappedComponent) =>
    @Component
    class CatchError extends WrappedComponent {
        render() {
            try {
                return WrappedComponent.options.render.call(this, this.$createElement)
            } catch (e) {
                return (
                    <div style={{ color: 'red' }}>
                        { errorMessage || e.message }
                    </div>
                )
            }
        }
    }
  • 接下来我们用装饰器的方式来使用
// 这里需要引入textbox, vue-class-component,以及在文档中14小节导出的searchButton
@CatchError('组件渲染错误')
@Inject(
    Icon,
    {
        type: 'search'
    },
    searchButton,
    {
        text: 'Search',
        click() {
            alert('点击了按钮')
        }
    }
)
@Component(
    {
        props: 'placeholder'
    }
)
export class Search extends Textbox {
    render() {
        if (!this.placeholder) {
            throw new Error('不能为空')
        }

        return Textbox.options.render.call(this, this.$createElement)
    }
}

17.实际上我们需要解决的问题

  • 如何优雅的改变组件之间的依赖关系?
  • 如何优雅的监视一个组件事件执行?
  • 如何优雅的处理渲染错误?
  • 如何优雅的扩展一个组件的外观?

18.接下来我们看一下如何优雅的扩展一个组件的外观

import Vue from 'vue'
import Component from 'vue-class-component'

export default const border = (color) => (WrappedComponent) =>
    @Component
    class BorderController extends WrappedComponent {
        render() {
            const view = WrappedComponent.options.render.call(this, this.$createElement)

            return (
                <div style={{ border: `2px solid ${color}`}}>
                    { view }
                </div>
            )
        }
    }
  • 接下来我们用装饰器来尝试一下
// 这里需要引入之前所有的信息
@CatchError('模板渲染错误')
@Border('red')
@Border('yellow')
@BOrder('blue')
@Inject(
    Icon,
    {
        type: 'search'
    },
    SearchButton,
    {
        text: 'Search',
        click() {
            alert('点击了按钮')
        }
    }
)
@Component(
    {
        props: {
            placeholder: String
        }
    }
)
export class Search extends Textbox {}
  • 看一下效果

  • 尝试一下函数式编程,改造一小下

const compose = (...fn) => fn.reduce((a, b) => (...args) => a(b(...args)))
const Borders = compose(Border('red'), Border('yellow'), Border('blue'))

@CatchError('模板渲染错误')
@Borders
@Inject(
    Icon,
    {
        type: 'search'
    },
    SearchButton,
    {
        text: 'Search',
        click() {
            alert('点击了按钮')
        }
    }
)
@Component(
    {
        props: {
            placeholder: String
        }
    }
)
export class Search extends Textbox {}

19.实际上我们需要解决的问题

  • 如何优雅的改变组件之间的依赖关系?
  • 如何优雅的监视一个组件事件执行?
  • 如何优雅的处理渲染错误?
  • 如何优雅的扩展一个组件的外观

20.总结

  • 在实际的业务需求中,需要考虑人员,考虑项目本身去做更改,这样可以节省很多的开发成本。相对于后续如果要扩展并且维护一些复杂的组件或者功能可以非常轻松。但也需要针对不同的项目人员采取不同的实现方式。达到项目统一,代码统一,阅读统一。
  • 喜欢函数式、面向对象编程,比如安源同学,可以后续考虑vue中应用这种开发模式,其实oo编程在实际的项目中可以解决很多的业务场景,复杂化,耦合性高的功能(对于前端,是组件的复杂程度),耦合性过高的组件脱离了组件化的中心思想(高可用,可组合,可复用,可维护)。我们在实际的开发中都拥有这类似的问题,组件的耦合高了,后续需求变了,然后会在这个组件上面改来改去。最后越来越复杂。
  • 我在开发过程中,除了紧急的项目先寥寥草草的写上,大部分的项目我都会把每一个细微的组件拆分的很细,跟思老师合作,他应该阅读过我的代码和项目的目录结构。这样有利有弊
    • 好处是细小的组件可以抽离出来分成,业务型组件通用型组件,这样后续再有类似的功能可以直接拿来几个不相干的组件拼成一个大的组件,每个组件可低耦合业务
    • 坏处是文件分的过多,项目的文件比较多,但如果命名统一,规则统一,找组件也很方便很快速
  • vue-cli3.0的版本其实已经引入了vue-class-component的依赖,如果工作流统一,开发方式统一,可以完全采用这种形式。之前伟伟老师跟我说过想用JSX,Vue本身就支持JSX语法,所以这样我们去开发系统的时候可以分成ComponentPureComponent,更细粒度的映射了某个组件的应用场景,从抽象变到了明确
  • 上述这种情况是面对vue-cli2.0版本的代码,如果是vue-cli3.0应用起来不是那么复杂
  • 下一篇文章我会用vue-cli3.0写一写ts的语法
  • 一直在写项目今天抽出来时间写的这么一篇文章,写的比较潦草,大家凑乎看

发表评论