TS下基于Vue Composition API的表单组件化实践

2020-01-14

Vue Composition API

Vue Composition API RFC

这名字太长了,一下都以3.0API或者hook代称。

3.0在API层面发生了非常大的变动,大部分对开发是友好的,也有一些看起来比较扯淡的地方,后面专门写一篇文章来唠。

在这里列举一些我认为比较核心的变动要点,看完应该就能有一个宏观的认识。

废弃Options API

3.0API中不会有,datamethodscomputedmounted等等字段了,具体的变化参考RFC我就不细说了。取而代之的是一个setup函数。

setup这个名字在社区被喷过,但是想想其实是比较准确的描述。

这个函数有两种用法:

  • 返回一个Object,值可以在模板中被访问
  • 返回一个函数,其返回值是一个render function/JSX

这个函数就是用来挂载数据的,所以可以理解为create过程,因此取代了beforeCreatecreated钩子。

独立的响应式系统

2.0的响应式是和Vue实例强绑定的,Vue实例上的数据才会有响应式特性。但3.0中,响应式作为一个独立的系统存在,并且不一定和Vue配合使用。

这个设计的核心目的就是解耦,响应式和Vue解耦之后,才可能废弃旧的API。这和React Hooks的设计目的是有重合的,statesetState方法强耦合,使得逻辑难以做到完整抽取,那就用useState将这个耦合解除掉。

响应式解耦之后,只需要将相应的逻辑分割出来,在setup中返回所需的状态和方法就行。对3.0设计的分析和理解会单独写篇文章,从实践来看,开发灵活度得到了很大提升,但从2.0到3.0需要适应一个较大的思维变化。

No Class API

在Vue-next的代码里,一个class都没有用,啥都是函数和闭包来实现的。目的也很清楚地在Motivation中提到:

Better Type Interface

至于为什么放弃class based API,RFC中有详细的讨论,我就不多bb了。实际开发下来,类型推导简直不要太爽。

为什么要做这件事

我们业务场景中的表单有一下特点:

  • 分步骤,步骤间分页
  • 表单项联动范围广
  • 表单流程随场景变化,但变化不大,比如一个页面80%相同,造成复用问题
  • 表单项功能可能在很多场景中复用

因此我们需要一个解决复用和表单联动问题的工程实践。这个实践尽可能利用了3.0API的特性的优势来提高开发体验和效率。

一种常见思路:使用全局model和context

目前的各种方案中,我认为比较好的是使用一个全局的model对象存储表单项数据。更细致一点的会区分model和context来更好地划分逻辑。

这个方案的好处是灵活,总线机制能让你在表单项层面想怎么搞就怎么搞,不管怎么动态,数据最终由总线串联起来就行。

在设计层面,model和context可以为一个表单项提供完整的上下文,在表单项的域中,无需关心外界的其他信息,可以将注意力集中在业务逻辑上。

但这种方案的劣势同样是灵活。model和context的设计使得其值是在运行时决定的,如果希望开发阶段能够得到model和context的类型信息,就需要手动定义interface并使用极其丑陋的方式为model和context声明类型。

由于其本身具有的运行时属性,这个问题很难从设计层面解决。

所以说

这种思路会面临几个问题:

  • 开发时失去类型推导

    这个主要是爽不爽的问题

  • model和context注入方式

    注入方式无非两种,prop传入全局变量读取

    使用prop就会遇到多层组件透传的问题。

    使用全局变量本身就是一个强耦合的操作。但是这里是否需要考虑这个因素是见仁见智的,要看你是否真的需要这个表单组件拥有那么广的可复用性,一般来说这个组件也就是在这组表单里用。

  • 表单项和环境的剥离是不完全的

    表单项的理想抽象是一个提供一组数据的组件。至于这组数据在最终的结果中的key是什么,在表单项中应该是没有感知的。而使用model方案处理表单项关联时如果将key视为一个未知量会就会遇到问题,因为需要关联的key是未知的。这时有两个方法解决,被依赖项在值修改时写context或者使用一个表单项内部使用的固定innerKey。

    第一个方法,由于context是运行时的没有类型,需要不同组件开发时协商好,维护成本就比较高。

    第二个方法,实际造成了耦合,而且每个innerKey对应的值是否存在是未知的,数据类型是什么也是未知的

    所以说model方案的最大问题还是解耦不全。

当然这个方案不是不好,这个方案的不足主要还是在开发上,脱离业务谈技术都是耍流氓。比如可视化拖拽生成页面一类的产品使用这个方案就很好,因为这样生成的表单中,表单项的key和表单之间的关系都是相对确定的,也不需要开发啥。

核心思路

  • 表单由一组表单项组成
  • 表单项是一个相对独立的域,它的功能是提供一组数据
  • 在Vue表单组件中调用封装的表单项组件其实就是一种配置化,配置描述语言是js和vue template
  • 表单项不直接依赖其他表单项,多个表单项的耦合在表单层面实现

数据串联

每个表单项组件提供getData函数,该函数返回表单项暴露的数据。

表单提供getFormData函数,完成表单项数据的组合,指定各项数据的key,用于与后端的交互。

由于getData类型是可推导的,getFormData类型也是可推导的。现在需要考虑的是getData从哪里取数据的问题。

组件数据存储和复用

表单项组件的getData不应该通过Vue实例外暴露,实际上在3.0API中我们应当尽量避免通过Vue实例暴露方法,hook的一个目的就是把方法从class中抽取取来。

这样设计造成的问题是,states不能存储在Vue实例中,只能存在外部。

用单例存在外部,就会出现复用的问题,不能简单地放在变量里,否则一个组件被调用多次时拿到的值是同一个引用。

因此我们需要设计一个map的结构存储多个值。组件可以根据自身的key定位到表单项的states。这个map我们就叫它cache吧。

我们可以设计一个函数来构造cache和getter,就像这样

const [cache, getCacheByKey] = getChache({value: 'foo'}) // 参数是states

这样的好处在于,cache的构建方式是统一的,并且可以保留states完整的类型信息。

然后getData就可以从顺利地拿到数据,并且获得数据的类型推导了。

example

最后的实现就是这样

export const [
    inputCache,
    getInputStates,
] = cacheFactory({ value_: '' })

export const getInputData = (key) => {
    return inputCache[key].value_
}

这些变量都需要在单独的ts文件暴露,因为在shim-vue的声明文件中定义了.vue文件导出的是一个组件对象,没有其他的方法,所以写在vue文件中是不能被ts识别的。

这在一定程度上违背了code orgization的初衷,而直接从vue里导出实际上是有效的,这个问题应该能通过写一个插件来解决。

cacheFactory

这里给出一个我的实现

注意本文只是提供一种思路,像cacheFactory的命名、实现一类的东西,并不是非要这么做,只是我觉得方便。

type Cache<T> = {[key: string]: UnwrapRef<T>}

export function cacheFactory<T> (data: T): [Cache<T>, (key?: string) => UnwrapRef<T>] {
    let cache = {} as Cache<T>
    const getStates = (key = 'default') => {
        if (cache[key]) {
            return cache[key]
        }
        cache[key] = reactive(deepClone(data))
        return cache[key]
    }
    return [cache, getStates]
}

</T></T></T></T></T></T>

注意在我们这个思路中,cacheFactory接收的实际是states的格式或者说类型,所以构造cache时一定是深拷贝或者其他方式转换过的。深拷贝会限制这里的数据类型,但我认为这里的数据是表单项对外暴露的数据源,本来就没什么复杂性,用深拷贝问题并不大。

另一个坑来自Vue的类型声明。回到上面在example的例子中,我用了value_,而非value做key。问题来自UnwrapRef的声明。

declare type BailTypes = Function | Map<any, any=""> | Set<any> | WeakMap<any, any=""> | WeakSet<any>;
export interface Ref<T> {
    value: T;
}
export declare type UnwrapRef<T> = T extends Ref<infer V=""> ? UnwrapRef2<V> : T extends BailTypes ? T : T extends object ? {
    [K in keyof T]: UnwrapRef2<T[K]>;
} : T;
declare type UnwrapRef2<T> = T extends Ref<infer V=""> ? UnwrapRef3<V> : T extends BailTypes ? T : T extends object ? {
    [K in keyof T]: UnwrapRef3<T[K]>;
} : T;
... // 中间是很多层UnwrapRef的定义
declare type UnwrapRef10<T> = T extends Ref<infer V=""> ? V : T;

</infer></T></T[K]></V></infer></T></T[K]></V></infer></T></T></any></any,></any></any,>

如果用了value字段会matchT extends Ref<infer V>,被解析成Ref中的T。所以如果用{value: ''},返回的cache会被解析成string。

Context

context是我们着重期望得到类型推导的部分,目的是在不同步骤的组件之间能够准确地知道上下文有什么,而不是依赖于开发时的约定。

使用Vuex或context对象

我曾经使用过两种方案:

  • 基于Vuex存上下文数据
  • 在表单层面设置一个context对象存数据

对于使用Vuex,其实是在2.0的框架下比较好的方案,能够通过一些方式进行类型标注,但是需要注意一定要在强业务弱复用的代码中使用,尽量不要和复用的公共组件耦合。

context对象是上述model/context模式中实现的。在这个模式中,因为context是提供给表单项的外部环境信息,所以一定是耦合在表单项逻辑中的。这样带来的问题是,对一个表单项来说,外部环境是不定的,所以context的一个字段是否存在并不可知。这样带来了很多不确定性。

当然这个劣势是建立在表单项内部环境和外部环境完全解耦的理想假设下的。如果不是基于这个思想设计,是有解决方法的。

静态的context

如果我们希望context的类型能够推导,那一定要设计成静态的方法,不能把这些东西放在运行时来做。以前要实现这一点是比较困难的,因为响应式数据和Vue实例绑定。

比较好的方法是用一个独立的Vue实例来做数据容器,静态函数将数据存在容器里。这实际上就是一个简化版的Vuex,Vuex底层同样是使用Vue实例实现响应式的。现在响应式系统独立让这件事变得很简单。

从上面两个方案的实践中我们得到一些经验:

  • 在表单项中使用context会产生耦合
  • 区分表单项内部环境和外部环境,然后通过参数接收需要的数据才是合理的封装

依赖上一节描述的通过外部Cache暴露表单项数据的设计,我们可以实现一个具有完整类型推导的Context。

example

这里直接给出一个我开发实践中的实例。你可以先看下一节的要点总结再看工程代码。

// context.ts
export function getLandingContext () {
    // 获取该表单页中的值
    let downloadType = computed(() => getCheckBoxStates(formKeys.downloadType).value_)
    let platform = computed(() => getCheckBoxStates(formKeys.platform).value_)
    let urlInput = getUrlInputStates()
    // 一些被多方依赖的属性
    const isApp = computed(() => downloadType.value === LandingType.DOWNLOAD_URL)
    const isIOS = computed(() => platform.value === Platform.iOS)
    return {
        isApp,
        isIOS,
        // 表单项依赖上下问的参数
        platformOpts: computed(() =>
            isApp.value
                ? landingFields.platform_app
                : landingFields.platform_link
        ),
        convertFetchType: computed(() =>
            !isApp.value
                ? 'external'
                : platform.value === Platform.iOS
                    ? 'ios' : 'android'
        ),
        urlInputType: computed(() =>
            isApp ? 'app' : 'link'
        ),
        convertDisabled: computed(() =>
            urlInput.url === '' || urlInput.inputing || !!urlInput.error
        ),
        convertHint: computed(() => {
            if (urlInput.inputing) {
                return '请先完成输入'
            }
            return !urlInput.url
                ? '请先正确填写上方下载链接地址' : ''
        }),
        // 上下文相关的计算函数
        needAlertOnChange: () =>
            localStorage.getItem('landingChangeAlert') !== '1' &amp;&amp;
            (!!convert.url || !isEmpty(convert.convert) || !!webUrl.url)
        ,
    }
}

// globalContext.ts
export const generalContext = () => ({
    ...getLandingContext(),
})

// useage
setup () {
    const context = generalContext()
    // ...
}

points

总结一些要点,内容可以在上面的代码中找到参考。

  • context的结构和引用
    • 表单页/模块的context函数
    • 合并所有context的函数(就叫它mixedinContext吧)
    • 组件中通过mixedinContext得到全局的context对象
  • 函数中表单项值需要使用computed来维护响应式,这是3.0API开发中都需要注意的点。
  • 表单中的计算属性基本都是依赖于上下文内容的,所以大多数都直接在context函数中定义了。这样的好处是在表单组件中不需要过度关心上下文内容,做一下逻辑封装。
  • context返回的内容一般是:
    • computed值,响应式的
    • 返回一些值的函数,始终会在相关逻辑中被调用,所以丢失响应式不会影响功能
  • context函数内容实现方式是任意的,注意维护其响应式特性即可。
  • context是和某个表单内容强关联的,不同的表单需要定义不同的context函数,重复的context计算逻辑可以抽取出来

复用逻辑:全局context <- 模块context <- 单一context

状态合并

我们考虑这么一种情况:表单中的几项有非常强的关联,例如下面的业务场景。

l7IjiT.jpg

转化目标列表需要根据链接地址或应用包名拉取。而应用包名也通过输入的链接地址拉取。重新输入或选择链接地址或应用包名后需要清空已选的转化目标并重新拉取。还有一些其他的表单逻辑这里不赘述,反正你知道很复杂就行了。


如果使用上述的封装逻辑是不合适的,因为将表单项之间分割是为了有效封装而不是增加表单联动的成本。

这时候也许你会想,那把图里的所有内容合并成一个组件不就好了吗?但这样做会导致组件复用性大大降低。因为这样把表单的框架和表单项的内容耦合了。框架就是表单项的label、中间的必选的点点和布局之类的,内容就是右边的输入框、按钮之类的。我们封装表单项组件时是实现右边的内容部分,左边内容是什么无所谓。

这里我们实现的方式也比较简单,是将这几个关联选项的状态抽取到一个大的state中,表单最终读取这个大state的数据。这样并不会对单一的表单项复用性有影响,表单项依旧可以维护自己的cache,使用watch函数在恰到时机更新外部合并的state就行。对大量复用的表单项,watch可以在表单层面实现,对不会复用的表单项可以直接关联到外部state。

这样做的好处是,表单项间数据联动逻辑和外部state封装在一个文件中,通过watch等实现,甚至可以给表单暴露一些hook执行操作。表单层面不再对这些联动进行管理。这也是3.0API的一个设计目的。

表单组合

配置化思考

我们总是希望通过组件化和配置化来一劳永逸地解决动态表单的问题。但这个问题实际上需要根据业务场景和对象来区别看待。比如需要给运营同学用的拖拽表单,就应当使用json来描述表单内容,再由前端runtime对表单进行渲染。而需要前端来开发的一些业务逻辑复杂的表单就不适合这种配置思路,因为从js到json是语言表达能力的降维。

这里提到一个观点,配置化在做的实际上是配置语言表达能力和业务逻辑复杂程度之间的权衡。配置语言表达能力越低,表达复杂业务逻辑的困难度和复杂性就越高。而json就是一种表达能力弱的语言,所以使用json来生成表单必定是面向业务逻辑复杂度一般的情况。

那如果我们直接用js来描述配置呢?这当然是可行的并且很高效的。比如这里的按钮组,每个步骤的按钮组是不一样的,点击回调也不一样,其排列、样式、回调等等特性就是通过通过一个object来描述的。

l7XFPI.jpg

更进一步,既然用Vue在构建项目,用Vue的语法来描述配置当然也是可以的。这就是在核心思路一节中我提到的,如果组件封装程度足够高,那Vue组件本身就是一种对表单的配置描述。这也就是为什么将大量计算属性集中到context函数中,为什么将表单联动逻辑抽取出来。这样的设计让表单组件本身包含的逻辑极少,只是将组件和逻辑关联起来。

我们根据以下要点进行表单组合

表单项组件嵌套在表单框架组件中

<moduleItem title="下载方式" required="">
    <FormCheckBox :form-key="formKeys.downloadType" :fields="formFields.downloadType" :disabled="reviewing" :filter="changeFilter" :onChange="downloadTypeChange"/>
</moduleItem>

表单框架的定义在上一节中提过。

通过Context控制表单项行为

如是否显示、数据源、显示内容等

如果表单项之间存在多级if嵌套逻辑,我们统一打平到一层判断,反正都是一个computed值。

为每个表单模块定义initgetData方法

表单模块有自己初始化数据的逻辑和根据context输出数据的方法,这些方法是这个表单模块独有的,不会复用。表单提交的内容将从getData方法中取得。

至于ajax请求逻辑放在那里,和表单是本身没什么关系,能拿到数据就行了,随便在哪里都可以。

Conclusion

以上内容描述了我们在表单开发中Vue Composition API的工程实践,写这篇文章是因为的3.0API的加持下,表单场景我们可以用更灵活的方式实现组件化和功能封装。

表单的业务场景是变化多端的,这篇文章也只是提供了一种思考角度,其中一些设计可能存在缺陷。我们期望实现的无非几点:

  • 更好的组件化和复用
  • 更好的逻辑分割
  • 更好的类型推导

希望本文能够为你理解Vue3.0API提供灵感。

原文链接:juejin.im

上一篇:核心版vue-router, 仅仅只需80行代码
下一篇:小程序中switch case如何优化
相关教程
关注微信

扫码加入 JavaScript 社区

相关文章

首次访问,需要验证
微信扫码,关注即可
(仅需验证一次)

欢迎加入 JavaScript 社区

号内回复关键字:

回到顶部