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

背景

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

创建next项目

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

sh 复制代码
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按钮,开始分析网页。

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

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

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

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

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

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

实战例子

前言

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

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

不使用流式渲染

改造layout.tsx组件

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代码

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文件

tsx 复制代码
// src/app/about/page.tsx

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

效果展示

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

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

Lighthouse分析的数据

performance分析的数据

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

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

客户端渲染

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

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

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文件,改造成客户端组件

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毫秒了

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

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

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

服务端组件改造

tsx 复制代码
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>
  )
}

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

再看一下返回的html内容

在客户端组件使用dayjs

tsx 复制代码
'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文件,里面存放的是上面客户端组件的内容

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

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

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

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

流式渲染

代码实现

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

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

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文件

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组件这里有些人可能会报错

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

text 复制代码
 "react": "latest",
 "react-dom": "latest"
    
 "@types/react": "latest",
 "@types/react-dom": "latest",

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

text 复制代码
"typescript": "latest"

效果展示

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

流式渲染原理

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

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

js 复制代码
// 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

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

看一下第一次返回的内容

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

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

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

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

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

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元素。

html 复制代码
 <div hidden id="S:0">
    <div>hello<!-- -->2024-03-01</div>
  </div>

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

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

js 复制代码
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。

使用骨架屏

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

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

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

sh 复制代码
pnpm dlx shadcn-ui@latest add skeleton

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

tsx 复制代码
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>
  )
}

效果展示

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

封装获取数据组件

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

tsx 复制代码
// 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中使用组件

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的值类型。

可以自定义请求错误信息

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

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 (
    <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>
  )
}

也支持嵌套

总结

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

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

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

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

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax