[译]如何优雅地用 Vue 创建数据驱动的用户界面

8,598 阅读7分钟

翻译: 珈蓝 from 迅雷前端

翻译自 Evan Schultz 的文章 Do it with Elegance: How to Create Data-Driven User Interfaces in Vue

本文演示了如何利用 Vue 的动态组件根据 schema 来生成一个动态的表单生成器,在管理后台、设置中心等类似的场景中,你完全可以利用这种思路来更效率地开发界面。

虽然我们通常在构建大部分的视图时知道需要用到哪些组件,但有时我们直到运行时才知道它们是什么组件(译者注:动态组件)。这意味着我们需要基于应用程序状态、用户设置或来自 API 请求的响应结果来构建视图。一个常见的情况是构建动态表单,其中所需的问题和组件由 JSON 对象配置,或者字段根据用户的答案进行更改。

所有现代的 JavaScript 框架都有处理动态组件的方法。这篇文章将向你展示如何在 Vue.JS 中实现它,它为上面的场景提供了一个非常优雅简单的解决方案。

一旦你看到用 Vue.JS 实现它是多么的简单,你可能会受到启发并且开始思考你以前从未考虑过的动态组件应用。

我们要先学会走才能学会跑,所以首先我将介绍动态组件的基础知识,然后深入讨论如何使用这些概念构建你自己的动态表单构造器。

基础

Vue 有一个叫做 <component>的内置组件,你可以在VueJS 指南的动态组件中了解完整的详细信息。

指南上写道:

“你可以使用相同的挂载点并使用保留的元素在多个组件之间动态切换,并动态绑定到其 is 属性。”

这意味着切换组件可以向像下面这样简单:

<component :is="componentType">  

让我们再多补充一点,看看发生了什么。我们将创建两个组件叫做 DynamicOne 和 DynamicTwo - One 和 Two 都是一样的,所以我不会重复展示这两个的代码。

<template>  
  <div>Dynamic Component One</div>
</template>  
<script>  
export default {  
  name: 'DynamicOne',
}
</script>

下面是一个能够在它们之间切换的快速示例,我们在 App.vue 中设置我们的组件。

import DynamicOne from './components/DynamicOne.vue';
import DynamicTwo from './components/DynamicTwo.vue';

export default {
  name: 'app',
  components: {
    DynamicOne,
    DynamicTwo,
  },
  data() {
    return {
      showWhich: 'DynamicOne',
    };
  },
};

注意:showWhich data 属性的值是字符串DynamicOne-这是在组件的components对象中创建的属性名。

在我们的模板中,我们将设置两个按钮来切换这两个动态组件。

<button @click="showWhich = 'DynamicOne'">Show Component One</button>  
<button @click="showWhich = 'DynamicTwo'">Show Component Two</button>

<component :is="showWhich"></component>  

点击这两个按钮将会交换显示 DynamicOne 和 DynamicTwo

看到这你也许会想,“那又怎样呢?这很方便——但我用v-if一样很简单”。

当你意识到<component>可以像其他任何组件一样工作时,这个例子就开始发挥作用了,并且它可以与诸如v-for之类的东西结合用于迭代集合,或者将is绑定到 input 的属性、data 属性或计算属性上。

关于 props 和事件

组件不是孤立地存在,它们需要一种方式与周围的世界交流。在 Vue 中,这种方式是通过 props 和事件实现的。

你可以用和其他组件一样的方式在动态组件上设置 props 和绑定事件,并且如果加载的组件不需要该属性,Vue 也不会报未知属性的错误。

让我们来修改我们的组件来展示一个问候组件。一个组件会接受firstNamelastName,另一个会接受firstNamelastNametitle

关于事件,我们将在DynamicOne中添加一个按钮,它将发射一个叫做"upperCase"的事件,在DynamicTwo中,这个按钮将发射一个叫做"lowerCase"的事件。

把它们组合在一起,修改后的动态组件看起来像这样:

<component  
  :is="showWhich"
  :firstName="person.firstName"
  :lastName="person.lastName"
  :title="person.title"
  @upperCase="switchCase('upperCase')"
  @lowerCase="switchCase('lowerCase')">
</component>  

不是所有的属性或事件都需要在我们正在切换的动态组件上定义。

你需要预先知道 props 吗?

在这一点上,你可能会想知道,“如果组件是动态的,并且不是所有的组件都需要知道每个可能的 props,那我需要预先知道 props 并在模板中声明它们吗?”

谢天谢地,答案是否定的。Vue 提供了一个快捷方式,你可以用v-bind将一个对象的所有 key 都绑定到组件的 props 上。

这简化了模板:

<component  
  :is="showWhich"
  v-bind="person"
  @upperCase="switchCase('upperCase')"
  @lowerCase="switchCase('lowerCase')">
</component>  

关于表单

现在我们拥有这些动态组件积木,我们就可以开始在 Vue 基础上构建表单生成器了。

我们从一个基本的表单模式开始 - 一个描述表单的字段,标签,选项等的 JSON 对象。首先,我们从下列类型的输入表单开始:

  • 文本和数字输入域
  • 一个选项列表

初始模式是这样的:

schema: [
  {
    fieldType: 'SelectList',
    name: 'title',
    multi: false,
    label: 'Title',
    options: ['Ms', 'Mr', 'Mx', 'Dr', 'Madam', 'Lord'],
  },
  {
    fieldType: 'TextInput',
    placeholder: 'First Name',
    label: 'First Name',
    name: 'firstName',
  },
  {
    fieldType: 'TextInput',
    placeholder: 'Last Name',
    label: 'Last Name',
    name: 'lastName',
  },
  {
    fieldType: 'NumberInput',
    placeholder: 'Age',
    name: 'age',
    label: 'Age',
    minValue: 0,
  },
];

看起来非常简单:可以配置标签,占位符等,选择列表还列出了可能的选项options。在这个例子中,我们将保持组件的实现一直如此简单。

TextInput.vue - template

<div>  
  <label>{{label}}</label>
  <input type="text"
    :name="name"
    placeholder="placeholder">
</div>  

TextInput.vue - script

export default {
  name: 'TextInput',
  props: ['placeholder', 'label', 'name'],
};

SelectList.vue - template

  <div>
    <label>{{label}}</label>
    <select :multiple="multi">
      <option v-for="option in options"
              :key="option">
        {{option}}
      </option>
    </select>
  </div>

SelectList.vue - script

export default {
  name: 'SelectList',
  props: ['multi', 'options', 'name', 'label'],
};

要根据上面定义的模式生成表单,需要添加以下内容:

<component
  v-for="(field, index) in schema"  
  :key="index"
  :is="field.fieldType"
  v-bind="field">
</component>  

表单效果如下:

数据绑定

如果生成表单但不绑定数据,它会有用吗?可能不会。我们目前正在生成一个表单,但没有办法将数据绑定到它。你的第一反应可能是在模式中添加一个value属性,并且在组件中使用v-model,如下所示:

<input type="text"  
  :name="name"
  v-model="value"
  :placeholder="placeholder">

这种方法存在一些潜在的缺陷,但我们最关心的是 Vue 会给我们一个错误/警告:

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"

found in

---> <TextInput> at src/components/v4/TextInput.vue
       <FormsDemo> at src/components/DemoFour.vue
         <App> at src/App.vue
           <Root>

尽管 Vue 确实提供了语法糖,使组件状态的双向绑定更容易,但框架仍然偏向于单向数据流。我们试图直接在组件内修改父组件的数据,所以 Vue 会向我们发出警告。

仔细看看v-model,它没有太多的魔力,所以让我们按照Vue 的表单输入组件指南中的描述来分解它。

<input v-model="something">

