手写react-router(一)

430 阅读16分钟

测试用例

import React from "react";
import ReactDOM from "react-dom";
import { HashRouter as Router, Route } from "react-router-dom";

let Home = () => <div>Home</div>;
let User = () => <div>User</div>;
let Profile = () => <div>Profile</div>;

function App() {
  return (
    <Router>
      <div>
        <Route path="/home" component={Home} />
        <Route path="/user" component={User} />
        <Route path="/profile" component={Profile} />
      </div>
    </Router>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

现在我们来开始写react-router-dom

我们先看看看

let Home = (...rest) => {
  console.log(rest);
  return <div>Home</div>;
};

这里的参数就是 (props,context)

image-20190817232034326

然后我们测试每个Route渲染的组件,都会发现它的porps上有以上三个对象

image-20190817232125044

这里我们主要实现个别几个属性

  • history
    • push
  • location
    • patchname
  • match
    • params
    • path
    • url

这些数据都是父组建传递给子组建的

即这些属性是HashRouter传递给Route

那我们就思考下怎么实现这种东西的传递?

代码实现

首先我们创建一个src/react-router-dom文件夹

然后创建一个index.js文件来统一导出组件

import HashRouter from './HashRouter'
import Route from './Route'

export { HashRouter, Route }

现在开始实现代码实现

因为Route是可以在Router内部的任意位置的

那么作为Router就应该通过上下文来传递属性

于是我们先创建一个上下文

在老版 Context API 中,“上下文”只是一个概念,并不对应一个代码,两个组件之间达成一个协议,就诞生了“上下文”。

在新版 Context API 中,需要一个“上下文”对象,上下文数据的提供或者被消费的代码很大概率是分布在不同文件的

所以为了方便各文件调用上下文,我们单独使用一个文件夹来创建,需要使用该上下文的时候,直接引入该文件即可

context.js

import { createContext } from 'react';
export default createContext();

Route.js

接下来我们来实现Route.js

HashRouter应该作为一个提供者,为每个消费者Route提供我们上面说的三个属性

  • history
  • location
  • match

那对于消费者的Route来说

他的作用就是

<Route path="/home" component={Home} />

如果当前的hashpath匹配,那么就把Home渲染出来,即

render(){
  if(hash == path)	
    return <Home/>
  else  
    return null
}

那问题来了,我们怎么获取的hash呢?

这个hash就是作为提供者的HashRouter给予的

而他是通过上下文提供的,这里我们假装HashRouter已经准备好了数据

那我们先在函数里声明我们要使用到的上下文对象

import React,{Component} from 'react';
import Context from './context';
export default class Route extends Component{
    static contextType =  Context;  
}

那现在开始,我们就可以使用this.context来获取上下文数据了

let pathname = this.context.location.pathname;

这个数据就是我们前面说到的hash

那现在轮到我们获取Route组建上的属性了

let {path,component: Component}=this.props;

这里我们给component重命名为Component,因为到时这个属性会作为组件渲染

所以需要首字母大写,即<Component/>

那判断逻辑就

if (path === pathname) {
  return <Component/>
} else {
  return null;
}

综上,代码就是

import React, { Component } from 'react';
import Context from './context';
export default class Route extends Component {
	static contextType = Context;
	render() {
		let { path, component: Component } = this.props;
		let pathname = this.context.location.pathname;
		if (path == pathname) {
			return <Component />
		} else {
			return null;
		}
	}
}

HashRouter.js

我们在前面,已经假装使用了HashRouter已经实现的数据,即

let pathname = this.context.location.pathname;

在实现该数据前,我们思考下HashRouter的大致架构

import React from "react";
import { HashContext } from "./HashContext";

export default class HashRouter extends React.Component {
  render() {
    let value = {
      location
    }
    return (
      <HashContext.Provider value={value}>
        {this.props.children}
      </HashContext.Provider>
    );
  }
}

上面代码主要的作用有两个

  • 渲染HashRouter,即{this.props.children}
  • 为子孙组件提供上下文,即<HashContext.Provider value={value}>

而刚刚Route要使用的数据就应该添加到value然后传递出去

我们现在来思考如何实现location.pathname

获取hahs的办法有

window.location.hash

urlhttp://localhost:3000/#/home

这个值为#/home,而我们想要的数据是/home,所以我们对返回的结果分割下

window.location.hash.slice(1)

那这个数据应该存储在哪呢?

我们要的效果是,当浏览器durl发生变化时

相应的路由也要发生变化

如果提供者的value值发生变化了,那么订阅该上下文的组件也会重新渲染

所以我们应该把数据放在this.state

然后再添加一个监听函数,当url发生变化时我们就修改this.state

就让就会触发提供者的value发生变化,从而整个页面发生变化

就有

state = {
  location: { pathname: window.location.hash.slice(1) }
}

那监听函数应该放在didmount

这里我们还要给url赋一个默认值,因为我们这里统一是使用hash

即开始url就应该是http://localhost:3000/#/

这样我们才方便在后面添加地址

componentDidMount() {
  window.location.hash = window.location.hash || '/';
}

如果没有这句话,我们登录页面的地址http://localhost:3000/

这样的地方不方便我们直接在后面添加user之类的字符串

因为如果直接添加user,地址是http://localhost:3000/user这与我们的路由是不匹配的

我们的路由地址实际上是http://localhost:3000/#/user

然后现在我们要开始监听hash变化了

	window.addEventListener('hashchange', () => {	});

一旦发生了变化就会执行后面的回调函数

该回调函数里,我们应该更改this.state的i值,所以有

window.addEventListener('hashchange', () => {
  this.setState({
    location: {
      ...this.state.location,
      pathname: window.location.hash.slice(1) || '/'
    }
  });
});

那我们给提供者的value就是this.state

综上,代码就是

import React, { Component } from 'react'
import Context from './context';
export default class HashRouter extends Component {
	state = {
		location: { pathname: window.location.hash.slice(1) }
	}
	componentDidMount() {
		window.addEventListener('hashchange', () => {
			this.setState({
				location: {
					...this.state.location,
					pathname: window.location.hash.slice(1) || '/'
				}
			});
		});
		window.location.hash = window.location.hash || '/';
	}
	render() {
		return (
			<Context.Provider value={this.state}>
				{this.props.children}
			</Context.Provider>
		)
	}
}

测试用例

现在我们来添加Link的测试

import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';

let Home = () => <div>Home</div>
let Profile = () => <div>Profile</div>
let User = () => <div>User</div>

export default class App extends Component {
	render() {
		return (
			<div className="container">
				<nav className="navbar navbar-default">
					<div className="container-fluid">
						<div className="navbar-header">
							<div className="navbar-brand">管理系统</div>
						</div>
						<ul className="nav navbar-nav">
							<li><Link to="/home">首页</Link> </li>
							<li><Link to="/user">用户管理</Link></li>
							<li><Link to="/profile">个人设置</Link></li>
						</ul>
					</div>
				</nav>
				<div className="row">
					<div className="col-md-12">
						{/* <Switch> */}
						<Route path='/home' component={Home} />
						<Route path='/user' component={User} />
						<Route path='/profile' component={Profile} />
						{/* </Switch> */}
					</div>
				</div>
			</div>
		)
	}
}

image-20190820210948530

当我们点击Link的时候,就会实现页面的跳转

代码实现

Link

Link就类似与a,当我们点击他的时候浏览器的url就会发生改变

可以直接使用ahref来实现

但是为了兼容browerRouter我们统一使用window.location.hash来更改地址

<Link to="/home">首页</Link>

根据使用用例我们知道,Link的作用有以下两个

  1. 应该把Link的内容渲染出来
  2. to的设置为最新的url

因为涉及到url的前进后退,我们有专门的一个栈来存储hash地址

当我们往这个栈存储一个值的时候,浏览器就会更改为该地址

这个栈就是hisroty

当然这里我们还是先假装父组件已经提供了这个属性

所以代码就有

import React, { Component } from 'react'
import RouterContext from './context'
export default class Link extends Component {
	static contextType = RouterContext
	render() {
		return (
			<a onClick={() => this.context.history.push(this.props.to)}>
				{this.props.children}
			</a >)
	}
}

HashRouter.js

这里我们就要来给提供者的value添加上面需要的history

render() {
  let value = {
    location: this.state.location,
    history: {
      push: to => window.location.hash = to
    }
  }
  return (
    <Context.Provider value={value}>
      {this.props.children}
    </Context.Provider>
  )
}

还要记得在 index.js里导出

import HashRouter from './HashRouter'
import Link from './Link'
import Route from './Route'

export { HashRouter, Link, Route }

测试用例

现在我们要来实现一个二级路由,即涉及到路由参数的跳转

我们这里就只对User添加路由

// src/components/App.js
import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import User from './User'

let Home = () => <div>Home</div>
let Profile = () => <div>Profile</div>

export default class App extends Component {
	render() {
		return (
			<div className="container">
				<nav className="navbar navbar-default">
					<div className="container-fluid">
						<div className="navbar-header">
							<div className="navbar-brand">管理系统</div>
						</div>
						<ul className="nav navbar-nav">
							<li><Link to="/home">首页</Link> </li>
							<li><Link to="/user">用户管理</Link></li>
							<li><Link to="/profile">个人设置</Link></li>
						</ul>
					</div>
				</nav>
				<div className="row">
					<div className="col-md-12">
						<Route path='/home' component={Home} />
						<Route path='/user' component={User} />
						<Route path='/profile' component={Profile} />
					</div>
				</div>
			</div>
		)
	}
}

这里主要就是写用户管理页面,即User

image-20190820115519087

import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import UserAdd from './UserAdd';
import UserList from './UserList';
import UserDetail from './UserDetail';
export default class User extends Component {
	render() {
		return (
			<div className="row">
				<div className="col-md-2">
					<ul className="nav nav-pills nav-stacked">
						<li><Link to="/user/add">添加用户</Link></li>
						<li><Link to="/user/list">用户列表</Link></li>
					</ul>
				</div>
				<div className="col-md-10">
					<Route path="/user/add" component={UserAdd} />
					<Route path="/user/list" component={UserList} />
					<Route path="/user/detail/:id" component={UserDetail} />
				</div>
			</div>
		)
	}
}

这里我们我们就再也不用写Router

所以业界的在使用Router4的时候,就是直接在最外层套一个Router

// src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { HashRouter as Router } from 'react-router-dom'
import 'bootstrap/dist/css/bootstrap.css'
import App from './components/App'

ReactDOM.render(
  <Router>
    <App />
  </Router>
  , document.getElementById('root'))

UserAdd

那现在我们开始写下UserAdd

image-20190820115912382

<form>
  <label htmlFor="">用户名</label>
  <input type="text" />
  <input type="submit" />
</form>

image-20190820120033018

使用css美化下,现在就和上面的一样了

<form>
  <div className='form-group'>
    <label htmlFor="">用户名</label>
    <input type="input" className='form-control' />
  </div>
  <div className='form-group'>
    <input type="submit" className='btn btn-primary'/>
  </div>
</form>

我们现在要做的就是当用户点击Submit的时候我们把输入框的内容获取

这里为了方便,我们直接使用ref直接获取

<input ref={ele => this.textInput = ele} className='form-control' />

我们这里获取的是该dom的实例,注意我们不能直接获取ele.value,因为第一次的时候elenull

如果 ref 回调函数是以内联函数的方式定义的,在更新过程中它会被执行两次,第一次传入参数 null,然后第二次会传入参数 DOM 元素。这是因为在每次渲染时会创建一个新的函数实例,所以 React 清空旧的 ref 并且设置新的。通过将 ref 的回调函数定义成 class 的绑定函数的方式可以避免上述问题,但是大多数情况下它是无关紧要的。

那么我们就可以通过以下代码获取到用户输入的值了

let textInput = this.textInput.value

那希望点击submit的时候就把内容存储起来

我们就在form上绑定一个事件

<form onClick={this.handleSubmit}>
  // ...
</form>
handleSubmit = (event) => {
  event.preventDefault()
  let textInput = this.textInput.value
  }

这里记得我们要阻止默认事件,不然会跳转页面

用户的数据就以idname格式存储

handleSubmit = (event) => {
  event.preventDefault()
  let name = this.textInput.value
  let user = { id: Date.now(), name }
  }

存储的地方就使用localSorage

那我们先获取,往获取的内容添加,再重新存储进去

当然,第一次获取的时候是空的,所以如果为空的时候,我们就设置个默认值

handleSubmit = (event) => {
  // ...
  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';

export default class UserAdd extends Component {
  handleSubmit = (event) => {
    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 (
      <form onSubmit={this.handleSubmit}>
        <div className='form-group' onSubmit={this.handleSubmit}>
          <label htmlFor="">用户名</label>
          <input ref={ele => this.textInput = ele} className='form-control' />
        </div>
        <div className='form-group'>
          <input type="submit" className='btn btn-primary' />
        </div>
      </form>
    )
  }
}

UserList

那我们首先要获取刚刚存储的数据

先在this.state添加默认值

constructor(props) {
  super(props)
  this.state = { users: [] }
}

然后在didmount后获取数据

componentDidMount() {
  let usersStr = localStorage.getItem('users')
  let users = usersStr ? JSON.parse(usersStr) : []
  this.setState({ users })
}

这样获取到数据后修改this.state就会触发页面重新渲染

render() {
  return (
    <ul className='list-group'>
      {
        this.state.users.map((user) => (
          <li key={user.id} className='list-group-item'>
            {user.name}
          </li>
        ))
      }
    </ul>
  )
}

image-20190820150531193

现在我们有个需求,就是点击上面的用户名,进入详情

所以我们使用Link

<li key={user.id} className='list-group-item'>
  <Link to={'/user/detail/' + user.id} >{user.name}</Link>
</li>

现在页面就变成了可点击的了

比如点击后的进入的链接就是

http://localhost:3000/#/user/detail/1566283588589

UserDetail

那我们现在要写detail路由页面了

首先我们获取到已经添加好的数据

export default class UserDetail extends Component {
  constructor(props) {
    super(props);
    this.state = { user: {} };
  }
  componentDidMount() {
    let usersStr = localStorage.getItem('users')
    let users = usersStr ? JSON.parse(usersStr) : []
    console.log('<<<===users==>>>');
    console.log(users);
    console.log('==>>>><<<==');
  }
}

image-20190820152119175

我们现在要做的就是把url里的ip提取出来进行配对,然后把配对好的对象渲染出来

现在的问题就是怎么把http://localhost:3000/#/user/detail/1566283588589后面的数字提取出来

这个时候就要使用到了match

match

match里面有一个parmas属性

我们在原本的路由是这么设置的

<Route path="/user/add" component={UserAdd} />
<Route path="/user/list" component={UserList} />
<Route path="/user/detail" component={UserDetail} />

因为我们不是严格匹配,所以

http://localhost:3000/#/user/detail/1566283588589

刚好匹配第三个路由/user/detail

如果我们把这个路由修改成

<Route path="/user/detail/:id" component={UserDetail} />

此时我们再来查看该路由渲染时有什么区别

我们在被渲染的组件里输出this.props

image-20190820160251009

注意看上面的pathurl,我们就能清楚的发现,url后面多出来的字符串就被提去出来存储在了match.params里面

既然知道了this.props.match.params.id是我们想要的数据

我们就可以通过find把想要的数据直接筛选出来了

let user = users.find(user => user.id === parseFloat(this.props.match.params.id))

注意了params.id是一个字符串,而我们的user.id是一个数字,我们要进行类型转换,当然使用==就可以自动转换,但是还是不推荐使用==

我们把把数组更新到state里面

那么我们就可以直接渲染出数据了

import React, { Component } from 'react';
export default class UserDetail extends Component {
  constructor(props) {
    super(props);
    this.state = { user: {} };
  }
  componentDidMount() {
    let usersStr = localStorage.getItem('users')
    let users = usersStr ? JSON.parse(usersStr) : []
    let user = users.find(user => user.id === parseFloat(this.props.match.params.id))
    this.setState({ user })
  }
  render() {
    return (
      <div>
        <label>用户id: </label>{this.state.user.id}
        <br></br>
        <label>用户名: </label>{this.state.user.name}
      </div>
    )
  }
}

代码实现

那在上面的代码中,我们要给上下文添加一个match.params

通过Route上的path属性地址更改为*path*="/user/detail/:id"

这样就可以通过match:params.id获取到

http://localhost:3000/#/user/detail/1566283588589中后面的数据

id = 1566283588589

很明显,我们需要使用正则对url进行提取

这里我们使用一个path-to-regexp

path-to-regexp

Installation

npm install path-to-regexp --save

Usage

const pathToRegexp = require('path-to-regexp')
 
// pathToRegexp(path, keys?, options?)
// pathToRegexp.parse(path)
// pathToRegexp.compile(path)
  • path A string, array of strings, or a regular expression.

  • keys An array to ==populate with填充== keys found in the path.

    /user/:id/:name

    上面这里的idname就是所谓的keys

  • options

    • sensitive When true the regexp will be ==case sensitive(大小写敏感)==. (default: false)

      regxp:正则表达式 Regular expression(重定向自RegExp)

    • strict When true the regexp allows an optional trailing delimiter to match. (default: false)

      如果不严格匹配的话,即/a//a是匹配的

    • end When true the regexp will match to the end of the string. (default: true)

      如果endtrue的话,/a/a/b是不匹配的

    • start When true the regexp will match from the beginning of the string. (default: true)

    • ==delimiter(定界符)== The default delimiter for ==segments(划分)==. (default: '/')

    • endsWith Optional character, or list of characters, to treat as "end" characters.

    • whitelist List of characters to consider delimiters when parsing. (default: undefined, any character)

const keys = []
const regexp = pathToRegexp('/foo/:bar', keys)
// regexp = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', delimiter: '/', optional: false, repeat: false, pattern: '[^\\/]+?' }]

这里我们只讲options里面的end参数

  • end: When true the regexp will match to the end of the string. (default: true)
    • end:true

      let pathToRegExp = require('path-to-regexp');
      let regx = pathToRegExp('/home', [], { end: true });
      console.log(regx);//   /^\/home\/?$/i
      console.log(regx.test('/home')); //true
      console.log(regx.test('/home/2')); //false
      

      homereg

    • end:false

      let pathToRegExp = require('path-to-regexp');
      let regx2 = pathToRegExp('/home', [], { end: false });
      console.log(regx2);//   /^\/home\/?(?=\/|$)/i
      console.log(regx2.test('/home')); //true
      console.log(regx2.test('/home/'));// true
      console.log(regx2.test('/home//')); //true
      console.log(regx2.test('/home/2')); //true
      

      homereg2

同上上面的代码我们知道

  • pathToRegExp返回一个正则表达式
  • end表示是否要严格匹配后续的字符串
    • true地址需要百分百相同
    • faluse前面如果匹配上的话就通过

那第二个参数[]是干什么用的呢?

  • keys: An array to ==populate with填充== keys found in the path.

    let pathToRegExp = require('path-to-regexp');
    let keys = [];
    let regx3 = pathToRegExp('/user/:id', keys, { end: true });
    console.log(regx3); // /^\/user\/([^\/]+?)(?:\/)?$/i
    console.log(keys);
    /*
      [ { name: 'id',
        prefix: '/',
        delimiter: '/',
        optional: false,
        repeat: false,
        pattern: '[^\\/]+?' } ]
    */
    

    uerreg

上面看的很难懂,我们把这个正则使用起来,比较好讲

let pathToRegExp = require('path-to-regexp');
let keys = [];
let regx3 = pathToRegExp('/user/:id', keys, { end: true });
let ret = '/user/1566283588589'.match(regx3)
console.log(ret);
/*
[ '/user/1566283588589',
  '1566283588589',
  index: 0,
  input: '/user/1566283588589',
  groups: undefined ]
 */

我们使用返回的正则对字符串进行过滤,于是我们发现返回数组的第二个参数是1566283588589

我们可以理解为:id是变量名,我们可以获取该变量名上具体的值

keys就是存储变量名的数组

我们设置多个变量名来体验

let pathToRegExp = require('path-to-regexp');
let keys = [];
let regx3 = pathToRegExp('/user/:name/:age', keys, { end: true });
console.log(keys);
/*
[ { name: 'name',
    prefix: '/',
    delimiter: '/',
    optional: false,
    repeat: false,
    pattern: '[^\\/]+?' },
  { name: 'age',
    prefix: '/',
    delimiter: '/',
    optional: false,
    repeat: false,
    pattern: '[^\\/]+?' } ]
*/

keys里面就有"变量名"nameage

这个时候,我们再来配合正则匹配的结果

let pathToRegExp = require('path-to-regexp');
let keys = [];
let regx3 = pathToRegExp('/user/:name/:age', keys, { end: true });
// console.log(keys);
let ret = '/user/wcdaren/22'.match(regx3)
/*
[ '/user/wcdaren/22',
  'wcdaren',
  '22',
  index: 0,
  input: '/user/wcdaren/22',
  groups: undefined ]
*/

我们发现返回的结果中第2和第3个参数刚好是上面keyskye对应的value

即,{nmae : wcdaren}{age : 22}

那我们要怎么做才能把两个数据合成上面的样子呢

let pathToRegExp = require('path-to-regexp');
let keys = [];
let regx3 = pathToRegExp('/user/:name/:age', keys, { end: true });
let [url, ...values] = '/user/wcdaren/22'.match(regx3)
keys = keys.map(key => key.name)
console.log(values); //[ 'wcdaren', '22' ]
console.log(keys); //[ 'name', 'age' ]

let params = keys.reduce((memo, key, indxe) => {
  memo[key] = values[indxe]
  return memo
}, {})
console.log(params); // { name: 'wcdaren', age: '22' }

那现在我们就可以开始来写我们Route

Route

我们现在的要求是把Routepath里实现正则

我们先在constructor里面把需要的regexpkeys准备好

constructor(props) {
  super(props)
  let { path } = props // /user/detail/:id
  this.keys = []
  this.regexp = pathToRegxp(path, this.keys, { end: false })
  this.keys = this.keys.map(key => key.name) 
}

那我们就开始使用上面获取的正则和key

我们从上下文中提取出location.pathname然后进行正则

let pathname = this.context.location.pathname;
let keys = []
let regxp = pathToRegxp(path, keys, { end: false })
let ret = pathname.match(regxp)

如果ret不为null的话,说明匹配上了

那我们就要提取出一个key-value结构的数据作为match.params

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,
    path,
    params
  }
  let props = {
    location: this.context.location,
    history: this.context.history,
    match
  }
  return <Component {...props}></Component>
}

最终的代码就是

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 } = this.props;
		let pathname = this.context.location.pathname;
		let keys = []
		let regxp = pathToRegxp(path, keys, { end: false })
		let ret = pathname.match(regxp)
		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,
				path,
				params
			}
			let props = {
				location: this.context.location,
				history: this.context.history,
				match
			}
			return <Component {...props}></Component>
		}
		return null;
	}
}