显示导航

使用 Grails 构建 Vue.js 应用程序

了解如何在应用程序中添加 Vue.js 前端

作者:Ben Rhine、Zachary Klein

Grails 版本 3.3.3

1 培训

Grails 培训 - 由创建并积极维护 Grails 框架的团队开发和提供!。

2 开始使用

在本指南中,我们将使用 Vue 配置文件 构建一个具有 Vue.js 应用程序作为前端的 Grails 应用程序。示例项目将是 Garage 应用程序,如 ReactVaadin 指南中所示。你可以参考这些指南,将 Vue.js 版本进行比较。

请注意,本指南并非对 Vue.js 的介绍。你可以参考 官方文档,或查看此 介绍性文章

2.1 所需内容

要完成本指南,你需要以下内容

  • 时间

  • 一款出色的文本编辑器或 IDE

  • 安装了 JDK 1.8 或更高版本,并且正确配置了 JAVA_HOME

2.2 如何完成指南

若要开始,请执行以下操作

Grails 指南存储库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,带有一些额外代码,以便你能抢先上手。

  • complete 一个完成的示例。这是完成指南呈现的步骤,并将这些更改应用于 initial 文件夹的结果。

若要完成指南,请转到 initial 文件夹

  • cdgrails-guides/building-a-vue-app/initial

并在后续章节中按照说明进行操作。

如果你 cdgrails-guides/building-a-vue-app/complete,则可以直接转到已完成的示例

3 运行应用程序

Vue 配置文件生成多项目架构,其中包含 serverclient 子项目。server 项目是使用 rest-api 配置文件的 Grails 应用程序,而 client 是使用 Vue-CLI 的 webpack 模板生成的 Vue 应用程序。为了运行整个项目,你需要分别启动 serverclient 应用程序。

转到 initial 目录

$ cd initial/

为了启动 Grails 应用程序,请运行以下命令

$ ./gradlew server:bootRun

Grails 应用程序将在 http://localhost:8080 提供

为了启动 Vue.js 应用程序,请在同一目录中打开一个第二个终端会话,并运行以下命令

$ ./gradlew client:start

Vue.js 应用程序将在 http://localhost:3000 提供。浏览到该 URL,你应该会看到默认的“Welcome”页面。

4 构建服务器

创建以下四个领域类。

$ grails create-domain-class demo.Vehicle
$ grails create-domain-class demo.Driver
$ grails create-domain-class demo.Make
$ grails create-domain-class demo.Model

以以下方式编辑领域类

server/grails-app/domain/demo/Vehicle.groovy
package demo

import grails.rest.Resource

@Resource(uri = '/vehicle')
class Vehicle {

    String name

    Make make
    Model model

    static belongsTo = [driver: Driver]
}
server/grails-app/domain/demo/Driver.groovy
package demo

import grails.rest.Resource

@Resource(uri = '/driver')
class Driver {

    String name

    static hasMany = [ vehicles: Vehicle ]

    static constraints = {
        vehicles nullable: true
    }
}
server/grails-app/domain/demo/Make.groovy
package demo

import grails.rest.Resource

@Resource(uri = '/make')
class Make {
    String name
}
server/grails-app/domain/demo/Model.groovy
package demo

import grails.rest.Resource

@Resource(uri = '/model')
class Model {
    String name
}

由于我们向领域类添加了 @Resource 注释,因此 Grails 将为它们中的每一个生成 RESTful URL 映射。让我们借助 GORM 数据服务来预加载一些数据。

server/grails-app/services/demo/MakeDataService.groovy
package demo

import grails.gorm.services.Service

@Service(Make)
interface MakeDataService {
    Make save(String name)
}
server/grails-app/services/demo/ModelDataService.groovy
package demo

import grails.gorm.services.Service

@Service(Model)
interface ModelDataService {
    Model save(String name)
}
server/grails-app/services/demo/DriverDataService.groovy
package demo

import grails.gorm.services.Service

@Service(Driver)
interface DriverDataService {
    Driver save(String name)
}
server/grails-app/services/demo/VehicleDataService.groovy
package demo

