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

React-Router原理及丐版实现 #4

Open
LuckyFBB opened this issue Feb 23, 2022 · 0 comments
Open

React-Router原理及丐版实现 #4

LuckyFBB opened this issue Feb 23, 2022 · 0 comments
Assignees

Comments

@LuckyFBB
Copy link
Owner

LuckyFBB commented Feb 23, 2022

前端路由

在 Web 前端单页面应用 SPA(Single Page Application)中,路由是描述 URL 和 UI 之间的映射关系,这种映射是单向的,即 URL 的改变会引起 UI 更新,无需刷新页面

如何实现前端路由

实现前端路由,需要解决两个核心问题

  1. 如何改变 URL 却不引起页面刷新?
  2. 如何监测 URL 变化?

在前端路由的实现模式有两种模式,hash 和 history 模式,分别回答上述两个问题

hash 模式

  1. hash 是 url 中 hash(#) 及后面的部分,常用锚点在页面内做导航,改变 url 中的 hash 部分不会引起页面的刷新

  2. 通过 hashchange 事件监听 URL 的改变。改变 URL 的方式只有以下几种:通过浏览器导航栏的前进后退、通过<a>标签、通过window.location,这几种方式都会触发hashchange事件

    image

history 模式

  1. history 提供了 pushStatereplaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新

  2. 通过 popstate 事件监听 URL 的改变。需要注意只在通过浏览器导航栏的前进后退改变 URL 时会触发popstate事件,通过<a>标签和pushState/replaceState不会触发popstate方法。但我们可以拦截<a>标签的点击事件和pushState/replaceState的调用来检测 URL 变化,也是可以达到监听 URL 的变化,相对hashchange显得略微复杂

    image

JS实现前端路由

基于 hash 实现

由于三种改变 hash 的方式都会触发hashchange方法,所以只需要监听hashchange方法。需要在DOMContentLoaded后,处理一下默认的 hash 值

// 页面加载完不会触发 hashchange,这里主动触发一次 hashchange 事件,处理默认hash
window.addEventListener("DOMContentLoaded", onLoad);
// 监听路由变化
window.addEventListener("hashchange", onHashChange);
// 路由变化时,根据路由渲染对应 UI
function onHashChange() {
  switch (location.hash) {
    case "#/home":
      routerView.innerHTML = "This is Home";
      return;
    case "#/about":
      routerView.innerHTML = "This is About";
      return;
    case "#/list":
      routerView.innerHTML = "This is List";
      return;
    default:
      routerView.innerHTML = "Not Found";
      return;
  }
}

hash实现demo

基于 history 实现

因为 history 模式下,<a>标签和pushState/replaceState不会触发popstate方法,我们需要对<a>的跳转和pushState/replaceState做特殊处理。

  • <a>作点击事件,禁用默认行为,调用pushState方法并手动触发popstate的监听事件
  • pushState/replaceState可以重写 history 的方法并通过派发事件能够监听对应事件
var _wr = function (type) {
  var orig = history[type];
  return function () {
    var e = new Event(type);
    e.arguments = arguments;
    var rv = orig.apply(this, arguments);
    window.dispatchEvent(e); 
    return rv;
  };
};
// 重写pushstate事件
history.pushState = _wr("pushstate");

function onLoad() {
  routerView = document.querySelector("#routeView");
  onPopState();
  // 拦截 <a> 标签点击事件默认行为
  // 点击时使用 pushState 修改 URL并更新手动 UI,从而实现点击链接更新 URL 和 UI 的效果。
  var linkList = document.querySelectorAll("a[href]");
  linkList.forEach((el) =>
    el.addEventListener("click", function (e) {
      e.preventDefault();
      history.pushState(null, "", el.getAttribute("href"));
      onPopState();
    })
  );
}
// 监听pushstate方法
window.addEventListener("pushstate", onPopState());
// 页面加载完不会触发 hashchange,这里主动触发一次 popstate 事件,处理默认pathname
window.addEventListener("DOMContentLoaded", onLoad);
// 监听路由变化
window.addEventListener("popstate", onPopState);
// 路由变化时,根据路由渲染对应 UI
function onPopState() {
  switch (location.pathname) {
    case "/home":
      routerView.innerHTML = "This is Home";
      return;
    case "/about":
      routerView.innerHTML = "This is About";
      return;
    case "/list":
      routerView.innerHTML = "This is List";
      return;
    default:
      routerView.innerHTML = "Not Found";
      return;
  }
}

history 实现 demo

react-router的理解

image

在 v4 之后,我们在 View 层直接从react-router-dom中引入BrowserRouter/HashRouterBrowserRouter/HashRouter又分别使用了react-router提供的 Router 组件和 history 提供的createBrowserHistory/createHashHistory方法。

react-router v3/v4/v6的应用

v3v4v6

image

history

在上文中说到,BrowserRouter使用history库提供的createBrowserHistory创建的history对象改变路由状态和监听路由变化。

❓那么 history 对象需要提供哪些功能讷?

  • 监听路由变化的listen方法以及对应的清理监听unlisten方法
  • 改变路由的push方法
// 创建和管理listeners的方法 
export const EventEmitter = () => {
  const events = [];
  return {
    subscribe(fn) {
      events.push(fn);
      return function () {
        events = events.filter((handler) => handler !== fn);
      };
    },
    emit(arg) {
      events.forEach((fn) => fn && fn(arg)); 
    }
  }
}

BrowserHistory

const createBrowserHistory = () => {
  const EventBus = EventEmitter();
  // 初始化location
  let location = {
    pathname: "/"
  };
  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.pathname
    };
    EventBus.emit(currentLocation); // 路由变化时执行回调
  };
  // 定义history.push方法
  const push = (path) => {
    const history = window.history;
    // 为了保持state栈的一致性
    history.pushState(null, "", path);
    // 由于push并不触发popstate,我们需要手动调用回调函数
    location = { pathname: path };
    EventBus.emit(location);
  };

  const listen = (listener) => EventBus.subscribe(listener);

  // 处理浏览器的前进后退
  window.addEventListener("popstate", handlePop);

  // 返回history
  const history = {
    location,
    listen,
    push
  };
  return history;
};

上述代码实现简单版本的 history,只有监听路由变化的listen/unlisten方法以及改变路由的push方法,详细的BrowserHistory源码

HashHistory

const createHashHistory = () => {
  const EventBus = EventEmitter();
  let location = {
    pathname: "/"
  };
  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.hash.slice(1)
    };
    EventBus.emit(currentLocation); // 路由变化时执行回调
  };
  // 不用手动执行回调,因为hash改变会触发hashchange事件
  const push = (path) => window.location.hash = path
  const listen = (listener: Function) => EventBus.subscribe(listener);
  // 监听hashchange事件
  window.addEventListener("hashchange", handlePop);
  // 返回的history上有个listen方法
  const history = {
    location,
    listen,
    push
  };
  return history;
};

BrowserHistory一样,hashHistory也是极简版,详细的hashHistory源码

react-router丐版

image

Router

Router 接受一个 history 属性,用history.listen创建监听者,使用 context 传递 history 和location 数据

export default class Router extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location // 将history的location挂载到state上
    };
    this.unlisten = props.history.listen((location) => {
      this.setState({ location });
    });
  }
  componentDidMount() {}
  componentWillUnmount() {
    this.unlisten();
  }
  render() {
    const { history, children } = this.props;
    const { location } = this.state;
    return (
      <RouterContext.Provider
        value={{
          history,
          location
        }}
      >
        {children}
      </RouterContext.Provider>
    );
  }
}

Router源码

BrowserRouter/HashRouter

只是给 Router 组件传递 history 属性

BrowserRouter

class BrowserRouter extends React.Component {
  history = createBrowserHistory();
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

HashRouter

class HashRouter extends React.Component {
  history = createHashHistory();
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

BrowserRouter源码/HashRouter源码

Route

Route可以接收component/render/children,但是它们渲染的优先级是不一样的。

v4/v5三个优先级不同

直接使用Route组件时,每个Route组件都会被渲染,会根据路由规则进行判断是否需要把组件渲染出来,目前代码中使用的正则来做匹配

export default class Route extends React.Component<IProps> {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const pathname = context.location.pathname;
          const {
            path,
            component: Component,
            exact = false,
            render,
            children
          } = this.props;
          const props = { ...context };
          const reg = pathToRegExp(path, [], { end: exact });
          // 判断url是否匹配
          if (!reg.test(pathname)) return null;
          if (Component) return <Component {...props} />;
          if (render) return render();
          if (children) return children;
        }}
      </RouterContext.Consumer>
    );
  }
}

Route源码

Link

在 Link 中,我们使用<a>标签来做跳转,但是 a 标签会使页面重新刷新,所以需要阻止 a 标签的默认行为,使用 context 中 history 的 push 方法

export default class Link extends React.Component<IProps> {
  render() {
    const { to, children } = this.props;
    return (
      <RouterContext.Consumer>
        {(context) => {
          return (
            <a
              href={to}
              onClick={(event) => {
                // 阻止a标签的默认行为
                event.preventDefault();
                context.history.push(to);
              }}
            >
              {children}
            </a>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

Link源码

Switch

Route 组件的功能是只要 path 匹配上当前路由就渲染组件,也就意味着如果多个 Route 的 path 都匹配上了当前路由,这几个组件都会渲染,例如/home/1能够匹配上/home/1/home,所以需要一个组件来控制匹配上一个 Route 就返回,所以 Switch 组件诞生了。

它的功能就是即使多个 Route 的 path 都匹配上了当前路由,也只渲染第一个匹配上的组件。

要实现该功能,把 Switch 的 children 拿出来循环,找出第一个匹配的 child,记录下当前的 child ,把其他的 child 全部干掉

export default class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const location = context.location;
          let element, match; // 两个变量记录第一次匹配上的子元素和match属性
          React.Children.forEach(this.props.children, (child) => {
            // 先检测下match是否已经匹配到了, 如果已经匹配过了,直接跳过
            if (!match && React.isValidElement(child)) {
              element = child;
              const { path, exact } = child.props;
              const reg = pathToRegExp(path, [], { end: exact });
              if (reg.test(location.pathname)) match = true;
            }
          });
          // <Switch>组件的返回值只是匹配上元素的拷贝,其他元素被忽略了
          // 如果一个都没匹配上,返回null
          return match ? React.cloneElement(element, { location }) : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

Switch源码

到现在 react-router 的核心组件以及 API 都实现完成,线上demo

总结

在本文中,从前端路由入手,分析了原生的 hash/history 的路由实现,react-router 底层依赖和上层使用,实现了简版的 react-router

需要注意的是,hash 模式下三种改变 url 的方法都会触发 hashchange 事件,而 history 模式下只有浏览器前进后退会触发popstatepushState/replaceState以及<a>标签都不会。<a>标签的默认行为会触发页面刷新,所以在实现路由时需要用e.preventDefault阻止默认行为。

@LuckyFBB LuckyFBB self-assigned this Feb 23, 2022
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

1 participant