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

其他章节请看:

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 对应的组件渲染出来。

TipReact.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实战 系列

发表评论

评论已关闭。

相关文章

当前内容话题