import grails.gorm.services.Service

@Service(Vehicle)
interface VehicleDataService {
    Vehicle save(String name, Driver driver, Make make, Model model)
}
server/grails-app/init/demo/BootStrap.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
class BootStrap {

    DriverDataService driverDataService
    MakeDataService makeDataService
    ModelDataService modelDataService
    VehicleDataService vehicleDataService
    def init = { servletContext ->
        log.info "Loading database..."
        Driver driver1 = driverDataService.save("Susan")
        Driver driver2 = driverDataService.save("Pedro")

        Make nissan = makeDataService.save("Nissan")
        Make ford = makeDataService.save("Ford")

        Model titan = modelDataService.save("Titan")
        Model leaf = modelDataService.save("Leaf")
        Model windstar = modelDataService.save("Windstar")

        vehicleDataService.save("Pickup", driver1, nissan, titan)
        vehicleDataService.save("Economy", driver1, nissan, leaf)
        vehicleDataService.save("Minivan", driver2, ford, windstar)

    }
    def destroy = {
    }
}

重新启动 server 项目以在默认数据源中加载测试数据。

如果你希望使用 Grails 封装程序 ./grailsw run-app(而非 Gradle 封装程序)来运行 server 应用程序,则请确保启动应用程序时你处于 server 目录中。

4.1 测试 API

当 Grails 应用程序正在运行时,我们可以使用 cURL 或另一个 API 工具,来试验 Grails 为我们生成的 RESTful API。

/vehicle 发出一个 GET 请求以获取车辆列表

$ curl -X "GET" "http://localhost:8080/vehicle"

HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 06 Jan 2017 19:28:49 GMT
Connection: close

[{"id":1,"driver":{"id":1},"make":{"id":1},"model":{"id":1},"name":"Pickup"},
{"id":2,"driver":{"id":1},"make":{"id":1},"model":{"id":2},"name":"Economy"},
{"id":3,"driver":{"id":2},"make":{"id":2},"model":{"id":3},"name":"Minivan"}]

发出一个 GET 请求到 /driver/1,可以获取一个特定的司机实例

$ curl -X "GET" "http://localhost:8080/driver/1"

HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 06 Jan 2017 22:10:33 GMT
Connection: close

{"id":1,"name":"Susan","vehicle":[{"id":2},{"id":1}]}

发出一个 POST 请求到 /driver,可以创建一个新的司机实例

$ curl -X "POST" "http://localhost:8080/driver" \
      -H "Content-Type: application/json; charset=utf-8" \
      -d '{"name":"Edward"}'

HTTP/1.1 201
X-Application-Context: application:development
Location: http://localhost:8080/driver/3
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 06 Jan 2017 21:55:59 GMT
Connection: close

{"id":3,"name":"Edward"}

发出一个 PUT 请求到 /vehicle,可以更新一个汽车实例

$ curl -X "PUT" "http://localhost:8080/vehicle/1" \
       -H "Content-Type: application/json; charset=utf-8" \
       -d '{"name":"Truck","id":1}'

HTTP/1.1 200
X-Application-Context: application:development
Location: http://localhost:8080/vehicle/1
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 06 Jan 2017 22:12:31 GMT
Connection: close

{"id":1,"driver":{"id":1},"make":{"id":1},"model":{"id":1},"name":"Truck"}

4.2 自定义 API

默认情况下,由 Grails 生成的 RESTful URL 仅提供关联的对象的 ID。

$ curl -X "GET" "http://localhost:8080/vehicle"
HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 06 Jan 2017 23:55:33 GMT
Connection: close

{"id":1,"name":"Pickup","make":{"id":1},"driver":{"id":1}}

这是许多 REST API 的标准,但我们马上需要从这个端点获取更多数据来使用 Vue 应用程序。对于JSON 视图来说,这是很好的一个场景。让我们创建一个新的 JSON 视图来呈现我们的汽车列表

$ mkdir grails-app/views/vehicle/

