SOLID 原则在 Vue 组件开发中的应用:将单个表单组件拆分成可复用的子组件组合


为什么要拆分

前面我们演示了如何将表单组件中的 JavaScript 业务逻辑抽象为一个通用的表单类以面向对象的风格处理,从而提高代码复用性。

同样的代码复用理念也适用于 Vue 组件模板,我们可以通过将文章发布表单组件模板中的各个元素拆分出来,组成一个表单子组件库,这样,后续创建其他表单组件的时候,就可以基于这个子组件库像搭积木一样快速构建满足业务需求的表单组件。

这样做的好处是显而易见的:一旦编写好表单子组件库后,以后就不再需要通过复制粘贴为不同功能表单组件编写重复的模板代码了,取而代之地,我们可以通过组合表单子组件库提供的素材,以更加优雅、更加灵活的方式创建不同的表单组件。

学院君注:大多数时候所谓的设计模式、优雅实现、代码重构都是在消除复制粘贴,所以当你在编写项目代码时,出现了对同一份代码的复制粘贴,就要考虑是否需要进行代码重构了。😁

配置 BrowserSync 自动刷新浏览器

开始重构组件代码之前,为了提升本地开发效率和体验,我们在 webpack.mix.js 配置使用 BrowserSync,这样就可以结合 npm run watch 命令在前端资源变动自动编译后,自动刷新浏览器页面:

mix.js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css')
    .browserSync('127.0.0.1:8000');

browserSync 方法中传入的是应用的域名,在本地我们是通过 php artisan serve 命令启动的内置服务器,所以对应的主机地址和端口是 127.0.0.1:8000,BrowserSync 会通过代理该地址和端口自动刷新在浏览器中打开的应用页面。

配置完成后,需要分别打开两个终端窗口执行下面两个常驻后台命令,启动应用服务器并初始化 BrowserSync 需要的依赖:

php artisan serve

npm run watch

-w1170

然后再次运行 npm run watch

-w750

就会自动打开浏览器,通过 http://localhost:3002 代理对 http://127.0.0.1:8000 的访问:

-w1031

我们切换到 http://localhost:3002/form 也可以正常访问表单视图。

编写表单子组件库

表单骨架组件

首先我们为所有表单组件抽象出一个通用的骨架组件,这个有点类似视图模板中的布局视图,如果要构建具体的功能表单组件,往里面填充对应的表单元素组件即可。

我们在 resources/js/components 目录下新建一个 form 子目录来存放表单组件库代码,然后新建 FormSection.vue 并编写表单骨架组件代码如下:

<style scoped>
.form {
    margin: 50px auto;
    padding: 30px;
}
</style>

<template>
    <div class="card col-8 form">
        <h3 class="text-center">
            <slot name="title"></slot>
        </h3>
        <hr>
        <form @submit.prevent="$emit('store')">
            <slot name="input-group"></slot>
            <slot name="action"></slot>
        </form>
        <slot name="toast"></slot>
    </div>
</template>

<script>
export default {

}
</script>

非常简单,我们将之前的 FormComponent 组件代码拷贝过来,将所有定制化代码分离出去,只留下这么个表单骨架,然后将需要定制的位置通过插槽预留好位置,以便后续基于这个骨架组件填充表单元素构建具体的功能表单组件。

注意到我们在 form 元素上通过 @submit.prevent="$emit('store')" 将表单提交事件上传给了父级作用域去处理,也就是引用 FormSection 组件构建具体功能表单组件的地方,比如我们的文章发布表单组件,因为处理表单提交的全局 Form 类需要和具体的表单组件绑定,不能在这里引入。

接下来,我们就可以为之前 FormComponent 组件中的每个表单元素编写独立的表单元子素组件了(暂时先以此为例,你可以基于自己的需求自行维护这个子组件库中的组件),整体的拆分思路如下:

-w1010

InputText 组件

resources/js/components/form 目录下新建 InputText.vueinput[type=text] 元素编写独立的子组件:

<template>
<div class="form-group">
    <input class="form-control" type="text" :id="name" :name="name" :value="value"
           @input="$emit('input', $event.target.value)"
           @keyup="$emit('keyup')">
</div>
</template>

