Εισαγωγή στο Redux-First Routing Model

Μια βιβλιοθήκη δρομολόγησης είναι ένα βασικό συστατικό κάθε σύνθετης εφαρμογής μιας σελίδας. Εάν αναπτύξετε εφαρμογές ιστού με το React και το Redux, πιθανότατα έχετε χρησιμοποιήσει ή τουλάχιστον ακούσατε για το React Router. Είναι μια γνωστή βιβλιοθήκη δρομολόγησης για το React και μια εξαιρετική λύση για πολλές περιπτώσεις χρήσης.

Αλλά το React Router δεν είναι η μόνη βιώσιμη λύση στο οικοσύστημα React / Redux. Στην πραγματικότητα, υπάρχουν τόνοι λύσεων δρομολόγησης που έχουν δημιουργηθεί για το React και για το Redux, το καθένα με διαφορετικά API, δυνατότητες και στόχους - και η λίστα αυξάνεται μόνο. Περιττό να πούμε ότι η δρομολόγηση από την πλευρά του πελάτη δεν πρόκειται να αποσυρθεί σύντομα και υπάρχει ακόμη πολύς χώρος για σχεδιασμό στις βιβλιοθήκες δρομολόγησης του αύριο.

Σήμερα, θέλω να επιστήσω την προσοχή σας στο θέμα της δρομολόγησης στο Redux. Θα παρουσιάσω και θα κάνω μια υπόθεση για το Redux-first routing - ένα παράδειγμα που καθιστά τη Redux το αστέρι του μοντέλου δρομολόγησης και το κοινό νήμα μεταξύ πολλών λύσεων δρομολόγησης Redux. Θα δείξω πώς να συνδυάσω το πυρήνα, API-αγνωστικικό σε λιγότερες από 100 γραμμές κώδικα, προτού εξερευνήσω επιλογές για χρήση σε πραγματικό κόσμο με το React και άλλα front-end πλαίσια.

Μια μικρή ιστορία

Στο πρόγραμμα περιήγησης, η τοποθεσία (πληροφορίες διεύθυνσης URL) και το ιστορικό περιόδου σύνδεσης (μια στοίβα τοποθεσιών που επισκέπτονται η τρέχουσα καρτέλα του προγράμματος περιήγησης) αποθηκεύονται στο καθολικό windowαντικείμενο. Είναι προσβάσιμα μέσω:

  • window.location (API τοποθεσίας)
  • window.history (API ιστορικού).

Το API ιστορικού προσφέρει τις ακόλουθες μεθόδους πλοήγησης ιστορικού , αξιοσημείωτες για την ικανότητά τους να ενημερώνουν το ιστορικό και την τοποθεσία του προγράμματος περιήγησης χωρίς να απαιτείται επαναφόρτωση σελίδας :

  • pushState(href) - ωθεί μια νέα θέση στη στοίβα ιστορικού
  • replaceState(href) - αντικαθιστά την τρέχουσα θέση στη στοίβα
  • back() - πλοηγεί στην προηγούμενη θέση στη στοίβα
  • forward() - πλοηγεί στην επόμενη θέση στη στοίβα
  • go(index) - πλοηγεί σε μια θέση στη στοίβα, προς οποιαδήποτε κατεύθυνση.

Μαζί, τα API ιστορίας και τοποθεσίας επιτρέπουν το σύγχρονο παράδειγμα δρομολόγησης από την πλευρά του πελάτη που είναι γνωστό ως pushState routing - ο πρώτος πρωταγωνιστής της ιστορίας μας.

Τώρα, είναι σχεδόν έγκλημα να αναφέρουμε τα APIs Ιστορία και Τοποθεσία χωρίς να αναφέρει μια σύγχρονη βιβλιοθήκη περιτύλιγμα όπως history.

ReactTraining / ιστορικό

Διαχειριστείτε το ιστορικό περιόδου σύνδεσης με το JavaScript github.com

historyπαρέχει ένα απλό αλλά ισχυρό API για διασύνδεση με το ιστορικό και την τοποθεσία του προγράμματος περιήγησης, ενώ καλύπτει ασυνέπειες μεταξύ διαφορετικών εφαρμογών προγράμματος περιήγησης. Χρησιμοποιείται ως ομότιμη ή εσωτερική εξάρτηση σε πολλές σύγχρονες βιβλιοθήκες δρομολόγησης και θα κάνω πολλές αναφορές σε αυτό σε αυτό το άρθρο.

