背景
最近在学习nextjs,在做实战demo的时候,关于next中的流式(异步)渲染以及如何优雅的使用骨架屏有一些心得,下面给大家分享一下。
创建next项目
找到合适的目录,使用下面命令:
sh
npx create-next-app@latest
除了第一个输入自己项目名称外,其他都用默认就行了。项目创建成功后会自动使用npm
安装依赖,如果想用pnpm
安装,可以手动给终止掉,然后自己使用pnpm
安装依赖。
网站性能指标
因为下面要用到网站性能指标来体现使用流式渲染带来的好处,所以先让大家了解一下如何衡量一个网站的用户体验好坏。
网站性能指标主要包括:
-
First Paint (FP):首次绘制,即浏览器开始绘制页面的任何部分的时间。
-
First Contentful Paint (FCP):首次内容绘制,即浏览器首次绘制文本、图像、非空白 canvas 或 SVG 的时间。
-
Largest Contentful Paint (LCP):最大内容绘制,反映用户看到的最大页面内容元素渲染完成的时间。
-
Time to Interactive (TTI):可交互时间,表示页面可被完全交互(响应用户输入)的时间。
-
Total Blocking Time (TBT):阻塞总时间,表示在 FCP 和 TTI 之间,页面处于不可交互状态的累计时间。
-
Cumulative Layout Shift (CLS):累积布局偏移,用来度量视觉稳定性,即页面在加载过程中,视觉内容发生意外移动的程度。
-
Speed Index (SI):速度指数,反映出页面的视觉加载速度。
-
Onload event:当一个网页上所有的元素(如图片、脚本等)都已经加载完毕时所记录的时间。
上面这些指标,我们平时关注比较多的是FCP、LCP、TTI,缩短他们的时间可以有效的提高用户体验。
我们可以使用一个工具来测试一个网站的这些指标,下面我以Google浏览器为例,测试一下掘金首页的性能。
访问juejin.cn/后,打开控制台,找到Lighthouse选项卡,然后点击Analyze page load
按钮,开始分析网页。
分析结束后,会给出一个评分,满分100分,77分是一个不太好的得分。
下面还可以看到诊断结果,可以根据诊断结果去做优化
一次结果可能因为一些原因导致结果不准确,可以多测量几次,点击左上角的加号,可以开始一个新的分析
还有一种方式可以获取到这些信息,使用performance
也可以。
点击这些图标也能看到一些性能指标
个人感觉,使用performance
比Lighthouse
更准确一点,这个下面会说。
实战例子
前言
废话不多说,先实现一个功能,让大家看一下使用流式渲染和未使用流式渲染的区别。
实现的功能要求页面分为左右两部分,左边为导航栏和右边为具体页面内容,导航栏是静态的,页面内容根据路由变化而变化。
不使用流式渲染
改造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
启动项目,使用Lighthouse
和performance
工具查看网站性能指标。
可以看到页面会一直卡着,用户体验很差。
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
,不然使用useState
和useEffect
这些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。