<script>
export default {
    props: ['name', 'value']
}
</script>

我们从父级作用域(即具体功能表单,比如文章发布表单组件)通过 props 属性将 namevalue 值传递过来,然后将当前元素的输入事件和键盘抬起事件通过 $emit 上传给父级作用域处理,这两个事件分别用于更新输入框绑定的属性值和清空错误信息,和骨架组件一样,处理表单属性值和错误信息的全局 Form 类需要和具体的功能表单组件绑定,不能在通用的子组件库中引入。

TextArea 组件

resources/js/components/form 目录下新建 TextArea.vuetextarea 元素编写独立的子组件:

<template>
    <textarea class="form-control" rows="5" :id="name" :name="name" :value="value"
              @input="$emit('input', $event.target.value)"
              @keyup="$emit('keyup')">
    </textarea>
</template>

<script>
export default {
    props: ['name', 'value']
}
</script>

基本逻辑和上面的 InputText 组件一样,不重复介绍了。

Label 组件

接下来,我们来为表单元素子组件编写外围组件,首先是 Label 子组件,在 resources/js/components/form 目录下新建 Label.vue,编写对应的子组件代码如下:

<template>
    <label :for="name">{{ label }}</label>
</template>

<script>
export default {
    props: ['name', 'label']
}
</script>

非常简单,不需要多做介绍了吧。

ErrorMsg 组件

接下来我们在 resources/js/components/form 目录下新建 ErrorMsg.vue 为每个输入框的错误提示编写独立的子组件代码:

<style scoped>
.alert {
    margin-top: 10px;
}
</style>

<template>
    <div class="alert alert-danger" role="alert" v-show="error">
        {{ error }}
    </div>
</template>

<script>
export default {
    props: ['error']
}
</script>

代码逻辑也非常简单,通过父级作用域传递的 error 属性值是否为空判定是否显示对应的错误信息。

Button 组件

表单提交操作是依赖用户点击提交按钮的,所以我们在 resources/js/components/form 目录下新建 Button.vue 编写按钮对应的子组件代码:

<template>
    <button :type="type" class="btn" :class="{'btn-primary': type==='submit', 'btn-default': type==='cancel'}">
        <slot></slot>
    </button>
</template>

<script>
export default {
    props: ['type']
}
</script>

这里的按钮子组件比起之前的表单提交按钮更加通用,可以从父级作用域传递 type 属性设置按钮类型,并根据该类型值展示不同的按钮样式,至于按钮上显示的文本则通过插槽的方式从父级作用域传入。

SuccessMsg 组件

最后是表单提交成功后的提示文案,我们在 resources/js/components/form 目录下新建 SuccessMsg.vue 编写对应的子组件代码:

<style scoped>
.alert {
    margin-top: 10px;
}
</style>

<template>
    <div class="alert alert-success" role="alert" v-show="success">
        <slot></slot>
    </div>
</template>

<script>
export default {
    props: ['success']
}
</script>

非常简单,也不再多做介绍了。

至此,我们就已经为文章发布表单组件准备好了所有的子组件库素材,对于单个表单组件来说,这要多花费不少时间,但是我们实际构建的项目肯定不止一种功能表单组件,比较常见的有登录表单、注册表单、文章发布、商品发布、发表评论等,使用这种子组件库组合的方式优势就非常明显了:每次为一种新的业务构建表单,只需要针对新增的元素编写子组件代码即可,而且随着子组件库的丰富,大多数情况下可能完全不需要创建新的子组件,只需要基于表单骨架组件,然后组合表单元素子组件构建出满足业务需求的功能表单组件即可,消除了复制粘贴式编码、消除了组件的不可维护、消除了组件的不可扩展。

重构文章发布表单组件

最后,我们基于上述表单子组件库来重构之前编写的文章发布表单组件。为了提高代码的可读性,我们将 FormComponent 重命名为 PostFormComponent,并编写新的文章发布表单组件代码如下:

