next14中流式(异步)渲染实现原理和优雅的使用骨架屏

189 阅读13分钟

背景

最近在学习nextjs,在做实战demo的时候,关于next中的流式(异步)渲染以及如何优雅的使用骨架屏有一些心得,下面给大家分享一下。

创建next项目

找到合适的目录,使用下面命令:

npx create-next-app@latest

除了第一个输入自己项目名称外,其他都用默认就行了。项目创建成功后会自动使用npm安装依赖,如果想用pnpm安装,可以手动给终止掉,然后自己使用pnpm安装依赖。

网站性能指标

因为下面要用到网站性能指标来体现使用流式渲染带来的好处,所以先让大家了解一下如何衡量一个网站的用户体验好坏。

网站性能指标主要包括:

  1. First Paint (FP):首次绘制,即浏览器开始绘制页面的任何部分的时间。

  2. First Contentful Paint (FCP):首次内容绘制,即浏览器首次绘制文本、图像、非空白 canvas 或 SVG 的时间。

  3. Largest Contentful Paint (LCP):最大内容绘制,反映用户看到的最大页面内容元素渲染完成的时间。

  4. Time to Interactive (TTI):可交互时间,表示页面可被完全交互(响应用户输入)的时间。

  5. Total Blocking Time (TBT):阻塞总时间,表示在 FCP 和 TTI 之间,页面处于不可交互状态的累计时间。

  6. Cumulative Layout Shift (CLS):累积布局偏移,用来度量视觉稳定性,即页面在加载过程中,视觉内容发生意外移动的程度。

  7. Speed Index (SI):速度指数,反映出页面的视觉加载速度。

  8. Onload event:当一个网页上所有的元素(如图片、脚本等)都已经加载完毕时所记录的时间。

上面这些指标,我们平时关注比较多的是FCP、LCP、TTI,缩短他们的时间可以有效的提高用户体验。

我们可以使用一个工具来测试一个网站的这些指标,下面我以Google浏览器为例,测试一下掘金首页的性能。

访问juejin.cn/后,打开控制台,找到Lighthouse选项卡,然后点击Analyze page load按钮,开始分析网页。

image.png

分析结束后,会给出一个评分,满分100分,77分是一个不太好的得分。

image.png

下面还可以看到诊断结果,可以根据诊断结果去做优化

image.png

一次结果可能因为一些原因导致结果不准确,可以多测量几次,点击左上角的加号,可以开始一个新的分析

image.png

image.png

还有一种方式可以获取到这些信息,使用performance也可以。

image.png

点击这些图标也能看到一些性能指标

image.png

个人感觉,使用performanceLighthouse更准确一点,这个下面会说。

实战例子

前言

废话不多说,先实现一个功能,让大家看一下使用流式渲染和未使用流式渲染的区别。

实现的功能要求页面分为左右两部分,左边为导航栏和右边为具体页面内容,导航栏是静态的,页面内容根据路由变化而变化。

不使用流式渲染

改造layout.tsx组件

//src/app/layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Link from 'next/link';
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <div className='flex'>
          <div className='w-[256px] p-[20px]'>
            <ul>
              <li>
                <Link href='/'>首页</Link>
              </li>
              <li>
                <Link href='/about'>关于</Link>
              </li>
            </ul>
          </div>
          <div className='flex-1'>
            {children}
          </div>
        </div>
      </body>
    </html>
  );
}

layout组件是next内置的一个组件,表示所有页面都会渲染这个组件。layout中添加加了两个路由,一个是首页,另外一个是关于页面。

Link组件可以理解为a标签,可以跳转路由。

改造首页page.tsx代码

// src/app/page.tsx
// 模拟获取数据比较慢的情况
function getData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello');
    }, 3000);
  })
}

export default async function Home() {
  // next14 app router模式可以在服务器组件组件中直接获取数据,不用使用getServerSideProps方法了。
 const data = await getData();

  return (
    <div>{data}</div>
  )
}

首页模拟了从数据库查询数据慢的情况

