61
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

universal-router で react-router を倒す

Last updated at Posted at 2018-02-05

Universal Router とは

isomorphicガチ勢の kriasoft 先生作の Router ライブラリ。
内部的には path-to-regexp だから express と同じルール

import UniversalRouter from 'universal-router'

const routes = [
  {
    path: '', // optional
    action: () => `<h1>Home</h1>`,
  },
  {
    path: '/posts',
    action: () => console.log('checking child routes for /posts'),
    children: [
      {
        path: '', // optional, matches both "/posts" and "/posts/"
        action: () => `<h1>Posts</h1>`,
      },
      {
        path: '/:id',
        action: (context) => `<h1>Post #${context.params.id}</h1>`,
      },
    ],
  },
]

const router = new UniversalRouter(routes)

router.resolve('/posts').then(html => {
  document.body.innerHTML = html // renders: <h1>Posts</h1>
})

router.resolve すると path でマッチしたオブジェクトを取れるだけ。それ以外は何もしない。

なぜ Universal Router が気になっているか

  • どこも react-router で苦しんでいる
    • とくに v3 から移行できないチームが多い
    • v5 も breaking change する予定あり
    • (個人的に) react-router 開発チームを信用してない
  • Reactのようなプレビュー層の寿命は短いが、ルーティングの寿命はもっと長い
    • 経験上、 Backbone 倒したはずが Backbone Router だけ生き残ってる現場が多い
    • 単一責務のライブラリは薄ければ薄いほどよい
  • なんやかんやで長期的な負債としてこのまま苦しみ続けたくない
  • yosuke_furukawa に調べてこいと言われた

使ってみた所感

完全な自由を得る代わりに、代償を支払う必要があるのだ…

具体的な内容

https://github.com/mizchi/fe-base という自分が毎回使いまわしているボイラープレート上で実装してみた。

自分の手元のコードの一例。

/* @flow */
import React from 'react'
import ReactDOM from 'react-dom'
import UniversalRouter from 'universal-router'
import createHistory from 'history/createBrowserHistory'
import routes from './routes'
import createStore from './store/createStore'

const router = new UniversalRouter(routes, {
  context: {
    store: createStore()
  }
})

const history = createHistory()
let el // root element

/* Scroll position controller */
const scrollPositionsHistory: { [string]: number } = {}
const updateScrollPosition = (location: { key: string }) => {
  scrollPositionsHistory[location.key] = {
    scrollX: window.pageXOffset,
    scrollY: window.pageYOffset
  }
}

const deletePosition = (location: { key: string }) => {
  delete scrollPositionsHistory[location.key]
}

const restoreScollPosition = (location: { hash: string }) => {
  let scrollX = 0
  let scrollY = 0
  const pos = scrollPositionsHistory[location.key]
  if (pos) {
    scrollX = pos.scrollX
    scrollY = pos.scrollY
  } else {
    const targetHash = location.hash.substr(1)
    if (targetHash) {
      const target = document.getElementById(targetHash)
      if (target) {
        scrollY = window.pageYOffset + target.getBoundingClientRect().top
      }
    }
  }
  // Restore the scroll position if it was saved into the state
  // or scroll to the given #hash anchor
  // or scroll to top of the page
  window.scrollTo(scrollX, scrollY)
}

let _off = false
const switchOffScrollRestorationOnce = () => {
  if (_off) {
    return
  }
  // Switch off the native scroll restoration behavior and handle it manually
  // https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration
  if (window.history && 'scrollRestoration' in window.history) {
    window.history.scrollRestoration = 'manual'
  }
  _off = true
  return
}

const onLocationChange = async (location, action): Promise<void> => {
  updateScrollPosition(location)
  try {
    const route = await router.resolve(location)
    if (route.redirect) {
      history.replace(route.redirect)
      return
    }

    if (action === 'PUSH') {
      deletePosition(location)
    }

    if (React.isValidElement(route)) {
      // For HMR
      // https://github.com/nozzle/react-static/issues/144#issuecomment-348270365
      const render = !!module.hot ? ReactDOM.render : ReactDOM.hydrate
      render(route, el, () => {
        switchOffScrollRestorationOnce()
        restoreScollPosition(history.location)
      })
    } else {
      // not react
    }
  } catch (e) {
    // or render 404
    console.error(e)
  }
}

export const start = () => {
  el = document.querySelector('.root')
  history.listen(onLocationChange)
  onLocationChange(history.location)
}

// HMR
if (module.hot) {
  module.hot.accept('./routes', () => {
    onLocationChange(history.location)
  })
}

/* Link */

function isLeftClickEvent(event: SytheticEvent<>) {
  return event.button === 0
}

function isModifiedEvent(event: SytheticEvent<>) {
  return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
}

const handleClick = ({ onClick, to }) => event => {
  if (onClick) {
    onClick(event)
  }

  if (isModifiedEvent(event) || !isLeftClickEvent(event)) {
    return
  }

  if (event.defaultPrevented === true) {
    return
  }

  event.preventDefault()
  if (
    to !== history.createHref(history.location).replace(location.origin, '')
  ) {
    history.push(to)
  } else {
    // console.info('reject transition by same url')
  }
}

type Props = {
  to: string,
  children: any,
  onClick?: Function
}

export function Link(props: Props) {
  const { to, children, onClick, ...others } = props
  return (
    <a href={to} {...others} onClick={handleClick({ onClick, to })}>
      {children}
    </a>
  )
}

https://github.com/kriasoft/react-starter-kit/blob/master/src/client.js を参考にしている。
このコードでは、 Reactをマウントすることと、 Link タグとスクロール位置同期を実装している。

それでこんなコードが動くようになった

routes/index.js
/* @flow */
import Counter from '../components/pages/Counter'
import * as CounterActions from '../reducers/counter'

type Context = {
  store: any
}

export default [
  {
    path: '',
    action: (context: Context) => {
      return (
        <App store={context.store}>
          <Home />
        </App>
      )
    }
  },
  {
    path: '/about',
    action: (context: Context) => {
      return (
        <App store={context.store}>
          <About />
        </App>
      )
    }
  },
  {
    path: '/counter',
    action: async (context: Context) => {
      const dispatch = context.store.dispatch
      dispatch(CounterActions.increment()) // sync
      await dispatch(CounterActions.incrementAsync()) // async
      return (
        <App store={context.store}>
          <Counter />
        </App>
      )
    }
  },
  {
    path: '/nested',
    children: [
      {
        path: '/a',
        action: () => {
          console.log('a')
        }
      },
      {
        path: '/b',
        action: () => {
          console.log('b')
        }
      },
      {
        path: '/c',
        action: () => {
          return { redirect: '/nested/a' }
        }
      }
    ],
    action: () => {
      console.log('nested action')
    }
  }
]

見てほしいのは counter のところで、context として store を引き回して、 コンロトローラーアクションとして色々とdispatchしてからrenderを行っている (ここでは暗に redux-promise を使っている)

感想

  • 規約として便利だが、引き受けるものが多すぎる
  • 各ステップをリファクタして universal-router-react のようなライブラリを作ろうとしたが、react-router で実際に発生した様々な要件がこの上で発生することを考えるとライブラリ化するのが難しく、kriasoft 先生がそうしてない気持ちはわかった。わかったが…きつい
  • isomorphic を倒すには便利だが、普通は next.js 使えばいいと思う

要約: isomorphicなライブラリ設計者や、超人向けです

61
37
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
61
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?