和下面相同的

<input  
  v-bind:value="something"
  v-on:input="something = $event.target.value">

随着魔法揭示,我们想要完成的是:

  • 让父组件将值提供给子组件
  • 让父组件知道值已更新

我们通过绑定到value并发出@input事件来通知父组件值已经发生变化,从而完成此操作。

来看看我们的TextInput组件

 <div>
  <label>{{label}}</label>
  <input type="text"
    :name="name"
    :value="value"
    @input="$emit('input',$event.target.value)"
    :placeholder="placeholder">
  </div>

由于父组件负责提供该值,因此它也负责处理绑定到它自己的组件状态。为此,我们可以在组件上使用v-model

FormGenerator.vue - template

<component v-for="(field, index) in schema"
  :key="index"
  :is="field.fieldType"
  v-model="formData[field.name]"
  v-bind="field">
</component>  

注意我们如何使用v-model ="formData[field.name]"。我们需要在这个 data 属性上设置一个对象:

export default {  
  data() {
  return {
    formData: {
      firstName: 'Evan'
    },
}

我们可以将对象留空,或者如果我们有一些我们想要设置的初始字段值,我们可以在这里指定它们。

现在我们已经完成了生成表单的工作,并且发现这个组件承担了相当多的责任。虽然这不是复杂的代码,但如果表单生成器本身是一个可复用组件,那将会很好。

打造可复用的生成器

对于这个表单生成器,我们希望将模式作为一个 prop 传递给它,并且能够在组件之间建立数据绑定。

用生成器的模板是这样:

GeneratorDemo.vue - template

<form-generator :schema="schema" v-model="formData">  
</form-generator>  

这大大简化了父组件。它只关心FormGenerator,而不关心每个可用的输入类型、连接的事件等等。

接下来,创建一个名为FormGenerator的组件。这几乎是复制粘贴最初的代码然后进行一些微小但关键的调整:

  • v-modle改为:value,然后用@input处理事件
  • 添加valueschema 到 props 上
  • 实现 updateForm方法

FormGenerator 组件如下:

FormGenerator.vue - template

<component
  v-for="(field, index) in schema"
  :key="index"
  :is="field.fieldType"
  :value="formData[field.name]"
  @input="updateForm(field.name, $event)"
  v-bind="field">
</component>

FormGenerator.vue - template

import NumberInput from '@/components/v5/NumberInput';
import SelectList from '@/components/v5/SelectList';
import TextInput from '@/components/v5/TextInput';

export default {
  name: 'FormGenerator',
  components: {NumberInput, SelectList, TextInput},
  props: ['schema', 'value'],
  data() {
    return {
      formData: this.value || {},
    };
  },
  methods: {
    updateForm(fieldName, value) {
      this.$set(this.formData, fieldName, value);
      this.$emit('input', this.formData);
    },
  },
};

由于formData属性并不知道我们传入的每一个可能的字段,我们使用this.$set,这样 Vue 的响应系统就可以跟踪它的任何变化,并允许FormGenerator组件跟踪它自己的内部状态。

现在我们有了一个基本的、可复用的表单生成器。

在组件内使用它:

GeneratorDemo.vue - template

<form-generator :schema="schema" v-model="formData">  
</form-generator>  

GeneratorDemo.vue - script

import FormGenerator from '@/components/v5/FormGenerator'

export default {  
  name: "GeneratorDemo",
  components: { FormGenerator },
  data() {
    return {
      formData: {
        firstName: 'Evan'
      },
      schema: [{ /* .... */ },
}

现在你已经看到了表单生成器如何利用 Vue 的基础动态组件创建一些高度动态的、数据驱动的 UI。我鼓励你好好研究下GitHub上的示例代码或者在CodeSanbox上实践。如果你有任何问题或者想聊一聊,可以随时通过 Twitter, Github, 或邮件联系我。

扫一扫关注迅雷前端公众号