Redux και pushState Routing

Ο δεύτερος πρωταγωνιστής της ιστορίας μας είναι ο Redux . Είναι το 2017, οπότε θα σας αφήσω την εισαγωγή και θα φτάσω στο σημείο:

Χρησιμοποιώντας απλή δρομολόγηση pushState σε μια εφαρμογή Redux, χωρίζουμε την κατάσταση της εφαρμογής σε δύο τομείς: το ιστορικό του προγράμματος περιήγησης και το κατάστημα Redux.

Εδώ είναι αυτό που μοιάζει με React Router, το οποίο αρχικοποιεί και αναδιπλώνεται history:

history → React Router ↘ view Redux ↗

Τώρα, γνωρίζουμε ότι δεν πρέπει να βρίσκονται όλα τα δεδομένα στο κατάστημα. Για παράδειγμα, η κατάσταση τοπικού στοιχείου είναι συχνά ένα κατάλληλο μέρος για την αποθήκευση δεδομένων που είναι συγκεκριμένα για ένα μόνο στοιχείο.

Αλλά τα δεδομένα τοποθεσίας δεν είναι ασήμαντα. Είναι ένα δυναμικό και σημαντικό μέρος της κατάστασης εφαρμογής - το είδος των δεδομένων που ανήκουν στο κατάστημα. Το κράτημα στο κατάστημα επιτρέπει την πολυτέλεια της Redux, όπως τον εντοπισμό σφαλμάτων χρόνου ταξιδιού και την εύκολη πρόσβαση από οποιοδήποτε στοιχείο που συνδέεται με το κατάστημα.

Πώς λοιπόν μεταφέρουμε την τοποθεσία στο κατάστημα;

Δεν υπάρχει πρόβλημα για το γεγονός ότι το πρόγραμμα περιήγησης διαβάζει και αποθηκεύει πληροφορίες ιστορικού και τοποθεσίας στο window, αλλά αυτό που μπορούμε να κάνουμε είναι να διατηρήσουμε ένα αντίγραφο των δεδομένων τοποθεσίας στο κατάστημα και να τα διατηρήσουμε σε συγχρονισμό με το πρόγραμμα περιήγησης.

Δεν είναι αυτό react-router-reduxγια το React Router;

Ναι, αλλά μόνο για να ενεργοποιήσετε τις δυνατότητες ταξιδιού χρόνου των Redux DevTools. Η εφαρμογή εξαρτάται ακόμη από τα δεδομένα τοποθεσίας που διατηρούνται στο React Router:

history → React Router ↘ ↕ view Redux ↗

Η χρήση react-router-reduxγια την ανάγνωση δεδομένων τοποθεσίας από το κατάστημα αντί για το React Router αποθαρρύνεται (λόγω πιθανών αντικρουόμενων πηγών αλήθειας).

Μπορούμε να κάνουμε καλύτερα;

Μπορούμε να φτιάξουμε ένα εναλλακτικό μοντέλο δρομολόγησης - ένα που είναι κατασκευασμένο από το μηδέν για να παίξει καλά με το Redux, επιτρέποντάς μας να διαβάσουμε και να ενημερώσουμε την τοποθεσία με τον τρόπο Redux - με store.getState()και store.dispatch();

Μπορούμε απολύτως, και ονομάζεται Redux-first routing .

Redux-First Routing

Η δρομολόγηση Redux-first είναι μια παραλλαγή της δρομολόγησης pushStateπου κάνει το Redux το αστέρι του μοντέλου δρομολόγησης.

Μια λύση δρομολόγησης Redux-first ικανοποιεί τα ακόλουθα κριτήρια :

  • Η τοποθεσία διατηρείται στο κατάστημα Redux.
  • Η τοποθεσία αλλάζει με την αποστολή ενεργειών Redux.
  • Η εφαρμογή διαβάζει δεδομένα τοποθεσίας αποκλειστικά από το κατάστημα.
  • Το ιστορικό καταστήματος και προγράμματος περιήγησης διατηρείται συγχρονισμένο πίσω από τα παρασκήνια.

Ακολουθεί μια βασική ιδέα για το πώς μοιάζει:

history ↕ Redux → router → view

Περιμένετε, δεν υπάρχουν ακόμη δύο πηγές δεδομένων τοποθεσίας;

