不知道什么时候开始的以讹传讹,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的演示组件(如下图)。
其他配置
本节是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让类似的操作更简单,而且如果能保持数据流的简单可以不使用这类工具。
什么!大佬竟然研究laravel了!
没研究,普通使用。
现在还在跑的有一个4.1(升到5有点麻烦就先没动了),其它都跟着升到5.7的。
不用Django和Rails或者只有PHP环境就都是用Laravel了。