一文搞懂最强首屏渲染方案【Next.js】

4,859 阅读22分钟

阅读时间:25min


本篇文章主要针对对于Next.js有一定使用经验并且想探寻Next.js内部一些实现原理的同学进行阅读,对于好奇服务端渲染想入门Next.js的小伙伴我建议最好的方式就是跟着其官方文档的教程,花费大概半天的时间实现一个基于Next.js的小博客,这样你就能很快学会使用它并且了解它的一些关键特性,使用Next.js开发本身并不难。

对于服务端渲染本身其实已经是比较成熟的技术了,比如用java结合velocity模板文件进行服务端渲染,或者基于php进行服务端渲染,Next.js做的好的地方是将服务端渲染通过Node.js和React进行实现,对于前端来说这可以说是当前最主流的技术了,所以Next.js火也就是理所因当了,同时通过Next.js也让前端扩充了技术范围不再局限于浏览器,而是可以实现大部分的服务端工作,如果你熟悉数据库的相关知识,通过Next.js再搭配TypeORM或者Sequelize完全可以独立实现一个全栈项目,或许在未来前端基本上就可以实现大部分的软件开发工作,而后端应该更多的精力去处理架构、高并发、性能优化等更为复杂的工作。

本篇文章主要从介绍服务端渲染的大致概念、Next.js中服务端渲染的三种方式、以及其核心Link组件的实现原理、以及基于Express、React实现一个简单的服务端渲染项目来了解Next.js究竟是如何实现服务端渲染的。

1.服务端渲染介绍

在说服务端渲染前我们需要先讲一下和服务端渲染相对的一个概念,客户端渲染(CSR:Client Side Render)。

对于使用Vue或者React构建的单页面web应用,当我们打开浏览器访问页面时服务器一般只会返回一个比较简单的html模版,之后浏览器再加载相应的js并进行解析,生成dom元素将页面渲染出来。这个过程中当用户看到html模版,到页面完成渲染的过程便称之为白屏时间,根据资料如果白屏时间超过2秒便会对用户体验有一定的影响,好的网站这个时间一般要控制在1秒左右。
2828104768-61c44c17d2a05_fix732.png
对于客户端渲染还存问的题就是SEO不友好,搜索引擎获取到的只是简单的html模版,在不执行js的情况下无法获取页面的详细内容,虽然可以通过在head标签里添加内容来解决,但对实时性要求较高的博客页面也是束手无策。

解决单页面应用SEO不友好以及白屏时间较长的常用方案就是服务端渲染

服务端渲染就是页面html先在服务端渲染完成,此刻得到的一堆的html字符串,返回给浏览器后便可直接进行解析渲染,完成后用户便可看到完整的页面便大大减少了白屏时间,之后再加载js使网页可进行正常交互。同时由于服务端返回的html已经拥有较为完整的网页内容了,搜索引擎也可进行更好的SEO。
3820410511-61c44c2ade37a_fix732.png

2.Next.js常见的三种渲染方式

讲完了服务端渲染是什么后再来看看通过Next.js实现服务端渲染的三种方式:SSG、SSR、ISR。

SSG:

SSG(Static Site Generation),翻译过来是静态网站生成,我更喜欢将其称之为预渲染,也就是在项目构建时生成包含内容的html,之后将相应的html、js、css等静态资源发布到相应的CDN节点,这样当用户进行访问时页面可直接渲染便较好的性能,尤其适合公司官网等变动频率不高的静态资源。

在Next.js中,默认是支持预渲染的,如果我们没有使用类似getServerSideProps(服务端渲染函数),那么我们通过npm run build构建项目时便会自动生成一个html页面。比如以下这段代码,我们可以通过getStaticProps给组件传递一些初始参数,该方法将在构建时执行并将参数传递给组件,最终得到的html便包含组件所需要的参数。

const PostsIndex: NextPage<Props> = (props) => {
  const { posts } = props;
  console.log(posts);
  return (
    <div>
      <h1>文章列表</h1>
      {posts.map((p) => (
        <div key={p.id}>
          <Link href={`/posts/${p.id}`} as={`/posts/${p.id}`}>
            <a>{p.id}</a>
          </Link>
        </div>
      ))}
    </div>
  );
};

export default PostsIndex;

export const getStaticProps = async () => {
  const posts = await getPosts();
  return {
    props: {
      posts: JSON.parse(JSON.stringify(posts)),
    },
  };
};

最终得到的html页面类似这样,这其中便是利用React的renderToString API 将一个React组件渲染为html字符串,至于script标签包裹的json内容有什么用将在文章的后面Next.js服务端渲染原理中介绍。

...
<div id="__next">
  <div>
    <h1>文章列表</h1>
    <div><a href="/posts/第一篇博客">第一篇博客</a></div>
    <div><a href="/posts/第二篇博客">第二篇博客</a></div>
  </div>