根据惯例,任何针对 restful 控制器(比如那些由 @Resource 生成的控制器)而创建的相应视图目录中的 JSON 视图都将用于替代默认的 JSON 表示。现在,我们可以通过为汽车创建新的 JSON 模板来自定义我们的 JSON 输出

$ vim grails-app/views/vehicle/_vehicle.gson

编辑文件,将以下内容包括进去

server/grails-app/views/vehicle/_vehicle.gson
import demo.Vehicle

model {
    Vehicle vehicle
}
json {
    id vehicle.id

    name vehicle.name

    make name: vehicle.make.name,
        id: vehicle.make.id

    model name: vehicle.model.name,
            id: vehicle.model.id

    driver name: vehicle.driver.name,
        id: vehicle.driver.id
}

现在,当我们访问我们的 API 时,我们将看到每个 makemodeldrivernameid 都包括进去了。

HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 07 Jan 2017 00:24:18 GMT
Connection: close

{"id":1,"name":"Pickup","make":{"name":"Nissan","id":1},"model":{"name":"Titan","id":1},"driver":{"name":"Susan","id":1}}

5 构建客户端

在这一点上,我们的 server 项目已经完成,我们可以开始构建我们的 Vue.js client 应用程序了。让我们概述一下 Vue 配置文件和 Vue-CLI 提供的项目结构。

5.1 默认 Vue.js 应用程序布局

Vue-CLI 项目包含运行、测试和构建 Vue.js 项目所需的所有配置。它遵循 Vue.js 社区推荐的约定。

vueDirListing
图 1。/client

build 目录包含 webpack 配置文件,包括 dev、prod 和 test 环境的特定于环境的配置。

config 目录包含非构建相关的配置,包括如上所述的特定于环境的配置。我们要关注的一个配置属性是 SERVER_URL,它指向 Grails 的 URL server 应用程序,默认设置为 http://localhost:8080。如有需要,你可以编辑它来指向另一个服务器。

vueConfig
图 2。/client/config
虽然它们具有类似的功能,但请记住,Vue/webpack 环境设置与 Grails 环境没有直接关系 - 例如,如果你用特定的环境运行/构建你的 Grails 项目,那么它将不会自动影响 client 项目中的环境。

static 目录包含不应由 webpack 处理的静态资源 - 你将不会在本指南中使用它。

test 目录包含 Vue.js 应用程序的单元和集成(端到端)测试。

vueTest
图 3。/client/test

src目录包含我们的 Vue.js 项目的实际源代码。它包含组件、资产(例如 CSS 文件)和默认的 Vue-Router 配置的子目录。这是 Vue.js 应用程序的典型项目结构,但是你可以在src目录内应用适合你需要的任何目录结构。在本文档的余下部分,我们将在该目录中花费大部分时间。

vueApp
图 4. /client/src

5.2 Vue 组件

单文件组件

我们将创建多个 Vue 组件来使用/与我们的 API 交互。在本指南中,我们将使用单文件组件。单文件组件允许我们在一个文件中封装模板(HTML)、样式和组件的 Vue 实例(它处理组件的数据和行为)。这些文件的扩展名为.vue

单文件组件需要一些附加处理才能在浏览器中呈现。Vue 配置文件提供的client项目已配置为正确编译单文件组件。

组件已导出,可以导入到其他组件中。他们还可以接受props、触发和响应事件,并且包含内部数据(就像所有 Vue 组件一样)。参考Vue.js 文档以了解有关单文件组件的更多信息。

5.3 UI 头部组件

我们的第一个组件将是头部栏。在client/src/components下新建一个名为AppHeader.vue的文件,并按如下所示编辑

/client/src/components/AppHeader.vue
<template id="app-header-template">
  <div class="container">
    <div class="jumbotron">
      <img src="../assets/logo.png">
      <h1>Welcome to the Garage</h1>
    </div>
  </div>
</template>

<script>
export default {
  name: 'app-header'
}
</script>

<style>
</style>

<template>包含将由组件呈现的 HTML 模板。在上面的示例中,我们正在呈现一个<div>标签来表示我们 UI 的主标题,包括一个横幅图片和<h1>标签。

每个<template>都必须只包含一个根级别元素。

