手写react-router(三)

396 阅读8分钟

withRouter

测试用例

withRouter

You can get access to the history object’s properties and the closest <Route>'s match via the withRouter higher-order component. withRouterwill pass updated match, location, and history props to the wrapped component whenever it renders.

import React from "react";
import PropTypes from "prop-types";
import { withRouter } from "react-router";

// A simple component that shows the pathname of the current location
class ShowTheLocation extends React.Component {
  static propTypes = {
    match: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    history: PropTypes.object.isRequired
  };

  render() {
    const { match, location, history } = this.props;

    return <div>You are now at {location.pathname}</div>;
  }
}

// Create a new component that is "connected" (to borrow redux
// terminology) to the router.
const ShowTheLocationWithRouter = withRouter(ShowTheLocation);
Important Note

withRouter does not subscribe to location changes like React Redux’s connect does for state changes. Instead, re-renders after location changes propagate out from the <Router> component. This means that withRouterdoes not re-render on route transitions unless its parent component re-renders.

Static Methods and Properties

All non-react specific static methods and properties of the wrapped component are automatically copied to the "connected" component.

Component.WrappedComponent

The wrapped component is exposed as the static property WrappedComponent on the returned component, which can be used for testing the component in isolation, among other things.

// MyComponent.js
export default withRouter(MyComponent)

// MyComponent.test.js
import MyComponent from './MyComponent'
render(<MyComponent.WrappedComponent location={{...}} ... />)

wrappedComponentRef: func

A function that will be passed as the ref prop to the wrapped component.

class Container extends React.Component {
  componentDidMount() {
    this.component.doSomething();
  }

  render() {
    return <MyComponent wrappedComponentRef={c => (this.component = c)} />;
  }
}

![image-20190822112352840](/Users/wcdaren/Library/Application Support/typora-user-images/image-20190822112352840.png)

当我们希望每次切换路由的时候都会显示实时的路径时

我们就可以使用witchRouter对一个组件进行包裹

那么被包裹的组件就可以获取作为Route时拥有的属性

  • history
  • match
  • location

实际上就是等于被包裹的组件等于平时Routecomponent

import NavHeader from './NavHeader'

<div className="navbar-header">
  {/* <div className="navbar-brand">管理系统</div> */}
  <NavHeader />
</div>
import React, { Component } from 'react'
import { withRouter } from '../react-router-dom';
class NavHeader extends Component {
	render() {
		const { match, location, history } = this.props
		return (
			< div className="navbar-heading" >
				<div className="navbar-brand" onClick={() => history.push('/')}>正在{location.pathname}</div>
			</div >
		)
	}
}
export default withRouter(NavHeader)

注意最后的包裹withRouter(NavHeader)

我们在NavHeader使用的this.props就是withRouter提供的

代码实现

withRouter.js

我们上面的使用中就清楚的认识到,被包裹的组建,只不过类似于作为Routecomponent属性的组件

所以我们内部实现就是把传进来的参数,作为Routecomponetn

import React from 'react'
import { Route } from '../react-router-dom';
export default function (Component) {
	return () => (
		<Route component={Component} />
	)
}

记得在index.js导出

import HashRouter from './HashRouter'
import Link from './Link'
import Route from './Route'
import Switch from './Switch'
import Redirect from './Redirect'
import withRouter from './withRouter'

export { HashRouter, Link, Route, Switch, Redirect, withRouter }

这个组件是默认渲染的

我们要修改Route.js

Route.js

let { path = '/', component: Component, render, children } = this.props;

如果this.props没有传递path属性,我们就默认为/

这样就可以走if (Component) return <Component {...props} />

在这里我们顺便添加一个exact属性

exact: bool

When true, will only match if the path matches the location.pathnameexactly.

<Route exact path="/one" component={About} />
path location.pathname exact matches?
/one /one/two true no
/one /one/two false yes

这个值就是更好作为pathToRegex里面end的参数

let regxp = pathToRegxp(path, keys, { end: exact })

最终Reout.js代码就是

import React, { Component } from 'react';
import Context from './context';
import pathToRegxp from 'path-to-regexp'

