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,为什么这就是一个嵌套路由,里面发生了什么?

自定义路由效果展示

效果展示

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

posted @ 2022-08-24 22:44  彭加李  阅读(667)  评论(0编辑  收藏  举报