其他章节请看:

​​react实战 系列​​

React 中的表单和路由的原理

React 中的表单是否简单好用,受控组件和非受控是指什么?

React 中的路由原理是什么,如何更好的理解 React 应用的路由?

请看下文:

简单的表单

你有见过在生成环境中没有涉及任何表单的应用吗?大多 web 应用都会涉及表单。比如登录、注册、提交信息。

表单由于难用有时名声不好,于是许多框架针对表单做了一些神奇的事情来减轻程序员的负担。

React 并未采用神奇的方法,但它却能让表单更容易使用。

在做实验测试 react 中表单是否真的容易使用之前,我们在稍微聊一下表单。

不同框架处理表单的方式都不尽相同,很难说一种比另一种要好。有的需要我们了解很多框架的内部实现,有的很容易使用但是可能不够灵活。

开发者需要有一个思维模型(针对表单),该模型能让开发者创建可维护的代码,并在 bug 出现时及时修复他们。

当涉及表单时,React 不会提供太多“魔法”,并且在过多了解表单和过少了解之间找到了一个中间带。React 中表单的​​思维模型​​​其实是你已经了解的东西,并​​没有特别的 api​​。表单就是我们看到的东西。开发者使用组件、状态、属性来创建表单。

我们在回顾下 React 部分思维模式:

  • React 有两种主要处理数据的方式:​​状态​​​和​​属性​
  • 组件是 js 类,除了 react 提供的生命周期钩子、render(),组件还可以拥有自定义的类方法,可以用来相应事件,或者做任何其他事
  • 与常规的 dom 元素一样,可以在 React 组件上注册事件,例如 onClick、onChange等
  • 父组件可以将​​回调函数​​作为属性传给子组件,使组件之间通信。

下面我们通过实验测试 react 中表单是否真的简单。

表单小示例

创建一个子组件 ​​CreateCommentComponet​​,用户能通过它来提交评论。

<script type="text/babel">
class CreateCommentComponet extends React.Component {
constructor(props) {
super(props);
this.state = { text: "" };
this.onInputChange = this.onInputChange.bind(this);
}

onInputChange(e) {
// e 是 React 合成事件,对用户来说就像原生的 event。
const text = e.target.value;
this.setState(() => ({ text: text })); // {1}
}
render() {
return <div className="CreateCommentComponet">
<p>您输入的评论是:{this.state.text}</p>
<textarea
value={this.state.text} /* {2} */
placeholder="请输入评论"
onChange={this.onInputChange}
/>
</div>
}
}

ReactDOM.render(
<CreateCommentComponet />,
document.getElementById('root')
);
</script>

页面内容如下:

<div id="root">
<div class="CreateCommentComponet">
<p>您输入的评论是:</p>
<textarea placeholder="请输入评论"></textarea>
</div>
</div>

当我们在 textarea 中输入文字,例如 ​​111​​,文字也会同步到 p 元素中。就像这样:

<div id="root">
<div class="CreateCommentComponet">
<p>您输入的评论是:111</p>
<textarea placeholder="请输入评论">111</textarea>
</div>
</div>

为什么我输入不了字符?

比如现在我们将 ​​this.setState​​(行{1})注释,然后给 textarea 输入字符,页面什么也没发生。

初学者这时就很​​困惑​​,为什么我输不了字符,什么鬼?

其实这是正常的,也正是 React 尽职的表现。

React 保持​​虚拟 dom​​​ 和​​真实 dom​​ 的同步,现在用户给 textarea 输入字符,尝试更改 dom,但用户并没有更新虚拟 dom,所以 React 也不会对用户做任何改变。

假如此时 textarea 变了,那岂不是又回到老的做事方式,由我们自己管理真实 dom。而非现在面向 React 编程,即通过声明组件在不同状态下的行为和外观,React 根据虚拟 DOM 生成和管理真实 dom。

如果注释 ​​value={this.state.text}​​​(行{2}),此刻就由受控组件变成​​非受控组件​​,也就是说 textarea 的值不在受 React 控制。

通过事件和事件处理器更新状态来严格控制如何更新,按照这种设计的组件称为​​受控组件​​。因为我们严格控制了组件。非受控组件,组件保持自己的内部状态,不在使用 value 属性设置数据。

Tip:有关受控组件和非受控组件的介绍请看 ​​这里​​

表单验证和清理

表单得加上前端校验,告诉用户提供的数据不能满足要求或无意义。

至于清理,笔者这里自定义了一个 Filter 类,用于清理冒犯性的内容,比如将 fuck 清理为 ****。