</div>
<script id="__NEXT_DATA__" type="application/json">
  {
  "props": {
  "pageProps": {
  "posts": [
  {
  "id": "第一篇博客",
  "title": "方方的第一篇博客",
  "date": "2020-05-01T00:00:00.000Z"
  },
  {
  "id": "第二篇博客",
  "title": "方方的第二篇博客",
  "date": "2020-05-02T00:00:00.000Z"
  }
  ]
  },
  "__N_SSG": true
  },
  "page": "/posts",
  "query": {},
  "buildId": "Mci5k_tT5B7x5Zg3WnVcI",
  "nextExport": false,
  "isFallback": false,
  "gsp": true
  }
</script>
...

如果是动态路由的场景我们也可以通过利用getStaticPaths来生成多个静态页面,比如我们在pages/posts文件夹下创建以[id].js命名的文件,通过如下方法便可暴露/posts/1/posts/2两个路由,在匹配到对应的路由时在getStaticProps的参数中便可拿到getStaticPaths返回的paths中的路由对象,fallbackfalse,代表如果没有匹配到路由则返回404,比如路由为/posts/3则返回404。

// pages/posts/[id].js

// Generates `/posts/1` and `/posts/2`
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
    fallback: false, // can also be true or 'blocking'
  }
}

虽然SSG可以很好的解决页面白屏以及SEO不友好的问题,但是对于数据动态性要求较高的场景便无法满足了,比如需要根据用户的登录状态来决定页面展示什么样的数据,因为页面都是以静态资源的方式部署在CDN的所以便无法实现数据动态化。对于SSG主要适用于那些数据动态性较低的场景,比如技术文档、官网等,如果是少量数据需要进行动态化可以利用SSG+CSR客户端渲染来完成。

SSR:

SSR(Server Side Rendering)我更喜欢将其称之为服务端渲染,在Next.js中可通过在相应路由的页面中添加getServerSideProps给组件提供动态参数,该页面便会在请求到来时在服务端进行实时渲染并返回浏览器。

function Page({ data }) {
  // Render data...
}

// This gets called on every request
export async function getServerSideProps() {
  // Fetch data from external API
  const res = await fetch(`https://.../data`)
  const data = await res.json()

  // Pass data to the page via props
  return { props: { data } }
}

export default Page

与getStaticProps不同的是如果页面中添加了getServerSideProps那么在通过npm run build构建项目时只会生成React编译后的的js,不会渲染出html,因为页面对应的html只有在请求进来时才会在服务端动态渲染完成。如下图Next.js项目编译后使用○标注的便是服务端渲染页面的资源,使用●标注的便是采用预渲染页面的资源
截屏2022-09-06 09.12.37.png

虽然服务端渲染可以很好的解决预渲染数据无法动态化的问题,但由于在每次请求时代码都需要在服务端进行编译、构建,这样当请求量增多时服务端无疑会产生过多的压力,相应带来的运维成本也会增加许多。所以在使用Next.js开发项目时对于不同页面SSR、SSG、CSR要灵活选用,像网站首页这种数据多处于静态的使用SSG便好,对于文章详情页面这种需要实时更新的可以使用SSR,在首屏加载无要求的情况下使用CSR便好。

ISR:

ISR(Incremental Static Regeneration)也称之为增量渲染,其是SSG预渲染的一种补充,虽然SSG可以很好的将页面静态资源进行提前进行构建并部署到CDN来提高用户访问效率,但如果需要生成的页面过多比如有十几万个那么很难在服务端一次性生成,这时就需要使用到ISR做增量渲染。

Next.js的ISR是基于SSG进行实现的,同样是利用getStaticProps以及getStaticPaths两个Api,实现方式如下:

// pages/posts/[id].js
function Post(props) {
	const { postData } = props;
  
  return <div>{postData.title}</div>
}

export async function getStaticPaths() {
  const paths = await fetch('https://.../posts');
  return {
    paths,
    // 页面请求的降级策略,这里是指不降级,等待页面生成后再返回,类似于 SSR
    fallback: 'blocking'
  }
}

export async function getStaticProps({ params }) {
  // 使用 params.id 获取对应的静态数据
  const postData = await getPostData(params.id)
  return {
    props: {
      postData
    },
    // 开启 ISR,最多每10s重新生成一次页面
    revalidate: 10,
  }
}

具体流程就是在构建时和SSG一样先根据getStaticPaths返回的paths路由数组生成页面,当用户请求的路由命中时便返回已经在服务端预构建好的页面,revalidate设置为10代表在十秒后页面将在服务端进行重新构建并进行缓存,这样当下次请求进来用户将得到最新的页面。

如果没有命中路由将有两种选择,fallback设置为'blocking'代表页面不降级,也就如果没有命中路由接下来的流程将和服务端渲染一样,首先在服务端获取页面所需的数据,之后通过renderToString构建生成页面并返回浏览器,直到页面在服务端渲染完成,请求会暂时被阻塞住。如果fallback设置为true代表页面降级,当请求来时服务端会立刻渲染出不包含实时数据的html返回给浏览器,之后直到服务端获取到页面的实时数据后再通过json文件的方式返回给浏览器,Next.js会将json文件里的数据和当前浏览器渲染出的页面进行更新重新渲染,用户便可得到完整的页面。