export default class Route extends Component {
	static contextType = Context;
	render() {
		let { path = '/', component: Component, render, children, exact = false } = this.props;
		let pathname = this.context.location.pathname;
		let keys = []
		let regxp = pathToRegxp(path, keys, { end: exact })
		let ret = pathname.match(regxp)
		let props = {
			location: this.context.location,
			history: this.context.history,
		}
		if (ret) {
			let [url, ...values] = ret
			keys = keys.map(key => key.name)
			let params = keys.reduce((memo, key, index) => {
				memo[key] = values[index]
				return memo
			}, {})
			let match = {
				url: pathname,
				isExact: pathname === url,
				path,
				params
			}
			props.match = match
			if (Component) return <Component {...props} />
			else if (render) return render(props)
			else if (children) return children(props)
			else return null
		}
		return children ? children(props) : null
	}
}

Prompt

测试用例

Prompt

Used to prompt the user before navigating away from a page. When your application enters a state that should prevent the user from navigating away (like a form is half-filled out), render a <Prompt>.

import { Prompt } from 'react-router'

<Prompt
  when={formIsHalfFilledOut}
  message="Are you sure you want to leave?"
/>

message: string

The message to prompt the user with when they try to navigate away.

<Prompt message="Are you sure you want to leave?" />

message: func

Will be called with the next location and action the user is attempting to navigate to. Return a string to show a prompt to the user or true to allow the transition.

<Prompt
  message={location =>
    location.pathname.startsWith("/app")
      ? true
      : `Are you sure you want to go to ${location.pathname}?`
  }
/>

when: bool

Instead of conditionally rendering a <Prompt> behind a guard, you can always render it but pass when={true} or when={false} to prevent or allow navigation accordingly.

image-20190822152758622

现在我们的需求是,如果输入框里面有文字,我们在离开前就问先问一下用户是否离开,因为离开后编辑的文字就会不见了

还有我们在提交后,用户可能还想要再次添加,所以我们也要在用户点击提交后询问一次

我们先往state存储一个状态

 state = { formIsHalfFilledOut: false }

然后我们要在该页面添加Prompt

render() {
  return (
    <>
    <Prompt
      when={this.state.formIsHalfFilledOut}
      message={location => `你是否将要离开,前往${location.pathname}`}
      />
    <form onSubmit={this.handleSubmit}>
      <div className='form-group' onSubmit={this.handleSubmit}>
        <label>用户名</label>
        <input
          ref={ele => this.textInput = ele}
          className='form-control'
          onChange={event => this.setState({ formIsHalfFilledOut: event.target.value.length > 0 })}
          />
      </div>
      <div className='form-group'>
        <input type="submit" className='btn btn-primary' />
      </div>
    </form>
    </>
  )
}

其实我们在input里面添加了一个事件

onChange={event => this.setState({ formIsHalfFilledOut: event.target.value.length > 0 })}

当输入框有值适,我们就把formIsHalfFilledOut设为true,所以就会触发Prompt出现的条件

还有我们点击提交后,我们也要修改formIsHalfFilledOuttrue

这里注意了,我们要使用setState的第二个参数,意为改变后再执行的回调函数

因为我们的setState是异步的,如果我们只是修改formIsHalfFilledOut的值后

就直接执行页面跳转是不会触发拦截的

handleSubmit = (event) => {
  this.setState({
    formIsHalfFilledOut: true
  }, () => {
    event.preventDefault()
    let name = this.textInput.value
    let user = { id: Date.now(), name }
    let usersStr = localStorage.getItem('users')
    let users = usersStr ? JSON.parse(usersStr) : []
    users.push(user)
    localStorage.setItem('users', JSON.stringify(users))
    this.props.history.push('/user/list')
  })
}

综上,代码就是

import React, { Component } from 'react';
import { Prompt } from '../react-router-dom'

export default class UserAdd extends Component {
  state = { formIsHalfFilledOut: false }
  handleSubmit = (event) => {
    this.setState({
      formIsHalfFilledOut: true
    }, () => {
      event.preventDefault()
      let name = this.textInput.value
      let user = { id: Date.now(), name }
      let usersStr = localStorage.getItem('users')
      let users = usersStr ? JSON.parse(usersStr) : []
      users.push(user)
      localStorage.setItem('users', JSON.stringify(users))
      this.props.history.push('/user/list')
    })
  }
  render() {
    return (
      <>
        <Prompt
          when={this.state.formIsHalfFilledOut}
          message={location => `你是否将要离开,前往${location.pathname}`}
        />
        <form onSubmit={this.handleSubmit}>
          <div className='form-group' onSubmit={this.handleSubmit}>
            <label>用户名</label>
            <input
              ref={ele => this.textInput = ele}
              className='form-control'
              onChange={event => this.setState({ formIsHalfFilledOut: event.target.value.length > 0 })}
            />
          </div>
          <div className='form-group'>
            <input type="submit" className='btn btn-primary' />
          </div>
        </form>
      </>
    )
  }
}

代码实现

关于弹窗的实现就要涉及到

Window.confirm()

The **Window.confirm()** method displays a modal dialog with an optional message and two buttons: OK and Cancel.

SyntaxSection

result = window.confirm(message);
  • message is an optional string to display in the dialog.
  • result is a boolean indicating whether OK (true) or Cancel (false) was selected. If a browser is ignoring in-page dialogs, then result is always false.

ExampleSection

if (window.confirm("Do you really want to leave?")) { 
  window.open("exit.html", "Thanks for Visiting!");
}

Produces:

firefox confirm

NotesSection

Dialog boxes are modal windows — they prevent the user from accessing the rest of the program's interface until the dialog box is closed. For this reason, you should not overuse any function that creates a dialog box (or modal window). Regardless, there are good reasons to avoid using dialog boxes for confirmation.

如果上面讲得不是很明白,我们独立创建一个HTML文件测试

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<title>Document</title>
</head>

<body>
	<script>
		let ret = window.confirm('confirm测试')
		console.log('<<<===ret==>>>');
		console.log(ret);
		console.log('==>>>><<<==');
	</script>
</body>

</html>

打开该页面后

image-20190822163038689

如果我们添加OK

image-20190822163103078

我们的ret就为true

如果我们添加Cancel

image-20190822163154684

那么我们就知道我们的Prompt实际上就是触发了windown.confirm这么简单

<Prompt
  when={this.state.formIsHalfFilledOut}
  message={location => `你是否将要离开,前往${location.pathname}`}
  />

再来看看Prompt的两个属性

  • when
  • message
    • string
    • func

HashRouter.js

那我们假设上下文就实现了这个功能,该接口就为block且存储在history

import React from 'react'
import Context from './context';
export default class Prompt extends React.Component {
	static contextType = Context;
	render() {
		this.history = this.context.history;
		const { when, message } = this.props;
		if (when) {
			this.history.block(message);
		} else {
			this.history.block(null);
		}
		return null;
	}
}

那我们可以猜想到这个接口实际上就是window.confirm

现在我们到HashRouter.js去实现这个block

我们在实例中添加一个message属性来存储消息

message = null

那么我们把这个属性添加到上下文中去

let value = {
  //...
  history: {
    // ...
    block: message => {
      if (typeof message === 'string') {
        this.message = location => message
      } else {
        this.message = message
      }
    },
    // ...
  }
}
return (
  <Context.Provider value={value}>
    {this.props.children}
  </Context.Provider>
)

现在我们把要显示的消息存储起来了

那我们的window.confirm什么时候调用呢?

当页面跳转的时候,我们就会调用这个api

所以,我们应该放在history.push里面

let value = {
  //...
  history: {
    push: to => {
      // 如果message有值,表示需要触发window.confirm
      if (this.message) {
        let allow = window.confirm(this.message(this.state.location))
        // 如果点 Cancel 即返回结果为false,我们就不渲染,保持原来的页面
        if (!allow) return
      }
      if (typeof to === 'object') {
        let { pathname, state } = to;
        this.locationState = state;
        window.location.hash = pathname;
      } else {
        window.location.hash = to;
      }
    },
    block: message => this.message = message,
    // ...
  }
}
return (
  <Context.Provider value={value}>
    {this.props.children}
  </Context.Provider>
)

但是这会有一个问题

就是当我们在跳转页面的时候,又会触发这个弹窗,因为此时的this.message是有值的

所以我们需要在离开页面前清除this.message

于是我们写一个清除的api给创建this.message的组件在销毁时调用

	unblock: () => this.message = null

最终的代码就是