Tip:清理的功能,笔者最初想用 npm 包 bad-words,但它好像只支持 require 这种构建的环境。

<script type="text/babel">
/*
bad-words
自定义清理函数。
用法如下:
let filter = new Filter()
filter.clean('a b fuck c fuck') => a b **** c ****
*/
class Filter {
constructor() {
this.cleanWord = ['fuck']
this.placeHolder = '*'
}
// 增加过滤单词
addCleanWord(...words){
this.cleanWord = [...this.cleanWord, ...words]
}
clean(msg) {
this.cleanWord.forEach(
item => msg = msg.replace(new RegExp(item, 'g'),
new Array(item.length).fill(this.placeHolder).join('')))
return msg
}
}
class CreateCommentComponet extends React.Component {
constructor(props) {
super(props);
this.state = { text: "", valid: false };
this.handleSubmit = this.handleSubmit.bind(this)
this.onInputChange = this.onInputChange.bind(this);
}
handleSubmit = () => {
if (!this.state.valid) {
console.log('校验失败,不能提交')
return
}
console.log('提交')
}
// e 是 React 合成事件,对用户来说就像原生的 event。
onInputChange(e) {
// 清理输入。
const filter = new Filter()
const text = filter.clean(e.target.value);
this.setState(() => ({ text: text, valid: text.length <= 10 }));
}
render() {
return <div className="CreateCommentComponet">
<p>您输入的评论是:{this.state.text}</p>
<textarea
value={this.state.text} /* {2} */
placeholder="请输入评论"
onChange={this.onInputChange}
/>
<p><button onClick={this.handleSubmit}>submit</button></p>
</div>
}
}

ReactDOM.render(
<CreateCommentComponet />,
document.getElementById('root')
);
</script>

当用户输入 ​​1 2 fuc fuck​​​ 时,则会显示 ​​您输入的评论是:1 2 fuc ****​​。

最终版本

最后加上父组件,子组件将提交的评论发送给父组件,并重置自己。再由父组件提交评论到后端。

<script type="text/babel">
class CommentComponet extends React.Component {
// 默认没有评论
state = { comments: [] }
handleCommontSubmit = (commont) => {
// 本地模拟提交
this.setState({ comments: [...this.state.comments, commont] })
}
render() {
return <div>
<p>已发表评论有:</p>
{
this.state.comments.length === 0
? <p>暂无评论</p>
: <ul>{this.state.comments.map((item, i) => <li key={i}>{item}</li>)}</ul>
}
<CreateCommentComponet handleCommontSubmit={this.handleCommontSubmit} />
</div>
}
}
class CreateCommentComponet extends React.Component {
constructor(props) {
super(props);
this.state = { text: "", valid: false };
this.handleSubmit = this.handleSubmit.bind(this)
this.onInputChange = this.onInputChange.bind(this);
}
handleSubmit = () => {
if (!this.state.valid) {
console.log('校验失败,不能提交')
return
}
this.props.handleCommontSubmit(this.state.text)
// 重置
this.setState({ text: '' })
}
// e 是 React 合成事件,对用户来说就像原生的 event。
onInputChange(e) {
const text = e.target.value;
this.setState(() => ({ text: text, valid: text.length <= 10 })); // {1}
}
render() {
return <div className="CreateCommentComponet">
<p>您输入的评论是:{this.state.text}</p>
<textarea
value={this.state.text} /* {2} */
placeholder="请输入评论"
onChange={this.onInputChange}
/>
<p><button onClick={this.handleSubmit}>submit</button></p>
</div>
}
}

ReactDOM.render(
<CommentComponet />,
document.getElementById('root')
);
</script>

页面结构如下:

<div id="root">
<div>
<p>已发表评论有:</p>
<p>暂无评论</p>
<div class="CreateCommentComponet">
<p>您输入的评论是:</p><textarea placeholder="请输入评论"></textarea>
<p><button>submit</button></p>
</div>
</div>
</div>

当我们输入两条评论后,页面结构如下:

<div id="root">
<div>
<p>已发表评论有:</p>
<ul>
<li>评论1</li>
<li>评论2...</li>
</ul>
<div class="CreateCommentComponet">
<p>您输入的评论是:</p><textarea placeholder="请输入评论"></textarea>
<p><button>submit</button></p>
</div>
</div>
</div>

Tip:按照现在的写法,如果有 10 个 input,则需要定义 10 个 onInputChange 事件,其实是可以优化成一个,请看 ​​这里​​。

React 路由

根据前面两篇博文的学习,我们会创建 ​​react 组件​​​,也理解了 ​​react 的数据流和生命周期​​。似乎还少点什么?

平时总说的 SPA(单页面应用)就是前后端分离的基础上,再加一层​​前端路由​

Tip:在新的 Web 应用框架中,服务器最初会下发 html、css、js等资源,之后客户端应用“接管”工作,服务器只负责发送原始数据(通常是 json)。从这里开始,除非用户手动刷新页面,否则服务器只会下发 json 数据。

路由有许多含义和实现,对我们来说,它是一个​​资源导航系统​​​。如果你使用浏览器,它会根据不同的 url(网址) 返回不同的页面(数据)。在服务端,路由着重将传入的请求路径匹配到源自数据库的资源。​​对于 React ,路由通常意味着将组件(人们想要的资源)匹配到 url(将用户想要的东西告诉系统的方式)​

Tip:需要路由的原因有很多,例如:

  • 界面的不同部分需要。用户需要在浏览器历史中前进和后退
  • 网站的不同部分需要他们自己的 url,以便轻松的将人们路由到正确的地方
  • 按页面拆分代码有助于促进模块化,从而拆分应用

下面我们构建一个简单的路由,以便更好的理解 React 应用的路由。

比如之前学习 react 路由中有这么一段代码:

<Router>
<div>
<h2>About</h2>
<hr />
<ul>
<li>
<Link to="/about/article1">article1</Link>
</li>
<li>
<Link to="/about/article2">article2</Link>
</li>

</ul>
<Switch>
<Route path="/about/article1">
文章1...
</Route>
<Route path="/about/article2">
文章2...
</Route>
</Switch>
</div>
</Router>

这里有 Router、Route、Link,为什么这就是一个​​嵌套路由​​,里面发生了什么?

自定义路由效果展示

react实战系列 —— React 中的表单和路由的原理_表单

Tip:为了方便,笔者就在开源项目 spug 中进行。用 react cli 创建的项目也都可以。

创建路由 Route.js

以下是 Route.js 的完整代码。功能很简单,就是作为 url 和组件映射的​​数据容器​​。

import PropTypes from 'prop-types';
import React from 'react';
// package.json 没有,或许像 prop-types 自动已经引入了
import invariant from 'invariant';

/**
* Route 组件主要作为 url 和 组件映射的数据容器
* Route 不渲染任何东西,如果渲染,就报错。好奇怪!
* 其实这只是一种 React 可以理解,开发者也能通过它将路由和组件关联在一起的方式而已。
*
* 用法:<Route path="/home" component={Home} />。路径 `/home` 指向 `Home` 组件
*/
class Route extends React.Component {
static propTypes = {
path: PropTypes.string,
// React 元素或函数
component: PropTypes.oneOfType([PropTypes.element, PropTypes.func])
};
// 一旦被调用,我们就知道事情不对了。
render() {
return invariant(false, "<Route> elements are for config only and shouldn't be rendered");
}
}

export default Route;

Tip:invariant 一种在开发中提供描述性错误但在生产中提供一般错误的方法。这里一旦调用了 render() 就会报错,我们就知道事情不对了。

var invariant = require('invariant');

invariant(someTruthyVal, 'This will not throw');
// No errors

invariant(someFalseyVal, 'This will throw an error with this message');
// Error: Invariant Violation: This will throw an error with this message

第一个参数是假值就报错,真值不会报错。

创建路由器 Router.js

Router 用于管理路由。请看这段代码:

<Router location={this.state.location}>
<Route path="/" component={Home} />
<Route path="/test" component={Test} />
</<Router>

当 Router 的 location 是 ​​/​​​,则渲染 Home 组件。如果是 ​​/test​​ 则渲染 Test 组件。

大概​​思路​​​是:通过一个变量 routes 来存储路由信息,比如 ​​/​​​ 对应一个 Home,​​/test​​ 对应 Test,借助 enroute(微型路由器),根据不同的 url 渲染出对应的组件。

完整代码如下:

import PropTypes from 'prop-types';
import React, { Component } from 'react';
// 微型路由器,使用它将路径匹配到组件上
import enroute from 'enroute';
import invariant from 'invariant';

export default class Router extends Component {
// 定义两个属性。必须有子元素 和 location。其中子元素至少有2个,否则就不是数组类型。
// 你换成其他规则也没问题
static propTypes = {
children: PropTypes.array.isRequired,
location: PropTypes.string.isRequired
};
constructor(props) {
super(props);

/**
* 用来存储路由信息
* 例如:{/test: render(), /profile: render(), ...}
*/
this.routes = {};

// 添加路由
this.addRoutes(props.children);

// 注册路由器。当匹配对应 url,则会调用对应的方法,比如匹配 /test,则调用相应的 render() 方法。render() 方法会返回相应的 React 组件
this.router = enroute(this.routes);
}

// 向路由器中添加路由。需要两个东西:正确的 url 和 对应的组件
addRoute(element, parent) {
// Get the component, path, and children props from a given child
const { component, path, children } = element.props;

// 没有 component 就会报错
invariant(component, `Route ${path} is missing the "path" property`);
// path 必须是字符串
invariant(typeof path === 'string', `Route ${path} is not a string`);

// Set up Ccmponent to be rendered
// 返回组件。参考 enroute 的用法。
const render = (params, renderProps) => { // {1}

// 如果匹配 <Route path="/test">,this 则是父组件 Router
const finalProps = Object.assign({ params }, this.props, renderProps);

// Or, using the object spread operator (currently a candidate proposal for future versions of JavaScript)
// const finalProps = {
// ...this.props,
// ...renderProps,
// params,
// };
// finalProps 有父组件的 location、children 和 enroute 传来的 params
const children = React.createElement(component, finalProps);
// parent.render 父路由的 render(及行 {1} 定义的 render() 方法)
return parent ? parent.render(params, { children }) : children;
};

// 有父路由,则连接父路由
const route = this.normalizeRoute(path, parent);

// If there are children, add those routes, too
if (children) {
// 注册路由
this.addRoutes(children, { route, render });
}

// 将路由和 render 关联
this.routes[this.cleanPath(route)] = render;
}

addRoutes(routes, parent) {
// 每个 routes 中的元素将调用一次回调函数(即下面的第二个实参)
// 下面这个 this 是什么?是这个组件的实例,箭头函数是没有 this 的。
React.Children.forEach(routes, route => this.addRoute(route, parent));
}
// 将// 替换成 /
cleanPath(path) {
return path.replace(/\/\//g, '/');
}
// 确保父路由和子路由返回正确的 url。例如:`/a` 和 `b` => `/a/b`
normalizeRoute(path, parent) {
// 绝对路由,直接返回
if (path[0] === '/') {
return path;
}
// 没有父路由,直接返回
if (!parent) {
return path;
}
// 连接父路由
return `${parent.route}/${path}`;
}
// 这里需要有 location 属性
// 将 url 对应的组件渲染出来
render() {
const { location } = this.props;
invariant(location, '<Router/> needs a location to work');
return this.router(location);
}
}

Router 组件说明:

  • ​render()​​ - 将 url 对应的组件渲染出来
  • ​cleanPath()​​​、​​normalizeRoute()​​ - 用于路径处理
  • ​addRoutes()​​ - 依次注册子路由
  • ​addRoute()​​​ - 注册路由,最后存入变量 routes 中。例如 ​​/​​​ 对应 ​​/ 的 render()​​​、​​/test​​​ 对应 ​​/test 的 render()​​。对于嵌套路由,只会返回父路由对应的组件。
  • ​constructor()​​ - 定义变量 routes 存储路由信息,通过 addRoutes 添加路由,最后利用 enroute 返回 this.router。于是 render() 就能将 url 对应的组件渲染出来。

Tip:​​React.Children​​ 提供了用于处理 this.props.children 不透明数据结构的实用方法。例如 forEach、map等

入口 App.js

最终测试的入口文件 App.js 代码如下:

import React, { Component } from 'react';
import Route from './myrouter/Route'
import Router from './myrouter/Router'
// 这个库能更改浏览器中的 url
import { history } from './myrouter/history'
// 链接
import Link from './myrouter/Link'
// 类似 404 的组件
import NotFound from 'myrouter/NotFound';

// 以下都是路由切换的组件(或子页面)
import Home from './myrouter/Home'
import Test from './myrouter/Test'
import Post from './myrouter/Post'
import Profile from './myrouter/Profile'
import EmailSetting from './myrouter/EmailSetting'

class App extends Component {
componentDidMount() {
// 地址变化时触发
history.listen((location) => {
this.setState({ location: location.pathname })
});
}
// window.location.pathname,包含 URL 中路径部分的一个DOMString,开头有一个“/"。
// 例如 https://developer.mozilla.org/zh-CN/docs/Web/API/Location?a=3 的 pathname 是 /zh-CN/docs/Web/API/Location
state = { link: '', location: window.location.pathname }

handleChange = (e) => {
this.setState({ link: e.target.value })
}

handleClick = () => {
history.push(this.state.link)
}

render() {
return (

<div style={{margin: 20}}>
<div style={{ border: '1px solid red', marginBottom: '20px' }}>
<h3>导航1</h3>
<p>请输入要跳转的导航(例如 /、/test、/posts/:postId、/profile/email、不存在的url):<br />
<input value={this.setState.link} onChange={this.handleChange} />
<button onClick={this.handleClick}>导航跳转</button></p>
</div>
<div style={{ border: '1px solid red', marginBottom: '20px' }}>
<h3>导航2</h3>
<p>
<Link to="/">主页</Link> <Link to="/test">测试</Link>
</p>
</div>

<main style={{ border: '1px solid blue' }}>
<h3>不同的子页面:</h3>
{/* 有一个绑定到组件的路由组成的路由器 */}
<Router location={this.state.location}>
<Route path="/" component={Home} />
<Route path="/test" component={Test} />
<Route path="/posts/:postId" component={Post} />
<Route path="/profile" component={Profile}>
<Route path="email" component={EmailSetting} />
</Route>
{/* 都没有匹配到,就渲染 NotFound */}
<Route path="*" component={NotFound}/>
</Router>
</main>
</div>
);
}
}

export default App;

Router 的 location 初始值是 window.location.pathname,点击​​导航跳转​​时调用会通过 ​​history​​ 更改浏览器的 url,接着会触发 history.listen,于是通过 this.setState 来更改 Router 的 location,React 则会渲染 url 相应的组件。

Tip:其他组件都在与 ​​App.js​​​ 同级目录 ​​myrouter​​ 中。

Link.js

一个简单的封装。点击 a 时,调用 history.push() 方法。

import PropTypes from 'prop-types';
import React from 'react';
import { navigate } from './history';

function Link({ to, children }) {
return <a href={to} onClick={e => {
e.preventDefault()
navigate(to)
}}>{children}</a>
}

Link.propTypes = {
to: PropTypes.string,
children: PropTypes.node
};

export default Link;

history.js

对 history 库简单处理:

import { createBrowserHistory } from "history";
const history = createBrowserHistory();
const navigate = to => history.push(to);
export {history, navigate}

NotFound.js

import React  from "react";
import Link from './Link'
export default function(){
return <div>
<p>404 !什么也没有。</p>
<Link to='/' children="主页"/>
</div>
}

其他组件

Home.js

// spug 的函数组件都有 `import React from 'react';`,尽管没有用到 React,奇怪!
import React from 'react';
class Home extends React.Component {
render() {
return (
<div className="home">
主页
</div>
);
}
}

export default Home;

Post.js

import React from 'react';
class Post extends React.Component {
render() {
return (
<div className="post-component">
<p>post</p>
<p>postId:{this.props.params.postId}</p>
</div>
);
}
}

export default Post;

Profile.js

import React from 'react';
class Profile extends React.Component {
render() {
return (
<div className="Profile-component">
<p>个人简介</p>
{this.props.children}
</div>
);
}
}

export default Profile;

Tip: 嵌套路由笔者其实没有实现。比如 ​​http://localhost:3000/profile​​ 就会报错。

EmailSetting.js

import React from 'react';
class EmailSetting extends React.Component {
render() {

return (
<div className="EmailSetting-component">
<p>个人简介 {'->'} 设置邮件</p>
</div>
);
}
}

export default EmailSetting;

Test.js

用于测试 invariant、enroute 等库。

import React from 'react';
import invariant from 'invariant';
import enroute from 'enroute';

function edit(params, props){
// params {id: "3"}
console.log('params', params)
// props {additional: "props"}
console.log('props', props)
}

const router = enroute({
'/users/new': function(){},
'/users/:id': function(){},
'/users/:id/edit': edit,
'*': function(){}
})

router('/users/3/edit', {additional: 'props'})

class Test extends React.Component {
render() {
this.addRoutes()
// import invariant from 'invariant';
// return invariant(false, '这个值是假值就会抛出错误')
return <p>测试页</p>
}
log(v){
console.log('v', v)
}
addRoutes() {
[...'abc'].forEach(item => {this.log(item)})
// [...'abc'].forEach(function(item){this.log(item)}, this)
}
}

export default Test;

其他章节请看:

​​react实战 系列​​

作者:​​彭加李