ff-stream-web

git clone git://archive.git.mtrnord.blog/MTRNord/ff-stream-web.git
Log | Files | Refs | README | LICENSE

commit 31db9312f8a302bc52ec6644c7bcbd663ca12ff6
parent 310bb5dd1d5dca996eaf16d1d6eaaf6d5981b246
Author: Konstantin Tarkus <hello@tarkus.me>
Date:   Wed, 20 Jul 2016 06:58:47 +0300

Merge PR #756: Update how-to-implement-routing.md

Diffstat:
Mdocs/recipes/how-to-implement-routing.md | 298++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
1 file changed, 189 insertions(+), 109 deletions(-)

diff --git a/docs/recipes/how-to-implement-routing.md b/docs/recipes/how-to-implement-routing.md @@ -1,137 +1,217 @@ -## How to Implement Routing and Navigation [![img](https://img.shields.io/badge/discussion-join-green.svg?style=flat-square)](https://github.com/kriasoft/react-starter-kit/issues/116) +## How to Implement Routing and Navigation - * [Step 1: Basic Routing](#step-1-basic-routing) - * [Step 2: Asynchronous Routes](#step-2-asynchronous-routes) - * [Step 3: Parameterized Routes](#step-3-parameterized-routes) - * Step 4: Handling Redirects - * Step 5: Setting Page Title and Meta Tags - * Step 6: Code Splitting - * Step 7: Nested Routes - * Step 8: Integration with Flux - * Step 9: Server-side Rendering +Let's see how a custom routing solution under 100 lines of code may look like. -### Step 1: Basic Routing +First, you will need to implement the **list of application routes** in which each route can be +represented as an object with properties of `path` (a parametrized URL path string), `action` +(a function), and optionally `children` (a list of sub-routes, each of which is a route object). +The `action` function returns anything - a string, a React component, etc. For example: -In its simplest form the routing looks like a collection of URLs where each URL -is mapped to a React component: +#### `src/routes/index.js` ```js -// client.js -import React from 'react'; -import Layout from './components/Layout'; -import HomePage from './components/HomePage'; -import AboutPage from './components/AboutPage'; -import NotFoundPage from './components/NotFoundPage'; -import ErrorPage from './components/ErrorPage'; - -const routes = { - '/': <Layout><HomePage /></Layout>, - '/about': <Layout><AboutPage /></Layout> -}; - -const container = document.getElementById('app'); - -function render() { - try { - const path = window.location.hash.substr(1) || '/'; - const component = routes[path] || <NotFoundPage />; - React.render(component, container); - } catch (err) { - React.render(<ErrorPage {...err} />, container); +export default [ + { + path: '/tasks', + action() { + const resp = await fetch('/api/tasks'); + const data = await resp.json(); + return data && { + title: `To-do (${data.length})`, + component: <TodoList {...data} /> + }; + } + }, + { + path: '/tasks/:id', + action({ params }) { + const resp = await fetch(`/api/tasks/${params.id}`); + const data = await resp.json(); + return data && { + title: data.title, + component: <TodoItem {...data} /> + }; + } } -} - -window.addEventListener('hashchange', () => render()); -render(); +]; ``` -### Step 2: Asynchronous Routes +Next, implement a **URL Matcher** function that will be responsible for matching a parametrized +path string to the actual URL. For example, calling `matchURI('/tasks/:id', '/tasks/123')` must +return `{ id: '123' }` while calling `matchURI('/tasks/:id', '/foo')` must return `null`. +Fortunately, there is a great library called [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) +that makes this task very easy. Here is how a URL matcher function may look like: -Just wrap React components inside your routes into asynchronous functions: +#### `src/core/router.js` ```js -import React from 'react'; -import fetch from './core/fetch'; -import Layout from './components/Layout'; -import HomePage from './components/HomePage'; -import AboutPage from './components/AboutPage'; -import NotFoundPage from './components/NotFoundPage'; -import ErrorPage from './components/ErrorPage'; - -const routes = { - '/': async () => { - const response = await fetch('/graphql?query={content(path:"/"){title,html}}'); - const data = await response.json(); - return <Layout><HomePage {...data} /></Layout> - }, - '/about': async () => { - const response = await fetch('/graphql?query={content(path:"/about"){title,html}}'); - const data = await response.json(); - return <Layout><AboutPage {...data} /></Layout>; +import toRegExp from 'path-to-regexp'; + +function matchURI(path, uri) { + const keys = []; + const pattern = toRegExp(path, keys); // TODO: Use caching + const match = pattern.exec(uri); + if (!match) return null; + const params = Object.create(null); + for (let i = 1; i < match.length; i++) { + params[keys[i - 1].name] = + match[i] !== undefined ? match[i] : undefined; } -}; - -const container = document.getElementById('app'); - -async function render() { - try { - const path = window.location.hash.substr(1) || '/'; - const route = routes[path]; - const component = route ? await route() : <NotFoundPage />; - React.render(component, container); - } catch (err) { - React.render(<ErrorPage {...err} />, container); + return params; +} +``` + +Finally, implement a **Route Resolver** function that given a list of routes and a URL/context +should find the first route matching the provided URL string, execute its action method, and if the +action method returns anything other than `null` or `undefined` return that to the caller. +Otherwise, it should continue iterating over the remaining routes. If none of the routes match to the +provided URL string, it should throw an exception (Not found). Here is how this function may look like: + +#### `src/core/router.js` + +```js +import toRegExp from 'path-to-regexp'; + +function matchURI(path, uri) { ... } // See above + +async function resolve(routes, context) { + for (const route of routes) { + const uri = context.error ? '/error' : context.pathname; + const params = matchURI(route.path, uri); + if (!params) continue; + const result = await route.action({ ...context, params }); + if (result) return result; } + const error = new Error('Not found'); + error.status = 404; + throw error; } -window.addEventListener('hashchange', () => render()); -render(); +export default { resolve }; +``` + +That's it! Here is a usage example: + +```js +import router from './core/router'; +import routes from './routes'; + +router.resolve(routes, { pathname: '/tasks' }).then(result => { + console.log(result); + // => { title: 'To-do', component: <TodoList .../> } +}); ``` -### Step 3: Parameterized Routes +While you can use this as it is on the server, in a browser environment it must be combined with a +client-side navigation solution. You can use [`history`](https://github.com/ReactTraining/history) +npm module to handles this task for you. It is the same library used in React Router, sort of a +wrapper over [HTML5 History API](https://developer.mozilla.org/docs/Web/API/History_API) that +handles all the tricky browser compatibility issues related to client-side navigation. + +First, create `src/core/history.js` file that will initialize a new instance of the `history` module +and export is as a singleton: -**(1)** Convert the list of routes from hash table to an array, this way the -order of routes will be preserved. **(2)** Wrap this collection into a Router -class, where you can put `.match(url)` async method. **(3)** Use [path-to-regexp](https://github.com/pillarjs/path-to-regexp) -to convert Express-like path strings into regular expressions which are used -for matching URL paths to React components. +#### `src/core/history.js` ```js -import React from 'react'; -import Router from 'react-routing/src/Router'; -import fetch from './core/fetch'; -import Layout from './components/Layout'; -import ProductListing from './components/ProductListing'; -import ProductInfo from './components/ProductInfo'; -import NotFoundPage from './components/NotFoundPage'; -import ErrorPage from './components/ErrorPage'; - -const router = new Router(on => { - on('/products', async () => { - const response = await fetch('/graphql?query={products{id,name}}'); - const data = await response.json(); - return <Layout><ProductListing {...data} /></Layout> - }); - on('/products/:id', async ({ params }) => { - const response = await fetch('/graphql?query={product(id:"${params.id}"){name,summary}}'); - const data = await response.json(); - return <Layout><ProductInfo {...data} /></Layout>; - }); -}]); +import createHistory from 'history/lib/createBrowserHistory'; +import useQueries from 'history/lib/useQueries'; +export default useQueries(createHistory)(); +``` -const container = document.getElementById('app'); +Then plug it in, in your client-side bootstrap code as follows: -async function render() { - const state = { path: window.location.hash.substr(1) || '/' }; - await router.dispatch(state, component => { - React.render(component, container); +#### `src/client.js` + +```js +import ReactDOM from 'react-dom'; +import history from './core/history'; +import router from './core/router'; +import routes from './routes'; + +const container = document.getElementById('root'); + +function renderRouteOutput({ title, component }) { + ReactDOM.render(component, container, () => { + document.title = title; }); } -window.addEventListener('hashchange', () => render()); -render(); +function render(location) { + router.resolve(routes, location) + .then(renderRouteOutput) + .catch(error => router.resolve(routes, { ...location, error }) + .then(renderRouteOutput)); +} + +render(history.getCurrentLocation()); // render the current URL +history.listen(render); +``` + +Whenever a new location is pushed into the `history` stack, the `render()` method will be called, +that itself calls the router's `resolve()` method and renders the returned from it React component +into the DOM. + +In order to trigger client-side navigation without causing full-page refresh, you need to use +`history.push()` method, for example: + +```js +import React from 'react'; +import history from '../core/history'; + +class App extends React.Component { + transition = event => { + event.preventDefault(); + history.push({ + pathname: event.currentTarget.pathname, + search: event.currentTarget.search + }); + }; + render() { + return ( + <ul> + <li><a href="/" onClick={this.transition}>Home</a></li> + <li><a href="/one" onClick={this.transition}>One</a></li> + <li><a href="/two" onClick={this.transition}>Two</a></li> + </ul> + ); + } +} +``` + +Though, it is a common practice to extract that transitioning functionality into a stand-alone +(`Link`) component that can be used as follows: + +```html +<Link to="/tasks/123">View Task #123</Link> ``` -### Step 4. Handling Redirects +### Routing in React Starter Kit + +React Starter Kit (RSK) uses [Universal Router](https://github.com/kriasoft/universal-router) npm +module that is built around the same concepts demonstrated earlier with the major differences that +it supports nested routes and provides you with the helper `Link` React component. It can be seen as +a lightweight more flexible alternative to React Router. + +- It has simple code with minimum dependencies (just `path-to-regexp` and `babel-runtime`) +- It can be used with any JavaScript framework such as React, Vue.js etc +- It uses the same middleware approach used in Express and Koa, making it easy to learn +- It uses the exact same API and implementation to be used in both Node.js and browser environments + +The [Getting Started page](https://github.com/kriasoft/universal-router/blob/master/docs/getting-started.md) +has a few examples how to use it. + +### Related Articles + +- [You might not need React Router](https://medium.freecodecamp.com/you-might-not-need-react-router-38673620f3d) by Konstantin Tarkus + +### Related Projects + +- [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) +- [`history`](https://github.com/ReactTraining/history) +- [Universal Router](https://github.com/kriasoft/universal-router) + +### Related Discussions -Coming soon. Stay tuned! +- [How to Implement Routing and Navigation](https://github.com/kriasoft/react-starter-kit/issues/748) +- [How to Add a Route to RSK?](https://github.com/kriasoft/react-starter-kit/issues/754)