// src/react-router-dom/HashRouter.js
import React, { Component } from 'react'
import Context from './context';
export default class HashRouter extends Component {
	state = {
		location: { pathname: window.location.hash.slice(1) }
	}
	locationState = undefined
	message = null
	componentDidMount() {
		window.location.hash = window.location.hash || '/';
		window.addEventListener('hashchange', () => {
			this.setState({
				location: {
					...this.state.location,
					pathname: window.location.hash.slice(1) || '/',
					state: this.locationState
				}
			});
		});
	}
	render() {
		let value = {
			location: this.state.location,
			history: {
				push: to => {
					if (this.message) {
						let allow = window.confirm(this.message(this.state.location))
						if (!allow) return
					}
					if (typeof to === 'object') {
						let { pathname, state } = to;
						this.locationState = state;
						window.location.hash = pathname;
					} else {
						window.location.hash = to;
					}
				},
				block: message => {
					if (typeof message === 'string') {
						this.message = location => message
					} else {
						this.message = message
					}
				},
				unblock: () => this.message = null
			}
		}
		return (
			<Context.Provider value={value}>
				{this.props.children}
			</Context.Provider>
		)
	}
}

Prompt.js

那我们现在还要回到Prompt.js里在销毁前调用这个清除this.message

componentWillUnmount() {
  this.history.unblock();
}

最终的代码如下

// src/react-router-dom/Prompt.js
import React from 'react'
import Context from './context';
export default class Prompt extends React.Component {
	static contextType = Context;
	componentWillUnmount() {
		this.history.unblock();
	}
	render() {
		this.history = this.context.history;
		const { when, message } = this.props;
		if (when) {
			this.history.block(message);
		} else {
			this.history.block(null);
		}
		return null;
	}
} 

BrowserRouter

react-router 的工作方式,是在组件树顶层放一个 Router 组件,然后在组件树中散落着很多 Route 组件(注意比 Router 少一个“r”),顶层的 Router 组件负责分析监听 URL 的变化,在它保护伞之下的 Route 组件可以直接读取这些信息。

很明显,Router 和 Route 的配合,就是之前我们介绍过的“提供者模式”,Router 是“提供者”,Route是“消费者”。

更进一步,Router 其实也是一层抽象,让下面的 Route 无需各种不同 URL 设计的细节,不要以为 URL 就一种设计方法,至少可以分为两种。

  • 第一种很自然,比如 / 对应 Home 页,/about 对应 About 页,但是这样的设计需要服务器端渲染,因为用户可能直接访问任何一个 URL,服务器端必须能对 /的访问返回 HTML,也要对 /about 的访问返回 HTML。
  • 第二种看起来不自然,但是实现更简单。只有一个路径 /,通过 URL 后面的 # 部分来决定路由,/#/ 对应 Home 页,/#/about 对应 About 页。因为 URL 中#之后的部分是不会发送给服务器的,所以,无论哪个 URL,最后都是访问服务器的 / 路径,服务器也只需要返回同样一份 HTML 就可以,然后由浏览器端解析 # 后的部分,完成浏览器端渲染。

在 react-router,有 BrowserRouter 支持第一种 URL,有 HashRouter 支持第二种 URL。

因为 create-react-app 产生的应用默认不支持服务器端渲染,为了简单起见,我们在下面的例子中使用 HashRouter,在实际产品中,其实最好还是用 BrowserRouter,这样用户体验更好。

import React, { Component } from 'react'
import Context from './context';
(function (history) {
	var pushState = history.pushState;
	history.pushState = function (state, title, pathname) {
		if (typeof window.onpushstate == "function") {
			window.onpushstate(state, pathname);
		}
		return pushState.apply(history, arguments);
	};
})(window.history);
export default class BrowserRouter extends Component {
	state = {
		location: { pathname: '/' }
	}
	block = null
	componentDidMount() {
		window.onpopstate = (event) => {
			this.setState({
				location: {
					...this.state.location,
					pathname: document.location.pathname,
					state: event.state
				}
			});
		};
		window.onpushstate = (state, pathname) => {
			this.setState({
				location: {
					...this.state.location,
					pathname,
					state
				}
			});
		};
	}
	render() {
		let that = this;
		let value = {
			location: that.state.location,
			history: {
				push(to) {
					if (that.block) {
						let allow = window.confirm(that.block(that.state.location));
						if (!allow) return;
					}
					if (typeof to === 'object') {
						let { pathname, state } = to;
						window.history.pushState(state, '', pathname);
					} else {
						window.history.pushState('', '', to);
					}
				},
				block(message) {
					that.block = message;
				},
				unblock() {
					that.block = null;
				}
			}
		}
		return (
			<Context.Provider value={value}>
				{this.props.children}
			</Context.Provider>
		)
	}
}