<template>
    <FormSection @store="store">
        <template slot="title">发布新文章</template>
        <template slot="input-group">
            <div class="form-group">
                <Label name="title" label="标题"></Label>
                <InputText name="title" v-model="form.title" @keyup="clear('title')"></InputText>
                <ErrorMsg :error="form.errors.get('title')"></ErrorMsg>
            </div>

            <div class="form-group">
                <Label name="author" label="作者"></Label>
                <InputText name="author" v-model="form.author" @keyup="clear('author')"></InputText>
                <ErrorMsg :error="form.errors.get('author')"></ErrorMsg>
            </div>

            <div class="form-group">
                <Label name="content" label="内容"></Label>
                <TextArea name="content" v-model="form.content" @keyup="clear('content')"></TextArea>
                <ErrorMsg :error="form.errors.get('content')"></ErrorMsg>
            </div>
        </template>
        <template slot="action">
            <Button type="submit">立即发布</Button>
        </template>
        <template slot="toast">
            <SuccessMsg :success="form.success">文章发布成功。</SuccessMsg>
        </template>
    </FormSection>
</template>

<script>
import FormSection from './form/FormSection';
import InputText from './form/InputText';
import TextArea from './form/TextArea';
import Button from './form/Button';
import SuccessMsg from './form/SuccessMsg';
import Label from "./form/Label";
import ErrorMsg from "./form/ErrorMsg";

export default {

    components: {FormSection, InputText, TextArea, Label, ErrorMsg, Button, SuccessMsg},

    props: ['author'],

    data() {
        return {
            form: new Form({
                title: '',
                author: this.author,
                content: ''
            })
        }
    },

    methods: {
        store() {
            this.form.post('/post')
                .then(data => console.log(data))   // 自定义表单提交成功处理逻辑
                .catch(data => console.log(data)); // 自定义表单提交失败处理逻辑
        },
        clear(field) {
            this.form.errors.clear(field);
        }
    }
}
</script>

在新的 PostFormComponent 中,为了使用表单子组件库提供的组件,需要先引入它们,并且通过 components 属性在当前组件中声明它们才可以在模板代码中渲染这些子组件。

我们通过内容分发填充了 FormAction 骨架组件的插槽,并且在需要的时候,组合子组件来构建最终的文章发布表单组件,这里由于清理错误的事件现在是从表单元素子组件中传递过来,所以我们在这些表单元素子组件上定义了一个 @keyup="clear(field)" 来监听子组件传递过来的事件并进行处理。

resources/js/app.js 中修改文章发布表单组件的声明代码:

Vue.component('post-form', require('./components/PostFormComponent.vue').default);

form.blade.php 视图模板中调整文章发布表单组件的引入代码:

<post-form author="{{ auth()->check() ? auth()->user()->name : '匿名用户' }}"></post-form>

就可以在浏览器中看到正常渲染的新表单视图了(由于配置了 BrowserSync,所以该页面会自动刷新):

-w774

我们测试错误提示、错误清理、提交成功场景,代码都可以正常工作:

-w756

-w759

-w761

基于表单组件库快速构建登录表单

为了体现出表单子组件库的强大扩展性,我们基于它来快速搭建一个登录表单组件。

提供密码输入组件

我们需要为登录表单新建一个密码输入框子组件,考虑到这个子组件和 InputText 除了 type 属性值不同外,其他都一模一样,我们将 InputText 子组件进行扩展,将 type 值调整为通过 props 属性从父级作用域传入:

<template>
<div class="form-group">
    <input class="form-control" :type="input_type" :id="name" :name="name" :value="value"
           @input="$emit('input', $event.target.value)"
           @keyup="$emit('keyup')">
</div>
</template>

<script>
export default {
    props: ['type', 'name', 'value'],
    data() {
        return {
            input_type: this.type ?? 'text',
        }
    }
}
</script>

如果父级作用域没有传递,默认值是 text,这样一来之前引用该子组件的代码不用修改也可以正常工作。

编写用户登录表单组件

resources/js/components 目录下新建一个 LoginForm.vue,基于子组件库快速搭建登录表单如下:

<style scoped>

</style>

