開門見山的說,服務端渲染有兩個特點:
- 響應快,用戶體驗好,首屏渲染快
- 對搜索引擎友好,搜索引擎爬蟲可以看到完整的程序源碼,有利於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
的官網教程,並跟着寫一個實際的項目最好,這樣的提升是最大的。
最後祝願大家都能夠掌握使用服務端渲染,前端技術日益精進!