開門見山的說,服務端渲染有兩個特色:css
若是你的站點或者公司將來的站點須要用到服務端渲染,那麼本文將會是很是適合你的一篇入門實戰實踐教學。本文采用 next
框架進行服務器渲染框架的搭建,最終將完成幾個目標:html
本文的最終目標是全部人都能跟着這篇教程搭建本身的(第)一個服務端渲染項目,那麼,開始吧。前端
咱們先新建一個目錄,名爲 jt-gmall
,而後進入目錄,在目錄下新建 package.json
,添加如下內容:react
{ "scripts": { "start": "next", "build": "next build", "serve": "next start" } }
而後咱們須要安裝相關依賴:git
antd 是一個 UI 組件庫,爲了讓頁面更加美觀一些。因爲相關依賴比較多,安裝過程可能會比較久,建議切個淘寶鏡像,會快一點。github
npm i next react react-dom antd -S
依賴安裝完成後,在目錄下新建 pages
文件夾,同時在該文件夾下建立 index.jsx
ajax
const Home = () => <section>Hello Next!</section> export default Home;
在 next
中,每一個 .js
文件將變成一個路由,自動處理和渲染,固然也能夠自定義,這個在後面的內容會講到。npm
咱們運行 npm start
啓動項目並打開 http://localhost:3000
,此時能夠看到 Hello Next!
被顯示在頁面上了。json
咱們第一步已經完成,可是咱們會感受這和咱們平時的寫法差別不大,那麼實現上有什麼差別嗎?後端
在打開控制檯查看差別以前,咱們先思考一個問題,SEO 的優化
是怎麼作到的,咱們須要站在爬蟲的角度
思考一下,爬蟲爬取的是網絡請求獲取到的 html
,通常來講(大部分)
的爬蟲並不會去執行或者等待 Javascript 的執行,因此說網絡請求拿到的 html
就是他們爬取的 html
。
咱們先打開一個普通的 React
頁面(客戶端渲染),打開控制檯,查看 network
中,對主頁的網絡請求的響應結果以下:
咱們從圖中能夠看出,客戶端渲染的 React
頁面只有一個 id="app"
的 div
,它做爲容器承載渲染 react
執行後的結果(虛擬 DOM 樹),而普通的爬蟲只能爬取到一個 id="app"
的空標籤,爬取不到任何內容。
咱們再看看由服務端渲染,也就是咱們剛纔的 next
頁面返回的內容是什麼:
這樣看起來就很清楚了,爬蟲從客戶端渲染的頁面中只能爬取到一個無信息的空標籤
,而在服務端渲染的頁面中卻能夠爬取到有價值的信息內容
,這就是服務端渲染對 SEO 的優化。那麼在這裏再提出兩個問題:
AJAX
請求的數據也進行 SEO 優化嗎?先解答第一個問題,答案是固然能夠,可是須要繼續往下看,因此咱們進入後面的章節,對 AJAX
數據的優化以及首屏渲染的優化邏輯。
本文的目的不止是教會你如何使用,還但願可以給你們帶來一些認知上的提高,因此會涉及到一些知識點背後的探討。
咱們先回顧第一章的問題,服務端渲染能夠對 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
的官網教程,並跟着寫一個實際的項目最好,這樣的提高是最大的。
最後祝願你們都可以掌握使用服務端渲染,前端技術日益精進!