<script>标签中,我们以模块的形式导出了一个 JavaScript 对象。该对象将用作 Vue 组件的实例定义,并用于为组件提供数据和行为。在这种情况下,我们的组件完全是表示性的,所以在这个对象中我们没有太多内容。我们将在本指南的后面看到更多有关此对象中可用功能的示例。

单文件组件的最后一部分是<style>标签。在这里,你可以指定组件特定的 CSS 规则。这些规则将“限定”到组件的模板,并且不会影响任何其他 HTML 元素。

client/src/components/form下创建一个名为VehicleFormHeader.vue的新文件(如果需要,创建一个form目录),并按如下所示编辑

/client/src/components/VehicleAddHeader.vue
<template id="add-vehicle-header-template">
  <div id="headerRow" class="row">
    <div class="col">
      <h3>Add a Vehicle:</h3>
    </div>
    <div class="col"></div>
    <div class="col"></div>
  </div>
</template>

<script>
export default {
  name: 'vehicle-form-header'
}
</script>

<style>
</style>
默认情况下,当你在模板中使用组件时,元素名称将是组件的名称,用连字符连接。例如,AppHeader将变为<app-header>

5.4 选择和表格组件

选择组件

当创建新车辆时,我们需要一个通用的 <select>,允许用户从可用的 MakeModelDriver 记录中进行选择。

vueSelect

client/src/components/form 下创建文件 FieldSelect.vue,并按照如下所示编辑内容

/client/src/components/form/FieldSelect.vue
<template id="driverSelect-template" xmlns="http://www.w3.org/1999/xhtml">
  <div class="form-group"> (4)
    <select class="form-control" v-model="selected" @change="updateValue()"> (7)
      <option disabled :value="null">Select a {{field}}</option> (2)
      (1)
      <option v-for="value in values" :value="value.id" :key="value.id">
        {{ value.name }}
      </option>
    </select>
  </div>
</template>

<script>
export default {
  (1)
  props: ['values', 'field'], (2)
  data: function () { (2)
    return {
      selected: null (3)
    }
  },
  methods: { (5)
    updateValue: function () { (6)
      this.$emit('input', this.selected)
    }
  }
}
</script>

<style>
  /* Add custom rules here */
</style>
1 使用 values 声明一个 prop,该 prop 将显示我们选择的对象列表,并将作为 HTML 属性传递到组件中。例如,<field-select values="[obj1,obj2,obj3]"/>
2 第二个 prop 被命名为 field,它将作为正在选择字段的可读名称(它用作默认的“无选择”选项)。
3 data() 函数返回一个对象,该对象将成为组件的初始数据(或状态)。在这种情况下,我们 data 中只有一个变量,即 selected,它将存储选择列表的当前值。
4 v-model 指令在元素的“value”和 data 中的变量之间设置双向绑定。当值发生更改时,模型变量(selected)将被更新,反之亦然。
5 methods 是一个包含任意 JavaScript 函数的对象,可以从模板或组件中的其他方法中进行调用。
6 updateValue 方法发出一个事件,这样父组件就能对此组件中的更改做出响应。在这种情况下,我们发出 selected 的值,它将是列表中用户选择的选项。
7 我们使用 updateValue 方法作为 <select> 元素的 onChange 事件的事件处理程序,使用 @change 属性(还支持其他事件,诸如 @click@focus 等)。
单向与双向数据绑定

Vue.js 支持单向双向数据绑定,而此组件展示了这两种方法。当在模板表达式中使用了数据变量({{field}}),或通过 :value="field" 语法将其绑定为组件的一个属性时,元素在 field 变量发生改变时(如果发生改变的话)将被更新。此绑定是单向的,元素不能直接更改 field 的值。

但是,如果一个元素使用了 v-model 指令,那将创建一个双向数据绑定。例如,<select v-model="selected"> 意味着 select 元素的值将被绑定到 data.selected 的值,但如果该值在元素中发生了更改(例如,用户在 select 列表中选择了不同的选项),那么 data.selected 的值将被更新为新值。

