findxc / blog

88 stars 5 forks source link

通过切割代码和预加载来提高页面加载速度 #48

Open findxc opened 3 years ago

findxc commented 3 years ago

预加载的好与不好

预加载意味着会发更多的请求,并且很可能用户最终也不会使用,这些流量会造成更大的服务器压力,换句话就是你会为此花更多钱。

好处就是用户体验会好点,对于网速很快的用户来说提升只是一点点,对于网速稍慢的用户来说提升会更明显。

参考资料

关于 React.lazy 和动态 import 的一些测试

参考 Lazy loading (and preloading) components in React 16.6 | by Rodrigo Pombo | HackerNoon.com | Medium 做了一些测试。

不做动态 import

import { useState } from 'react'

// 这种是把 Desc 和当前页面其它代码打包到一个文件了
// 访问这个页面时就会下载这个文件然后显示界面
// 点击按钮后 Desc 会马上显示
import Desc from './Desc'

function App() {
  const [showDesc, setShowDesc] = useState(false)

  return (
    <div>
      <button onClick={() => setShowDesc(true)}>显示描述</button>
      {showDesc && <Desc />}
    </div>
  )
}

export default App

加上动态 import

import { useState, lazy, Suspense } from 'react'

// 这种是把 Desc 单独打包了,但是在点击按钮的时候才会去下载文件
// 所以会先显示 loading... 然后文件下载完后显示 Desc
const Desc = lazy(() => import('./Desc'))

function App() {
  const [showDesc, setShowDesc] = useState(false)

  return (
    <div>
      <button onClick={() => setShowDesc(true)}>显示描述</button>
      <Suspense fallback={<div>loading...</div>}>
        {showDesc && <Desc />}
      </Suspense>
    </div>
  )
}

export default App

加上动态 import 并提前下载文件

import { useState, lazy, Suspense } from 'react'

// 会在加载这个页面时就去下载 Desc 文件
// 当点击按钮的时候一般 Desc 文件已经下载完了,所以会直接显示
const descPromise = import('./Desc')
const Desc = lazy(() => descPromise)

function App() {
  const [showDesc, setShowDesc] = useState(false)

  return (
    <div>
      <button onClick={() => setShowDesc(true)}>显示描述</button>
      <Suspense fallback={<div>loading...</div>}>
        {showDesc && <Desc />}
      </Suspense>
    </div>
  )
}

export default App

加上动态 import 并在鼠标 hover 时下载文件

这里就算鼠标多次 hover ,也只会发一个下载文件的请求的。

import { useState, lazy, Suspense } from 'react'

const importDesc = () => import('./Desc')
const Desc = lazy(importDesc)

function App() {
  const [showDesc, setShowDesc] = useState(false)

  // 在鼠标移入时再去下载 Desc
  const onMouseEnter = () => {
    importDesc()
  }

  return (
    <div>
      <button onClick={() => setShowDesc(true)} onMouseEnter={onMouseEnter}>
        显示描述
      </button>
      <Suspense fallback={<div>loading...</div>}>
        {showDesc && <Desc />}
      </Suspense>
    </div>
  )
}

export default App

封装一下

import { useState, lazy, Suspense } from 'react'

function lazyWithPreload(importFunc) {
  const Component = lazy(importFunc)
  // 加上一个 preload 属性,方便调用
  Component.preload = importFunc
  return Component
}

const Desc = lazyWithPreload(() => import('./Desc'))

function App() {
  const [showDesc, setShowDesc] = useState(false)

  // 在鼠标移入时再去下载 Desc
  const onMouseEnter = () => {
    Desc.preload()
  }

  return (
    <div>
      <button onClick={() => setShowDesc(true)} onMouseEnter={onMouseEnter}>
        显示描述
      </button>
      <Suspense fallback={<div>loading...</div>}>
        {showDesc && <Desc />}
      </Suspense>
    </div>
  )
}

export default App

如果动态 import 的组件 A 里面还动态 import 了其它的组件 B

这种情况的话,是会在需要展示组件 B 的时候才去下载组件 B 的代码,因为你在鼠标移上去的时候只预加载了组件 A 。

// App.js
import { useState, Suspense } from 'react'
import lazyWithPreload from './lazyWithPreload'

const Desc = lazyWithPreload(() => import('./Desc'))

function App() {
  const [showDesc, setShowDesc] = useState(false)

  // 在鼠标移入时再去下载 Desc
  const onMouseEnter = () => {
    Desc.preload()
  }

  return (
    <div>
      <button onClick={() => setShowDesc(true)} onMouseEnter={onMouseEnter}>
        显示描述
      </button>
      <Suspense fallback={<div>loading...</div>}>
        {showDesc && <Desc />}
      </Suspense>
    </div>
  )
}

export default App
// Desc.js
import { Suspense } from 'react'
import lazyWithPreload from './lazyWithPreload'

const SubDesc = lazyWithPreload(() => import('./SubDesc'))

function Desc() {
  return (
    <div>
      <div>这是一段描述,假装这是一个很复杂的组件。</div>
      {/* 这里会先显示 loading 然后再显示 SubDesc 内容 */}
      <Suspense fallback={<div>loading...</div>}>
        <SubDesc />
      </Suspense>
    </div>
  )
}

