前言
在 SPA 中,通常就是整个应用使用一个 HTML 页面,通过销毁和创建组件的方式来模拟路由跳转。数据在浏览器通过 API 获取,当组件初始化时调用 API 获取初始数据。
例如在 React 中会使用 useEffect
hook,在 Vue 中使用 mounted
hook。但是通过这种方式在第一次渲染的时候数据展示为空,当获取到数据时,进行第二次渲染才会展示数据。存在不必要的渲染、更长的加载时间以及糟糕的 SEO 等问题。
预渲染
预渲染是指在网站在提供给用户之前生成静态版本的过程。当用户访问网站时,浏览器会向服务器发送请求获取页面所需的资源,通过预渲染,服务器会提前生成页面的静态版本,并将其存储在缓存中。当用户请求页面时,服务器可以直接返回缓存的内容。
预渲染是指在网站在提供给用户之前生成静态 HTML,以及页面所需的 Javascript 代码。还可以将生成的资源存储在缓存或者推送到 CDN,当用户请求资源时,可以得到更快的响应。
另外,由于是在服务端负责生成 web 页面的所有内容,也就是说在首次加载页面时就能读取到完整的 HTML 内容,因此 SEO 爬虫可以快速读取实际内容并且根据此来给网页进行排名。
预渲染流程:
当浏览器加载资源后,Javascript 代码被执行,页面变成可交互状态,这个过程就叫做注水(Hydration)。
客户端渲染流程:
NextJS 中的预渲染
在 NextJS 出现之前,需要大量且繁琐的工作来实现预渲染和解决与 SEO、服务器负载或缓存相关的所有问题。
NextJS 是一个基于 React 的全栈框架,它提供的主要功能包括预渲染、内置的路由 API、图像优化等。其中它的预渲染能力,被业界称之为是服务端渲染的最佳实践。
NextJS 默认情况下会预渲染所有的页面,也就是说 NextJS 会提前为每个页面生成 HTML,而不是在客户端执行 js 代码生成。因此,用户会在首次加载的时候就能看到完整的 HTML 内容,而不是白屏等待 JS 加载和执行。
NextJS 主要提供了三种预渲染方式,分别是 服务端渲染(SSR), 静态生成(SSG) 以及 增量静态生成(ISR)。
服务端渲染(SSR)
SSR 即当用户访问页面,发送请求到服务器时,服务器会在后台动态生成html内容以及嵌入拉取到的动态数据,返回完整的 HTML 内容以及所需的 js 代码,整个从数据库获取数据以及创建 html 页面返回给用户的过程就叫服务端渲染。
在 NextJS 中配置一个页面进行 SSR 要使用 getServerSideProps
函数,该函数会在每次请求时在服务器调用。
// 该函数在每次请求都会调用
export async function getServerSideProps() {
// 调用 API 拉取数据
const posts = await getPostsFromDatabase()
// 在组件中可以使用 props 访问同名属性
return { props: { posts } };
}
SSR 最早就是为了解决 SPA 产生的 SEO、首屏渲染时间等问题诞生的,在服务端直接实现同构渲染用户看到的页面,能最大程度上提高用户体验。
SSR 存在的问题
但 SSR 引入了另外一个问题,既然要做服务端渲染,就需要一个实时在线的后台服务来处理页面请求,通常基于 Node.js。那么就需要面临以下几个挑战:
- 这个服务需要计算资源和公共网络流量来部署,消耗的资源与页面的流量成正比。当页面流量突然增加时,渲染服务也需要进行扩容。
- 服务器只能在有限的地区部署,所以对于距离服务器较远的用户而言,加载速度跟静态资源的 CDN 相比慢了一个数量级。
- 另外,存在传统服务端在运维、监控告警等方面的负担,需要额外的人力来开发和维护。
为了解决这些问题,首先需要对 SSR 进行审视,服务端渲染出的页面,逻辑上可以分成两大块:
- 变化不频繁,甚至不会变化的内容:例如文章、排行榜、商品信息等,这些数据就非常适合缓存;
- 变化比较频繁的内容:例如用户头像、实时评论等。
例如在一篇博客中,内容是偏向静态的,很少改动,那么每次用户的内容请求,都通过服务端来渲染就非常不值得,因为每次服务端渲染出来大部分内容都是一样的。
那么我们完全可以将文章的页面渲染为静态页面,就可以通过 SSG 生成并缓存。
静态生成 (SSG)
这是一种在构建期间生成 HTML 的方式,预渲染的 HTML 内容会直接通过每次请求直接返回给客户端,而无需在客户端生成。如果页面不依赖动态内容,Next.js 会默认静态生成该页面。如果需要通过 REST API 或 GraphQL 等方式获取额外的数据,作为静态生成的一部分,将提前获取数据,并生成 HTML。所有这些步骤都会在构建期间完成,所有预渲染的内容可以存放在 CDN,以此优化性能。
这样做有很多好处:
- 由于页面已经被静态化了,所以它是 SEO 友好的,能被搜索引擎轻松爬取;
- 大大减轻了服务端渲染的资源负担,不需要额外做一套 Node.js 服务;
- 用户始终通过 CDN 加载页面核心内容,CDN 的边缘节点有缓存,速度极快;
- 通过 HTTP API + CSR,页面内次要的动态内容也可以被很好地渲染;
- 数据有变化时,重新触发一次网站的异步渲染,然后推送新的内容到 CDN 即可。
- 由于每次都是全站渲染,所以网站的版本可以很好的与 Git 的版本对应上,甚至可以做到原子化发布和回滚。
这便是 Next.js 这样的网站生成器解决的问题,属于 React 更上一层的框架(Meta Framework),通过 SSR 把动态化的 Web 应用渲染为多个静态页面,并且对高度动态的内容也保留了 CSR 的能力。
这种方式非常适合静态内容页,像是“关于”、“联系方式”等内容不经常变动的页面。
要在 Next.js 中使用 SSG,需要使用 getStaticProps
来获取数据:
// 这个函数会在构建期间调用
export async function getStaticProps({ params }) {
const data = await getContentFromDatabase(params.id)
return { props: { data } };
}
SSG 存在的问题
但是 SSG 也存在一些问题,对于构建大型静态网站来说,构建需要花费数个小时的时间。随着页面数量的增加,构建时间也会呈线性递增。对于大型 Web 应用来说,选择完全的 SSG 是不可行的,需要更灵活、个性化、混合的解决方案。
增量静态生成(ISR)
Next.js 提供了在运行时生成或更新静态页面的能力。增量静态生成(ISR) 能够更新动态内容,而无需重新构建。在享受 SSG 带来的好处的同时,能够扩展更多的页面,解决了 SSG 构建时长太长的问题。
有了 ISR 就可以灵活的选择页面在构建时静态生成还是运行时按需生成。以一个电商网站为例,假设一个电商网站拥有10W个产品信息,每个产品页以50ms的速度静态生成,不使用 ISR 要花费将近 2 个小时来构建。而有了 ISR,可以定制合适的策略来权衡构建速度和运行时生成页面的时间,从而达到预期效果:
- 更快的构建速度 在构建时只生成最受欢迎的1000个产品页,对其他页面的请求会在缓存未命中的时候按需静态生成。
- 更高的缓存命中率 构建时生成 1w 个产品页,确保更多的页面被提前缓存。
在 Next.js 中实现 ISR
ISR 同样也是通过使用 getStaticProps
API 实现的,通过指定 revalidate: 60
,Next.js 就能够在该页面使用 ISR。
动态内容拉取
当然在构建期间生成的产品页也需要更新数据,Next.js 支持为每个页面定义重新生成的时间,这里把它设置为 60 秒。最开始请求的产品页会显示构建产生的缓存的页面,展示最初价格。随着产品的数据更新,在 60 秒后的下一个请求仍然显示缓存页面,同时触发 Next.js 在后台进行页面重建。一旦页面成功生成,Next.js 将使缓存失效,并显示更新后的产品页,如果后台重建失败,则旧页面保持不变。
示例代码如下:
// pages/products/[id].js
export async function getStaticProps({ params }) {
return {
props: {
product: await getProductFromDatabase(params.id)
},
revalidate: 60
}
}
生成路径定义
那么又如何实现定义哪些页面在构建期间生成呢?
在 Next.js 中使用getStaticPaths
来实现。这里假设我们想要更快的构建速度,通过在 getStaticPaths
中提供这 1000 个产品的 id ,在构建期间只生成最受欢迎的 1000 个产品的页面。
首先要配置 fallback
属性,在初始构建后请求任何其他产品时如何回退,它有两个可选值:blocking
和 true
。
blocking
(首选):当请求尚未生成的页面时,Next.js 将采用服务端渲染的方式呈现页面,之后的请求将直接返回已经生成的缓存的静态文件。true
:当请求尚未生成的页面时,Next.js将立即提供一个带有加载状态的静态页面。当数据加载完成后,页面将重新渲染带有新数据的页面,并将被缓存。之后的请求将直接返回已经生成的缓存的静态文件。
示例代码如下:
// pages/products/[id].js
export async function getStaticPaths() {
const products = await getTop1000Products()
const paths = products.map((product) => ({
params: { id: product.id }
}))
return { paths, fallback: 'blocking' }
}
最后
Next.js 的首要关注点是最终用户。所谓“最佳解决方案”是相对的,取决于行业、受众和应用的性质。开发人员可以通过在不离开框架范围内切换解决方案,以达到不同的目的。