阅读 170

实现一个简单版React Router v4理解其原理

对于React-Router的实现原理,参考自Build your own React Router v4这篇英文原文,另外,React-Router底层库history的源码也值得一读。

接下来是关于React-Router v4的一个简单版实现,通过这个实现来理解路由的原理,这里可能不会完全按照英文原文翻译,会有些意译或者省略的地方。

在单页应用中,应用代码和路由代码是极其重要的两部分,两者是相辅相成的,你是否对这两者有一些困惑。

关于路由的一些疑问点:

  1. 路由通常是相对比较复杂的,这让很多库的作者,在如何找到合适的路由抽象变得更加复杂。
  2. 因为这些复杂的原因,路由库的使用者倾向于盲目的相信库本身的抽象,而不是去理解背后的原理。

本文会通过实现一个简易版的React Router v4版本来点亮前一个问题的灯塔,并且让你了解背后的原理后去判断这样的抽象是否合适。

场景

下面是用于测试React Router实现的业务代码,你也可以访问线上的最终版代码

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

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

const Topic = ({ topicId }) => (
    <h3>{topicId}</h3>
)

const Topics = ({ match }) => {
    const items = [
        { name: 'Rendering with React', slug: 'rendering' },
        { name: 'Components', slug: 'components' },
        { name: 'Props v. State', slug: 'props-v-state' },
    ]
    
    return (
        <div>
            <h2>Topics</h2>
            <ul>
                {items.map(({ name, slug }) => (
                    <li key={name}>
                        <Link to={`${match.url}/${slug}`}>{name}</Link>
                    </li>
                ))}
            </ul>
            {items.map(({ name, slug }) => (
                <Route key={name} path={`${match.path}/${slug}`} render={() => (
                    <Topic topicId={name} />
                )} />
            ))}
            <Route exact path={match.url} render={() => (
                <h3>Please select a topic.</h3>
            )}/>
        </div>
    )
}

const App = () => (
    <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>
)
复制代码

关于这段代码,简单解释一下。Route组件是在当一个URL匹配Route中的path属性时渲染对应的组件UI,Link组件是一个声明式的应用内导航。换句话说,Link组件可以用来更新URL,Route组件基于新的URL来改变UI。如果关于React Router v4的使用上有疑问,建议去官方的文档了解后再继续阅读。

在React Router v4中API仅仅只是组件。这表示如果已经熟悉React,那么你对组件及关于组合的想法,同样应用在路由的代码中。而且,由于你熟悉如何创建组件,那么创建React Router也就是你所熟悉的,创建更多的组件而已。

Route组件

我们要开始创建Route组件。在开始之前,先检查一下API(看看有哪些props)。

在上面的例子中,<Route>接收了3个props。exactpathcomponent。这表示Route组件的propTypes现在看起来是这样的,

static propTypes = {
    exact: PropTypes.bool,
    path: PropTypes.string,
    component: PropTypes.func,
}
复制代码

这里面有些微妙之处。首先,path没有申明必填是因为如果一个Route没有提供path,那么它会被自动渲染。其次,component也没有申明必填是因为有多个不同的方法告诉React Router在路径匹配时你要渲染的UI,一个没有在上述例子中的方法是通过render属性。例如,

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

render允许我们方便的提供一个内联函数来返回一些UI而不用创建一个单独的组件。因此我们也要添加到propTypes中,

static propTypes = {
    exact: PropTypes.bool,
    path: PropTypes.string,
    component: PropTypes.func,
    render: PropTypes.func,
}
复制代码

现在我们知道Route接收哪些props,让我们再谈谈它实际上做了什么。Route根据在path属性中指定的位置与URL进行匹配并渲染对应的UI。基于此定义,我们知道<Route>需要一些功能来检查当前URL是否匹配组件的path属性。如果匹配,则渲染一些UI。如果不匹配,就仅仅返回null不做任何渲染。

让我们看一下代码的实现,关于匹配的函数matchPath会在后续完成。

class Route extends Component {
    static propTypes = {
        exact: PropTypes.bool,
        path: PropTypes.string,
        component: PropTypes.func,
        render: PropTypres.func,
    }
    
    render() {
        const {
            path,
            exact,
            component,
            render,
        } = this.props;
        
        const match = matchPath(
            location.pathname,
            { path, exact }
        )
        
        if (!match) {
            // 由于当前位置不匹配path属性,不做任何事
            return null;
        }
        
        if (component) {
            // component属性优先级比render函数高
            // 如果当前位置匹配path属性,创建一个元素并传递match作为属性
            
            return React.createElement(component, { match });
        }
        
        if (render) {
            // 如果匹配路径但是component没有定义,调用render属性并传递match作为参数
            
            return render({ match });
        }
        
        return null;
    }
}
复制代码

现在Route看起来比较可靠了。如果当前位置匹配传递进来的path属性,则渲染一些UI,否则什么都不做。

让我们暂停一下,谈谈通常路由的实现方法。在一个客户端应用中,一般只有两种方法能让用户更新URL。第一个是用户点击一个超链接标签,另一个是点击后退和前进按钮。基本上,我们的路由需要注意当前的URL并基于此进行渲染UI。也就是说,我们的路由需要注意URL的变化,并基于新的URL确定哪个新的UI需要显示。如果我们知道只有通过超链接标签和后退/前进按钮可以更新URL,那么我们就可以规划和响应这些变化。我们会在后续构建我们的<Link>组件时接触超链接标签,现在先聚焦在后退/前进按钮上。React Router使用History.listen方法来监听当前的URL,但是为了避免引入其他的库,我们使用HTML5的popstate事件。popstate,会在用户点击后退或前进按钮时被触发,这就是我们想要的。由于Route需要基于当前的URL进行UI渲染,这让Route有能力在popstate事件发生时进行监听并重新渲染。通过重新渲染,每个Route可以重新检查他们是否匹配新的URL,如果匹配,则渲染UI,否则不做任何事。让我们看一下代码:

class Route extends Component {
    static propTypes: {
        path: PropTypes.string,
        exact: PropTypes.bool,
        component: PropTypes.func,
        render: PropTypes.func,
    }
    
    componentWillMount() {
        addEventListener("popstate", this.handlePop);
    }
    
    componentWillUnmount() {
        removeEventListener("popstate", this.handlePop);
    }
    
    handlePop = () => {
        this.forceUpdate();
    }
    
    render() {
        const {
            path, 
            exact,
            component,
            render,
        } = this.props;
    
        const match = matchPath(location.pathname, { path, exact });
        
        if (!match)
            return null;
        
        if (component)
            return React.createElement(component, { match });
            
        if (render) 
            return render({ match });
            
        return null;
    }
}
复制代码

你会注意到我们做的就是当组件挂载时添加popstate事件监听,当popstate事件触发时,我们会调用forceUpdate并开始重新渲染。

现在,不管有多少<Route>渲染,每个组件都会基于后退/前进按钮进行监听,重新匹配,和重新渲染。

还有一件事情直到还没有做的是matchPath函数。这个函数是我们路由的关键,因为这个函数用来决定当前的URL是否和<Route>组件匹配。对于matchPath的一个细微点是,我们要考虑<Route>exact属性。如果你不熟悉exact是什么,这里有个根据文档的直接解释。

当为true时,只会当路径和location.pathname完全匹配时匹配。

path location.pathname exact matches?
/one /one/two true no
/one /one/two false yes

现在,让我们开始matchPath函数的实现。如果回头看Route组件,你会发现matchPath函数的声明看起来是这样的,

const match = matchPath(location.pathname, { path, exact });
复制代码

这里的match是一个对象或者null,基于是否有匹配结果。根据这个函数签名,我们可以构建matchPath的第一部分,

const matchPath = (pathname, options) => {
    const { exact = false, path } = options;
}
复制代码

这里我们使用一些ES6的语法。创建一个exact变量并且等于options.exact,如果这个值没有定义,则默认设置为false。另外,创建一个path变量等于options.path。

之前有提到“path不要求必填的原因是,如果一个Route没有path,那么它会被自动渲染”。这是间接的决定matchPath函数是否渲染,让现在我们添加这个功能。

const matchPath = (pathname, options) => {
    const { exact = false, path } = options;
    
    if (!path) {
        return {
            path: null,
            url: pathname,
            isExact: true,
        }
    }
}
复制代码

现在到了匹配的部分。React Router使用pathToRegex,我们这里简化一下,就使用一个简单的正则表达式。

const matchPath = (pathname, options) => {
    const { exact = false, path } = options;
    
    if (!path) {
        return {
            path: null,
            url: pathname,
            isExact: true,
        }
    }
    
    const match = new RegExp(`^${path}`).exec(pathname);
}
复制代码

如果你不熟悉.exec,它的逻辑是这样的,如果找到匹配返回一个包含匹配结果文本的数组,否则返回null。

这里是当我们的实例app路由到/topics/components时每个match的结果,

path location.pathname 返回值
/ /topics/components ['/']
/about /topics/components null
/topics /topics/components ['/topics']
/topics/rendering /topics/components null
/topics/components /topics/components ['/topics/components']
/topics/props-v-state /topics/components null
/topics /topics/components ['/topics']

注意在我们的app中会获取每一个<Route>match。因为,每个<Route>都会在它的渲染函数中调用matchPath

现在我们知道.exec返回的match是什么了,我们需要做的就是找出是否有匹配结果。

const matchPath = (pathname, options) => {
    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) {
        // 如果有一个匹配结果,并且不是在exact属性中指定的结果
        
        return null;
    }
    
    return {
        path,
        url,
        isExact,
    }
}
复制代码

Link组件

之前有谈到用户只有两种方法可以更新URL,一种是通过后退/前进按钮,另一种是点击一个超链接标签。我们在Route中通过监听popstate事件关注了点击后退/前进按钮进行重新渲染,现在通过构建Link组件来关注一下超链接标签。

Link的API看起来像这样,

<Link to='/some-path' replace={false} />
复制代码

这里的to是一个字符串,表示要链接的位置;replace是一个布尔值,如果是true,点击链接后会在history栈中替换当前的入口记录,而不会添加一个新的记录。

添加propType到我们的Link组件中,

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

现在我们知道在Link组件的渲染方法中需要返回一个超链接标签,但是显示我们不能在每次切换路由的时候引起页面的全量刷新,因此我们要添加一个onClick处理函数来劫持超链接的默认跳转。

class Link extends Component {
    static propTypes = {
        to: PropTypes.string.isRequired,
        replace: PropTypes.bool,
    }
    
    handleClick = (event) => {
        const { replace, to } = this.props;
        event.preventDefault();
        
        // 这里是路由
    }
    
    render() {
        const { to, children } = this.props;
        
        return (
            <a href={to} onClick={this.handleClick}>
                {children}
            </a>
        )
    }
}
复制代码

现在缺少的就是真正的改变当前位置。React Router是使用Historypushreplace方法,但是我们这里为了避免引入一个依赖,使用HTML5的pushStatereplaceState

我们在文章里忽略了History这个库是为了避免外部的依赖,但是对于真正的React Router是很关键的,用于处理在不同浏览器环境管理会话历史的差异。

pushStatereplaceState同时都接收三个参数。第一个是一个关联新的历史入口的对象,我们不需要这个功能所以传一个空对象。第二个是标题,我们也不需要传一个null。第三个,我们实际要使用的,是一个相对的URL。

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

const historyReplace = (path) => {
    history.replaceState({}, null, paht);
}
复制代码

Link组件内部,我们要根据replace属性来调用historyPush或者historyReplace

class Link extends Component {
    static propTypes = {
        to: PropTypes.string.isRequired,
        replace: PropTypes.bool,
    }
    
    handleClick = (event) => {
        const { replace, to } = this.props;
        event.preventDefault();
        
        replace ? historyReplace(to) : historyPush(to);
    }
    
    render() {
        const { to, children } = this.props;
        
        return (
            <a href={to} onClick={this.handleClick}>
                {children}
            </a>
        )
    }
}
复制代码

组合

现在只有一个最重要的内容要添加。如果使用当前的路由代码来配合一开始的场景,会发现一个大的问题。使用导航的时候,URL会更新,但是UI会一直保持不变。这是因为我们即使通过historyRepalcehistoryPush函数改变位置,我们的<Route>并不知道改变,并且也不知道要重新匹配和重新渲染。为了解决这个问题,我们要跟踪<Route>是否已渲染,并且在任何路由改变时调用forceUpdate

React Router是通过结合你包裹在一个Router组件代码里相关的setState, context和history.listen来解决这个问题的。

为了保持我们的路由简单,我们通过维护一个他们的实例在一个数组中来跟踪哪些<Route>被渲染,然后任何时候一个位置的改变发生时,我们就可以遍历数组并调用所有实例上的forceUpdate方法。

let instances = [];

const register = (comp) => instances.push(comp);
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1);
复制代码

这里我们创建了两个函数。register是在任何时候一个<Router>挂载时调用,当组件卸载时调用unregister。然后,不管何时我们调用historyPushhistoryReplace(每次用户点击<Link>时),我们会遍历所有的实例并调用forceUpdate

让我们更新<Route>组件先,

class Route extends Component {
    statc propTypes: {
        path: PropTypes.string,
        exact: PropTypes.bool,
        component: PropTypes.func,
        render: PropTypes.func,
    }
    
    componentWillMount() {
        addEventListener("popstate", this.handlePop);
        register(this);
    }
    
    componentWillUnmount() {
        unregister(this);
        removeEventListener("popstate", this.handlePop);
    }
}
复制代码

现在,让我们更新historyPushhistoryReplace

const historyPush = (path) => {
    history.pushState({}, null, path);
    instances.forEach(instance => instance.forceUpdate());
}

const historyReplace = (path) => {
    history.replaceState({}, null, path);
    instances.forEach(instance => instance.forceUpdate());
}
复制代码

现在任何时候一个<Link>被点击时位置会改变,每个<Route>将会注意到并重新匹配和渲染。

我们完整的路由代码就如下所示,示例的场景app可以完美的工作。

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

let instances = []

const register = (comp) => instances.push(comp)
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)

const historyPush = (path) => {
  history.pushState({}, null, path)
  instances.forEach(instance => instance.forceUpdate())
}

const historyReplace = (path) => {
  history.replaceState({}, null, path)
  instances.forEach(instance => instance.forceUpdate())
}

const matchPath = (pathname, options) => {
  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: {
    path: PropTypes.string,
    exact: PropTypes.bool,
    component: PropTypes.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

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

    if (!match)
      return null

    if (component)
      return React.createElement(component, { match })

    if (render)
      return render({ match })

    return null
  }
}

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }
  handleClick = (event) => {
    const { replace, to } = this.props

    event.preventDefault()
    replace ? historyReplace(to) : historyPush(to)
  }

  render() {
    const { to, children} = this.props

    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    )
  }
}
复制代码

红利: React Router API中同样提供了<Redirect>组件。使用前面写的代码,可以直接创建这样的组件。

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;
    }
}
复制代码

注意这个组件实际上没有渲染任何UI,只是一个纯粹的路由跳转。

总结

希望这篇文章能帮助你创建一个关于在React Router中发生了什么的模型,同时能帮助你欣赏React Router的优雅和“只有组件”的API。我总是说,React会让你成为一个更好的JavaScript开发者,我也同样相信React Router可以让你成为一个更好的React开发者。因为一切都是组件,如果你了解React,那么你也同样了解React Router。

注意

  • 示例代码是以react v15.4.2版本演示
  • 示例代码中可以忽略类型校验
  • 个人参考代码
关注下面的标签,发现更多相似文章
评论