Next.js 实现原理系列(2):页面的 prefetch

背景

本文是 Next.js 实现原理系列 的第二篇文章,承接上一篇 Next.js 实现原理系列(1):Link 基础 中“问题驱动”的方式,研究 Next.js 中的 Link 与 Router。

本篇涉及的源码文件:

packages/next/client/link.tsx
packages/next/shared/lib/router/router.ts
packages/next/client/page-loader.ts
packages/next/client/route-loader.ts

注:本系列文章基于 Next.js v12.0.4 版本。

prefetch 的到底是什么?

prefetch 功能

Next.js 一个非常实用的功能就是,你在浏览当前页面的时候,Next.js 会自动 prefetch 页面上所有的站内链接页面。这样一来,当你真正点击某个链接时,就可以迅速加载该页面,快得简直就像是在读取本地文件。

可能这就是为什么同样是 client-side navigation,Next.js 要比常规的 React SPA 的页面跳转快这么多吧!

这个功能是默认开启的。只要不像下面这样把 <Link>prefetch 置为 false,Next.js 就会在当前页面默认 prefetch 该链接:

<Link href="/some/route" prefetch={false}>
  <a>Some text</a>
</Link>

这个特性很好,但一些问题接踵而来:

  • prefetch 的究竟是什么内容?
  • prefetch 的内容是如何存储的?

接下来就尝试回答这个问题。

代码片段 1:Link 中的 prefetch

<Link> 组件中,有两个时机会调用 prefetch

  • 一个是在 useEffect 中。大概来说就是,当 <Link> 组件初始加载,或者其指向的 URL 发生变化时,就会触发prefetch()函数。这里的前提是 <Link>prefetch 属性不为 false
  • 另一个是,如果用户鼠标 hover 在 <Link> 上,也会触发prefetch()函数。这个是通过 onMouseEnter 实现的,并且即便prefetch 属性为 false 也会触发。

这两处逻辑比较简单,相关源码分别在 packages/next/client/link.tsx 的第 264 和第 294 行,就不放在这里了。

下面来看看,上面两处都调用了的 prefetch()函数到底做了什么:

// packages/next/client/link.tsx
// 38 行
function prefetch(
  router: NextRouter,
  href: string,
  as: string,
  options?: PrefetchOptions
): void {
  if (typeof window === 'undefined' || !router) return
  // 如果不是站内链接则退出
  if (!isLocalURL(href)) return
  // ...
  // 50 行:调用 router 的 prefetch 函数
  router.prefetch(href, as, options).catch((err) => {
    if (process.env.NODE_ENV !== 'production') {
      // rethrow to show invalid URL errors
      throw err
    }
  })
  // ...
}

可以看到,这里并没有写多少逻辑,而是调用了 router.prefetch 来实现 prefetch。应该说这是一种很好的解耦,把 prefetch 的具体实现细节交给相关组件去处理。

代码片段 2:router 中的 prefetch

经过一番努力,顺藤摸瓜,找到了 router 中的 prefetch 函数:

// packages/next/shared/lib/router/router.ts
// 1634 行
async prefetch(
  url: string,
  asPath: string = url,
  options: PrefetchOptions = {}
): Promise<void> {
  // ...
  // 1717 行
  await Promise.all([
    // ...
    // 1737 行
    this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route),
  ])

可以看到,这里还是没写太多逻辑,而是在 Promise.all 中执行了 this.pageLoader.loadPage() 或者 this.pageLoader.prefetch()。看来真正的 prefetch 逻辑,还要到 pageLoader.prefetch() 里去找!

代码片段 3:page-loader 中的 prefetch

找到 page-loader 的相关代码才发现,这部分逻辑竟然又委托给了另一个组件 routeLoader

// packages/next/client/page-loader.ts
// 181 行
prefetch(route: string): Promise<void> {
  return this.routeLoader.prefetch(route)
}

这段代码真的是一点额外逻辑都没有,直接透传参数,调用 routeLoader.prefetch()

代码片段 4:route-loader 中的 prefetch

找到 route-loader,这回终于看到 prefetch 的主要逻辑了:

// packages/next/client/route-loader.ts
// 426 行
prefetch(route: string): Promise<void> {
  // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
  // License: Apache 2.0
  let cn
  if ((cn = (navigator as any).connection)) {
    // Don't prefetch if using 2G or if Save-Data is enabled.
    if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
  }
  // 这里是重点,获取需要 prefetch 的文件
  return getFilesForRoute(assetPrefix, route)
    .then((output) =>
      Promise.all(
        canPrefetch
          // 这里通过 DOM 实现对文件的 prefetch
          ? output.scripts.map((script) => prefetchViaDom(script, 'script'))
          : []
      )
    )
    .then(() => {
      requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
    })
    .catch(
      // swallow prefetch errors
      () => {}
    )
},

