jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

Implementing a React-Router from scratch.

In the process of implementing a React single-page application, one essential ecosystem to master is the React-Router library. This article will start from scratch and implement the key parts of React-Router.

React-Router v4 is a groundbreaking update that is completely incompatible with previous versions. Compared to the previous configuration-based routing approach, v4 implements routing through individual route components (Link, Route, Redirect, etc.). I believe this approach truly aligns with React's component-based thinking. If you already know React, learning React-Router is just a matter of learning a few more components. Therefore, our implementation will mainly focus on v4.

Getting Started#

First, let's quickly start a project using create-react-app and enter the official demo in index.js. Let's take a look at the App component that will be rendered:

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>
)

This includes the two core components of React-Router: Route and Link.

Next, let's focus on implementing these two components.

Route#

In the example above, we can see that Route can take four props: exact, path, component, and render. Let's start by implementing the basic Route component:

class Route extends Component {
    static propTypes = {
        path: PropTypes.string, // path to match
        exact: PropTypes.bool,  // whether to match exactly
        component: PropTypes.func, // component to render if matched
        render: PropTypes.func  // custom render content
    }

    render () {
        const { path, exact, component, render } = this.props
        // check if the route matches
        const match = matchPath(window.location.pathname, { path, exact })
        // return null if not matched
        if (!match) return null
        // if a component is provided
        if (component) return React.createElement(component, { match })
        // if custom render content is provided
        if (render) return render({ match })

        return null
    }
}

The core of the Route component is to render if there is a match and not render if there is no match (return null).

Now let's take a look at the implementation of the matching function matchPath:

const match = (pathname, options) => {
    const { path, exact = false } = options
    
    // if no path is provided
    if (!path) return { path: null, url: pathname, isExact: true }
    
    // match the url with a regular expression
    const match = new RegExp(`^${path}`).exec(pathname)
    // return null if not matched
    if (!match) return null
    
    // check if it is an exact match
    const url = match[0]
    const isExact = pathname === url
    if (exact && !isExact) return null
    
    return {
        path,
        url,
        isExact
    }
}

To ensure compatibility, React-Router uses the pathToRegex library for regular expression matching. Here, we simply use the built-in RegExp object in JavaScript to implement it.

So far, we have implemented the matching and rendering logic of the Route component. Now, how do we trigger a re-render of the Route component when the route changes?

class Route extends Component {
    ...
    componentWillMount () {
        // listen for clicks on the browser's forward/back buttons
        window.addEventListener('popstate', this.handlePop)
    }
    componentWillUnmount () {
		window.addEventListener('popstate', this.handlePop)
    }
    handlePop = () => {
		this.forceUpdate()
    }
    ...
}

There are two scenarios for route changes, one of which is when the browser's forward/back buttons are clicked. We listen for this click event in the two lifecycle methods and call the built-in forceUpdate method to force a UI update.

In React-Router, setState, context, and history.listen are used together to solve this problem.

The other scenario for route changes is when a Link component is clicked. This leads us to the implementation of the Link component.

The core of the Link component is to update the URL in a declarative manner. It's easy to see that it ultimately renders an a tag:

class Link extends Component {
    static propTypes = {
        to: PropTypes.string.isRequired,
        replace: PropTypes.bool
    }
	// prevent default navigation and call the custom update route method
    handleClick = e => {
		const { replace, to } = this.props
        e.preventDefault()
        replace ? historyReplace(to) : historyPush(to)
    }

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

The difference between the custom historyReplace and historyPush methods is whether they insert or replace the history stack:

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

That's how the Link component is implemented. Now, we can easily raise a question: how do we match the corresponding Route when a Link is clicked? Although we listen for events during the mounting and unmounting of the Route component, it does not take effect when a Link is clicked.

Therefore, it is necessary to save the Route component as an instance when it is loaded (regardless of whether it is matched or not).

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

componentWillUnmount () {
	unregister(this)
    window.addEventListener('popstate', this.handlePop)
}

The implementation of register and unregister is as follows:

let instances = []

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

So, in the historyPush and historyReplace methods, we need to iterate through all the Route instances and force them to update one by one:

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

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

Now, let's import the Route and Link components into index.js and start the project. You should see that it runs correctly.

In React-Router, setState, context, and history.listen are used in combination to solve this problem.

Conclusion#

The principles of React Router v4 are worth learning. React can make you a better JavaScript developer, and React-Router can make you a better React developer.

References:

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.