开门见山的说,服务端渲染有两个特点:
- 响应快,用户体验好,首屏渲染快
- 对搜索引擎友好,搜索引擎爬虫可以看到完整的程序源码,有利于SEO
如果你的站点或者公司未来的站点需要用到服务端渲染,那么本文将会是非常适合你的一篇入门实战实践教学。本文采用 next
框架进行服务器渲染框架的搭建,最终将完成几个目标:
- 项目结构的划分;
- SEO 优化以及首屏加载速度的提升;
- 登录鉴权以及路由的处理;
- 对报错信息的处理;
本文的最终目标是所有人都能跟着这篇教程搭建自己的(第)一个服务端渲染项目,那么,开始吧。
第一个 Hello World 页面
我们先新建一个目录,名为 jt-gmall
,然后进入目录,在目录下新建 package.json
,添加以下内容:
{
"scripts": {
"start": "next",
"build": "next build",
"serve": "next start"
}
}
然后我们需要安装相关依赖:
antd 是一个 UI 组件库,为了让页面更加美观一些。由于相关依赖比较多,安装过程可能会比较久,建议切个淘宝镜像,会快一点。
npm i next react react-dom antd -S
依赖安装完成后,在目录下新建 pages
文件夹,同时在该文件夹下创建 index.jsx
const Home = () => <section>Hello Next!</section>
export default Home;
在 next
中,每个 .js
文件将变成一个路由,自动处理和渲染,当然也可以自定义,这个在后面的内容会讲到。
我们运行 npm start
启动项目并打开 http://localhost:3000
,此时可以看到 Hello Next!
被显示在页面上了。
我们第一步已经完成,但是我们会感觉这和我们平时的写法差异不大,那么实现上有什么差异吗?
在打开控制台查看差异之前,我们先思考一个问题,SEO 的优化
是怎么做到的,我们需要站在爬虫的角度
思考一下,爬虫爬取的是网络请求获取到的 html
,一般来说(大部分)
的爬虫并不会去执行或者等待 Javascript 的执行,所以说网络请求拿到的 html
就是他们爬取的 html
。
我们先打开一个普通的 React
页面(客户端渲染),打开控制台,查看 network
中,对主页的网络请求的响应结果如下:
我们从图中可以看出,客户端渲染的 React
页面只有一个 id="app"
的 div
,它作为容器承载渲染 react
执行后的结果(虚拟 DOM 树),而普通的爬虫只能爬取到一个 id="app"
的空标签,爬取不到任何内容。
我们再看看由服务端渲染,也就是我们刚才的 next
页面返回的内容是什么:
这样看起来就很清楚了,爬虫从客户端渲染的页面中只能爬取到一个无信息的空标签
,而在服务端渲染的页面中却可以爬取到有价值的信息内容
,这就是服务端渲染对 SEO 的优化。那么在这里再提出两个问题:
- 服务端渲染可以对
AJAX
请求的数据也进行 SEO 优化吗? - 服务端渲染对首屏加载的渲染提升体现在何处?
先解答第一个问题,答案是当然可以,但是需要继续往下看,所以我们进入后面的章节,对 AJAX
数据的优化以及首屏渲染的优化逻辑。
对 AJAX 异步数据的 SEO 优化
本文的目的不止是教会你如何使用,还希望能够给大家带来一些认知上的提升,所以会涉及到一些知识点背后的探讨。
我们先回顾第一章的问题,服务端渲染可以对 AJAX 请求的数据也进行 SEO 优化吗?
,答案是可以的,那么如何实现,我们先捋一捋这个思路。
首先,我们知道要优化 SEO,就是要给爬虫爬取到有用的信息,而我们不能控制爬虫等待我们的 AJAX
请求完毕再进行爬取,所以我们需要直接提供给爬虫一个完整的包含数据的 html
文件,怎么给?答案已经呼之欲出,对应我们的主题 服务端渲染
,我们需要在服务端完成 AJAX
请求,并且将数据填充在 html
中,最后将这个完整的 html
让爬虫爬取。
知识点补充: 可以做到执行js
文件,完成ajax
请求,并且将内容按照预设逻辑填充在html
中,需要浏览器的js 引擎
,谷歌使用的是v8
引擎,而Nodejs
内置也是v8
引擎,所以其实next
内部也是利用了Nodejs
的强大特性(可运行在服务端、可执行js
代码)完成了服务端渲染的功能。
下面开始实战部分,我们新建文件 ./pages/vegetables/index.jsx
,对应的页面是 http://localhost:3000/vegetables
// ./pages/vegetables/index.jsx
import React, { useState, useEffect } from "react";
import { Table, Avatar } from "antd";
const { Column } = Table;
const Vegetables = () => {
const [data, setData] = useState([{ _id: 1 }, { _id: 2 }, { _id: 3 }]);
return <section style={{ padding: 20 }}>
<Table dataSource={data} pagination={false} >
<Column render={text => <Avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" />} />
<Column key="_id" />
</Table>
</section>
}
export default Vegetables;
进入 vegetables
页面后发现我们的组件已经渲染,这也对应了我们开头所说的 next
路由规则,但是我们发现这个布局有点崩,这是因为我们还没有引入 css 的原因,我们新建 ./pages/_app.jsx
import App from 'next/app'
import React from 'react'
import 'antd/dist/antd.css';
export default class MyApp extends App {
static async getInitialProps({ Component, router, ctx }) {
let pageProps = {}
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx)
}
return { pageProps }
}
render() {
const { Component, pageProps } = this.props
return <>
<Component {...pageProps} />
</>
}
}
这个文件是 next
官方指定用来初始化的一个文件,具体的内容我们后面会提到。现在再看看页面,这个时候你的布局应该就好看多了。
当然这是静态数据,打开控制台也可以看到数据都已经被完整渲染在 html
中,那我们现在就开始获取异步数据,看看是否还可以正常渲染。此时需要用到 next
提供的一个 API,那就是 getInitialProps
,你可以简单理解为这是一个在服务端执行生命周期函数,主要用于获取数据,在 ./pages/_app.jsx
中添加以下内容,最终修改后的结果如下:
由于我们的代码可能运行在服务端也可能运行在客户端,但是服务端与不同客户端环境的不同导致一些 API 的不一致,fetch
就是其中之一,在Nodejs
中并没有实现fetch
,所以我们需要安装一个插件isomorphic-fetch
以进行自动的兼容处理。请求数据的格式为
graphql
,有兴趣的童鞋可以自己去了解一下,请求数据的地址是我自己的小站,方便大家做测试使用的。
import React, { useState } from "react";
import { Table, Avatar } from "antd";
import fetch from "isomorphic-fetch";
const { Column } = Table;
const Vegetables = ({ vegetableList }) => {
if (!vegetableList) return null;
// 设置页码信息
const [pageInfo, setPageInfo] = useState({
current: vegetableList.page,
pageSize: vegetableList.pageSize,
total: vegetableList.total
});
// 设置列表信息
const [data, setData] = useState(() => vegetableList.items);
return <section style={{ padding: 20 }}>
<Table rowKey="_id" dataSource={data} pagination={pageInfo} >
<Column dataIndex="poster" render={text => <Avatar src={text} />} />
<Column dataIndex="name" />
<Column dataIndex="price" render={text => <>¥ {text}</>} />
</Table>
</section>
}
const fetchVegetable = (page, pageSize) => {
return fetch("http://dev-api.jt-gmall.com/mall", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
// graphql 的查询风格
body: JSON.stringify({ query: `{ vegetableList (page: ${page}, pageSize: ${pageSize}) { page, pageSize, total, items { _id, name, poster, price } } }` })
}).then(res => res.json());
}
Vegetables.getInitialProps = async ctx => {
const result = await fetchVegetable(1, 10);
// 将查询结果返回,绑定在 props 上
return result.data;
}
export default Vegetables;
效果图如下,数据已经正常显示
下面我们来好好捋一捋这一块的逻辑,如果你此时打开控制台刷新页面会发现在 network
控制台看不到这个请求的相关信息,这是因为我们的请求是在服务端发起的,并且在下图也可以看出,所有的数据也在 html
中被渲染,所以此时的页面可以正常被爬虫抓取。
那么由此就可以解答上面提到的第二个问题,服务端渲染对首屏加载的渲染提升体现在何处?
,答案是以下两点:
-
html
中直接包含了数据,客户端可以直接渲染,无需等待异步ajax
请求导致的白屏/空白时间,一次渲染完毕; - 由于
ajax
在服务端发起,我们可以在前端服务器与后端服务器之间搭建快速通道(如内网通信),大幅度提升通信/请求速度;
我们现在来完成第二章的最后内容,分页数据的加载
。服务端渲染的初始页面数据由服务端执行请求,而后续的请求(如交互类)都是由客户端继续完成。
我们希望能实现分页效果,那么只需要添加事件监听,然后处理事件即可,代码实现如下:
// ...
const Vegetables = ({ vegetableList }) => {
if (!vegetableList) return null;
const fetchHandler = async page => {
if (page !== pageInfo.current) {
const result = await fetchVegetable(page, 10);
const { vegetableList } = result.data;
setData(() => vegetableList.items);
setPageInfo(() => ({
current: vegetableList.page,
pageSize: vegetableList.pageSize,
total: vegetableList.total,
onChange: fetchHandler
}));
}
}
// 设置页码信息
const [pageInfo, setPageInfo] = useState({
current: vegetableList.page,
pageSize: vegetableList.pageSize,
total: vegetableList.total,
onChange: fetchHandler
});
//...
}
到这里,大家应该对 next
和服务端渲染已经有了一个初步的了解。服务端渲染简单点说就是在服务端执行 js
,将 html
填充完毕之后再将完整的 html
响应给客户端,所以服务端由 Nodejs
来做再合适不过,Nodejs
天生就有执行 js
的能力。
我们下一章将讲解如何使用 next
搭建一个需要鉴权的页面以及鉴权失败后的自动跳转问题。
路由拦截以及鉴权处理
我们在工作中经常会遇到路由拦截和鉴权问题的处理,在客户端渲染时,我们一般都是将鉴权信息存储在 cookie、localStorage
进行本地持久化,而服务端中没有 window
对象,在 next
中我们又该如何处理这个问题呢?
我们先来规划一下我们的目录,我们会有三个路由,分别是:
- 不需要鉴权的
vegetables
路由,里面包含了一些所有人都可以访问的实时菜价信息; - 不需要鉴权的
login
路由,登录后记录用户的登录信息; - 需要鉴权的
user
路由,里面包含了登录用户的个人信息,如头像、姓名等,如果未登录跳转到user
路由则触发自动跳转到login
路由;
我们先对 ./pages/_app.jsx
进行一些改动,加上一个导航栏,用于跳转到对应的这几个页面,添加以下内容:
//...
import { Menu } from 'antd';
import Link from 'next/link';
export default class MyApp extends App {
//...
render() {
const { Component, pageProps } = this.props
return <>
<Menu mode="horizontal">
<Menu.Item key="vegetables"><Link href="/vegetables"><a>实时菜价</a></Link></Menu.Item>
<Menu.Item key="user"><Link href="/user"><a>个人中心</a></Link></Menu.Item>
</Menu>
<Component {...pageProps} />
</>
}
}
加上导航栏以后,效果如上图。如果这时候你点击个人中心会出现 404
的情况,那是因为我们还没有创建这个页面,我们现在来创建 ./pages/user/index.jsx
:
// ./pages/user/index.jsx
import React from "react";
import { Descriptions, Avatar } from 'antd';
import fetch from "isomorphic-fetch";
const User = ({ userInfo }) => {
if (!userInfo) return null;
const { nickname, avatarUrl, gender, city } = userInfo;
return (
<section style={{ padding: 20 }}>
<Descriptions title={`欢迎你 ${nickname}`}>
<Descriptions.Item label="用户头像"><Avatar src={avatarUrl} /></Descriptions.Item>
<Descriptions.Item label="用户昵称">{nickname}</Descriptions.Item>
<Descriptions.Item label="用户性别">{gender ? "男" : "女"}</Descriptions.Item>
<Descriptions.Item label="所在地">{city}</Descriptions.Item>
</Descriptions>
</section>
)
}
// 获取用户信息
const getUserInfo = async (ctx) => {
return fetch("http://dev-api.jt-gmall.com/member", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
// graphql 的查询风格
body: JSON.stringify({ query: `{ getUserInfo { nickname avatarUrl city gender } }` })
}).then(res => res.json());
}
User.getInitialProps = async ctx => {
const result = await getUserInfo(ctx);
// 将 result 打印出来,因为未登录,所以首次进入这里肯定是包含错误信息的
console.log(result);
return {};
}
export default User;
组件编写完毕后,我们进入 http://localhost:3000/user
。此时发现页面是空白的,是因为进入了 if (!userInfo) return null;
这一步的逻辑。我们需要看看控制台的输出,发现内容如下:
因为请求发生在服务端的 getInitialProps
,此时的输出是在命令行输出的,并不会在浏览器控制台输出,写服务端渲染的项目这一点要习惯。
{
errors:
[
{ message: '401: No Auth', locations: [Array], path: [Array] }
],
data: { getUserInfo: null }
}
拿到报错信息之后,我们只需要处理报错信息,然后在出现 401
登录未授权时跳转到登录界面即可,所以在 getInitialProps
函数中再加入以下逻辑:
import Router from "next/router";
// 重定向函数
const redirect = ({ req, res }, path) => {
// 如果包含 req 信息则表示代码运行在服务端
if (req) {
res.writeHead(302, { Location: path });
res.end();
} else {
// 客户端跳转方式
Router.push(path);
}
};
User.getInitialProps = async ctx => {
const result = await getUserInfo(ctx);
const { errors, data } = result;
// 判断是否为鉴权失败错误
if (errors && errors.length > 0 && errors[0].message.startsWith("401")) {
return redirect(ctx, '/login');
}
return { userInfo: data.getUserInfo };
}
这里格外需要注意的一点就是,你的代码可能运行在服务端也可能运行在客户端,所以在很多地方需要进行判断,执行对应的函数,这样才是一个具有健壮性的服务端渲染项目。在上面的例子中,重定向函数就对环境进行了判断,从而执行对应的跳转方法,防止页面出错。
现在刷新页面,我们应该跳转到了登录页面,那么我们现在就来把登录页面实现一下,鉴于方便实现,我们登录界面只放一个登录按钮,完成登录功能,实现如下:
// ./pages/login/index.jsx
import React from "react";
import { Button } from "antd";
import Router from "next/router";
import fetch from "isomorphic-fetch";
const Login = () => {
const login = async () => {
const result = await fetch("http://dev-api.jt-gmall.com/member", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: `{ loginQuickly { token } }` })
}).then(res => res.json());
// 打印登录结果
console.log(result);
}
return (
<section style={{ padding: 20 }}>
<Button type="primary" onClick={login}>一键登录</Button>
</section>
)
}
export default Login;
代码写到这里就可以先停一下,思考一下问题了,打开页面,点击一键登录按钮,这时候控制会输出响应结果如下:
{
"data": {
"loginQuickly": {
"token": "7cdbd84e994f7be693b6e578549777869e086b9db634363635e2f29b136df1a1"
}
}
}
登录拿到了一个 token
信息,现在我们的问题就变成了,如何存储 token
,保持登录态的持久化处理。我们需要用到两个插件,分别是 js-cookie
和 next-cookies
,前者用于在客户端存储 cookie
,而后者用于在服务端和客户端获取 cookie
,我们先用 npm
进行安装:
npm i js-cookie next-cookies -S
随后我们修改 ./pages/login/index.jsx
,在登录成功后将 token
信息存储到 cookie
之中,同时我们也需要修改 ./pages/user/index.jsx
,将 token
作为请求头发送给 api 服务端
,代码实现如下:
// ./pages/login/index.jsx
//...
import cookie from 'js-cookie';
const login = async () => {
//...
const { token } = result.data.loginQuickly;
cookie.set("token", token);
// 存储 token 后跳转到个人信息界面
Router.push("/user");
}
// ./pages/login/index.jsx
//...
import nextCookie from 'next-cookies';
//...
// 获取用户信息
const getUserInfo = async (ctx) => {
// 在 cookie 中获取 token 信息
const { token } = nextCookie(ctx);
return fetch("http://dev-api.jt-gmall.com/member", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 在首部带上身份认证信息 token
'x-auth-token': token
},
// graphql 的查询风格
body: JSON.stringify({ query: `{ getUserInfo { nickname avatarUrl city gender } }` })
}).then(res => res.json());
}
//...
之所以使用cookie
存储 token 是利用了cookie
会随着请求发送给服务端,服务端就有能力获取到客户端存储的cookie
,而localStorage
并没有该特性。
我们在 user
页面刷新,查看控制台(下图),会发现 html
文件的请求头中有 cookie
信息,服务端获取 cookie
的原理就是在请求头中获取客户端传输过来的 cookie
,这也是服务端渲染和客户端渲染的一大区别。
到这一步,关于登录鉴权路由控制的问题已经解决。这里的话,再抛出一个问题,我们的请求是在服务端发起的,如果发生了错误,html
无法正常填充,我们应该怎么处理?带着这个问题,进入下一章吧。
对报错信息的处理
我们的代码在大部分时候都是可控可预测的,一般来说只有网络请求是不好预测的,所以我们从下面这段函数来思考如何处理网络请求错误:
// ./pages/vegetables/index.jsx
// ...
const fetchVegetable = (page, pageSize) => {
return fetch("http://dev-api.jt-gmall.com/mall", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
// graphql 的查询风格
body: JSON.stringify({ query: `{ vegetableList (page: ${page}, pageSize: ${pageSize}) { page, pageSize, total, items { _id, name, poster, price } } }` })
}).then(res => res.json());
}
Vegetables.getInitialProps = async ctx => {
const result = await fetchVegetable(1, 10);
// 将查询结果返回,绑定在 props 上
return result.data;
}
我们修改一行代码,把请求信息中的 ... vegetableList (page ...
修改成 ... vegetableListError (page ...
来测试一下会发现什么。
修改过后打开页面进行刷新,发现界面变成空白,这是因为 if (!vegetableList) return null;
这行代码导致的,我们在 getInitialProps
中输出一下请求结果 result
,会发现返回的对象中包含 errors
信息。那么问题就很简单了,我们只需要把错误信息传递给组件的 props
,然后交由组件处理下一步逻辑就好了,具体实现如下:
const Vegetables = ({ errors, vegetableList }) => {
if (errors) return <section>{JSON.stringify(errors)}</section>
//...
}
Vegetables.getInitialProps = async ctx => {
const result = await fetchVegetable(1, 10);
if (result.errors) {
return { errors: result.errors };
}
// 将查询结果返回,绑定在 props 上
return result.data;
}
到这里你应该就明白了,我们可以在开发环境将详细错误信息直接呈现,方便我们调试,而正式环境我们可以返回一个指定的错误页面,例如 500 服务器开小差
。
到这里就结束了吗?当然没有,这样的话我们就需要在每个页面加入这个错误处理,这样的操作非常繁琐而且缺乏健壮性,所以我们需要写一个高阶组件来进行错误的处理,我们新建文件 ./components/withError.jsx
;
// ./components/withError.jsx
import React from "react";
const WithError = () => WrappedComponent => {
return class Error extends React.PureComponent {
static async getInitialProps(ctx) {
const result = WrappedComponent.getInitialProps && (await WrappedComponent.getInitialProps(ctx));
// 这里从业务上来说与直接返回 result 并无区别
// 这里只是强调对发生错误时的特殊处理
if (result.errors) {
return { errors: result.errors };
}
return result.data;
}
render() {
const { errors } = this.props;
if (errors && errors.length > 0) return <section>Error: {JSON.stringify(errors)}</section>
return <WrappedComponent {...this.props} />;
}
}
}
export default WithError;
同时我们也需要修改 ./pages/vegetables/index.jsx
中的 getInitialProps
函数,将对响应结果的处理延迟到组合类,同时删除之前添加的所有错误处理函数,修改后如下:
const Vegetables = ({ vegetableList }) => {
if (!vegetableList) return null;
//...
}
Vegetables.getInitialProps = async ctx => {
const result = await fetchVegetable(1, 10);
// 将查询结果返回,绑定在 props 上
return result;
}
export default WithError()(Vegetables);
此时刷新页面,会发现结果和刚才是一样的,只不过我们只需要在导出组件的时候进行 WithError()(Vegetables)
操作即可。这个函数其实也可以放在根组件,这个就交由大家自己去探究了。
结语
此时此刻,React 服务端渲染入门教程已经结束了,相信大家对服务端渲染也有了更加深刻的理解。如果想要了解更多,可以看看 next
的官网教程,并跟着写一个实际的项目最好,这样的提升是最大的。
最后祝愿大家都能够掌握使用服务端渲染,前端技术日益精进!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。