显示导航

构建 React 应用程序

使用 React 1.x 简介创建具有 React 视图的 Grails 应用程序

作者:Zachery Klein

Grails 版本 3.3.5

1 Grails 培训

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

2 入门

在本指南中,你将使用 React 作为视图层创建一个 Grails 应用程序。你将使用 React 简介的 1.0.2 版本(单项目,使用 Webpack 和 Asset Pipeline)。

对初始项目做了一些更改(与使用 React 简介 1.0.2 版本的默认 Grails 项目相比)。提供包含更改内容的补丁文件,见指南库

2.1 需要的东西

完成本指南,需拥有以下内容

  • 手头有些时间

  • 一个不错的文本编辑器或 IDE

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

2.2 如何完成本指南

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

Grails 指南库包含两个文件夹

  • initial 初始项目,通常是一个简单的 Grails 应用程序,包含一些额外的代码,可让你轻松上手。

  • complete 一个完整的示例,它是完成指南介绍的步骤并将这些更改应用到initial文件夹的结果。

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

  • grails-guides/building-a-react-app/initial 中使用 cd

然后按照后续章节中的说明进行操作。

如果你在 grails-guides/building-a-react-app/complete 中使用 cd,就可以直接转到完成的示例

3 编写应用程序

React 概要文件包括一些默认的 React 示例代码。如果你想查看正在操作的示例,可以随时按原样运行该应用程序。

让我们从为该应用程序创建域模型开始。

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

现在让我们在 grails-app/domain/demo/ 中编辑我们的域类。我们将添加一些属性和 @Resource 注释。

grails-app/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]

    static constraints = {
    }
}
grails-app/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
    }
}
grails-app/grails-app/domain/demo/Make.groovy
package demo

import grails.rest.Resource

@Resource(uri = '/make')
class Make {

    String name

    static constraints = {
    }
}
grails-app/grails-app/domain/demo/Model.groovy
package demo

import grails.rest.Resource

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

    String name

    static constraints = {
    }
}

由于我们在域类中添加了 @Resource 注释,Grails 将为每个域类生成 RESTful URL 映射。让我们预装一些数据

grails-app/init/demo/BootStrap.groovy
package demo

import demo.Driver
import demo.Make
import demo.Model
import demo.Vehicle

class BootStrap {

    def init = { servletContext ->
        log.info "Loading database..."
        def driver1 = new Driver(name: "Susan").save()
        def driver2 = new Driver(name: "Pedro").save()

        def nissan = new Make(name: "Nissan").save()
        def ford = new Make(name: "Ford").save()

        def titan = new Model(name: "Titan").save()
        def leaf = new Model(name: "Leaf").save()
        def windstar = new Model(name: "Windstar").save()

        new Vehicle(name: "Pickup", driver: driver1, make: nissan, model: titan).save()
        new Vehicle(name: "Economy", driver: driver1, make: nissan, model: leaf).save()
        new Vehicle(name: "Minivan", driver: driver2, make: ford, model: windstar).save()

    }
    def destroy = {
    }
}

4 运行应用程序

现在,如果运行我们的应用程序,我们可以试一试 Grails 为我们生成的 RESTful API。使用 Grails 3.2.4 的本地安装程序或提供的一个封装程序来启动应用程序:./gradlew bootRun./grailsw run-app

$ ./grailsw run-app

现在我们可以使用 cURL 或其他 API 工具来演练该 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"}]

/driver/1 进行 GET 请求来获取特定驾驶员实例

$ 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}]}

/driver 进行 POST 请求来创建新的驾驶员实例

$ 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"}

/vehicle 进行 PUT 请求来更新车辆实例

$ 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.1 自定义 API

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

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}}

这足以满足许多用法,但我们需要尽快为我们的 React 组件获取更多数据。这是 JSON 视图 大显身手的地方。让我们创建一个新的 JSON 视图来渲染我们的车辆列表

$ mkdir grails-app/views/vehicle/

按照惯例,任何 restful 控制器(例如由 @Resource 生成的控制器)对应视图目录中的任何 JSON 视图都将代替默认的 JSON 表示形式使用。现在我们可以通过为车辆创建一个新的 JSON 模板来定制我们每个车辆的 JSON 输出

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