<template>
    <FormSection @store="login">
        <template #title>用户登录</template>
        <template #input-group>
            <div class="form-group">
                <Label name="email" label="邮箱"></Label>
                <InputText name="email" v-model="form.email" @keyup="form.errors.clear('email')"></InputText>
                <ErrorMsg :error="form.errors.get('email')"></ErrorMsg>
            </div>
            <div class="form-group">
                <Label name="password" label="密码"></Label>
                <InputText type="password" name="password" v-model="form.password" @keyup="form.errors.clear('password')"></InputText>
                <ErrorMsg :error="form.errors.get('password')"></ErrorMsg>
            </div>
        </template>
        <template #action>
            <Button type="submit">登录</Button>
        </template>
        <template slot="toast">
            <SuccessMsg :success="form.success">用户登录成功。</SuccessMsg>
        </template>
    </FormSection>
</template>

<script>
import FormSection from "./form/FormSection";
import InputText from "./form/InputText";
import Label from "./form/Label";
import ErrorMsg from "./form/ErrorMsg";
import SuccessMsg from "./form/SuccessMsg";
import Button from "./form/Button";

export default {
    components: {FormSection, InputText, Label, ErrorMsg, SuccessMsg, Button},
    data() {
        return {
            form: new Form({
                email: '',
                password: ''
            })
        }
    },

    methods: {
        login() {
            this.form.post('/user/login');
        }
    }
}
</script>

视图中引入登录组件

在引入登录表单组件到视图模板之前,需要现在 app.js 中注册它:

Vue.component('login-form', require('./components/LoginForm.vue').default);

然后在 resources/views 目录下新建登录表单视图文件 login.blade.php,引入 login-form 组件:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>用户登录表单</title>

    <!-- Fonts -->
    <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">

    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body class="antialiased">
<div id="app" class="container">
    <login-form></login-form>
</div>
<script src="{{ asset('js/app.js') }}" type="text/javascript"></script>
</body>
</html>

注册后端路由

为了可以正常渲染登录表单视图,并处理表单提交请求,需要在 routes/web.php 中注册如下路由:

Route::view('/login-form', 'login');
Route::post('/user/login', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required'
    ]);

    $email = $request->input('email');
    $password = $request->input('password');
    if ($email == 'test@xueyuanjun.com' && $password == '123456') {
        return response()->json(['success' => true, 'message' => '用户登录成功']);
    }

    throw \Illuminate\Validation\ValidationException::withMessages(['email' => ['邮箱密码不匹配']]);
});

测试登录表单功能

这样一来,我们就可以在浏览器中通过 http://localhost:3002/login-form 访问登录表单视图了:

-w781

可以看到,这个登录表单已经可以正常渲染了。

你可以通过属性和类名绑定为不同的功能表单组件定义不同的渲染样式。

我们可以对所有登录场景进行测试:

-w765

-w759

-w753

-w759

自己亲自体验下,相信你会爱上这种组件构建方式,真的是丝般顺滑。

组件库重构背后的设计原则

单一职责原则

这种将一个复杂功能组件拆分成多个子组件组合方式实现的出发点是提高组件代码复用率,但如果站在更高层次,其背后的指导思想是 SOLID 原则中的单一职责原则:每个子组件都负责单一职责。

就像后端编程中一个类、一个控制器最好只负责一个功能一样,如果你的组件代码中包含了两个以上功能,对于大型项目而言,就是代码重构开始的时机。比如表单中的输入框、按钮、错误提示,以及表单之外的其他功能组件,比如点赞、收藏,都可以通过这种设计原则进行拆分。

当然,这也是 Unix 的设计哲学:程序应该只关注一个目标,并尽可能把它做好。

所以大道至简,在不同的语言、不同的框架、不同的场景中都是相通的。

开放封闭原则

另外,这种可复用的表单子组件库设计还符合 SOLID 原则中的开放封闭原则:当我们编写一段程序代码时,它应该是对扩展开放、对修改封闭的。

放到这里,就是我们不应该每次单独为新功能从头编写完整的表单组件,以及当表单组件中的部分元素需要调整时不应该去修改已存在的表单组件代码,而是通过扩展的方式来添加新功能 —— 比如新创建一个子组件引入,或者在原有子组件基础上扩展,并且这个扩展不会影响其他已经在使用该组件的代码。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 通过 props 和 Vue 原型实例在不同组件之间共享数据状态

>> 下一篇: 基于子组件构建列表组件并实现视图模式切换功能