Front Tech

Laravel(REST API)+React入门范例

不知道什么时候开始的以讹传讹,React变成了“很难入门”的框架,但如果只讨论使用而不是源码,React其实是近几年主流前端框架中最轻量、最简单的:只专注于视图(View)的处理,从发布至今多次拆分模块(拆出了ReactDOM,拆分了原来的addons),让React核心部分更简单。当然,React的生态很庞大,刚上手就面对整个生态会觉得复杂,但如果是用React完成日常工作中最常见的应用场景——前端读取后端数据实现增删该查,是非常简单的。

本文使用setSetate()和生命周期(Lifecycle)方法,完成具有完整功能的前端React组件。假设读者有一定的Javascrit经验,比如基础的ES6语法。可以使用命令行,能配置PHP运行Laravel。基本不涉及PHP代码,选Laravel是因为它已经预装了React的开发环境,节省配置时间。

环境

Ubuntu 16.04
Laravel 5.6
PHP 7.3.0

安装&配置

安装composer,创建项目`dmyz`:


wget https://install.phpcomposer.com/composer.phar
# 或者按照官方文档安装为composer命令。国内下载比较慢,建议设置中文镜像
php composer.phar config -g repo.packagist composer https://packagist.phpcomposer.com
php composer.phar create-project --prefer-dist laravel/laravel dmyz "5.6.*" -vvv

切换到项目根目录,将前端库更换成React,安装依赖的Javascript包,进行第一次打包:


cd dmyz/
php artisan preset react
npm install && npm run dev

完成后在public目录下会生成`mix-manifest.json`文件。最后修改`resources/views/welcome.blade.php`,加载生成的文件,添加特定ID的DIV用来挂载(mount)React组件:


<!doctype html>
<html lang="en">
  <head>
    <meta name="author" content="Perchouli" />
    <meta name="csrf-token" content="{{ csrf_token() }}" />
    <link rel="stylesheet" href="{{ mix('css/app.css') }}" />
  </head>
  <body>
    <div id="example"></div>
    <script src="{{ mix('js/app.js') }}" defer></script>
  </body>
</html>

配置完成,执行`php artisan serve`启动PHP开发服务器。访问http://127.0.0.1:8000可以看到React的演示组件(如下图)。

React example

其他配置

本节是Typescript和ExtractTextPlugin的配置,不使用可以跳过。

laravel-mix已经支持Typescript,但如果是React+Typescript(JSX)的环境,直接使用`mix.ts`会报错,建议配置Webpack规则:


mix.react('resources/assets/js/app.tsx', 'public/js')
  .webpackConfig({
    module: {
      rules: [{
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: ['/node_modules/', '/vendor/']
      }]
    }
  });

React的一些库在Javascript中加载CSS,打包文件时如果需要将CSS输出到`app.css`文件,laravel-mix已经安装了`style-loader`和`css-loader`,所以可以使用以下配置:


let mix = require('laravel-mix');
const ExtractTextPlugin = require("extract-text-webpack-plugin");

mix.react('resources/assets/js/ssp/app.js', 'public/js/ssp.js')
  .sass('resources/assets/sass/app.scss', 'public/css');

mix.webpackConfig({
  module: {
    rules: [
      {
        test: /\.css$/,
        loaders: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: [
            { loader: 'css-loader' }
          ]
        })
      }
    ]
  }
});

JSX

不关闭之前`php artisan serve`启动的PHP开发服务器,打开另一个窗口,在项目根目录执行`npm run hot`,如果修改.js文件,页面会自动刷新。

打开Laravel生成的`resources/assets/js/components/Example.js`文件,核心部分(不算前两行`import`)其实只有3行:首先继承`React.Component`,创建一个名为Example的组件(L4),这个组件的`render()`方法返回一个元素(element),最后把这个组件挂载到页面上指定的位置就可以了(L24)。


import React, { Component } from 'react';
import ReactDOM from 'react-dom';

export default class Example extends Component {
  render() {
    return (
      <div className="container">
        <div className="row justify-content-center">
          <div className="col-md-8">
            <div className="card">
              <div className="card-header">Example Component</div>
              <div className="card-body">
                Im an example component!
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

if (document.getElementById('example')) {
  ReactDOM.render(<Example />, document.getElementById('example'));
}

7-18行是JSX。React中的JSX只是`React.createElement`的语法糖,可以不使用。代码层面先看作是HTML就行了。实际使用中对照HTML主要注意以下几点:

  • Javascript的关键字在JSX中通常会进行替换,比如代码中的`className(class)`,以及`htmlFor(for)`;style样式属性的分隔符一般会改成驼峰,例如`backgroundColor(background-color)`
  • 标签不能自闭合,例如`<img src=””>`在JSX中会报错,必须是`<img src=”” />`
  • 多行JXS代码需要`()`,如果是单行可以省略

JSX不是React的特性,其他前端框架也有插件可以实现。讨论JSX很多时候是和VDOM思路相关,这是对Javascript性能问题例如redraw/reflow的一种改进。

setState

React需要调用的只有两个方法:`setState()`和`forceUpdate()`,后者很少会用到。state是React的核心,对比流行的MVC框架,可以看成一个简化的Model。修改state时,组件会重新渲染(render)。大多数情况下`setState()`是修改state的唯一方式。

给Example组件增加构造函数:


import React, { Component } from 'react';
import ReactDOM from 'react-dom';

export default class Example extends Component {
  constructor(props) {
    super(props);
    this.state = {};
    setTimeout(() => this.setState({action: 'call setState'}), 2000);
  }
  render() {
    alert(this.state.action || 'init render');
    return <div className="card-body">action: {this.state.action}</div>;
  }
}

ReactDOM.render(<Example />, document.getElementById('example'));
  • L5-11: 构造函数。设置组件的state(`this.state`)
  • L10: 等待2000ms,组件挂载(mount)完成后调用setState,修改this.state.action的值
  • L13: 如果this.state.action未指定,alert提示`init render`

页面会在两秒后显示this.state.action的值,也可以看到出现了两次alert。之前alert是写在render()里的,显然render()被触发了两次,第一次this.state.action未指定所以提示了`init render`,第二次是设置的值。这就是setState很重要的特性:

执行setState会重新触发组件的render()

所以,setState不能写在render()里,否则就无限循环了。这个例子用setTimeout()等待组件挂载,实际不会这么做,React有生命周期方法(Lifecycle methods)。

生命周期方法

当组件被挂载到DOM上时会触发`componentDidMount()`,将之前的代码做如下修改:


import React, { Component } from 'react';
import ReactDOM from 'react-dom';

export default class Example extends Component {
  constructor(props) {
    super(props);
    this.state = {
      action: 'init render'
    };
  }
  componentDidMount() {
    this.setState({action: 'call setState'});
  }
  _edit() {
    this.setState({action: 'edited'})
  }
  render() {
    return <div className="card-body" onClick={this._edit.bind(this)}>action: {this.state.action}</div>;
  }
}

ReactDOM.render(<Example />, document.getElementById('example'));
  • L8: 设置state的初始值
  • 11-13: 在`componentDidMount`中调用`setState()`,修改this.state.action
  • 14-16: 增加自定义的 `_edit()`,再次修改this.state.action的值
  • L18: 把`_edit()`绑定到div的click事件

点击div,页面上的内容会发生变化。所有的生命周期方法参看:http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

REST API

这节介绍使用Laravel设置REST API的方法,如果是其他数据源可以跳过。

利用APIResource需要以此完成以下几步:


#生成Resource和ResourceCollection
php artisan make:resource PageResource && php artisan make:resource PageResourceCollection
#生成名为Page的model,同时生成migration
php artisan make:model Page -m
#生成API类型的controller(没有create和edit方法)
php artisan make:controller --api PageController
#创建sqlite数据库文件,修改.env的数据库连接为sqlite(默认是mysql)
touch database/database.sqlite
sed -i '9,14d' .env # or sed -i '' '9,14d' .env # for MacOS 
echo DB_CONNECTION=sqlite >> .env
#修改.env文件后可能需要重新执行php artisan serve
#把PageController添加到routes/api.php,地址/api/v1/pages
echo "Route::group(['prefix' => 'v1',], function () {Route::apiResource('pages', 'PageController');});" >> routes/api.php

编辑3个文件,一是migration文件定义表结构,位置 `database/migrations/201…_create_page_table.php`


    public function up()
    {
        Schema::create('pages', function (Blueprint $table) {
            $table->increments('id');
            $table->string('title');
            $table->string('content');
            $table->softDeletes();
            $table->timestamps();
        });
    }

二是controller配置增删改查的方法,编辑`app/Http/Controllers/PageController.php`:


<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Page;
use App\Http\Resources\PageResource;
use App\Http\Resources\PageResourceCollection;

class PageController extends Controller
{
    public function index()
    {
        return new PageResourceCollection(Page::paginate());
    }

    public function store(Request $request)
    {
        $page = new Page($request->all());
        $page->save();
        return new PageResource($page);
    }

    public function show($id)
    {
        $page = Page::find($id);
        return $page ? new PageResource($page) : abort(404);
    }

    public function update(Request $request, $id)
    {
        $page = Page::find($id);
        $page->update($request->all());
        $page->save();
        return new PageResource($page);
    }

    public function destroy($id)
    {
        $page = Page::find($id);
        return ['deleted' => $page ? $page->delete() : false];
    }
}

三是model文件设置允许修改的字段,编辑`app/Page.php`:


<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Page extends Model
{
    protected $fillable = ['title', 'content',];
}

最后执行migrations,创建之后通过curl添加2条测试数据:


php artisan migrate
curl -X POST http://127.0.0.1:8000/api/v1/pages -F title=dmyz -F content=dmyz.org
curl -X POST http://127.0.0.1:8000/api/v1/pages -F title=git -F content=perchouli

Example

setState()和生命周期方法,以及事件绑定,掌握这些就可以用React写一个增删改查的应用了。首先是读取数据,在前端显示,完整代码如下:


import React, { Component } from 'react';
import ReactDOM from 'react-dom';

export default class Example extends Component {
  constructor(props) {
    super(props);
    this.state = {
      pageId: null,
      pages: []
    };
  }
  componentDidMount() {
    window.axios.get('/api/v1/pages').then(response => {
      this.setState({pages: response.data.data});
    });
  }
  _submit(e) {
    //
  }
  _edit(pageId) {
    //
  }
  _delete(pageId) {
    //
  }
  render() {
    return (
      <div className="container">
        <div className="row">
          <form onSubmit={(e) => this._submit(e)}>
            <input name="title" placeholder="Title" />
            <input name="content" placeholder="Content" />
            <button className="btn btn-success" type="submit">Submit</button>
          </form>
          <table className="table">
            <thead><tr><th>ID</th><th>Title</th><th>Content</th><th></th></tr></thead>
            <tbody>
              {this.state.pages.map(page => {
                return (
                  <tr key={page.id}>
                    <td>{page.id}</td>
                    <td>{page.title}</td>
                    <td>{page.content}</td>
                    <td>
                      <button className="btn btn-danger btn-sm" onClick={() => this._delete(page.id)}>Remove</button>
                      <button className="btn btn-info btn-sm" onClick={() => this._edit(page.id)}>Edit</button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    );
  }
}

ReactDOM.render(<Example />, document.getElementById('example'));
  • L7-10: 设置两个初始state,pages为空数组,pageId为null
  • L13-15: 用axios请求接口,把返回的内容赋给`this.state.pages`。Laravel的bootstrap.js文件已经require了axios,也添加了CSRF TOKEN
  • L39-50: map遍历`this.state.pages`,显示每一项的内容。将`_edit()`和`_delete()`方法绑定到按钮的click事件
  • L30: `_submit()`方法绑定到form表单的submit事件

效果如下图:

最后只要补上方法就完工了,代码逻辑都是对state进行处理,然后调用`setState()`方法更新视图。比如`_delete()`,是从this.state.pages中移除这条记录:


  _delete(pageId) {
    let pages = this.state.pages.filter(page => page.id != pageId);
    window.axios.delete(`/api/v1/pages/${pageId}`).then(response => {
    if (response.data.deleted == true) {
       this.setState({pages});
     }
   });
  }

`_submit()`是提交表单数据,把返回的数据push到`this.state.pages`:


  _submit(e) {
   e.preventDefault();
   const formData = new FormData(e.currentTarget);
   let pages = this.state.pages;

   if (this.state.pageId === null) {
     window.axios.post('/api/v1/pages/', formData).then(response => {
       pages.push(response.data.data);
       this.setState({pages});
     });
   }
   else {
     window.axios.put(`/api/v1/pages/${this.state.pageId}/`, formData).then(response => {
       pages = this.state.pages.map(page => {
         return (page.id == this.state.pageId) ? response.data.data : page;
       });
       this.setState({pages});
     });
   }

`_edit()`最简单,就是设置`this.state.pageId`和input的value。完整代码如下:


import React, { Component } from 'react';
import ReactDOM from 'react-dom';

export default class Example extends Component {
  constructor(props) {
    super(props);
    this.state = {
      pageId: null,
      pages: []
    };
  }
  componentDidMount() {
    window.axios.get('/api/v1/pages').then(response => {
      this.setState({pages: response.data.data});
    });
  }
  _submit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    let pages = this.state.pages;

    if (this.state.pageId === null) {
      window.axios.post('/api/v1/pages/', formData).then(response => {
        pages.push(response.data.data);
        this.setState({pages});
      });
    }
    else {
      window.axios.put(`/api/v1/pages/${this.state.pageId}/`, formData).then(response => {
        pages = this.state.pages.map(page => {
          return (page.id == this.state.pageId) ? response.data.data : page;
        });
        this.setState({pages});
      });
    }
  }
  _edit(pageId) {
    this.setState({pageId}, () => {
      let page = this.state.pages.filter(page => page.id == pageId)[0];
      document.getElementsByName('title')[0].value = page.title;
      document.getElementsByName('content')[0].value = page.content;
    });
  }
  _delete(pageId) {
    let pages = this.state.pages.filter(page => page.id != pageId);
    window.axios.delete(`/api/v1/pages/${pageId}`).then(response => {
      if (response.data.deleted == true) {
        this.setState({pages});
      }
    });
  }
  render() {
    return (
      <div className="container">
        <div className="row">
          <form onSubmit={(e) => this._submit(e)}>
            <input name="title" placeholder="Title" required />
            <input name="content" placeholder="Content" required />
            <button className="btn btn-success" type="submit">Submit</button>
            <button className="btn" type="reset" hidden={this.state.pageId === null} onClick={() => this.setState({pageId: null})}>Cancel</button>
          </form>
          <table className="table">
            <thead><tr><th>ID</th><th>Title</th><th>Content</th><th></th></tr></thead>
            <tbody>
              {this.state.pages.map(page => {
                return (
                  <tr key={page.id}>
                    <td>{page.id}</td>
                    <td>{page.title}</td>
                    <td>{page.content}</td>
                    <td>
                      <button className="btn btn-danger btn-sm" onClick={() => this._delete(page.id)}>Remove</button>
                      <button className="btn btn-info btn-sm" onClick={() => this._edit(page.id)}>Edit</button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    );
  }
}

ReactDOM.render(<Example />, document.getElementById('example'));

组件拆分和传参

如果不涉及组件拆分重用,了解以上内容就够了。会增加复杂度的是组件之间的联动和传参,React的Props可以看成组件之间传递的参数,传递的Props对在子组件中无法修改。通过良好的设计也能让复杂度保持在可控范围内。

尽量避免不经设计写好一个功能复杂的庞大组件再拆分成小组件。而是先考虑子组件的功能和需要的参数,再去写上层组件的方法。

之前的例子可以抽出表单写成`Form`组件。表单需要处理数据提交,这个prop没什么疑问。而点击Cancel按钮,取消编辑状态至少有三种写法:一是在上层组件上写一个`_cancel()`方法,传递给Form组件,此方法将上层组件的this.state.pageId设置为null。二是Form组件增加一个state参数,存储pageId。三是将需要的参数作为children传到Form组件中进行处理。


// 第一种写法
export default class Example extends Component {
  _cancel () {
    this.setState({ pageId: null });
  }
//...
  <Form submit={(e) => this._submit(e)} cancel={() => this._cancel()} />
}

// 第二种写法
class Form extends Component {
  constructor (props) {
    super(props);
    this.state = {
      pageId: this.props.pageId
    };
  }

  componentWillReceiveProps (nextProps) { //or getDerivedStateFromProps
    this.setState({pageId: nextProps.pageId});
  }

  render () {
    return (
      <form onSubmit={(e) => this.props.submit(e)}>
//...
        <button className="btn" type="reset" onClick={() => this.setState({pag
eId : null})}>Cancel</button>
      </form>
    );
  }
}

export default class Example extends Component {
//...
         <Form submit={(e) => this._submit(e)} pageId={this.state.pageId} />
}


// 第三种写法
class Form extends Component {
  render () {
    const compositionProps = this.props.children.props;
    return (
      <form onSubmit={(e) => this.props.submit(e)}>
        {this.props.children}
        //...
      </form>
    );
  }
}

export default class Example extends Component {
//...
         <Form submit={(e) => this._submit(e)} >
           <input type="hidden" name="id" defaultValue={this.state.pageId || 0} />
         </Form>
}

第一种写法代码量少,但在子组件操作上层的state了;第二种写法把上层的Props作为子组件的state,这种写法是不鼓励的,需要增加生命周期方法componentDidMount。16.4修复了getDerivedStateFromProps的问题,可以用它来判断Props是否变化再决定是否修改state。第三种写法比较笨,不够灵活。优点是容易理解,提高了直接复制代码的可用性。

已知问题/其他说明

PUT请求修改数据现在是无效的,`Content-Type: multipart/form-data`的PUT请求在Laravel中`$request->all()`是空,PHP7.3仍然没有解决这个问题。通常是利用POST接收然后转发给PUT处理,或者用`x-www-form-urlencoded`请求。

另一个常被提到的是Redux。如果是现在刚开始接触React,暂时不用了解。React 16.3以后Context API正式公布了,16.7也会引入hooks让类似的操作更简单,而且如果能保持数据流的简单可以不使用这类工具。

3 1 投票
文章评分
订阅评论
提醒
guest

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据

2 评论
最新
最旧 最多投票
内联反馈
查看所有评论
血衫非弧
5 年 前

什么!大佬竟然研究laravel了!