一个使用Hook和Suspense的`<Router />`

原文信息: 查看原文查看原文

A <Router /> with Hooks and Suspense

- James K Nelson

Navi 是React的一个新种类的路由。它让你声明式地将URL绘制至内容,即使那个内容是异步获取的。

React团队刚发布的新API,叫做Hooks。太神奇了。它可以让你声明模型状态和副作用。你或许已经在网上读到它了,所以我不准备跟你讲hooks自身,但是…

新的API带来了新的可能。直接入话题,Navi的新<Router>组建使用Hooks和Suspense来让路由的工作比以前更简单。它使得各种各样的东西成为可能 - 你甚至只用三行代码就可以添加动画加载过渡。

所以你怎么使用这些超级强大的hooks?我们将用一分钟的时间来完成它,在这之前,什么是<Router>(路由)?

一个<Router routes />可以连接到多少个路由上

你想要连接多少路由就连接多少,因为Navi让你动态按需import()整个路由树。但是,它是怎么实现的呢?

Navi用于声明routes的函数中有招数。对于简单的routes,你可以只使用Navi的mount()route()函数。但是对于大的内容块,你可以使用async/await在异步数据和视图中声明依赖项 - 或者你甚至可以使用lazy()将整个路由树分割开。

<Router routes={
  mount({
    '/': route({
      title: 'My Shop',
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/products': lazy(() => import('./productsRoutes')),
  })
} />

如果你看一下这个例子,你会看到你已经获得了一个有两个routes的<Router>,包括一个shop的落地页和一个懒加载/products的URL。

让我们来构建这个shop剩下的部分。

你的下一步,你会需要决定在哪里来渲染当前route的视图元素。为了做到这一点,你只是将一个<View />元素放到你的<Router>

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <View />
    </Layout>
  </Router>,
  document.getElementById('root')
)

很简单,对吧?但是,等一下…如果你看一个懒加载的/productsURL呢?这个route将通过一个import()被加载,然后返回一个Promise,且这首先不会渲染任何东西。幸运的是,React的新<Suspense>特性让你可以声明式地等待promise去resolve。所以只将你的<View>折叠到一个<Suspense>标签内,你就可以得到释放了。

(译者注:以下代码效果见:https://frontarm.com/demoboard/?id=e2701e9e-306c-48d8-90cc-0ee9b1dc7eb3

// index.js
import { mount, route, lazy } from 'navi'
import React, { Suspense } from 'react'
import ReactDOM from 'react-dom'
import { Router, View } from 'react-navi'
import api from './api'
import Landing from './Landing'
import Layout from './Layout'

const routes =
  mount({
    '/': route({
      title: "Hats 'n' Flamethrowers 'r' Us",
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/product': lazy(() => import('./product')),
  })

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <Suspense fallback={null}>
        <View />
      </Suspense>
    </Layout>
  </Router>,
  document.getElementById('root')
)
// production.js
import React from 'react'
import { mount, route } from 'navi'
import api from './api'

export default mount({
  '/:id': route({
    async getView(request) {
      let product = await api.fetchProduct(request.params.id)
      return <Product product={product} />
    }
  })
})

function Product({ product }) {
  return (
    <article className='Product'>
      <span className='Product-emoji'>{product.emoji}</span>
      <h1>{product.title}</h1>
      <span>{product.price}</span>
    </article>
  )
}
// Landing.js
import React from 'react'
import { Link, useCurrentRoute } from 'react-navi'

export default function Landing() {
  // useCurrentRoute 返回最近加载的Route对象
  let { data } = useCurrentRoute()
  let productIds = Object.keys(data)

  return (
    <ul>
      {productIds.map(id => 
        <li key={id}>
          <Link
            href={`/product/${id}`}
            prefetch={null}>
            {data[id].title}
          </Link>
        </li>
      )}
    </ul>
  )
}
// Layout.js
import BusyIndicator from 'react-busy-indicator@1.0.0'
import React from 'react'
import { Link, useLoadingRoute } from 'react-navi'

export default function Layout({ children }) {
  // 如果有一个route还没有完成加载,它可以使用`useLoadingRoute`恢复
  let loadingRoute = useLoadingRoute()

  return (
    <div className="Layout">
      {/* 这个组件在一个延迟之后显示一个加载提示 */}
      <BusyIndicator isBusy={!!loadingRoute} delayMs={200} />
      <header className="Layout-header">
        <h1 className="Layout-title">
        <Link href='/' prefetch={null}>
          Hats 'n' Flamethrowers 'r' Us
        </Link>
        </h1>
      </header>
      <main>
        {children}
      </main>
    </div>
  )
}
// api.js
import { NotFoundError } from 'navi'

const db = {
  hat: {
    emoji: '🧢',
    title: 'Hat',
    price: '$50.00',
  },
  flamethrower: {
    emoji: '🔥🔫',
    title: 'Not a flamethrower',
    price: '$500.00',
  },
}

export default {
  fetchProduct: async (id) => {
    await delay(100)
    let product = await db[id]
    if (!product) {
      throw new NotFoundError()
    }
    return product
  },
  fetchProducts: async () => {
    await delay(100)
    return db
  }
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

兄弟,只给我看hooks?

好的,你已经看过了怎么渲染一个route的视图。但是你有没有注意到你的route也定义了一个getData()函数?

route({
  title: 'My Shop',
  getData: () => api.fetch('/products'),
  view: <Landing />,
})

你怎么获得数据呢?使用React hooks

Navi的hook useCurrentRoute()可以被任何函数式组件调用,且使用一个<Router>标签来渲染。它返回一个Route对象,这个对象包含Navi知道的关于当前URL的任何东西。

(译者注:以下代码效果见:https://frontarm.com/demoboard/?id=86c7576d-e276-43a3-b948-2507c72c090e

// index.js
import { mount, route, lazy } from 'navi'
import React, { Suspense } from 'react'
import ReactDOM from 'react-dom'
import { Router, View } from 'react-navi'
import api from './api'
import Landing from './Landing'
import Layout from './Layout'

const routes =
  mount({
    '/': route({
      title: "Hats 'n' Flamethrowers 'r' Us",
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/product': lazy(() => import('./product')),
  })

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <Suspense fallback={null}>
        <View />
      </Suspense>
    </Layout>
  </Router>,
  document.getElementById('root')
)
// product.js
import React from 'react'
import { mount, route } from 'navi'
import api from './api'

export default mount({
  '/:id': route({
    async getView(request) {
      let product = await api.fetchProduct(request.params.id)
      return <Product product={product} />
    }
  })
})

function Product({ product }) {
  return (
    <article className='Product'>
      <span className='Product-emoji'>{product.emoji}</span>
      <h1>{product.title}</h1>
      <span>{product.price}</span>
    </article>
  )
}
//Landing.js
import React from 'react'
import { Link, useCurrentRoute } from 'react-navi'

export default function Landing() {
  // useCurrentRoute返回最近加载的Route对象
  let route = useCurrentRoute()
  let data = route.data
  let productIds = Object.keys(data)

  console.log('views', route.views)
  console.log('url', route.url)
  console.log('data', route.data)
  console.log('status', route.status)

  return (
    <ul>
      {productIds.map(id => 
        <li key={id}>
          <Link href={`/product/${id}`}>{data[id].title}</Link>
        </li>
      )}
    </ul>
  )
}
// Layout.js
import BusyIndicator from 'react-busy-indicator@1.0.0'
import React from 'react'
import { Link, useLoadingRoute } from 'react-navi'

export default function Layout({ children }) {
  // 如果有一个route还没有完成加载,它可以使用`useLoadingRoute`恢复
  let loadingRoute = useLoadingRoute()

  return (
    <div className="Layout">
      {/* 这个组件在一个延迟之后显示一个加载提示 */}
      <BusyIndicator isBusy={!!loadingRoute} delayMs={200} />
      <header className="Layout-header">
        <h1 className="Layout-title">
        <Link href='/' prefetch={null}>
          Hats 'n' Flamethrowers 'r' Us
        </Link>
        </h1>
      </header>
      <main>
        {children}
      </main>
    </div>
  )
}
// api.js
import { NotFoundError } from 'navi'

const db = {
  hat: {
    emoji: '🧢',
    title: 'Hat',
    price: '$50.00',
  },
  flamethrower: {
    emoji: '🔥🔫',
    title: 'Not a flamethrower',
    price: '$500.00',
  },
}

export default {
  fetchProduct: async (id) => {
    await delay(100)
    let product = await db[id]
    if (!product) {
      throw new NotFoundError()
    }
    return product
  },
  fetchProducts: async () => {
    await delay(100)
    return db
  }
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

Ok,到这里都很好。但是想象一下,你只是刚点击了一个链接到被动态引入的/products,它将需要一些时间来获取route,所以这时候你将显示什么?

可视化加载routes

当routes需要很长时间加载时,你会想向用户展示一些加载指示的东西,而且有一些方法你可以使用。一种方法是在初始化加载的时候,使用<Suspense>显示一个fallback。但是这看起来有点糟糕。

你想要做的是在下一个route加载的时候,在当前页面上显示一个加载条。好吧,除非这个过渡只需要100ms。然后你可能只想在下一页准备好之前,一直显示当前页面,因为花费100ms显示一个加载条看起来也很糟糕。

这只是一个问题。用目前可用的工具来做这个事是非常难,对吧?但实际上,你只需要往上面的demo中添加3行代码,使用useLoadingRoute()hook 和 react-busy-indicator包。

(译者注:以下代码效果见:https://frontarm.com/demoboard/?id=fa65eb4c-c5f2-45ed-bafd-dd4a71adb86e

// index.js
import { mount, route, lazy } from 'navi'
import React, { Suspense } from 'react'
import ReactDOM from 'react-dom'
import { Router, View } from 'react-navi'
import api from './api'
import Landing from './Landing'
import Layout from './Layout'

const routes =
  mount({
    '/': route({
      title: "Hats 'n' Flamethrowers 'r' Us",
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/product': lazy(() => import('./product')),
  })

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <Suspense fallback={null}>
        <View />
      </Suspense>
    </Layout>
  </Router>,
  document.getElementById('root')
)
// product.js
import React from 'react'
import { mount, route } from 'navi'
import api from './api'

export default mount({
  '/:id': route({
    async getView(request) {
      let product = await api.fetchProduct(request.params.id)
      return <Product product={product} />
    }
  })
})

function Product({ product }) {
  return (
    <article className='Product'>
      <span className='Product-emoji'>{product.emoji}</span>
      <h1>{product.title}</h1>
      <span>{product.price}</span>
    </article>
  )
}
// Landing.js
import React from 'react'
import { Link, useCurrentRoute } from 'react-navi'

export default function Landing() {
  // useCurrentRoute 返回最近加载过的Route对象
  let { data } = useCurrentRoute()
  let productIds = Object.keys(data)

  return (
    <ul>
      {productIds.map(id => 
        <li key={id}>
          <Link
            href={`/product/${id}`}
            prefetch={null}>
            {data[id].title}
          </Link>
        </li>
      )}
    </ul>
  )
}
// Layout.js
import BusyIndicator from 'react-busy-indicator@1.0.0'
import React from 'react'
import { Link, useLoadingRoute } from 'react-navi'

export default function Layout({ children }) {
  // 如果有一个route还没有完成加载,它可以使用`useLoadingRoute`恢复
  let loadingRoute = useLoadingRoute()

  return (
    <div className="Layout">
      {/* 这个组件在一个延迟之后显示一个加载提示 */}
      <BusyIndicator isBusy={!!loadingRoute} delayMs={200} />
      <header className="Layout-header">
        <h1 className="Layout-title">
        <Link href='/' prefetch={null}>
          Hats 'n' Flamethrowers 'r' Us
        </Link>
        </h1>
      </header>
      <main>
        {children}
      </main>
    </div>
  )
}
// api.js
import { NotFoundError } from 'navi'

const db = {
  hat: {
    emoji: '🧢',
    title: 'Hat',
    price: '$50.00',
  },
  flamethrower: {
    emoji: '🔥🔫',
    title: 'Not a flamethrower',
    price: '$500.00',
  },
}

export default {
  fetchProduct: async (id) => {
    await delay(1000)
    let product = await db[id]
    if (!product) {
      throw new NotFoundError()
    }
    return product
  },
  fetchProducts: async () => {
    await delay(100)
    return db
  }
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

去尝试在这些页面间多点击几次。有没有察觉到返回到首页的过渡多么的顺滑?顺滑到你都没有察觉到它实际上有100ms的延迟?很好!这才是你的用户真正想要的体验。

它是这样工作的:useCurrentRoute()返回最近加载完成的route,useLoadingRoute()返回任何已经请求但是还没加载完成的route。或者,如果用户没有点击一个链接,它会返回undefined

想要在页面加载的时候显示一个加载条?那就调用useLoadingRoute(),检查下如果有返回值,就渲染一个加载条(如果有)!你可以用CSS transitions完成剩下的工作。

更巧妙的技巧

我不打算现在就扔给你一整套的GuideAPI参考与其他工具整合的文档。你现在在看一篇博文,所以你可能没有时间查看所有有意思的信息。让我来问你一个问题:

如果route没有加载,会发生什么?

异步数据和视图的一个事情是,它们不工作。幸运的是,React有一个处理这种不工作的问题的工具:Error Boundaries(异常边界)。

让我们倒回一会到包裹你的<View/>的标签<Suspense>。当<View />遇到一个还没加载完成的route,它抛出一个promise,实际上请求React显示一会的fallback。你可以想象一下,<Suspense>捕获那个promise,然后在这个promise被resolve的时候,重新渲染它的子组件。

类似地,如果<View />发现getView()getData()已经抛出了一个异常,它会将这个异常重新抛出来。实际上,如果这个router遇到一个404页面消失了很长时间的异常,<View />也将会抛出它。这些异常可以被Error Boundary组件捕获。就绝大部分而言,你将需要编写你自己的error boundary,但是Navi包含一个<NotFoundBoundary>来显示它做了什么:

(译者注:以下代码效果见:https://frontarm.com/demoboard/?id=ddcba310-c4ea-4fb2-81e3-95f30f22f6a5

// index.js
import { mount, route, lazy } from 'navi'
import React, { Suspense } from 'react'
import ReactDOM from 'react-dom'
import { Router, View } from 'react-navi'
import api from './api'
import Landing from './Landing'
import Layout from './Layout'

const routes =
  mount({
    '/': route({
      title: "Hats 'n' Flamethrowers 'r' Us",
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/product': lazy(() => import('./product')),
  })

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <Suspense fallback={null}>
        <View />
      </Suspense>
    </Layout>
  </Router>,
  document.getElementById('root')
)
// product.js
import React from 'react'
import { mount, route } from 'navi'
import api from './api'

export default mount({
  '/:id': route({
    async getView(request) {
      let product = await api.fetchProduct(request.params.id)
      return <Product product={product} />
    }
  })
})

function Product({ product }) {
  return (
    <article className='Product'>
      <span className='Product-emoji'>{product.emoji}</span>
      <h1>{product.title}</h1>
      <span>{product.price}</span>
    </article>
  )
}
// Landing.js
import React from 'react'
import { Link, useCurrentRoute } from 'react-navi'

export default function Landing() {
  // useCurrentRoute 返回最近加载完成的Route对象
  let { data } = useCurrentRoute()
  let productIds = Object.keys(data)

  return (
    <ul>
      {productIds.map(id => 
        <li key={id}>
          <Link
            href={`/product/${id}`}
            prefetch={null}>
            {data[id].title}
          </Link>
        </li>
      )}
    </ul>
  )
}
// Layout.js
import BusyIndicator from 'react-busy-indicator@1.0.0'
import React from 'react'
import { Link, NotFoundBoundary, useLoadingRoute } from 'react-navi'

export default function Layout({ children }) {
  // 如果有一个route还没有完成加载,它可以使用`useLoadingRoute`恢复
  let loadingRoute = useLoadingRoute()

  return (
    <div className="Layout">
      {/* 这个组件在一个延迟之后显示一个加载提示 */}
      <BusyIndicator isBusy={!!loadingRoute} delayMs={200} />
      <header className="Layout-header">
        <h1 className="Layout-title">
        <Link href='/'>
          Hats 'n' Flamethrowers 'r' Us
        </Link>
        </h1>
      </header>
      <main>
        <NotFoundBoundary render={renderNotFound}>
          {children}
        </NotFoundBoundary>
      </main>
    </div>
  )
}

function renderNotFound() {
  return (
    <div className='Layout-error'>
      <h1>404 - Not Found</h1>
    </div>
  )
}
// api.js
import { NotFoundError } from 'navi'

const db = {
  hat: {
    emoji: '🧢',
    title: 'Hat',
    price: '$50.00',
  },
  flamethrower: {
    emoji: '🔥🔫',
    title: 'Not a flamethrower',
    price: '$500.00',
  },
}

export default {
  fetchProduct: async (id) => {
    await delay(100)
    let product = await db[id]
    if (!product) {
      throw new NotFoundError()
    }
    return product
  },
  fetchProducts: async () => {
    await delay(100)
    return db
  }
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

但这不是全部!!

Ok,我想这篇文章已经超时了,但是有一些更详细的文档:

你也可以从Navi库下examples目录下看到示例的完整代码,包括:

分享于 2019-04-01

访问量 2919

预览图片