Ναι, αλλά αν μπορούμε να πιστέψουμε ότι το ιστορικό του προγράμματος περιήγησης και το κατάστημα Redux είναι συγχρονισμένα, μπορούμε να δημιουργήσουμε τις εφαρμογές μας για να διαβάζουμε μόνο δεδομένα τοποθεσίας από το κατάστημα . Στη συνέχεια, από την άποψη της εφαρμογής, υπάρχει μόνο μία πηγή αλήθειας - το κατάστημα.

Πώς επιτυγχάνουμε τη δρομολόγηση Redux-first;

Μπορούμε να ξεκινήσουμε δημιουργώντας ένα εννοιολογικό μοντέλο, συγχωνεύοντας τα θεμελιώδη στοιχεία των μοντέλων δρομολόγησης από πλευράς πελάτη και κύκλου ζωής δεδομένων Redux.

Επανεξέταση του μοντέλου δρομολόγησης από τον πελάτη

Client-side routing is a multi-step process that starts with navigation and ends with renderingrouting itself is only one step in that process! Let’s review the details:

  • Navigation — Everything starts with a change in location. There are 2 types of navigation: internal and external. Internal navigation is accomplished from within the app (eg. via the History API), while external navigation occurs when the user interacts with the browser’s navigation bar or enters the application from an external site.
  • Responding to navigation — When the location changes, the application responds by passing the new location to the router. Older routing techniques relied on polling window.location to accomplish this, but nowadays we have the handy history.listen utility.
  • Routing — Next, the new location is matched to its corresponding page content. The code that handles this step is called a router, and it generally takes an input parameter of matching routes and pages called a route configuration.
  • Rendering — Finally, the content is rendered on the client. This step may, of course, be handled by a front-end framework/library like React.

Note that routing libraries don’t have to handle every part of the routing model.

Some libraries, like React Router and Vue Router, do — while others, like Universal Router, are concerned solely with a single aspect (like routing), thus providing flexibility in the other aspects:

Revisiting the Redux Data Lifecycle Model

Redux boasts a one-way data flow/lifecycle model that likely needs no introduction — but here’s a brief overview for good measure:

  • Action — Any change in state starts by dispatching a Redux action (a plain object containing a type and optional payload).
  • Middleware — The action passes through the store’s chain of middlewares, where actions may be intercepted and additional behaviour may be executed. Middlewares are commonly used to handle side-effects in Redux applications.
  • Reducer — The action then reaches the root reducer, which calculates the store’s next state as a pure function of the previous state and the received action. The root reducer may be composed of individual reducers that each handle a slice of the store’s state.
  • New state — The store saves the new state returned by the reducer, and notifies its subscribers of the change (in React, via connect).
  • Rendering — Finally, the store-connected view may re-render in accordance with the new state.

Building a Redux-First Routing Model

The unidirectional nature of the client-side routing and Redux data lifecycle models lend themselves well to a merged model that satisfies the criteria we laid out for Redux-first routing.

In this model, the router is subscribed to the store, navigation is accomplished via Redux actions, and updates to the browser history are handled by a custom middleware. Let’s examine the details of this model:

  • Internal navigation via Redux actions — Instead of using the History API directly, internal navigation is achieved by dispatching one of 5 navigation actions that mirror the history navigation methods.
  • Updating the browser history via middleware — A middleware is used to intercept the navigation actions and handle the side-effect of updating the browser history. Since the new location isn’t necessarily or easily known without first consulting the browser history (eg. in the case of a go action), the navigation actions are prevented from reaching the reducer.
  • Responding to navigation — The flow of execution continues with a history listener that responds to navigation (from both the middleware and external navigation) by dispatching a second action that does contain the new location.
  • Location Reducer — The action dispatched by the listener then reaches the location reducer, which adds the location to the store. The location reducer also determines the shape of the location state.
  • Connected routing — The store-connected router can then reactively determine the new page content when notified of a change in location in the store.
  • Rendering — Finally, the page may be re-rendered with the new content.

Note that this isn’t the only way to accomplish Redux-first routing — some variations feature the use of a store enhancer and/or additional logic in the middleware — but it’s a simple model that covers all of the bases.

A Basic Implementation

Following the model we just looked at, let’s implement the core API — the actions, middleware, listener, and reducer.

We’ll use the history package as an internal dependency, and build the solution incrementally. If you’d rather follow along with the final result, you may view it here.

Actions

We’ll start by defining the 5 navigation actions that mirror the history navigation methods:

// constants.jsexport const PUSH = 'ROUTER/PUSH';export const REPLACE = 'ROUTER/REPLACE';export const GO = 'ROUTER/GO';export const GO_BACK = 'ROUTER/GO_BACK';export const GO_FORWARD = 'ROUTER/GO_FORWARD';
// actions.jsexport const push = (href) => ({ type: PUSH, payload: href,});
export const replace = (href) => ({ type: REPLACE, payload: href,});
export const go = (index) => ({ type: GO, payload: index,});
export const goBack = () => ({ type: GO_BACK,});
export const goForward = () => ({ type: GO_FORWARD,});

Middleware

Next, let’s define the middleware. It should intercept the navigation actions, call the corresponding history navigation methods, then stop the action from reaching the reducer — but leave all other actions undisturbed:

// middleware.jsexport const routerMiddleware = (history) => () => (next) => (action) => { switch (action.type) { case PUSH: history.push(action.payload); break; case REPLACE: history.replace(action.payload); break; case GO: history.go(action.payload); break; case GO_BACK: history.goBack(); break; case GO_FORWARD: history.goForward(); break; default: return next(action); }};

If you haven’t had the chance to write or examine the internals of a Redux middleware before, check out this introduction.

History Listener

Next, we’ll need a history listener that responds to navigation by dispatching a new action containing the new location information.

First, let’s add the new action type and creator. The interesting parts of the location are the pathname, search, and hash — so that’s what we’ll include in the payload:

// constants.jsexport const LOCATION_CHANGE = 'ROUTER/LOCATION_CHANGE';
// actions.jsexport const locationChange = ({ pathname, search, hash }) => ({ type: LOCATION_CHANGE, payload: { pathname, search, hash, },});

Then let’s write the listener function:

// listener.jsexport function startListener(history, store) { history.listen((location) => { store.dispatch(locationChange({ pathname: location.pathname, search: location.search, hash: location.hash, })); });}

We’ll make one small addition — an initial locationChange dispatch, to account for the initial entry into the application (which doesn’t get picked up by the history listener):

// listener.jsexport function startListener(history, store) { store.dispatch(locationChange({ pathname: history.location.pathname, search: history.location.search, hash: history.location.hash, }));
 history.listen((location) => { store.dispatch(locationChange({ pathname: location.pathname, search: location.search, hash: location.hash, })); });}

Reducer

Next, let’s define the location reducer. We’ll use a simple state shape, and do minimal work in the reducer:

// reducer.jsconst initialState = { pathname: '/', search: '', hash: '',};
export const routerReducer = (state = initialState, action) => { switch (action.type) { case LOCATION_CHANGE: return { ...state, ...action.payload, }; default: return state; }};

Application Code

Finally, let’s hook up our API into the application code:

// index.jsimport { combineReducers, applyMiddleware, createStore } from 'redux'import { createBrowserHistory } from 'history'import { routerReducer } from './reducer'import { routerMiddleware } from './middleware'import { startListener } from './listener'import { push } from './actions'
// Create the history objectconst history = createBrowserHistory()
// Build the root reducerconst rootReducer = combineReducers({ // ...otherReducers, router: routerReducer,}) // Build the middlewareconst middleware = routerMiddleware(history)
// Create the storeconst store = createStore(rootReducer, {}, applyMiddleware(middleware))
// Start the history listenerstartListener(history, store)
// Now you can read location data from the store!let currentLocation = store.getState().router.pathname
// You can also subscribe to changes in the location!let unsubscribe = store.subscribe(() => { let previousLocation = currentLocation currentLocation = store.getState().router.pathname
 if (previousLocation !== currentLocation) { // You can render your application reactively here! }})
// And you can dispatch navigation actions from anywhere!store.dispatch(push('/about'))

And that’s all there is to it! Using our tiny (under 100 lines of code) API, we’ve met all of the criteria for Redux-first routing:

  • The location is held in the Redux store. ✔
  • The location is changed by dispatching Redux actions. ✔
  • The application reads location data solely from the store. ✔
  • The store and browser history are kept in sync behind the scenes. ✔

View all the files together here — feel free to import them into your project, or use it as a starting point to develop your own implementation.

The redux-first-routing package

I’ve also put the API together into the redux-first-routing package, which you may npm install and use in the same way.

mksarge/redux-first-routing

redux-first-routing — A minimal, framework-agnostic base for accomplishing Redux-first routing.github.com

It includes an implementation similar to the one we built here, but with the notable addition of query parsing via the query-string package.

Wait — what about the actual routing component?

You may have noticed that redux-first-routing is only concerned with the navigational aspect of the routing model:

By decoupling the navigational aspect from the other aspects of our routing model, we’ve gained some flexibility — redux-first-routing is both router-agnostic, and framework-agnostic.

You can therefore pair it with a library like Universal Router to create a complete Redux-first routing solution for any front-end framework:

Or, you could build opinionated bindings for your framework of choice — and that’s what we’ll do for React in the next and final section of this article.

Usage with React

Let’s finish our exploration by looking at how we might build store-connected components for declarative navigation and routing in React.

Declarative Navigation

For navigation, we can use a store-connected k/> component similar to the one in React Router and other React routing solutions.

It simply overrides the default behaviour of anchor element <a/> and dispatches a push action when clicked:

// Link.jsimport React from 'react';import { connect } from 'react-redux';import { push as pushAction, replace as replaceAction } from './actions';
const Link = (props) => { const { to, replace, children, dispatch, ...other } = props;
 const handleClick = (event) => { // Ignore any click other than a left click if ((event.button && event.button !== 0) || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented === true) { return; } // Prevent the default behaviour (page reload, etc.) event.preventDefault();
 // Dispatch the appropriate navigation action if (replace) { dispatch(replaceAction(to)); } else { dispatch(pushAction(to)); } };
 return ( {children} );};
export default connect()(Link);

You may find a more complete implementation here.

Declarative Routing

While there’s not much to a navigational component, there are countless ways to design a routing component — making it the most interesting part of any routing solution.

What is a router, anyway?

You can generally view a router as a function or black box with two inputs and one output:

route configuration ↘ matched content current location ↗

Though the routing and subsequent rendering may occur in separate steps, React makes it easy and intuitive to bundle them together into a declarative routing API. Let’s look at two strategies for accomplishing this.

Strategy 1: A monolithic r/> com ponent

We can use a monolithic, store-connected r/> component that:

  • accepts a route configuration object via props
  • reads the location data from the Redux store
  • calculates the new content whenever the location changes
  • renders/re-renders the content as appropriate.

The route configuration may be a plain JavaScript object that contains all of the matching paths and pages (a centralized route configuration).

Here’s how this might look:

const routes = [ { path: '/', page: './pages/Home', }, { path: '/about', page: './pages/About', }, { path: '*', page: './pages/Error', },]
React.render( , document.getElementById('app'))

Pretty simple, right? No need for nested JSX routes — just a single route configuration object, and a single router component.

If this strategy is appealing to you, check out my more complete implementation in the redux-json-router library. It wraps redux-first-routing and provides React bindings for declarative navigation and routing using the strategies we’ve examined so far.

mksarge/redux-json-router

redux-json-router - Declarative, Redux-first routing for React/Redux browser applications.github.com

Strategy 2: Composable e/> comp onents

While a monolithic component may be a simple way to achieve declarative routing in React, it’s definitely not the only way.

The composable nature of React allows another interesting possibility: using JSX to define routes in a decentralized manner. Of course, the prime example is React Router’s e/> API:

React.render( ... 

Other routing libraries explore this idea too. While I haven’t had the chance do it, I don’t see any reason why a similar API couldn’t be implemented on top of the redux-first-routing package.

Instead of relying on location data provided by

r/>, the &lt;Route/> component could simply connect to the store:

React.render( ... 

If that’s something that you’re interested in building or using, let me know in the comments! To learn more about different route configuration strategies, check out this introduction on React Router’s website.

Conclusion

I hope this exploration has helped deepen your knowledge about client-side routing and has shown you how simple it is to accomplish it the Redux way.

If you’re looking for a complete Redux routing solution, you can use the redux-first-routing package with a compatible router listed in the readme. And if you find yourself needing to develop a tailored solution, hopefully this post has given you a good starting point for doing so.

If you’d like to learn more about client-side routing in React and Redux, check out the following articles — they were instrumental in helping me better understand the topics I covered here:

  • Let The URL Do The Talking by Tyler Thompson
  • You Might Not Need React Router by Konstantin Tarkus
  • Do I Even Need a Routing Library? by James K. Nelson
  • and countless informative discussions in the react-router-redux issues.

Client-side routing is a space with endless design possibilities, and I’m sure some of you have played with ideas similar to the ones I’ve shared here. If you’d like to continue the conversation, I’ll be glad to connect with you in the comments or via Twitter. Thanks for reading!

Edit 22/06/17: Also check out this article on redux-first-router, a separate project that uses intelligent action types to achieve powerful routing capabilities.