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,開頭有一個「/"。
  // 例如 //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: 嵌套路由筆者其實沒有實現。比如 //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實戰 系列

Tags: