Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SPA路由原理+build your own react router v4 #53

Open
amandakelake opened this issue Jun 29, 2018 · 1 comment
Open

SPA路由原理+build your own react router v4 #53

amandakelake opened this issue Jun 29, 2018 · 1 comment

Comments

@amandakelake
Copy link
Owner

amandakelake commented Jun 29, 2018

全文目录结构如下

  • 一、浏览器 history和hash
  • 二、简单hash router实现
  • 三、简单 history 路由实现
  • 四、Build your own React Router v4
    • 1、React Router v4 基础使用
    • 2、<Route> 剖析
    • 3、<Link>剖析
    • 4、升级<Route>组件:pushStatereplaceState不会触发onpopstate事件
    • 5、定义matchPath方法
    • 6、完整代码

一、浏览器 history和hash

Hash

location.hash的值是url中#后面的内容
比如https://www.google.com/#amandakelake的hash值为amandakelake
hash发生变化的url都会被浏览器记录下来,所以浏览器的前进后退都可以用
特点:
1、改变 url 的同时,不刷新页面,hash是用来指导浏览器行为的,对服务端是无用的,所以不会包括在http请求中,所以可以随意刷新
2、浏览器提供了onhashchange事件来监听hash的变化

history

Manipulating the browser history - Web API 接口 | MDN
HTML5中history对象上新的API
通过pushState()replaceState()可以修改url的地址
两者区别在于:pushState会改变history.length,而replaceState不改变history.length

注意:无论是replaceState()方法还是pushState()方法,其更新或添加会话历史记录后,改变的只是浏览器关于当前页面的标题和URL的记录情况,并不会刷新或改变页面展示,所以不会触发onpopstate事件

popstate事件

每当活动的历史记录项发生变化时, popstate 事件都会被传递给window对象,popstate 事件的状态属性 state 会包含一个当前历史记录状态对象的拷贝
注意:pushStatereplaceState不会触发onpopstate事件,只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在Javascript代码中调用history.back()

history模式的问题

虽然丢掉了#,不怕浏览器的前进和后退,但是页面怕刷新,会把请求发送到服务器,但如果没有对应的资源,就会404。而hash则没有这个问题,因为浏览器请求不带它玩。

reload()、replace()、location直接赋值三者区别

location.reload()会取客户端的缓存页面
location.replace(url)总是重新请求加载url指向的页面
location.href = url等效于使用pushState()修改URL,会创建一条新会话记录

二、简单hash router实现

class Router {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
  }

  // routes 用来存放不同路由对应的回调函数
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  updateView() {
    this.currentUrl = location.hash.slice(1) || '/';
    // 如果存在该路径,则执行该路径对应的回调函数
    this.routes[this.currentUrl] && this.routes[this.currentUrl]();
    // 对应下面的html文件的routes如下
    // {
    //   '/': () => {
    //     document.getElementById('content').innerHTML = 'Home';
    //   },
    //   '/about': () => {
    //     document.getElementById('content').innerHTML = 'About';
    //   },
    //   '/topics': () => {
    //     document.getElementById('content').innerHTML = 'Topics';
    //   }
    // }
  }

  // init 用来初始化路由,在 load 事件发生后刷新页面,
  // 并且绑定 hashchange 事件,当 hash 值改变时触发对应回调函数
  init() {
    window.addEventListener('load', this.updateView.bind(this), false);
    window.addEventListener('hashchange', this.updateView.bind(this), false);
  }
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>

<body>
  <div id="app">
    <ul>
      <li>
        <a href="#/">home</a>
      </li>
      <li>
        <a href="#/about">about</a>
      </li>
      <li>
        <a href="#/topics">topics</a>
      </li>
    </ul>
    <div id="content"></div>
  </div>
  <script src="./Router.js"></script>
  <script>
    const router = new Router();
    router.init();
    router.route('/', function () {
      document.getElementById('content').innerHTML = 'Home';
    });
    router.route('/about', function () {
      document.getElementById('content').innerHTML = 'About';
    });
    router.route('/topics', function () {
      document.getElementById('content').innerHTML = 'Topics';
    });
  </script>
</body>

</html>

2a626611-8839-4bbe-8a03-96e51007a4f5

三、简单 history 路由实现

hash 的改变可以触发 onhashchange 事件,而 history 的改变并不会触发任何事件,这让我们无法直接去监听 history 的改变从而做出相应的改变。

换个思路
罗列出所有可能触发 history 改变的情况,并且将这些方式一一进行拦截,变相地监听 history 的改变

对于一个应用而言,url 的改变(不包括 hash 值得改变)只能由下面三种情况引起:

  • 点击浏览器的前进或后退按钮 => 可以监听onpopstate事件
  • 点击 a 标签
  • 在 JS 代码中触发 history.push(replace)State 函数

history路由跟上面的hash类似,区别在于 init 初始化函数,首先需要获取所有特殊的链接标签,然后监听点击事件,并阻止其默认事件,触发 history.pushState 以及更新相应的视图

init() {
	  // 该函数对a标签进行监听,并阻止默认事件,触发更新
    this.bindLink();
    window.addEventListener('popstate', e => {
      this.updateView(window.location.pathname);
    });
    window.addEventListener('load', () => this.updateView('/'), false);
  }
bindLink() {
    const allLink = document.querySelectorAll('a[data-href]');
    for (let i = 0, len = allLink.length; i < len; i++) {
      const current = allLink[i];
      current.addEventListener(
        'click',
        e => {
          e.preventDefault();
          const url = current.getAttribute('data-href');
          history.pushState({}, null, url);
          this.updateView(url);
        },
        false
      );
    }
  }

四、Build your own React Router v4

到目前为止,我们已经大概知道了hash路由和history路由的原理,以及如何构建简单的Router,下面让我们跟着一篇国外好文来尝试构建自己的react router v4

Build your own React Router v4这篇文章真的巨好,炒鸡推荐

1、React Router v4 基础使用

首先去React Router v4 官方文档熟悉一下API ,起码得先知道<Route><Link>的概念

这是官网的第一个例子

import React from "react";
import { BrowserRouter as Router, Route, Link } from "react-router-dom";

const BasicExample = () => (
  <Router>
    <div>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/about">About</Link>
        </li>
        <li>
          <Link to="/topics">Topics</Link>
        </li>
      </ul>

      <hr />

      <Route exact path="/" component={Home} />
      <Route path="/about" component={About} />
      <Route path="/topics" component={Topics} />
    </div>
  </Router>
);

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const About = () => (
  <div>
    <h2>About</h2>
  </div>
);

const Topics = ({ match }) => (
  <div>
    <h2>Topics</h2>
    <ul>
      <li>
        <Link to={`${match.url}/rendering`}>Rendering with React</Link>
      </li>
      <li>
        <Link to={`${match.url}/components`}>Components</Link>
      </li>
      <li>
        <Link to={`${match.url}/props-v-state`}>Props v. State</Link>
      </li>
    </ul>

    <Route path={`${match.url}/:topicId`} component={Topic} />
    <Route
      exact
      path={match.url}
      render={() => <h3>Please select a topic.</h3>}
    />
  </div>
);

const Topic = ({ match }) => (
  <div>
    <h3>{match.params.topicId}</h3>
  </div>
);

export default BasicExample;

2、<Route> 剖析

首先看<Route>的使用

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

这里有三个属性,那么这个组件的属性应该是这样的

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func, // 组件返回UI  所以是func
}

如果不返回componnet,那么<Route>还有第二种用法,直接render UI

<Route path='/settings' render={({ match }) => {
  return <Settings authed={isAuthed} match={match} />
}} />

那么,应该还有一个属性render

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func, // 组件返回UI  所以是func
  render: PropTypes.func,
}

然后还需要一个能匹配url的功能性函数matchPath,稍后再定义这个函数,先把<Route>这个组件写出来,看注释应该很好理解

class Route extends Component {
  static propTypes = {
    exact: PropTypes.bool,
    path: PropTypes.string,
    component: PropTypes.func, // 组件返回UI  所以是func
    render: PropTypes.func,
  }

  render() {
    const { path, exact, component, render } = this.props;
    // window.location是全局变量,location可以直接拿到
    const match = matchPath(location.pathname, { path, exact });
    // 如果没有匹配上 path 属性,return null
    if(!match) return null;
    // 匹配上path属性,并且有component属性 直接创建新元素, 通过match传递过去
    if (component) return React.createElement(component, { match });
    // 匹配上path属性,没有component属性,检查render属性
    if (render) return render({ match });
    // 全都没有匹配上,只好null了
    return null;
  }
}

在客户端,对用户来说,有两种方式更新url

  • 点击a标签 => 通过onClick劫持a标签,跟踪所有Route并在路由改变的时候调用forceUpdate更新页面
  • 点击前进后退按钮 => 让<Route>组件监听popstate事件,调用forceUpdate更新页面

先看个简单的,点击前进后退按钮的处理,a标签稍后再说
componentWillMount的时候添加事件监听即可,在上面的Route组件中增加如下代码即可

componentWillMount() {
addEventListener('popstate', this.handlePop);
}

componentWillUnmount() {
removeEventListener('popstate', this.handlePop);
}

handlePop() {
this.forceUpdate();
}

到目前为止,<Route>组件已经解决了监听前进后退按钮的问题

3、<Link>剖析

再来看下a标签的处理,主要的做法是通过onClick劫持a标签,跟踪所有Route并在路由改变的时候调用forceUpdate更新页面

而我们的<Link>组件其实就是包装过的a标签

<Link to='/some-path' replace={false} />

它有两个属性,to和replace,然后还得返回一个a标签,我们仿照上面的<Route>组件来写一下demo

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }

  handleClick = (event) => {
    const { to, replace } = this.props;
    // 阻止a标签的默认事件,无法正常跳转
    event.preventDefault();
    // 下面是路由相关,下面马上写
  }

  render() {
    const { to, replace } = this.props;
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    );
  }
}

定义好<Link>的基础框架后,我们来考虑一下url如何跳转的问题
这里直接用HTML5的原生API:pushStatereplaceState

本文中我们为了防止引入额外的依赖,一直也没采用 History 库。但是它对真实的 React Router 却是至关重要的,因为它对不同的 session 管理和不同的浏览器环境进行了规范化处理。

pushState 和 replaceState 都接收三个参数。

  • 与历史实体相关联的对象,我们不需要 => {}
  • 标题,也不需要 => null
  • URL
    定义好两个方法
const historyPush = (path) => {
  history.pushState({}, null, path)
}
const historyReplace = (path) => {
  history.replaceState({}, null, path)
}

这时候的<Link>变成这样了

const historyPush = (path) => {
  history.pushState({}, null, path)
}
const historyReplace = (path) => {
  history.replaceState({}, null, path)
}

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }

  handleClick = (event) => {
    const { to, replace } = this.props;
    // 阻止a标签的默认事件,无法正常跳转
    event.preventDefault();
    // 路由逻辑
    replace ? historyReplace(to) : historyPush(to)
  }

  render() {
    const { to, replace } = this.props;
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    );
  }
}

如果有认真读上面第一部分基础内容的话,这里我们应该能发现一个问题

pushStatereplaceState不会触发onpopstate事件
(还记得吗?<Route>组件只是添加了对popstate的事件监听,回头翻一下看看?)

点击<Link>改变URL后,<Route>组件并不知道已经改变了,所以不知道需要重新匹配和渲染,所以UI也不会刷新

那么我们需要跟踪所有Route并在路由改变的时候调用forceUpdate更新页面

React Router 通过设置状态、上下文和历史信息的组合来解决这个问题。监听路由组件的内部代码。

4、升级<Route>组件:pushStatereplaceState不会触发onpopstate事件

为了使路由简单,我们通过把所有路由对象放到一个数组里的方式来实现 <Route> 跟踪。
每当发生地址改变的时候,就遍历一遍数组,调用相应对象的 forceUpdate 函数

let instances = [];
// componentWillMount的时候把该组件推进数组
const register = (componnet) => instances.push(componnet);
// componentWillUnmount的时候把该组件移除出数组
const unregister = (component) => instances.splice(instances.indexOf(component), 1)

然后更新组件的两个生命周期函数

componentWillMount() {
	addEventListener('popstate', this.handlePop);
	register(this);
}

componentWillUnmount() {
	unregister(this);
	removeEventListener('popstate', this.handlePop);
}

还要更新上面定义的historyPushhistoryReplace方法

const historyPush = (path) => {
  history.pushState({}, null, path)
  // 每次有跳转,都遍历所有的路由对象,然后强制更新
  instances.forEach(component => component.forceUpdate());
}
const historyReplace = (path) => {
  history.replaceState({}, null, path);
  instances.forEach(component => component.forceUpdate());
}

