其他章节请看:
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,为什么这就是一个嵌套路由,里面发生了什么?
自定义路由效果展示
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实战 系列
作者:彭加李