添加about/page.tsx文件

// src/app/about/page.tsx

export default function About() {
  return (
    <div>about</div>
  )
}

效果展示

使用npm run dev启动项目,使用Lighthouseperformance工具查看网站性能指标。

可以看到页面会一直卡着,用户体验很差。

Lighthouse分析的数据

image.png

performance分析的数据

image.png

可以看出Lighthouse给出的FCP和LCP的数据是有问题的,它去除了浏览器请求html的时间,我查了一些资料没查出来原因,有知道原因的,可以告知一下。

performance里的数据是对的,FP、FCP、LCP都是3.08s,也就是说用户打开网页到看到内容最少也要3.08s,这对于用户来说是不能接受的,右侧页面中请求数据比较慢,渲染慢还可以理解,因为ssr渲染就是这样的,后端渲染出完整的html再一起返回给前端。但是左侧的导航栏是静态的应该要先渲染出来,让用户可以正常操作,比如切换页面等。

客户端渲染

针对上面问题,第一个优化方案是客户端渲染来解决,在前端请求数据,然后渲染。

在对外提供一个获取数据的接口,next14中可以直接写接口,文件名是route.ts就行。

// src/app/api/data/route.ts

// 延迟函数
async function delay(ms: number) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

// 对外暴露GET请求
export async function GET() {
  await delay(3000);

  // 三秒后返回hello
  return new Response('hello', {
    status: 200
  })
}

通过http://localhost:3000/api/data这个url可以请求接口,/api/data是route.ts文件的文件路径。

改造page.tsx文件,改造成客户端组件

// src/app/page.tsx
'use client'

import { useEffect, useState } from 'react';

export default function Home() {
  const [data, setData] = useState('');

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.text())
      .then(data => {
        setData(data);
      })
  }, []);


  return (
    <div>{data ? data : 'loading...'}</div>
  )
}

客户端组件,文件顶部必须写上use client,不然使用useStateuseEffect这些hook会报错。

这时候再看FCP只需要144.11毫秒了

image.png

访问页面会立马显示内容,并且也不影响切换页面,用户体验很好

Kapture 2024-02-29 at 21.15.38.gif

使用这种方案确实可以提高用户体验,但是也失去了,我们使用next框架的意义。使用next框架,就是想用它的ssr,服务端渲染做seo优化,所以这个方案可以放弃了。

使用服务端渲染还有一个好处,假如我们页面需要用到dayjs库来处理日期。

服务端组件改造

import dayjs from 'dayjs';

// 模拟获取数据比较慢的情况
function getData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello');
    }, 3000);
  })
}

export default async function Home() {
  // next14 app router模式可以在服务器组件组件中直接获取数据,不用使用getServerSideProps方法了。
 const data = await getData();

  return (
    <div>{data}{dayjs().format("YYYY-MM-DD")}</div>
  )
}

看一下使用服务端组件时候,访问首页,请求的内容

image.png

再看一下返回的html内容

image.png

在客户端组件使用dayjs

'use client'

import dayjs from 'dayjs';
import { useEffect, useState } from 'react';

export default function Home() {
  const [data, setData] = useState('');

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.text())
      .then(data => {
        setData(`${data} ${dayjs().format("YYYY-MM-DD")}`);
      })
  }, []);


  return (
    <div>{data ? data : 'loading...'}</div>
  )
}

看一下请求,多了一个page.js文件,里面存放的是上面客户端组件的内容

image.png

再看一下返回的html,里面只有开始占位的loading...

image.png

再看一下page.js里面有啥,dayjs库也返回了。

image.png

不使用dayjs库时,page.js文件会小一些,因为不用返回dayjs库了。

image.png

大家应该明白了吧,服务端渲染会在服务端提前使用三方工具把数据处理好,然后返回。客户端渲染会在客户端组件中调用第三方工具处理数据,所以需要把三方库一起返回给前端。如果和上面例子一样的场景,这样会白白多传输了10多K内容,并且还会多一个js文件。所以使用服务端渲染还能减少传输内容,同时减少传输时间。