至此,prefetch 的主要逻辑终于开始浮出水面。可以看到:

  • 首先调用 getFilesForRoute() 获取需要 prefetch 的文件;
  • 然后调用 prefetchViaDom(),通过 DOM 实现对文件的 prefetch;

下面就分别看一下这两个函数。

代码片段 5:getFilesForRoute

来看一下 getFilesForRoute 是如何实现的:

// packages/next/client/route-loader.ts
// 260 行
function getFilesForRoute(
  assetPrefix: string,
  route: string
): Promise<RouteFiles> {
  //...
  // 275 行
  // 从 __BUILD_MANIFEST 中找到当前 route 所对应的文件,然后筛选其中的 JS 和 CSS 文件返回
  return getClientBuildManifest().then((manifest) => {
    if (!(route in manifest)) {
      throw markAssetError(new Error(`Failed to lookup route: ${route}`))
    }
    const allFiles = manifest[route].map(
      (entry) => assetPrefix + '/_next/' + encodeURI(entry)
    )
    return {
      scripts: allFiles.filter((v) => v.endsWith('.js')),
      css: allFiles.filter((v) => v.endsWith('.css')),
    }
  })
}

现在终于可以回答我们的第一个问题了:prefetch 的内容究竟是什么?

答案是,prefetch 的是加载当前 route(即当前 <Link /> 组件所对应的 route)所需要的 .js.css 文件。

那怎么知道加载当前 route 需要哪些文件呢?相关信息都储存在一个叫做 __BUILD_MANIFEST 的本地变量里。这个变量是在服务端生成的,然后传递给客户端,像一本字典一样,可以让客户端查询加载特定 route 都需要向服务端请求哪些文件。__BUILD_MANIFEST 的大致结构如下:

self.__BUILD_MANIFEST = {
  "/some/route": ["link/to/file1", "link/to/file2", ...]
  ...
}

下面是一个截屏的例子(来自 https://nextjs.org/learn/basics/navigate-between-pages/link-component):
请添加图片描述
这部分相关的源码暂时就不放上来了。总之,客户端会根据 __BUILD_MANIFEST 查到需要用到的文件,然后筛选出来其中的 .js.css(其实大部分应该都是 .js.css 文件),进行 prefetch。

下面就来看看到底是如何进行 prefetch 的。

代码片段 6:prefetchViaDom

看完下面这段源码,有种恍然大悟的感觉:

// packages/next/client/route-loader.ts
// 98 行
function prefetchViaDom(
  href: string,
  as: string,
  link?: HTMLLinkElement
): Promise<any> {
  return new Promise<void>((res, rej) => {
    // 如果已经有相应的 prefetch、preload,甚至已经运行相应 script 了的话,直接返回
    const selector = `
      link[rel="prefetch"][href^="${href}"],
      link[rel="preload"][href^="${href}"],
      script[src^="${href}"]`
    if (document.querySelector(selector)) {
      return res()
    }
	// 创建相应的 prefetch link
    link = document.createElement('link')

    // The order of property assignment here is intentional:
    if (as) link!.as = as
    link!.rel = `prefetch`
    link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
    link!.onload = res as any
    link!.onerror = rej

    // `href` should always be last:
    link!.href = href

	// 把 prefetch link 添加到 html 的 head 中
    document.head.appendChild(link)
  })
}

其实原理很简单,就是把需要 prefetch 的 script 写成如下形式,添加到 html 的 head 中:

<link as="script" rel="prefetch" href="/_next/static/chunks/5938-7234fcdc963c340b.js">

来看一张真实截屏(同样来自 https://nextjs.org/learn/basics/navigate-between-pages/link-component):
请添加图片描述
果然,<head> 中有大量 rel="prefetch" 的链接!

至此,第二个问题也解开了:prefetch 的内容是如何存储的?

答案就是,通过 <link rel="prefetch"> 的方式,浏览器会请求相应的 .js.css 文件,但不会执行它们,而是作为缓存把它们储存在浏览器中。等到用户真正跳转到该 route,需要加载这些文件时,就可以直接从本地加载,不需要请求服务器了。难怪快得像是从本地加载页面一样!

其实也是一种前端提高性能的常见方法。之前做一个项目的时候,同事为了提升网页加载速度,还手写过 <link rel="prefetch"> 这样的元素。Next.js 则完全把这一步骤自动化了!

小结

对我来说,问题的答案的确是我没猜到的,但又非常合情合理。

不过,这还只是 Next.js router 的冰山一角,还有很多有趣问题的答案等待发掘。有时间继续更新!

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值