这种灵活性意味着你既可以使用单向数据绑定,也可以使用双向数据绑定来开发 Vue.js,并在适当的时候结合使用。通常情况下,单向数据绑定会生成更简单、更可预测的代码。但是,双向数据绑定很方便,并且可以简化创建对应于组件数据的具有多个字段的表单。Vue.js 让开发者自己做出选择。

表组件

下一对组件将用于在我们的用户界面中展示车辆表。它们是展示组件,因此不需要任何方法或事件处理。

在 `client/src/components/table/` 下创建一个名为 `TableRow.vue` 的新文件,并添加以下内容

/client/src/components/TableRow.vue
<template id="tablerow-template" xmlns="http://www.w3.org/1999/xhtml">
    <tr> <!-- 1 -->
      <td>{{ item.id }}</td>
      <td>{{ item.name }}</td>
      <td>{{ item.make.name }}</td>
      <td>{{ item.model.name }}</td>
      <td>{{ item.driver.name }}</td>
    </tr>
</template>

<script>
export default {
  props: ['item'] (1)
}
</script>

<!-- Per Component Custom CSS Rules -->
<style>
  /* Add custom rules here */
</style>
1 此组件接受一个 `item` 的单个属性,该属性保存要在模板中呈现的记录。

在 `client/src/components/table/` 下创建一个名为 `VehicleTable.vue` 的新文件,并添加以下内容

/client/src/components/table/VehicleTable.vue
<template id="fulltable-template" xmlns="http://www.w3.org/1999/xhtml">
  <table class="table">
    <thead class="thead-inverse">
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Make</th>
        <th>Model</th>
        <th>Driver</th>
      </tr>
    </thead> (1)
      <table-row v-for="vehicle in vehicles"
                 :item="vehicle" :key="vehicle.id"></table-row> (2)
  </table>
</template>

<script>
import TableRow from './TableRow.vue' (3)

export default {
  props: ['vehicles'],
  components: { (3)
    TableRow
  }
}
</script>

<style>
  /* Add custom rules here */
</style>
1 `v-for` 指令允许我们遍历数组,类似于 GSP `<g:each>` 标记或 Angular 中的 `ng-for` 指令。
2 同样,我们使用 `:item` 语法将 `vehicle` 对象绑定到 `<table-row>` 组件的 `item` 属性。请注意,我们还绑定到 `:key` 属性 - 类似于 React,使用 `v-for` 迭代元素要求每个元素都有一个唯一的 `key`,在我们的例子中是 `vehicle.id`。
3 为了使用我们的 `<table-row>` 组件,我们在 `<script>` 标记的顶部导入它,然后在实例定义的 `components` 对象中指定它。

请注意,`components` 对象不是一个数组,而是一个对象。当键和值同名时,我们会使用一些 ES6 速记来组合它们。换句话说,上面的 `components` 对象和以下代码完全相同

components: {
      'TableRow': TableRow // == TableRow
    }

当然,这假设你希望为该组件使用与该组件的 `.vue` 文件(在我们示例中是 `TableRow.vue`)相同的名称。如果不是,你可以使用此对象在模板中有效地“重命名”该组件

请注意,`components` 对象不是一个数组,而是一个对象。当键和值同名时,我们会使用一些 ES6 速记来组合它们。换句话说,上面的 `components` 对象和以下代码完全相同

components: {
      'MyCustomRow': TableRow
    }

5.5 表单组件

在把所有内容连接在一起之前,我们使用的最后一个组件将是一个采用我们在 API 中预先填入的司机、制造商和型号来创建新车辆的表单。与我们迄今为止创建的组件相比,这是一个稍复杂的组件,但它建立在我们已经看到的相同特性之上。

在 `client/src/components/form` 下创建一个名为 `VehicleForm.vue` 的新文件,并按以下内容编辑