export default Desc

直接使用 loadable-components 库来做预加载

react-router 推荐的 code splitting 库是 loadable-components ,这个库是支持 预加载 功能的。

import { useState } from 'react'
import loadable from '@loadable/component'

const Desc = loadable(() => import('./Desc'), {
  fallback: <div>loading...</div>,
})

function App() {
  const [showDesc, setShowDesc] = useState(false)

  // 在鼠标移入时再去下载 Desc
  const onMouseEnter = () => {
    Desc.preload()
  }

  return (
    <div>
      <button onClick={() => setShowDesc(true)} onMouseEnter={onMouseEnter}>
        显示描述
      </button>
      {showDesc && <Desc />}
    </div>
  )
}

export default App

按路由切割代码后能做预加载吗

上面说的都是页面中某个次要内容的代码分割和预加载,下面来进入正题,按路由切割代码后,能在当前路由预加载其它路由的代码吗?

自己封装一下 Link

我们可以把 react-router-dom 库的 Link 组件再封装一层来实现预加载。

把路径以及对应的组件定义为数组,方便我们封装的 LinkWithPreload 去遍历数组找到组件,然后去执行组件的 preload 就好了。

// App.js
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
import loadable from '@loadable/component'

export const routes = [
  { path: '/', Component: loadable(() => import('./List')) },
  { path: '/detail/:id', Component: loadable(() => import('./Detail')) },
]

function App() {
  return (
    <Router>
      <Switch>
        {routes.map((item) => {
          const { path, Component } = item
          return (
            <Route key={path} exact path={path}>
              <Component />
            </Route>
          )
        })}
      </Switch>
    </Router>
  )
}

export default App
// LinkWithPreload.js
import { Link, matchPath } from 'react-router-dom'
import { routes } from './App'

function LinkWithPreload(props) {
  const { to } = props

  const onMouseEnter = () => {
    const find = routes.find((item) => {
      const { path } = item
      const match = matchPath(to, {
        path,
        exact: true,
      })
      return Boolean(match)
    })
    if (find) {
      find.Component.preload()
    }
  }

  return <Link {...props} onMouseEnter={onMouseEnter} />
}

export default LinkWithPreload

在需要使用 Link 的地方就 <LinkWithPreload to='/xxx'>查看</LinkWithPreload> 就可以了。

这里是鼠标移上去的时候预加载,如果你想,也可以改为使用 Intersection Observer ,判断 Link 组件进入可见区域时就预加载。

在什么时候进行预加载也是一种权衡,尽早预加载可以保证跳转页面的时候资源已经加载好了,但是会不可避免造成一些不必要的加载,因为你不知道用户会访问哪些页面。(当然如果你想你可以结合统计工具的数据,只对用户经常访问的页面做预加载来增加命中率 hhh)

直接使用 quicklink 库

https://github.com/GoogleChromeLabs/quicklink

它是监听的 Link 进入可视区域就进行预加载。

在 create-react-app 中使用:https://github.com/GoogleChromeLabs/quicklink/blob/master/demos/spa/README.md

使用这个库会需要配置 webpack-route-manifest 插件,这个插件会生成下面这个东西,然后就可以根据路由去预加载了。

image

quicklink 的相关实现见 https://github.com/GoogleChromeLabs/quicklink/blob/master/src/react-chunks.js#L61https://github.com/GoogleChromeLabs/quicklink/blob/master/src/index.mjs#L60

它是等路由组件进入可视区域后,然后拿到路由组件中所有 a 标签,然后再对应去做预加载。

timeoutFn(() => {
  // Find all links & Connect them to IO if allowed
  (options.el || document).querySelectorAll('a').forEach(link => {
    // If the anchor matches a permitted origin
    // ~> A `[]` or `true` means everything is allowed
    if (!allowed.length || allowed.includes(link.hostname)) {
      // If there are any filters, the link must not match any of them
      isIgnored(link, ignores) || observer.observe(link);
    }
  });
}, {
  timeout: options.timeout || 2000,
});

总结

为了减少加载一个页面时需要下载的代码,我们可以:

  1. 按路由切割代码,并预加载其它路由代码(Link hover 时或者进入可视区域时),这样跳转时下个页面加载会更快;
  2. 对弹窗、Tab 等当前不需要展示或者低优先级内容做代码切割,并预加载。

代码切割是为了减少必要代码的体积,预加载是为了低优先级组件代码在需要时也能尽快展示。

preload、prefetch、动态 import 区别

preload 和 prefetch 是 HTML link 标签的一个用法,用于提示浏览器去提前下载资源。preload 是希望提前下载当前页面的资源。prefetch 是希望提前下载其它页面的资源。

动态 import 是 JS 的一个语法,Webpack 打包时会把动态 import 的部分打包为单独的文件。可以用于实现按路由切割代码,或者把弹窗等低优先级界面代码从主界面代码切割出去,这样来加快主界面的加载速度。

当你希望预加载资源时,是使用 link 的 prefetch 还是说动态 import ,其实结果都是一样的,可以结合项目用的库来看怎么实现简单怎么来。