编辑文件包括以下内容

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 编写我们的 React 组件

现在我们可以开始创建我们的 React 组件来查看和交互我们的 API 了。我们暂时只处理列出车辆和创建新实例。

5.1 编写 Vehicles 组件

让我们从 Vehicles 组件开始,它将渲染我们的车辆实例的表格。

src/main/webapp/app/ 下创建名为 Vehicles.js 的 JavaScript 文件。

以下是我们的 Vehicle 组件

import React from 'react';
import {Table} from 'react-bootstrap';
import {array} from 'prop-types';

class Vehicles extends React.Component {

  render() {
    function renderVehicleRow(vehicle) {

      return (<tr key={vehicle.id}>
        <td>{vehicle.id}</td>
        <td>{vehicle.name}</td>
        <td>{vehicle.make.name}</td>
        <td>{vehicle.model.name}</td>
        <td>{vehicle.driver.name}</td>
      </tr>);
    }


    return <div>
      <Table striped bordered condensed hover>
        <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>Make</th>
          <th>Model</th>
          <th>Driver</th>
        </tr>
        </thead>
        <tbody>

        {this.props.vehicles.map(renderVehicleRow)}

        </tbody>
      </Table>
    </div>;
  }
}

Vehicles.propTypes = {
  vehicles: array
};

export default Vehicles;

请注意,在 renderVehicleRow 函数中,我们访问了我们 Vehicle 实例的自定义 JSON 表示形式,如,我们可以使用 vehicle.make.name

React 默认包括 React-Bootstrap,所以我们正在使用 Bootstrap Table 组件来简化代码。

5.2 编写 Garage 应用程序

现在,我们需要授权 Vehicles 组件访问来自我们的 API 的数据。我们可以从 Vehicles 组件内部来执行这一操作,但是一个更灵活的选择是创建一个“容器”组件,该组件将从 API 获取数据并通过 props 将其传递给“表示”组件(在本例中为 Vehicles)。

使用这种分离的方法,我们可在将来扩展该应用程序以发出更多 API 调用(甚至呈现不同的组件,如,一个 Drivers 表),而不必杂乱我们的 Vehicles 组件。我们称我们的“容器”组件为 Garage

src/main/webapp/app/ 下创建名为 Garage.js 的 JavaScript 文件。

以下是我们的 Garage 组件

import React from 'react';
import ReactDOM from 'react-dom';
import Vehicles from './Vehicles';

class Garage extends React.Component {

  constructor() {
    super();

    this.state = {
      vehicles: [{"id":1,"name":"Pickup","make":{"name":"Nissan","id":1},"model":{"name":"Titan","id":1},"driver":{"name":"Susan","id":1}}],
    }
  }

  render() {
    const {vehicles} = this.state;

    return <div>
      <Vehicles vehicles={vehicles} />
    </div>;
  }
}

ReactDOM.render(<Garage />, document.getElementById('garage'));

Garage 组件使用一个 state 对象,该对象可供所有 React 组件使用,但是为可选项。我们不需要在我们的 Vehicles 组件中使用 state,因为它通过 vehicles props 接收了所有数据。在编写 React 时,一个好的做法是集中化数个组件(甚至一个组件)中的状态,并将相关数据片段传递给子组件。

请注意,我们正在 statevehicles 集合中硬编码一个 JSON 对象 - 那是因为我们尚未设置 API 调用 - 几个章节后,我们将讨论这一部分。

Garage 组件包含对 ReactDOM.render 的调用,以便将组件呈现在页面上。现在,我们将创建一个新页面,从中加载我们的 React 组件。

5.3 呈现应用程序

React 1.0.2 配置文件依赖于 Webpack 将 React 组件捆绑到对浏览器就绪的 JavaScript 捆绑中,然后可以通过 Grails Asset Pipeline 加载它们。默认应用程序包含一个“index”捆绑,其在索引页上呈现。我们为 Garage 应用程序设置一个新的捆绑。

webpack.config.js 中,编辑 entry 部分,并添加一行来加载我们的 Garage.js 文件,如下所示

webpack.config.js
var path = require('path');

module.exports = {
  entry: {
    index: './src/main/webapp/index.js', (1)
    garage: './src/main/webapp/app/Garage.js' (2)
  },
//...
1 将通往 Garage.js 的路径作为 garage 入口点添加
2 别忘记逗号!

这将导致 Webpack 捆绑两个不同的 React 应用,“index”和“garage”。我们还需要配置 Webpack 为每个 React 应用输出单独的捆绑包,以便我们可以在 Grails 应用程序的不同页面上加载它们。

webpack.config.js 中,修改 output 部分,并按如下所示更改 filename

webpack.config.js
//...
output: {
  path: path.join(__dirname, 'grails-app/assets/javascripts'),
  publicPath: '/assets/',
  filename: 'bundle-[name].js'
},
//...
1 -[name] 添加到 filename 属性

现在,当我们启动 Grails 应用(或运行 ./gradlew webpack)时,Webpack 将生成两个捆绑包,一个是名为 bundle-index.js 的捆绑包,另一个名为 bundle-Garage.js 的捆绑包。我们可以使用 Grails Asset Pipeline 标签在我们的页面上加载这些捆绑包。

由于我们更改了捆绑包的 filename,我们需要快速更新我们的原始 index.gsp 页面以使用新名称。

编辑 grails-app/views/index.gsp,第 66 行

        <div id="app"></div>
        <asset:javascript src="bundle-index.js" /> (1)
1 将“bundle.js”更改为“bundle-index.js”

现在,我们终于准备好为我们的 Garage 应用创建一个主页。使用本地 Grails 安装或 ./grailsw 创建一个新的 Grails 控制器

./grailsw create-controller demo.GarageController

确保 GarageController 包含一个 index 动作。

grails-app/controllers/demo/GarageController.groovy
package demo

class GarageController {
    def index() { }
}

现在,在 grails-app/views/garage 下创建一个简单的 index.gsp 页面

grails-app/views/garage/index.gsp
<!doctype html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Garage</title>
    <asset:link rel="icon" href="favicon.ico" type="image/x-ico" />
</head>
<body>

<div id="content" role="main">
    <section class="row colset-2-its">
        <div id="garage"></div>
        <asset:javascript src="bundle-garage.js" />
    </section>
</div>

</body>
</html>

重新启动应用程序,并浏览至 http://localhost:8080/garage。您应该看到我们的新 React 应用加载在页面上,其中包含一行硬编码的数据。

5.4 从 API 中获取数据

现在我们的 Garage 组件已设置完毕,并在我们的页面上呈现 Vehicles 表格,我们终于可以将我们的 API 连接起来,将数据加载到我们的 React 视图中。为此,我们将使用 fetch API。

编辑 src/main/webapp/app/Garage.js

src/main/webapp/app/Garage.js
//...
import 'whatwg-fetch';    (1)

class Garage extends React.Component {

  constructor() {
    super();

    this.state = {
      vehicles: [] (2)
    }
  }

  componentDidMount() { (3)
    fetch('/vehicle')
      .then(r => r.json())
      .then(json => this.setState({vehicles: json}))
      .catch(error => console.error('Error retrieving vehicles: ' + error));
  }
//...
1 导入 fetch
2 删除硬编码数据。
3 从 API 加载数据

componentDidMount 是 React 组件生命周期方法之一。它在组件在页面上加载后立即触发。在此方法中,我们使用 fetch 对我们的 /vehicle 终结点发出请求(默认情况下为 GET 请求),解析 JSON 有效负载,并调用 this.setState 使用数据更新我们的 vehicles 集合。

重新启动应用程序(或等待 webpack 重新加载)以查看更改。您现在应该看到 Grails 应用中的 Vehicles 列表显示在 React Vehicles 表格中。

5.5 向 API 发布数据

本指南中的我们的最后一步是创建一个简单的表单来将新的 Vehicle 实例发布到我们的 API。

src/main/webapp/app 下创建一个名为 AddVehicleForm.js 的新 JavaScript 文件,内容如下

src/main/webapp/app/AddVehicleForm.js
import React from 'react';
import {array, func} from 'prop-types';

class AddVehicleForm extends React.Component {

  constructor(props) {
    super(props);
    this.state = { (1)
      name: '',
      make: {id: ''},
      model: {id: ''},
      driver: {id: ''}};
  }

  handleSubmit = (event) => { (2)
    event.preventDefault();

    const {name, make, model, driver} = this.state;

    if (!name || !make.id || !model.id || !driver.id) {
      console.warn("missing required field!");
      return;
    }
    this.props.onSubmit( {name, make, model, driver} ); (3)
    this.setState({ name: '', make: {id: ''}, model: {id: ''}, driver: {id: ''}});
  };

  handleNameChange = (event) => { (4)
    this.setState({ name: event.target.value });
  };

  handleMakeChange = (event) => { (4)
    this.setState({ make: {id: event.target.value} });
  };

  handleModelChange = (event) => { (4)
    this.setState({ model: {id: event.target.value} });
  };

  handleDriverChange = (event) => { (4)
    this.setState({ driver: {id: event.target.value} });
  };


  render() {

    function renderSelectList(item) { (5)
      return <option key={item.id} value={item.id}>{item.name}</option>
    }

    return(
      <div>
        <h3>Add a Vehicle:</h3>
        <form className="form form-inline" onSubmit={this.handleSubmit}  >
          <label>Name</label>
          <input className="form-control" name="name" type="text" value={ this.state.name } onChange={ this.handleNameChange } />

          <label>Make</label>
          <select className="form-control" name="make" value={this.state.make.id}
            onChange={this.handleMakeChange}>  {/*<6>*/}
            <option value={null}>Select a Make...</option>
            {this.props.makes.map(renderSelectList)}  {/*<5>*/}
          </select>

          <label>Model</label>
          <select className="form-control" name="model" value={this.state.model.id}
            onChange={this.handleModelChange}>  {/*<6>*/}
            <option value={null}>Select a Model...</option>
            {this.props.models.map(renderSelectList)}  {/*<5>*/}
          </select>

          <label>Driver</label>
          <select className="form-control" name="driver" value={this.state.driver.id}
            onChange={this.handleDriverChange}>  {/*<6>*/}
            <option value={null}>Select a Driver...</option>
            {this.props.drivers.map(renderSelectList)}  {/*<5>*/}
          </select>

          <input className="btn btn-success"  type="submit" value="Add to library" />
        </form>
      </div>
    );

  }
}

AddVehicleForm.propTypes = {
  makes: array,
  models: array,
  drivers: array,
  onSubmit: func
};

export default AddVehicleForm;
1 使用填充新 Vehicle 所需的所有属性初始化 state 对象
2 创建事件处理程序来处理表单提交
3 state 中的属性传递给 onSubmit 回调函数
4 当接收到用户输入时更新 state 的事件处理程序
5 使用我们道具中的数组在 select 列表中渲染选项
6 在用户更改输入值时调用事件处理程序

这是一个相当复杂的组件,所以如果您立即没弄明白,也不必担心。关键点在于 AddVehicleForm 组件允许用户设置创建新车辆实例所需 4 个属性:namemakemodeldriver。它获取名为 onSubmit 的函数道具,当提交表单时该函数被使用。

在 React 中将函数(处理程序)作为道具传递的这种方式是个好习惯。它允许轻松复用组件,因为特定功能可以通过不同的调用方(例如,通过将不同函数作为 onSubmit 道具传递)进行交换。与 state 类似的是,在几个组件中集中您的功能逻辑,并将这些函数作为道具传递给子组件,是使用 React 时一个好习惯。对于此模式更语义化的版本,您可以考虑 Flux 实现(例如 Redux),以将您的 state 和逻辑外部化。

由于 makemodeldriver 是关联,我们需要允许用户选择 ID,以便 Grails 可以在数据绑定期间进行赋值。AddVehicleForm 需要 3 个道具,预期其中包含这些关联的数组。我们需要提供这些道具才能使用 AddUserForm,所以我们来编辑 Garage 组件以检索这些列表。

编辑 src/main/webapp/app/Garage.js

src/main/webapp/app/Garage.js
//..
import AddVehicleForm from './AddVehicleForm'; (1)

class Garage extends React.Component {

  constructor() {
    super();

    this.state = {
      vehicles: [],
      makes: [],            (2)
      models: [],
      drivers: []
    }
  }

  componentDidMount() {
    fetch('/vehicle')
      .then(r => r.json())
      .then(json => this.setState({vehicles: json}))
      .catch(error => console.error('Error retrieving vehicles: ' + error));

    fetch('/make')                  (3)
      .then(r => r.json())
      .then(json => this.setState({makes: json}))
      .catch(error => console.error('Error retrieving makes: ' + error));

    fetch('/model')                 (3)
      .then(r => r.json())
      .then(json => this.setState({models: json}))
      .catch(error => console.error('Error retrieving models ' + error));

    fetch('/driver')                (3)
      .then(r => r.json())
      .then(json => this.setState({drivers: json}))
      .catch(error => console.error('Error retrieving drivers: ' + error));

  }

  render() {
    const {vehicles, makes, models, drivers} = this.state;  (4)

    return <div>
      <AddVehicleForm makes={makes} models={models} drivers={drivers}/> (5)
      <Vehicles vehicles={vehicles} />
    </div>;
  }
}
//...
1 导入 AddVehicleForm 组件
2 makesmodelsdrivers 添加到 state
3 从 API 检索数据
4 使用 ES6 解构语法从 this.state 检索车辆、制造商、型号、驾驶员
5 makesmodelsdrivers 传递给 AddVehicleForm

最后一步是实现一个函数,我们通过 onSubmit 道具将其传递给 AddVehicleForm。此函数需要完成两件事

  1. 将新车辆详细信息发布到 API,并从 API 检索结果

  2. 更新 state,以便我们可以在 Vehicles 表格中显示新创建的车辆

让我们来实现此函数。再次编辑 src/main/webapp/app/Garage.js

src/main/webapp/app/Garage.js
//..

class Garage extends React.Component {

  //...

  submitNewVehicle = (vehicle) => {   (1)
    fetch('/vehicle', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(vehicle)
    }).then(r => r.json())
      .then(json => {
        let vehicles = this.state.vehicles;
        vehicles.push({id: json.id, name: json.name, make: json.make, model: json.model, driver: json.driver});
        this.setState({vehicles});
      })
      .catch(ex => console.error('Unable to save vehicle', ex));
  };


  render() {
    const {vehicles, makes, models, drivers} = this.state;

    return <div>
      <AddVehicleForm onSubmit={this.submitNewVehicle} (2)
        makes={makes} models={models} drivers={drivers}/>
      <Vehicles vehicles={vehicles} />
    </div>;
  }
}


ReactDOM.render(<Garage />, document.getElementById('garage'));
//...
1 创建 submitNewVehicle 函数
2 将函数作为 onSubmit 道具传递给 AddVehicleForm

再次,我们使用fetch API,这次用于向 /vehicle 端点的 POST 请求。调用 JSON.stringify 将从 AddVehicleForm 接收的参数转换为 JSON 字符串,然后可以将其发布到我们的 Grails API。API 将返回新创建的 vehicle 实例,我们随后可以用 this.setState 将其解析并插入到我们的 state 对象中。

重新启动应用,或重新运行 webpack,您应该可以创建新的 Vehicle 实例,并看到它们已添加到表格中。刷新该页面以确认已将新实例持久到数据库中。

6 后续步骤

有很多机会可以扩展此应用程序的范围。以下是您自己可以改进的一些想法

  • 创建模态对话框表单以添加新司机、生产商和型号。使用 React-Bootstrap 的Modal 组件,为您提供一个良好开端。

  • 添加对现有 Vehicle 的更新支持。模态对话框可能也适用于此,或可能需要可编辑的表格行

  • 当前,生产商和型号是独立的。在生产商和型号之间添加适当的 GORM 关联,并将选择列表更改为仅显示当前选择的生产商的型号。您将需要使用 JavaScript Array.filter 方法。

7 您需要针对 Grails 获得帮助吗?

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

OCI 是 Grails 的家

团队介绍