流式渲染

代码实现

那上面问题没办法解决了吗?有的,本文的主角出现,那就是流式渲染。

把page.tsx的内容抽出来封装成单独组件

// src/app/components/Test.tsx
import dayjs from 'dayjs';

// 模拟获取数据比较慢的情况
function getData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello');
    }, 3000);
  })
}

export default async function Test() {
  // next14 app router模式可以在服务器组件组件中直接获取数据,不用使用getServerSideProps方法了。
 const data = await getData();

  return (
    <div>{data}{dayjs().format("YYYY-MM-DD")}</div>
  )
}

然后改造page.tsx文件

import { Suspense } from 'react';
import Test from './components/Test';

export default async function Home() {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Test />
    </Suspense>
  )
}

使用react中的Suspense组件可以轻松实现流式渲染。

Test组件这里有些人可能会报错

image.png

把package.json文件里的react相关版本都升级成最新的,然后重装一下依赖,最后重启一下vscode。

 "react": "latest",
 "react-dom": "latest"
    
 "@types/react": "latest",
 "@types/react-dom": "latest",

如果还不行把typescript包也升级一下

"typescript": "latest"

效果展示

image.png

先看一下performance分析的数据,FCP很快,LCP比较慢,FCP表示内容开始渲染,LCP表示所有内容渲染完毕,我们这种场景因为接口慢,没办法优化LCP了,但是FCP快,不至于让用户白白等着,啥都不能操作。

Kapture 2024-02-29 at 22.14.14.gif

流式渲染原理

既然流式渲染那么好用,下面我们来看一下它是如何实现的,会不会影响seo。

写一个node服务去请求网页,把请求的内容写入本地文件中,然后我们来观察返回的内容,来分析它是怎么实现的。

// request.js
const { writeFileSync } = require('fs');
const https = require('http');

// 记录最开始的请求时间
const startTime = Date.now();

https.get('http://localhost:3000', (response) => {
  let html = '';

  response.on('data', (chunk) => {
    html += chunk;
    // ${(Date.now() - startTime)} 计算每一次响应用时多少,把请求的内容写入到本地文件中
    writeFileSync(__dirname + `/htmls/html-${(Date.now() - startTime)}.html`, html)
  });

  response.on('end', () => {
    writeFileSync(__dirname + '/htmls/html-end.html', html)
  });

}).on("error", (error) => {
  console.log("Error: " + error.message);
});

使用node运行脚本, node request.js

image.png

可以看到这个html分为5次返回,第一次30毫秒就返回了,最后一次返回是3秒后。

看一下第一次返回的内容

image.png

可以看到第一次只返回了占位符loading...,并不会返回实际内容。

这里也可以告诉大家关于seo的答案,流式渲染并不会影响seo,因为影响seo的东西已经在第一次都返回了。

image.png

有人说,单页面程序也可以实现这个效果啊,为啥单页面程序seo不行呢?因为服务端渲染可以给每个页面设置title和mate,单页面程序做不到,单页面程序所有页面共用一个html。

image.png

在page文件中导出metadata,就可以自定义metadata信息,不过这个只能在服务器组件中使用,客户端组件中使用会报错。

再看一下最后一次end返回的html内容

<!DOCTYPE html>
<html lang="en">

<head>
 ...
</head>

<body class="__className_aaf875">
  <div class="flex h-screen bg-white">
    <div class="w-[256px] p-[20px]">
      <ul>
        <li><a href="/">首页</a></li>
        <li><a href="/about">关于</a></li>
      </ul>
    </div>
    <div class="flex-1"><!--$?--><template id="B:0"></template>
      <div>loading...</div><!--/$-->
    </div>
  </div>
  <div hidden id="S:0">
    <div>hello<!-- -->2024-03-01</div>
  </div>
  <script>
    $RC = function (b, c, e) {
      c = document.getElementById(c);
      c.parentNode.removeChild(c);

      var a = document.getElementById(b);

      if (a) {
        b = a.previousSibling;

        if (e) {
          b.data = "$!";
          a.setAttribute("data-dgst", e);
        } else {
          e = b.parentNode;
          a = b.nextSibling;

          var f = 0;

          do {
            if (a && 8 === a.nodeType) {
              var d = a.data;

              if ("/$" === d) {
                if (0 === f) break;
                else f--;
              } else {
                if ("$" !== d && "$?" !== d && "$!" !== d) {
                  f++;
                }
              }
            }

            d = a.nextSibling;
            e.removeChild(a);
            a = d;
          } while (a);

          for (; c.firstChild;) {
            e.insertBefore(c.firstChild, a);
          }

          b.data = "$";
        }

        b._reactRetry && b._reactRetry();
      }
    };

    $RC("B:0", "S:0");
  </script>
</body>

</html>

我删除了一部分没用的代码,最后一次返回的内容比前面多了一个js脚本,和一个隐藏的dom元素。

 <div hidden id="S:0">
    <div>hello<!-- -->2024-03-01</div>
  </div>

$RC方法主要实现了使用id为S:0的元素,替换id为B:0的loading占位符,还删除了隐藏的元素,把注释<!--$?-->中的问号去掉,表示已经替换完成。

image.png

到这里大家应该了解了next的异步渲染是怎么实现的了,下面根据这个原理,我基于node简单实现了一个流式渲染的demo。

const http = require('http');

const server = http.createServer((_, res) => {
  // 先返回一部分html
  res.write(`
  <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>test</title>
</head>
<body>
  <div>
    <div id="loading">loading...</div>
  </div>
</body>
</html>`);

  // 2秒之后返回js脚本把loading改为hello
  setTimeout(function () {
    res.end(`<script>
      const div = document.getElementById('loading');
      const parentNode = div.parentNode;
      parentNode.removeChild(div);
      parentNode.innerHTML = 'hello';
    </script>`);
  }, 2000);
});

server.listen(8000, function () {
  console.log('Server is listening on port 8000');
});

demo开始返回一个loading占位符,2秒过后把loading替换为hello。

Kapture 2024-03-01 at 19.45.14.gif

使用骨架屏

上面页面加载的时候,只显示了一个简单的文本loading,不太好看,我们使用骨架屏优化一下。这里的骨架屏组件使用现在比较热门的shadcn ui库里的组件。

可以跟着这个教程,在项目里安装使用shadcn。

安装成功后,使用下面命令安装skeleton组件

pnpm dlx shadcn-ui@latest add skeleton

组件安装成功后,使用Skeleton组件替换loading文本

import { Skeleton } from '@/components/ui/skeleton';
import { Suspense } from 'react';
import Test from './components/Test';

export default async function Home() {
  return (
    <Suspense fallback={(
      <div className="py-[20px] flex items-center space-x-4">
        <Skeleton className="h-12 w-12 rounded-full" />
        <div className="space-y-2">
          <Skeleton className="h-4 w-[250px]" />
          <Skeleton className="h-4 w-[200px]" />
        </div>
      </div>
    )}>
      <Test />
    </Suspense>
  )
}

效果展示

Kapture 2024-02-29 at 23.07.35.gif

上面只是最基础的骨架屏,可以根据自己的内容自由定制不同的骨架屏样式。

封装获取数据组件

上面代码中,不知道大家有没有发现有个很麻烦的地方,使用异步渲染时,必须要加一个组件,本来一个很简单的页面,还要单独拆出去一个组件,并且如果服务端组件中请求数据,还要考虑请求数据失败的情况,这些都让我觉得很麻烦,所以我封装了一个专门用来获取数据的公共组件。

// src/app/components/DataFetcher.tsx
import React from 'react';

export default async function DataFetcher<T extends () => Promise<any>>({
  handle,
  children,
  errorMessage,
}: {
  handle: T,
  children: (data: Awaited<ReturnType<T>>) => Exclude<React.ReactNode, React.PromiseLikeOfReactNode>,
  errorMessage?: (error: any) => Exclude<React.ReactNode, React.PromiseLikeOfReactNode>,
}) {
  try {
    // 执行传进来的方法
    const data = await handle();
    // 判断如果children为方法则执行,并且把上一步得到的数据作为参数传入,如果为组件则直接渲染
    return typeof children === 'function' ? children(data) : children;
  } catch (error: any) {
    // 如果有错误信息就返回错误信息,没有就返回默认的
    return errorMessage?.(error) || '数据加载失败';
  }
}

代码很简单,但是很好用,具体实现看代码中的注释。

在page.tsx中使用组件

import { Skeleton } from '@/components/ui/skeleton';
import dayjs from 'dayjs';
import { Suspense } from 'react';
import DataFetcher from './components/DataFetcher';

// 模拟获取数据比较慢的情况
function getData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello');
    }, 3000);
  })
}


export default async function Home() {
  return (
    <Suspense fallback={(
      <div className="py-[20px] flex items-center space-x-4">
        <Skeleton className="h-12 w-12 rounded-full" />
        <div className="space-y-2">
          <Skeleton className="h-4 w-[250px]" />
          <Skeleton className="h-4 w-[200px]" />
        </div>
      </div>
    )}>
      <DataFetcher handle={() => getData()}>
        {data => (
          <div>{data}{dayjs().format("YYYY-MM-DD")}</div>
        )}
      </DataFetcher>
    </Suspense>
  )
}

还可以根据handle的返回值类型,自动推断data的值类型。

image.png

可以自定义请求错误信息

image.png

image.png

一个页面中也可以有多个组件异步渲染,相互不影响。

import { Skeleton } from '@/components/ui/skeleton';
import dayjs from 'dayjs';
import { Suspense } from 'react';
import DataFetcher from './components/DataFetcher';

// 模拟获取数据比较慢的情况
function getData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello');
    }, 3000);
  })
}

export default async function Home() {
  return (
    <div>
      <Suspense fallback={(
        <div className="py-[20px] flex items-center space-x-4">
          <Skeleton className="h-12 w-12 rounded-full" />
          <div className="space-y-2">
            <Skeleton className="h-4 w-[250px]" />
            <Skeleton className="h-4 w-[200px]" />
          </div>
        </div>
      )}>
        <DataFetcher handle={() => getData()}>
          {data => (
            <div>{data}{dayjs().format("YYYY-MM-DD")}</div>
          )}
        </DataFetcher>
      </Suspense>
      <Suspense fallback={(
        <div className="py-[20px] flex items-center space-x-4">
          <Skeleton className="h-12 w-12 rounded-full" />
          <div className="space-y-2">
            <Skeleton className="h-4 w-[250px]" />
            <Skeleton className="h-4 w-[200px]" />
          </div>
        </div>
      )}>
        <DataFetcher handle={() => getData()}>
          {data => (
            <div>{data}{dayjs().format("YYYY-MM-DD")}</div>
          )}
        </DataFetcher>
      </Suspense>
    </div>
  )
}

也支持嵌套

image.png

总结

这一篇文章给大家分享了next14中流式(异步)渲染实现原理,并且封装了一个专门用来获取数据的异步服务端组件。

其实通过这篇文章还想给大家一个劝告:我们在平时的开发中,如果要想提升自己,一定要多思考,把繁琐的事情通过自己封装变得简单,日积月累下肯定能成为大佬,被裁员了也不怕找不到工作。

以我封装的这个组件为例,尽管代码看似简单,但它却极大地提升了我的开发效率。和大家说这些不是想说我很牛,这个组件我相信很多人都能写出来,但是也有一部分人觉得开发过程中功能实现就行了,不会主动去封装这个组件。虽然现在前端环境很差,但是我们只要超过那些不愿意进步的人,机会还是多多的,大家一起加油吧!

文章部分内容参考了Next.js 开发指南小册,下一篇打算给大家分享server action的实现原理以及如何优雅的使用server action。