/client/src/components/form/VehicleForm.vue
<template id="add-vehicle-template" xmlns="http://www.w3.org/1999/xhtml">
  <div>
    <vehicle-form-header/> (1)

    <div id="inputRow" class="row">
      <div class="col-sm-3">
        <div class="input-group">
          <input type="text" class="form-control" placeholder="Enter a name..." v-model="vehicle.name"> (2)
        </div>
      </div>
      <div class="col-sm-7">
        <div class="row">
          <div class="col-sm-4">
            <field-select v-model="vehicle.make" :field="'Make'" :values="makes"></field-select> (3)
          </div>
          <div class="col-sm-4">
            <field-select v-model="vehicle.model" :field="'Model'" :values="models"></field-select>  (4)
          </div>
          <div class="col-sm-4">
            <field-select v-model="vehicle.driver" :field="'Driver'" :values="drivers"></field-select>
          </div>
        </div>
      </div>
      <div class="col-sm-2">
        <div class="btn-group" role="group" aria-label="Add new vehicle">
          <button type="button" class="btn btn-success" @click="submit()">Add to Garage</button> (5)
        </div>
      </div>
    </div>
  </div>

</template>

<script>
import VehicleFormHeader from './VehicleFormHeader'
import FieldSelect from './FieldSelect'

export default {
  props: ['vehicle', 'makes', 'models', 'drivers'], (6)
  model: {
    prop: 'vehicle', (4)
    event: 'change'
  },
  components: {
    VehicleFormHeader,
    FieldSelect
  },
  methods: {
    submit () { (5)
      this.$emit('submit')
    }
  }
}
</script>

<style>
  /* Add custom rules here */
</style>
1 这是我们之前创建的 `VehicleFormHeader` 组件。
2 同样,我们使用 `v-model` 指令将输入值绑定到 `data` 中的变量。
3 这是我们之前创建的 FieldSelect 组件 - 请注意我们正在使用 v-model 指令进行双向绑定(允许组件更新我们的数据),以及使用单向数据绑定传递一系列的 :values
4 由于我们以通用方式编写 FieldSelect 组件,所以我们可以将其重用于表单中的每个选择列表。
5 请注意,我们实际上并没有在此组件中进行 POST 调用来创建车辆 - 该任务将委派给父组件,方法是发出一个 submit 事件(如在 submit() 方法中完成的那样)。
6 vehicle prop 表示正在从此表单中的字段创建的“新”车辆对象。makesmodelsdrivers prop 将用于填充选择组件的记录列表。

下一步

在这一点上,我们拥有构建表单和在表中显示车辆所需的所有组件。我们仍然需要实现 API 集成,然后将所有这些部分组合到一个正常运行的应用程序中。

5.6 车辆显示

client/src/components/ 中创建一个名为 Garage.vue 的新文件,并对其进行如下编辑

/client/src/components/Garage.vue
<template>
  <div id="garage">
    <app-header></app-header>
    <vehicle-form v-model="vehicle"
                  :makes="makes"
                  :models="models"
                  :drivers="drivers"
                  @submit="submitNewVehicle()">

    </vehicle-form>
    <vehicle-table :vehicles="vehicles"></vehicle-table>
  </div>
</template>

<script>
  import AppHeader from './AppHeader'
  import VehicleForm from './form/VehicleForm'
  import VehicleTable from './table/VehicleTable'

  export default {
    components: {
      AppHeader,
      VehicleForm,
      VehicleTable
    },
    data: function () {
      return {
        vehicles: [],
        vehicle: {name: '', make: null, model: null, driver: null},
        models: [],
        makes: [],
        drivers: [],
        serverURL: process.env.SERVER_URL
      }
    },
    created () {
      this.fetchData()
    },
    methods: {
      fetchData: async function () {
        try {
          await Promise.all([
            this.fetchVehicles(),
            this.fetchModels(),
            this.fetchModels(),
            this.fetchMakes(),
            this.fetchDrivers()
          ])
        } catch (error) {
          console.log(error)
        }
      },
      fetchVehicles: function () {
        fetch(`${this.serverURL}/vehicle`)
          .then(r => r.json())
          .then(json => { this.vehicles = json })
          .catch(error => console.error('Error retrieving vehicles: ' + error))
      },
      fetchModels: function () {
        fetch(`${this.serverURL}/model`)
          .then(r => r.json())
          .then(json => { this.models = json })
          .catch(error => console.error('Error retrieving models: ' + error))
      },
      fetchMakes: function () {
        fetch(`${this.serverURL}/make`)
          .then(r => r.json())
          .then(json => { this.makes = json })
          .catch(error => console.error('Error retrieving makes: ' + error))
      },
      fetchDrivers: function () {
        fetch(`${this.serverURL}/driver`)
          .then(r => r.json())
          .then(json => { this.drivers = json })
          .catch(error => console.error('Error retrieving drivers: ' + error))
      },
      submitNewVehicle: function () {
        const vehicle = this.vehicle
        fetch(`${this.serverURL}/vehicle`, {
          method: 'POST',
          headers: {'Content-Type': 'application/json'},
          body: JSON.stringify(vehicle)
        }).then(r => r.json())
          .then(json => {
            this.vehicles.push(json)
            this.vehicle = {name: '', make: null, model: null, driver: null}
          })
          .catch(ex => console.error('Unable to save vehicle', ex))
    }
  }
</script>

<!-- Per Component Custom CSS Rules -->
<style>
  #garage {
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    text-align: center;
    color: #2c3e50;
  }
</style>

分解它

因为这是一个大型组件,我们将分部分进行。

/client/src/components/Garage.vue
<template>
  <div id="garage">
    <app-header></app-header>
    <vehicle-form v-model="vehicle"
                  :makes="makes"
                  :models="models"
                  :drivers="drivers"
                  @submit="submitNewVehicle()"> (1)

    </vehicle-form>
    <vehicle-table :vehicles="vehicles"></vehicle-table> (2)
  </div>
</template>
1 我们已经将 submitNewVehicle() 方法(稍后我们将看到)设置为 submit 事件(我们在 VehicleForm.submit() 函数中发出的)的事件处理程序。
2 我们将 vehicles 数据变量绑定到 VehicleTable 组件的 vehicles prop。
/client/src/components/Garage.vue
<script>
import AppHeader from './AppHeader' (1)
import VehicleForm from './form/VehicleForm'
import VehicleTable from './table/VehicleTable'

export default {
  components: { (1)
    AppHeader,
    VehicleForm,
    VehicleTable
  },
  data: function () { (2)
    return {
      vehicles: [],
      vehicle: {name: '', make: null, model: null, driver: null},
      models: [],
      makes: [],
      drivers: [],
      serverURL: process.env.SERVER_URL (3)
    }
  },
1 在模板中导入我们的组件供使用
2 我们的 data() 函数返回组件的初始状态。重要的是我们在 data 对象中初始化我们打算使用的所有变量,因为如果我们在之后添加一个变量,它将不被视为一个响应式属性(即对该变量的更改不会触发组件更新)。
3 SERVER_URL 是在 client/config/dev.env.js 中设置的一个配置变量(testprod 环境有等效的配置文件)。你可以通过更改 SERVER_URL 变量来更改以下 API 调用的基本 URL。
/client/src/components/Garage.vue
  created () { (1)
    this.fetchData()
  },
  methods: {
    fetchData: async function () { (2)
      try {
        Promise.all([(3)
          this.fetchVehicles(),
          this.fetchModels(),
          this.fetchModels(),
          this.fetchMakes(),
          this.fetchDrivers()
        ])
      } catch (error) {
        console.log(error)
      }
    },
1 created 是几个生命周期钩子之一,这些钩子是在组件生命周期中的特定点被调用的方法(其它可用方法包括 beforeUpdateupdatedmounted 等。你可以从 Vue.js 文档 了解可用的生命周期钩子
2 fetchData 方法是其中我们调用其他方法从 API 检索数据的。由于这些 API 调用是独立的,不需要同步运行,因此我们已将 async 关键字添加到此函数中。
3 try/catch 块中,我们使用 Promise API “链”了我们多个 API 调用。由于我们不会从这些方法中返回任何内容,因此不需要使用通常在 async 函数中使用的 await 关键字。
/client/src/components/Garage.vue
    fetchVehicles: function () { (1)
      fetch(`${this.serverURL}/vehicle`)
        .then(r => r.json())
        .then(json => { this.vehicles = json })
        .catch(error => console.error('Error retrieving vehicles: ' + error))
    },
    fetchModels: function () {
      fetch(`${this.serverURL}/model`)
        .then(r => r.json())
        .then(json => { this.models = json })
        .catch(error => console.error('Error retrieving models: ' + error))
    },
    fetchMakes: function () {
      fetch(`${this.serverURL}/make`)
        .then(r => r.json())
        .then(json => { this.makes = json })
        .catch(error => console.error('Error retrieving makes: ' + error))
    },
    fetchDrivers: function () {
      fetch(`${this.serverURL}/driver`)
        .then(r => r.json())
        .then(json => { this.drivers = json })
        .catch(error => console.error('Error retrieving drivers: ' + error))
    },
1 以下几个方法将执行前一个代码片段中所引用的相应 API 调用。我们使用 fetch API 对资源端点进行 GET 调用,解析 JSON,并存储数据在适当的 data 变量中。
/client/src/components/Garage.vue
    submitNewVehicle: function () {
      const vehicle = this.vehicle (1)
      fetch(`${this.serverURL}/vehicle`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(vehicle)
      }).then(r => r.json())
        .then(json => {
          this.vehicles.push(json) (2)
          this.vehicle = {name: '', make: null, model: null, driver: null} (3)
        })
        .catch(ex => console.error('Unable to save vehicle', ex))
    }
  }
}
</script>
1 因为我们存储了 vehicle 对象(由 VehicleForm 组件使用)在顶级组件的 data 中,因此对 POST 请求保存车辆实例是微不足道的 - 我们只需从 data 中抓取变量(例如 this.vehicle),将其转换成 JSON 字符串,并使用 fetch 进行 POST 请求。
2 POST 请求将返回新创建的车辆实例,我们只需将其 push 到我们的 data.vehicles 数组中。
3 在新车辆添加到列表后,我们通过将 data.vehicle 设置为空对象来重置表单(记住用空/null 值初始化必需字段)
/client/src/components/Garage.vue
<!-- Per Component Custom CSS Rules -->
(1)
<style>
  #garage {
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    text-align: center;
    color: #2c3e50;
  }
</style>
1 此处包含了一些样式以美化应用程序的布局 - 您可以自由使用您喜欢的任何样式。请注意,这些样式受到限制或限定于组件自己的模板。

5.7 路由

如果您现在运行 client 应用程序(或者如果您在遵循该指南时一直运行 client:start 任务,则重新加载),您会注意到默认主页没有改变。这是因为 Vue 路由 - Vue.js 应用程序的官方路由库 - 被配置为在索引路由中显示 Welcome 组件。幸运的是,这是一个简单的更改。

按如下所示编辑文件 client/src/router/index.js

/client/src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Garage from '@/components/Garage' (1)

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Garage', (1)
      component: Garage (1)
    }
  ]
})
1 用我们的 Garage 组件替换索引 / 路由的 Welcome 组件的导入和用法。

运行应用程序

使用 ./gradlew client:start 运行 client 项目,并浏览至 http://localhost:3000。您应该会看到我们新的 Vue 应用程序,并能够与 Grails REST API 交互。恭喜,您已使用 Grails 构建了一个 Vue.js 应用程序!

complete

6 后续步骤

有很多机会可以扩展此应用程序的范围。以下是你自己可以做出的一些改进建议

  • 改进表单(或创建新的表单组件),以添加制造商、型号和驾驶员

  • 添加对编辑现有车辆的支持,或许可以使用模态对话框进行编辑表单

  • 目前 Makes & Model 域类是独立的。在 Make 和 Model 之间添加适当的 GORM 关联,并更改选择列表,以便仅为当前选择的 Make 显示 Model。您需要使用 JavaScript Array.filter 方法。

7 您需要有关 Grails 的帮助吗?

Object Computing, Inc. (OCI) 赞助了本指南的创建。可提供各种咨询和支持服务。

OCI 是 Grails 的归属

认识团队