ISR个人理解其主要用在对于一些用户的访问量不那么多的场景,比如有一个很长的列表,前几项是默认展示的用户的访问量也较多,点击后进入的详情页可以用SSG进行预构建,而列表剩下的访问量不太多的数据可以在请求来时再在服务端进行渲染,至于是降级还是不降级可以根据具体场景来看,大多数时候不降级会好一点,因为不会看到页面因为重新渲染有一定的抖动对用户体验会更好的一点。

总结:

以上就是使用Next.js进行服务端渲染的介绍了,这里想说一点的是对于Next.js核心就是用来进行服务端渲染的一个框架,而服务端渲染本质上就是用来解决网站SEO不友好以及白屏加载的问题,属于网站的性能优化的一小部分,对于大多数网站,使用SSG配合客户端渲染就足以,比如淘宝PC端阿里国际站首页,但对数据实时性要求较高的场景,比如知乎首页、segmentfault首页、掘金文章详情这样的博客系统类型网站都是使用SSR进行服务端渲染来实现。总的来说个人认为浏览器在渲染一个网页时要处理大量的线程,将渲染工作放在服务端对比客户端渲染性价比会更高一点。

3.Link组件原理解析

讲完了Next.js的一些核心概念后,再带大家一起看看Next.js实现原理的一些具体细节,如果Next.js仅仅是将Node.js与React进行结合那么想必也不会这么火,接下来就从Next.js的核心Link组件实现原理开始和大家一起再进一步的探寻Next.js。
在官网中推荐的使用方式是如下这样:

import Link from 'next/link'

function Home() {
  return (
    <ul>
      <li>
        <Link href="/">
          <a>Home</a>
        </Link>
      </li>
      <li>
        <Link href="/about">
          <a>About Us</a>
        </Link>
      </li>
      <li>
        <Link href="/blog/hello-world">
          <a>Blog Post</a>
        </Link>
      </li>
    </ul>
  )
}

export default Home

接下来我们来观察一下在使用Link组件后页面是如何进行跳转的,按照我们的推测,Next.js既然是进行服务端渲染,那么通过Next.js生成的每个页面应该都具有对应的html文件,当页面进行跳转时浏览器便会从服务器获取新的html页面并进行重新渲染,那么真实情况是不是这样呢?

拿Next.js的首页(当然其是通过Next.js开发的)来举例:
截屏2022-09-06 16.37.11.png

通过浏览器的控制台发现其加载了nextjs.org这个html文件,接下来当我们点击Start Learning这个按钮时页面应该会跳转到对应的文档页面并加载新的html。
截屏2022-09-06 16.45.28.png
当我们点击后奇怪的事情发生了,页面确实进行了跳转但并没有加载任何新的html文件,但是当我们重新刷新页面:
截屏2022-09-06 16.47.27.png
可以发现浏览器又加载了一个名为about-nextjs的html文件,这是为什么呢?Next.js不是进行服务端渲染的吗,怎么在页面跳转时和React的单页面应用一样没有加载任何的html文件,而当页面重新加载时又会加载新的html文件?

这里先说结论:Link组件最终会在页面上被渲染为一个带href属性的a标签,在浏览器可以运行js的情况下当我们点击该a标签时,点击事件会被监听,首先会通过e.preventDefault()禁用其默认跳转事件,之后Next.js会通过js渲染新的组件更新dom节点来完成页面的跳转工作,本质上和React的单页面应用跳转逻辑一样,而当搜索引擎爬取该页面时在无法执行js的情况下也可以根据a标签的href属性值进行跳转爬取其他页面,真是一箭双雕的解决方案!

Next.js生成的页面在进行跳转时还是通过类似单页面应用的形式卸载并重新渲染dom节点的方式完成,很好的解决了传统服务端渲染方案因为页面切换需要重新加载页面而带来不好的用户体验,让服务端渲染页面如单页面应用般丝滑切换,我想这也是Next.js可以如此火的原因之一,它不仅仅是一个进行服务端渲染的框架那么简单,而更像是一套全新的服务端渲染方案。同时接下来还会介绍Link组件的预加载功能,结合Link组件可以说Next.js在首屏加载上做到了一个框架所能做的全部。

源码分析:

接下来带大家从源码的角度看看Next.js具体是如何实现跳转的,核心代码next.js/packages/next/client/link.tsx下大家可以自行查看。

从第156行开始便是Link组件的核心逻辑之后是一些ts相关的处理我们暂且不关心,从第280行开始首先从Link组件的props中解构出一些参数,我们可以看到解构得到的childrenProp就是被Link组件所包裹的子组件,如果其未数字、或者字符类型,Next.js会自动帮其包裹一个a标签。

.........278let children: React.ReactNode

    const {
      href: hrefProp,
      as: asProp,
      children: childrenProp,
      prefetch: prefetchProp,
      passHref,
      replace,
      shallow,
      scroll,
      locale,
      onClick,
      onMouseEnter,
      onTouchStart,
      legacyBehavior = Boolean(process.env.__NEXT_NEW_LINK_BEHAVIOR) !== true,
      ...restProps
    } = props

    children = childrenProp

    if (
      legacyBehavior &&
      (typeof children === 'string' || typeof children === 'number')
    ) {
      children = <a>{children}</a>
    }
.........

之后从第335行开始通过React.Children.only方法验证children必须只有一个子节点否则抛出错误,因为后续会对children的子节点添加一些属性和事件,如果其拥有多个子节点那么添加在哪个上?

    let child: any
    if (legacyBehavior) {
      if (process.env.NODE_ENV === 'development') {
        if (onClick) {
          console.warn(
            `"onClick" was passed to <Link> with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onClick be set on the child of next/link`
          )
        }
        if (onMouseEnter) {
          console.warn(
            `"onMouseEnter" was passed to <Link> with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onMouseEnter be set on the child of next/link`
          )
        }
        try {
        // children必须只有一个子节点
          child = React.Children.only(children)
        } catch (err) {
          if (!children) {
            throw new Error(
              `No children were passed to <Link> with \`href\` of \`${hrefProp}\` but one child is required https://nextjs.org/docs/messages/link-no-children`
            )
          }
          throw new Error(
            `Multiple children were passed to <Link> with \`href\` of \`${hrefProp}\` but only one child is supported https://nextjs.org/docs/messages/link-multiple-children` +
              (typeof window !== 'undefined'
                ? " \nOpen your browser's console to view the Component stack trace."
                : '')
          )
        }
      } else {
        child = React.Children.only(children)
      }
    }

之后来到了408行,其定义了childProps这个对象,该对象拥有onTouchStartonMouseEnteronClickhrefref四个属性,我们先不关心这些属性的具体内容,先来看看childProps这个对象是用来干什么的。

    const childProps: {
      onTouchStart: React.TouchEventHandler
      onMouseEnter: React.MouseEventHandler
      onClick: React.MouseEventHandler
      href?: string
      ref?: any
    } = {
      ....
    }

从第491行开始,我们也可以根据注释看到如果child是一个a标签,并且其没有href属性,Next.js会给其添加一个href属性,而localeDomain个人猜测便是站内链接,和传递给Link组件的href属性一致,因为最终Link组件确实是被渲染为一个带href属性的a标签。

这里有一点要补充的是这里的legacyBehavior字段的值是在294行定义的一个全局变量:Boolean(process.env.__NEXT_NEW_LINK_BEHAVIOR)!== true,找遍Next.js发现并没有找到关于该属性的值,这里只能个人猜测legacyBehavior是代表是否要启动Link组件的跳转功能这里的值应该是true,如果有找到同学可以告诉我一下。


    // If child is an <a> tag and doesn't have a href attribute, or if the 'passHref' property is
    // defined, we specify the current 'href', so that repetition is not needed by the user
    if (
      !legacyBehavior ||
      passHref ||
      (child.type === 'a' && !('href' in child.props))
    ) {
      const curLocale =
        typeof locale !== 'undefined' ? locale : router && router.locale

      // we only render domain locales if we are currently on a domain locale
      // so that locale links are still visitable in development/preview envs
      const localeDomain =
        router &&
        router.isLocaleDomain &&
        getDomainLocale(as, curLocale, router.locales, router.domainLocales)

      childProps.href =
        localeDomain ||
        addBasePath(addLocale(as, curLocale, router && router.defaultLocale))
    }
    return legacyBehavior ? (
      React.cloneElement(child, childProps)
    ) : (
      <a {...restProps} {...childProps}>
        {children}
      </a>
    )

之后这里的逻辑就清晰了,最终Link组件的child会被拷贝一份,同时增加我们之前在childProps对象中定义的相关属性,被最终被渲染出来,那么接下来我们就仔细看看childProps这个对象究竟做了什么事?

我们可以看到childProps定义了onClick方法也就是最终Link组件的子组件child的onClick方法会被重写。

上面我们已经说过了legacyBehavior的值为true,我们可以看到在如下代码的21行,如果Link组件的子组件child存在onClick方法的话那么便会先执行该方法,之后在!e.defaultPrevented的情况下便会执行linkClicked方法,那么e.defaultPrevented属性是什么呢?根据查MDN我们可以得知其:

返回一个布尔值,表明当前事件是否调用了 event.preventDefault()方法。

也就是在child原生的onClick事件没有禁止点击的默认事件时linkClicked方法便会被执行,一般情况是不会给Link组件a标签添加onClick事件的,所以大概率linkClicked会被执行。

    const childProps: {
      onTouchStart: React.TouchEventHandler
      onMouseEnter: React.MouseEventHandler
      onClick: React.MouseEventHandler
      href?: string
      ref?: any
    } = {
      ref: setRef,
      onClick: (e: React.MouseEvent) => {
        if (process.env.NODE_ENV !== 'production') {
          if (!e) {
            throw new Error(
              `Component rendered inside next/link has to pass click event to "onClick" prop.`
            )
          }
        }

        if (!legacyBehavior && typeof onClick === 'function') {
          onClick(e)
        }
        if (
          legacyBehavior &&
          child.props &&
          typeof child.props.onClick === 'function'
        ) {
          child.props.onClick(e)
        }
        if (!e.defaultPrevented) {
          linkClicked(
            e,
            router,
            href,
            as,
            replace,
            shallow,
            scroll,
            locale,
            appRouter ? startTransition : undefined,
            p
          )
        }
      },
      onMouseEnter: (e: React.MouseEvent) => {
      	.......
      },
      onTouchStart: (e: React.TouchEvent<HTMLAnchorElement>) => {
        .......
    }

最后关于linkClicked方法的逻辑,如下代码18行如果Link子组件a标签的href属性是外链接(就是在跳转到其他网站)那么,便会直接跳转到其他网站,如果是内链接首先会禁用a标签的跳转事件(23行),并且执行navigate方法也就是调用router方法切换相应的路由。startTransition是React18的新特性,应该是Next.js为了兼容React18进行的一些优化,这里不详细探讨了。

function linkClicked(
  e: React.MouseEvent,
  router: NextRouter | AppRouterInstance,
  href: string,
  as: string,
  replace?: boolean,
  shallow?: boolean,
  scroll?: boolean,
  locale?: string | false,
  startTransition?: (cb: any) => void,
  prefetchEnabled?: boolean
): void {
  const { nodeName } = e.currentTarget

  // anchors inside an svg have a lowercase nodeName
  const isAnchorNodeName = nodeName.toUpperCase() === 'A'

  if (isAnchorNodeName && (isModifiedEvent(e) || !isLocalURL(href))) {
    // ignore click for browser’s default behavior
    return
  }

  e.preventDefault()

  const navigate = () => {
    // If the router is an NextRouter instance it will have `beforePopState`
    if ('beforePopState' in router) {
      router[replace ? 'replace' : 'push'](href, as, {
        shallow,
        locale,
        scroll,
      })
    } else {
      // If `beforePopState` doesn't exist on the router it's the AppRouter.
      const method: keyof AppRouterInstance = replace ? 'replace' : 'push'

      router[method](href, { forceOptimisticNavigation: !prefetchEnabled })
    }
  }

  if (startTransition) {
    startTransition(navigate)
  } else {
    navigate()
  }
}

最后还需要介绍一下的就是Link组件的prefetch预渲染了,通过查看Link组件的代码我们可以发现其分别在useEffect中、Link组件onMouseEnter的回调中onTouchStart的回调中,调用了prefetch方法,那么该方法有什么用呢?这里还是先说结论:prefetch方法会分别在以上对应的回调里提前加载Link组件href属性对应页面所需要的js、css资源,这样当Link组件对应的链接被点击时所跳转的页面会立刻被渲染出来,而不需要实时请求加载相应资源。接下来时具体的源码分析。

我们可以发现在Link组件的第77行其调用了router.prefetch方法接下来我们就去查看router.prefetch方法具体做了什么?

在很长时间的查找后,在next.js/packages/next/shared/lib/router/router.ts文件的第2208行我们找到了在router.ts中定义的prefetch方法。我们直接看第2315行,其通过一个Promise.all方法加载了this.pageLoader._isSsg方法和this.pageLoader方法,我们核心关注this.pageLoader.prefetch方法。

......
	await Promise.all([
      this.pageLoader._isSsg(route).then((isSsg) => {
        return isSsg
          ? fetchNextData({
              dataHref: this.pageLoader.getDataHref({
                href: url,
                asPath: resolvedAs,
                locale: locale,
              }),
              isServerRender: false,
              parseJSON: true,
              inflightCache: this.sdc,
              persistCache: !this.isPreview,
              isPrefetch: true,
              unstable_skipClientCache:
                options.unstable_skipClientCache ||
                (options.priority &&
                  !!process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE),
            }).then(() => false)
          : false
      }),
      this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route),
    ])
.......

接下来跳转到next.js/packages/next/client/page-loader.ts文件下,我们可以看到最后第171行定义了prefetch方法,其又去调用了routeLoader.prefetch方法。

......
	prefetch(route: string): Promise<void> {
    return this.routeLoader.prefetch(route)
  }
......

之后继续去next.js/packages/next/client/route-loader.ts文件中进行查找,我们可以发现在第407行定义了prefetch方法,在如下的第9行中调用了getFilesForRoute来获取所有需要加载文件的路径,在第14行调用了prefetchViaDom方法。

    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()
      }
      return getFilesForRoute(assetPrefix, route)
        .then((output) =>
          Promise.all(
            canPrefetch
              ? output.scripts.map((script) =>
                  prefetchViaDom(script.toString(), 'script')
                )
              : []
          )
        )
        .then(() => {
          requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
        })
        .catch(
          // swallow prefetch errors
          () => {}
        )
    }

最后的关键便在next.js/packages/next/client/route-loader.ts文件下的prefetchViaDom方法中,在第99行

function prefetchViaDom(
  href: string,
  as: string,
  link?: HTMLLinkElement
): Promise<any> {
  return new Promise<void>((res, rej) => {
    const selector = `
      link[rel="prefetch"][href^="${href}"],
      link[rel="preload"][href^="${href}"],
      script[src^="${href}"]`
    if (document.querySelector(selector)) {
      return res()
    }

    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

    document.head.appendChild(link)
  })
}

看到这里我想大家应该都明白了,在第7行是查找html中是否已经插入过相同的link标签,如果已有直接返回,否则在第15行先创建一个新的link节点,之后给其添加as、rel、onload、onerror、href属性,最关键的是**link!.rel = prefetch****设置link的rel属性为prefetch。**最终生成的link标签如下:

<link rel="preload" href="/_next/static/development/pages/posts.js?ts=1662551949498" as="script">

当我们给link标签设置rel="preload"属性时在html解析时对应的资源会被加载,但是在加载完成后并不会执行,而是在需要的时候再被执行,这样就很好的通过预加载的方式在当前页面加载时便可将其他页面所需要的资源进行加载,当需要跳转其他页面时便可进行无缝跳转。所以Next.js可以说是将首屏加载做到了极致了,如果再需要对页面进行相应的优化只能从具体业务的角度,通过火焰图、资源的加载顺序调整、利用缓存等方式对业务进行针对性的优化了。

这里最后还需要补充一点的是Next.js是如何通过类似/posts这样的路由而加载到该页面对应的资源的呢?不知道大家是否还记得在next.js/packages/next/client/route-loader.ts文件中还存在一个getFilesForRoute方法,该方法最终将页面对应资源的href属性传递给了prefetchViaDom方法,那么getFilesForRoute是如何拿到该页面路由对应的资源的呢?我们可以继续去看在第255行其调用了一个getClientBuildManifest方法,其是从self.__BUILD_MANIFEST这个变量中拿到了路由页面的资源地址:

export function getClientBuildManifest() {
  if (self.__BUILD_MANIFEST) {
    return Promise.resolve(self.__BUILD_MANIFEST)
  }

  const onBuildManifest = new Promise<Record<string, string[]>>((resolve) => {
    // Mandatory because this is not concurrent safe:
    const cb = self.__BUILD_MANIFEST_CB
    self.__BUILD_MANIFEST_CB = () => {
      resolve(self.__BUILD_MANIFEST!)
      cb && cb()
    }
  })

  return resolvePromiseWithTimeout(
    onBuildManifest,
    MS_MAX_IDLE_DELAY,
    markAssetError(new Error('Failed to load client build manifest'))
  )
}

我们尝试在网页发送的请求中寻找果然发现了self.__BUILD_MANIFEST变量对应的js文件:
截屏2022-09-07 22.17.26.png
最终在_buildManifest.js文件中找到了self.__BUILD_MANIFEST这个变量,可以发现所有页面路由对应的资源地址都在这个js文件里,包括js、css,这样就真相大白了,页面路由对应的资源文件地址会在构建时保存在_buildManifest.js文件里并发送给前端,前端在通过Link组件预加载时会从self.__BUILD_MANIFEST中取出相应路由对应的资源地址作为link标签的href属性进行预加载。

总结:

对于Link组件其核心就是如果其子节点是一个没有href属性的a标签那么Link组件最终便会渲染为该a标签并加上href属性,同时a标签的onClink属性将被重写,在该标签被点击时首先会执行该a标签自己的onClink方法,之后会通过e.preventDefault()禁用a标签的默认跳转事件,并切换页面将页面进行重新渲染。

其次就是在Link组的useEffect生命周期中、和onMouseEnter事件中,会执行prefetch方法,对Link组件所要跳转页面所需资源进行预加载,核心就是使用link标签设置rel属性值为preload,对资源进行预加载。

4.Next.js服务端渲染原理

在讲完了Link组件的相关实现原理后再来通过一个小项目带大家看看Next.js究竟是如何完成SSR服务端渲染的,该项目是基于React、Express、React-redux实现的一个服务端渲染项目,通过该项目大家也能窥探到Next.js实现服务端渲染的原理,对Next.js有一个更加深入的了解。

地址:见此

要实现服务端渲染首先我们需要利用React构建我们的项目,我们暂且实现Home与Login两个页面,同时还需要一个Head组件作为导航用。

组件实现:

Home组件如下:

import React, { Component } from "react";
import Header from "../../components/Header";
import { connect } from "react-redux";
import { getHomeList } from "./store/actions";

class Home extends Component {
  constructor(props) {
    super(props);
    this.state = { date: new Date() };
  }

  getList() {
    const { list } = this.props;
    return list.map((item) => <div key={item.id}>{item.name}</div>);
  }

  // 在服务端渲染时这个方法不会被执行
  componentDidMount() {
    this.props.getHomeList();
  }

  render() {
    return (
      <div>
        <Header />
        {this.state.date.toLocaleDateString()}
        {this.getList()}
        <button
          onClick={() => {
            alert("click1");
          }}
        >
          click
        </button>
      </div>
    );
  }
}

Home.loadData = (store) => {
  // 该方法在服务端执行,在请求来时通过dispatch相应的action在服务端初始化store
  // 实现组件数据更新
  return store.dispatch(getHomeList());
};

const mapStateToProps = (state) => ({
  list: state.home.newsList,
});

const mapDispatchToProps = (dispatch) => ({
  // 该方法在浏览器中执行,在页面渲染完成后通过dispatch相应的action在浏览器初始化store
  // 实现组件数据更新
  getHomeList() {
    dispatch(getHomeList());
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(Home);

我们知道Next.js的服务端渲染会在我们请求网页时发送请求获取数据,之后将实时的数据填充在组件中从而渲染出包含内容的html。在40行中我们可以看到Home组件依赖的数据源是从全局store(state.home.newsList)中拿到。而全局store中的数据是通过getHomeList这个action进行影响,也就是说如果我们想要实现Home组件数据的动态更新在组件内部可以通过调用props中的getHomeList方法来完成,而在组件外部可以通过调用组件的loadData方法来完成。

这样服务端渲染方案就很明显了,我们知道实现一个React组件的服务端渲染只需要调用React提供的renderToString方法便会得到被渲染为的html字符串的组件。

ReactDOMServer.renderToString(element)

Home组件的渲染结果类似这样:

      <div data-reactroot="">
          <div>
            <a href="/">Home</a>
            <br />
            <a href="/login">Login</a>
          </div>
          2022/9/9<div>Lily</div>
          <div>Tom</div>
          <div>Jerry</div>
          <button>click</button>
      </div>

要进行服务端渲染我们只需要在服务端收到请求后调用Home组件的loadData方法来触发全局的store更新,这样在Home组件中就能拿到最新的数据,服务端也就可以渲染出包含内容的html。

服务端渲染接口实现:

服务端处理请求的具体代码如下所示:

import express from "express";
import { matchRoutes } from "react-router-config";
import { render } from "./utils";
import { getStore } from "../store";
import routes from "../Routes";

const app = express();
// 利用中间件将public文件作为根目录,这里的public文件下的index.js就是clint文件夹下的index.js
app.use(express.static("public"));

app.get("*", function (req, res) {
  const store = getStore();
  // 根据路由的路径,来往store里面加数据
  const matchedRoutes = matchRoutes(routes, req.path);
  // 让matchRoutes里面所有的组件,对应的loadData方法执行一次
  const promises = [];
  matchedRoutes.forEach((item) => {
    if (item.route.loadData) {
      promises.push(item.route.loadData(store));
    }
  });
  Promise.all(promises).then(() => {
    res.send(render(store, routes, req));
  });
});

var server = app.listen(3000);

首先我们启动一个端口为3000的服务器,在请求进来时根据react-router提供的matchRoutes来匹配对应的路由,其中routes的内容如下:

import Home from './containers/Home';
import Login from './containers/Login';

export default [
	{
		path: '/',
    component: Home,
    loadData: Home.loadData,
    exact: true,
    key: 'home'
  }, {
		path: '/login',
    component: Login,
    exact: true,
    key: 'login'
  }
];

如果我们请求的req.path/目录得到的matchedRoutes如下(其实就是导出的routes对应的第一个路由):

[
  {
    route: {
      path: '/',
      component: [Function],
      loadData: [Function (anonymous)],
      exact: true,
      key: 'home'
    },
    match: { path: '/', url: '/', isExact: true, params: {} }
  }
]

接下来在第19行会调用Home组件的loadData方法去dispatch相应action来更新全局的store,在store更新后将通过render方法来渲染组件,具体代码如下:

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, Route } from "react-router-dom";
import { Provider } from "react-redux";

export const render = (store, routes, req) => {
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path} context={{}}>
        <div>
          {routes.map((route) => (
            <Route {...route} />
          ))}
        </div>
      </StaticRouter>
    </Provider>
  );
  return `
			<html>
				<head>
					<title>ssr</title>
				</head>
				<body>
					<div id="root">${content}</div>
					<script>window.context = {
						state: ${JSON.stringify(store.getState())}
					}</script>
					<script src='/index.js'></script>
				</body>
			</html>
	  `;
};

可以看到这里使用了react-dom/server中的renderToString方法,该方法就不过多介绍了,需要补充的是React还提供了一个服务端渲染方法为renderStaticMarkup,该方法的功能和renderToString类似不同的是renderStaticMarkup方法渲染出的html节点上不会增加类似data-react*这样的属性。

该属性的作用是当在服务端渲染的html在前端进行水合时data-react*属性会帮助React避免重复的节点渲染而只去给节点绑定一些事件,而通过renderStaticMarkup方法渲染出的html生成的dom节点在前端进行水合时会完全进行重新渲染,这样在用户看来可能页面会突然抖动一下,但是在服务端渲染时因避免了多余的属性会有更好的性能,两者可以根据具体场景灵活选用。

最后在render方法中还有两个需要关注的地方就是在返回给浏览器的html中还添加了组件依赖的state和一个index.js文件,那么这两个有什么作用呢?接下来在前端水合部分来好好分析一下。

					<script>window.context = {
						state: ${JSON.stringify(store.getState())}
					}</script>
					<script src='/index.js'></script>

前端水合:

说完了服务端渲染流程再来看看服务端渲染完成的html究竟是如何在前端进行水合的,话不多说直接上代码:

import React from "react";
import ReactDom from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import routes from "../Routes";
import { getClientStore } from "../store";
import { Provider } from "react-redux";

const store = getClientStore();

const App = () => {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <div>
          {routes.map((route) => (
            <Route {...route} />
          ))}
        </div>
      </BrowserRouter>
    </Provider>
  );
};

ReactDom.hydrate(<App />, document.getElementById("root"));

核心是第8行const store = getClientStore();可以看到在前端对代码进行水合前会重新初始化一下store,而getClientStore是什么呢?

import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';

const reducer = combineReducers({
	home: homeReducer
});

// 使用函数,避免共用一个实例
export const getStore = () => {
	return createStore(reducer, applyMiddleware(thunk));
}

// 客户端使用的store
export const getClientStore = () => {
	const initialState = window.context.state;
	return createStore(reducer, initialState, applyMiddleware(thunk));
}

在第15行可以看到当clientStore进行初始化时,初始值便是window.context.state,也就是在浏览器中进行水合时当前页面组件所依赖store的初始值就是我们在服务端渲染时在html中插入的在请求进来时所生成的服务端state。

client/index.js文件的最后一行ReactDom.hydrate(<App />, document.getElementById("root"));就是通过ReactDomhydrate方法将服务器生成的浏览器渲染出的dom进行一次“水合”,这里的水合核心做两件事:比对前端渲染和服务端渲染的内容是否一致如果不一致则会报错,第二件是就是给相应的dom元素添加相应的事件监听。这便是水合的全过程,有一点需要注意的是:前端水合所用的组件和服务端渲染的组件必须完全一致的,如果不一致React便会进行报错。

最后还有一点,不知道大家是否有注意,在Next.js中当我们通过Link前端切换路由时浏览器会发送一个请求来获取一个json文件,这个文件中的内容就是当前组件所依赖的props,为什么我们需要发送一个请求来获取当前页面所依赖的props?
截屏2022-09-09 20.46.00.png
答案也很简单,在Next.js中当我们通过Link跳转到其他页面时其实并不会重新加载html进行渲染,而是像React的单页面直接通过js卸载相应的节点渲染新的组件来实现页面切换的效果,而新组件所依赖的初始化state就是从浏览器所索取到的json文件中来。这里还有一个小细节就是最初我们声明Home组件时,在其componentDidMount生命周期函数里我们调用了getHomeList来获取页面所依赖的state,该方法只会在组件在浏览器中进行渲染时执行其实也就是模拟Next.js在跳转页面时发送请求获取json文件来初始化state的方式。

最后一个点就是Home组件在进行服务端渲染时在html中插入的index.js文件是什么?答案就在上面就是编译后的client/index.js文件,也就是前端对dom进行水合的核心逻辑。到这里使用React进行服务端渲染的所有逻辑就全部解释清楚了,我们来通过一个流程图进行一个总结:

最后:

该篇文章是我学习Next.js的一个总结,也许很多地方讲的还不够好但是如果你能将该篇文章所讲的大多数内容理解清楚那么对于服务端渲染以及Next.js都还是有一个比较不错的了解的,同时如果你在阅读该篇文章的时候有困惑的时候也欢迎微信和我交流,如果你觉得这篇文章不错也期待你点个赞或者在朋友圈分享一下,希望他能帮助到更多人!

在写完该篇文章后还有以下几点是该篇文章没有涉及到的地方,个人也还没很好的搞明白,希望以后有时间再探究探究:

  1. 当我们调用ReactDom.hydrate方法时究竟发生了什么,React究竟是如何进行水合的,这个详细的过程是什么样的?
  2. 在Link组件中,我们去切换页面并渲染新的组件的过程究竟是什么样的?
  3. React-router的原理究竟是怎么样的,我之前只知道大概是基于popstate、replaceState

这样的事件去实现的但具体还没有总结过,后续有机会补上这一块。

最后也欢迎大家关注【南橘前端】让我们一起成长!
qrcode_for_gh_e830c123c9ba_258.jpeg

致谢:

感谢深蓝一人提供的《React SSR 原理梳理》这篇好文章