到目前为止,只要点击<Link>组件,URL发生改变,每个<Route>组件都会收到消息,进行重匹配和重渲染

完美! perfect !!!我们试运行一下,go……

等下!上面好像还有个匹配函数没定义:matchPath 😢

5、定义matchPath方法

match是一个对象

const match = matchPath(location.pathname, { path, exact });

传入的参数:pathname+一个对象

const matchPatch = (pathname, options) => {
  // exact默认为false
  const { exact = false, path } = options
  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
  // 正则匹配  是否完全匹配
  const match = new RegExp(`^${path}`).exec(pathname)
  if (!match) {
    // 没有匹配上
    return null
  }
  const url = match[0]
  const isExact = pathname === url
  if (exact && !isExact) {
    // 匹配上了,但是不是精确匹配
    return null
  }
  return {
    path,
    url,
    isExact,
  }
}

上面的代码应该比较好理解

6、完整代码

附加增送一个Redirect组件,由上面基础应该很好理解了

import React, { PropTypes, Component } from 'react'

let instances = [];
// componentWillMount的时候把该组件推进数组
const register = (componnet) => instances.push(componnet);
// componentWillUnmount的时候把该组件移除出数组
const unregister = (component) => instances.splice(instances.indexOf(component), 1) 

const historyPush = (path) => {
  history.pushState({}, null, path) 
  // 每次有跳转,都遍历所有的路由对象,然后强制更新
  instances.forEach(component => component.forceUpdate());
}
const historyReplace = (path) => {
  history.replaceState({}, null, path);
  instances.forEach(component => component.forceUpdate());
}

const matchPatch = (pathname, options) => {
  // exact默认为false
  const { exact = false, path } = options
  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
  // 正则匹配  是否完全匹配
  const match = new RegExp(`^${path}`).exec(pathname)
  if (!match) {
    // 没有匹配上
    return null
  }
  const url = match[0]
  const isExact = pathname === url
  if (exact && !isExact) {
    // 匹配上了,但是不是精确匹配
    return null
  }
  return {
    path,
    url,
    isExact,
  }
}

class Route extends Component {
  static propTypes = {
    exact: PropTypes.bool,
    path: PropTypes.string,
    component: PropTypes.func, // 组件返回UI  所以是func
    render: PropTypes.func,
  }

  componentWillMount() {
    addEventListener('popstate', this.handlePop);
    register(this);
  }

  componentWillUnmount() {
    unregister(this);
    removeEventListener('popstate', this.handlePop);
  }

  handlePop() {
    this.forceUpdate();
  }

  render() {
    const { path, exact, component, render } = this.props;
    // window.location是全局变量,location可以直接拿到
    const match = matchPath(location.pathname, { path, exact });
    // 如果没有匹配上 path 属性,return null
    if (!match) return null;
    // 匹配上path属性,并且有component属性 直接创建新元素, 通过match传递过去
    if (component) return React.createElement(component, { match });
    // 匹配上path属性,没有component属性,检查render属性
    if (render) return render({ match });
    // 全都没有匹配上,只好null了
    return null;
  }
}

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }

  handleClick = (event) => {
    const { to, replace } = this.props;
    // 阻止a标签的默认事件,无法正常跳转
    event.preventDefault();
    // 路由逻辑
    replace ? historyReplace(to) : historyPush(to)
  }

  render() {
    const { to, replace } = this.props;
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    );
  }
}

// 只是重定向所用  并不渲染任何UI  
class Redirect extends Component {
  static defaultProps = {
    push: false
  }
  static propTypes = {
    to: PropTypes.string.isRequired,
    push: PropTypes.bool.isRequired,
  }
  componentDidMount() {
    const { to, push } = this.props
    push ? historyPush(to) : historyReplace(to)
  }
  render() {
    return null
  }
}

后记

感谢您耐心看到这里,希望有所收获!

如果不是很忙的话,麻烦点个star⭐【Github博客传送门】,举手之劳,却是对作者莫大的鼓励。

我在学习过程中喜欢做记录,分享的是自己在前端之路上的一些积累和思考,希望能跟大家一起交流与进步。

@zgw010
Copy link

zgw010 commented Mar 25, 2019

似乎是拼写错误?
matchPatch -> matchPath

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants