摘要:不過(guò)這時(shí)的控制臺(tái)會(huì)拋出這樣一則警告提醒我們?cè)诜?wù)端渲染時(shí)用來(lái)取代,并警告我們?cè)跁r(shí)將不能用去混合服務(wù)端渲染出來(lái)的標(biāo)簽。綜上所述,服務(wù)端和客戶端都是需要路由體現(xiàn)的。我們畫一下重點(diǎn),意思很明確,就是為了服務(wù)端渲染而打造的。
拋磚引玉
在早幾年前,jquery算是一個(gè)前端工程師必備的技能。當(dāng)時(shí)很多公司采用的是java結(jié)合像velocity或者freemarker這種模板引擎的開發(fā)模式,頁(yè)面渲染這塊交給了服務(wù)器,而前端人員負(fù)責(zé)用jquery去寫一些交互以及業(yè)務(wù)邏輯。但是隨著像react和vue這類框架的大火,這種結(jié)合模版引擎的服務(wù)端渲染模式逐漸地被拋棄了,而選用了客戶端渲染的模式。這樣帶來(lái)的直接好處就是,減少了服務(wù)器的壓力以及帶來(lái)了更好的用戶體驗(yàn),尤其在頁(yè)面切換的過(guò)程,客戶端渲染的用戶體驗(yàn)?zāi)鞘潜确?wù)端渲染好太多了。但是隨著時(shí)間的變化,人們發(fā)現(xiàn)客戶端渲染的seo非常差,另外首屏渲染時(shí)間也過(guò)長(zhǎng)。而恰巧這些又是服務(wù)端渲染的優(yōu)勢(shì),難道再回到以前那種模板引擎的時(shí)代?歷史是不會(huì)開倒車的,所以人們開始嘗試在服務(wù)端去渲染React或者Vue的組件,最終nuxtjs、nextjs這類的服務(wù)端渲染框架應(yīng)運(yùn)而生了。當(dāng)然本文的主要目的不是去介紹這些服務(wù)端渲染框架,而是介紹其思路,并且在不借助這些服務(wù)端渲染框架的情況下,自己動(dòng)手搭建一個(gè)服務(wù)端渲染項(xiàng)目,這里我們以React為例,小伙伴們,快跟我跟一起開始探索之旅吧!
簡(jiǎn)易的React服務(wù)端渲染 renderToString如果是寫過(guò)React的小伙伴們,相信對(duì)react-dom包的render的方法再熟悉不過(guò),一般我們都是這么寫:
import {render} from "react-dom"; import App from "./app"; render(,document.getElementById("root"));
但是我們發(fā)現(xiàn)render只是react-dom里的一部分,其他的方法不知道小伙伴們有沒有去研究過(guò),比如renderToString這個(gè)方法,這個(gè)方法在React官方文檔上是這么解釋的。
Render a React element to its initial HTML. React will return an HTML string. You can use this method to generate HTML on the server and send the markup down on the initial request for faster page loads and to allow search engines to crawl your pages for SEO purposes.
前面兩句說(shuō)的很清楚,React可以將React元素渲染成它的初始化Html,并且返回html字符串。然后畫重點(diǎn)“generate HTML on the server”,在服務(wù)端生成html,這不就是服務(wù)端渲染嗎?我們來(lái)試一下可不可以。
const express = require("express"); const app = express(); const React = require("react"); const {renderToString} = require("react-dom/server"); const App = class extends React.PureComponent{ render(){ return React.createElement("h1",null,"Hello World");; } }; app.get("/",function(req,res){ const content = renderToString(React.createElement(App)); res.send(content); }); app.listen(3000);
簡(jiǎn)單說(shuō)一下邏輯,首先定義一個(gè)App組件,返回的內(nèi)容很簡(jiǎn)單,就是“Hello World”,然后調(diào)用createElement這個(gè)方法去生成一個(gè)React元素,最后調(diào)用renderToString去根據(jù)這個(gè)React元素去生成一個(gè)html字符串并返回給了瀏覽器。預(yù)計(jì)效果是頁(yè)面顯示了“Hello World”,我們接下來(lái)驗(yàn)證一下。
結(jié)果跟我們預(yù)想的一致,顯示了“Hello World”,接下來(lái)我們看一下網(wǎng)頁(yè)的源代碼。
源代碼里也有“Hello World”,如果是客戶端渲染的話,是無(wú)法看到了Hello World的,就比如掘金這個(gè)網(wǎng)站就是典型的客戶端渲染,隨便打開一篇文章你都無(wú)法在網(wǎng)頁(yè)源代碼里看到其文章的內(nèi)容。我們?cè)倩氐椒?wù)端渲染上,什么叫React的服務(wù)端渲染?不就是將react組件的內(nèi)容可以在網(wǎng)頁(yè)源代碼里看到嗎?剛剛這個(gè)例子就給我們展現(xiàn)了通過(guò)renderToString這個(gè)方法去實(shí)現(xiàn)服務(wù)端渲染。到這里似乎是已經(jīng)完成了我們的服務(wù)端渲染了,但是這才剛剛開始,因?yàn)閮H僅這樣是有問題的,我們接著往下研究!
通過(guò)上面這個(gè)例子我們發(fā)現(xiàn)了這么幾個(gè)問題:
不能jsx的語(yǔ)法
只能commonjs這個(gè)模塊化規(guī)范,不能用esmodule
因此,我們需要對(duì)我們的服務(wù)端代碼進(jìn)行一個(gè)webpack打包的操作,服務(wù)端的webpack配置需要注意這幾點(diǎn):
一定要有 target:"node" 這個(gè)配置項(xiàng)
一定要有webpack-node-externals這個(gè)庫(kù)
所以你的webpack配置一定是這樣子的:
const nodeExternals = require("webpack-node-externals"); ... module.exports = { ... target: "node", //不將node自帶的諸如path、fs這類的包打進(jìn)去 externals: [nodeExternals()],//不將node_modules里面的包打進(jìn)去 ... };同構(gòu)
我們將前面的例子稍微做一下調(diào)整,然后變成如下這個(gè)樣子:
const express = require("express"); const app = express(); const React = require("react"); const {renderToString} = require("react-dom/server"); const App = class extends React.PureComponent{ handleClick=(e)=>{ alert(e.target.innerHTML); } render(){ returnHello World!
; } }; app.get("/",function(req,res){ const content = renderToString(); console.log(content); res.send(content); }); app.listen(3000);
我們給h1這個(gè)標(biāo)簽綁定一個(gè)click事件,事件響應(yīng)也很簡(jiǎn)單,就是彈出h1標(biāo)簽里的內(nèi)容。預(yù)計(jì)效果彈出“Hello World!”,我們執(zhí)行跑一下。如果你執(zhí)行之后,你會(huì)發(fā)現(xiàn)無(wú)論你如何點(diǎn)擊,都不會(huì)彈出“Hello World!”。為什么會(huì)這個(gè)樣子?其實(shí)很好理解,renderToString只是返回html字符串,元素對(duì)應(yīng)的js交互邏輯并沒有返回給瀏覽器,因此點(diǎn)擊h1標(biāo)簽是無(wú)法響應(yīng)的。如何解決這個(gè)問題呢?再說(shuō)解決方法之前,我們先講一下“同構(gòu)”這個(gè)概念。何為“同構(gòu)”,簡(jiǎn)單來(lái)說(shuō)就是“同種結(jié)構(gòu)的不同表現(xiàn)形態(tài)”。在這里我們用更通俗的大白話來(lái)解釋react同構(gòu)就是:
同一份react代碼在服務(wù)端執(zhí)行一遍,再在客戶端執(zhí)行一遍。
同一份react代碼,在服務(wù)端執(zhí)行一遍之后,我們就可以生成相應(yīng)的html。在客戶端執(zhí)行一遍之后就可以正常響應(yīng)用戶的操作。這樣就組成了一個(gè)完整的頁(yè)面。所以我們需要額外的入口文件去打包客戶端需要的js交互代碼。
import React from "react"; import {render} from "react-dom"; import App from "./app"; render(,document.getElementById("root"));
這里就跟我們寫客戶端渲染代碼無(wú)差,接著用webpack打包一下,然后再在渲染的html字符串中加入對(duì)打包后的js的引用代碼。
import express from "express"; import React from "react"; import {renderToString} from "react-dom/server"; import App from "./src/app"; const app = express(); app.use(express.static("dist")) app.get("/",function(req,res){ const content = renderToString(); res.send(` ssr ${content}`); }); app.listen(3000);
這里“/client/index.js”就是我們打包出來(lái)的用于客戶端執(zhí)行的js文件,然后我們?cè)賮?lái)看一下效果,此時(shí)頁(yè)面就可以正常響應(yīng)我們的操作了。
不過(guò)這時(shí)的控制臺(tái)會(huì)拋出這樣一則警告:
Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.
提醒我們?cè)诜?wù)端渲染時(shí)用ReactDOM.hydrate()來(lái)取代ReactDOM.render(),并警告我們?cè)趓eact17時(shí)將不能用ReactDOM.render()去混合服務(wù)端渲染出來(lái)的標(biāo)簽。
至于ReactDOM.hydrate()和ReactDOM.render()的區(qū)別就是:
ReactDOM.render()會(huì)將掛載dom節(jié)點(diǎn)的所有子節(jié)點(diǎn)全部清空掉,再重新生成子節(jié)點(diǎn)。而ReactDOM.hydrate()則會(huì)復(fù)用掛載dom節(jié)點(diǎn)的子節(jié)點(diǎn),并將其與react的virtualDom關(guān)聯(lián)上。
從二者的區(qū)別我們可以看出,ReactDOM.render()會(huì)將服務(wù)端做的工作全部推翻重做,而ReactDOM.hydrate()在服務(wù)端做的工作基礎(chǔ)上再進(jìn)行深入的操作。顯然ReactDOM.hydrate()此時(shí)是要比ReactDOM.render()更好。ReactDOM.render()在此時(shí)只會(huì)顯得我們很白癡,做了一大堆無(wú)用功。所以我們客戶端入口文件調(diào)整一下。
import React from "react"; import {hydrate} from "react-dom"; import App from "./app"; hydrate(流程圖 加入路由,document.getElementById("root"));
前面我們已經(jīng)用比較大的篇幅講明白了服務(wù)端渲染的原理,搞清楚了大致的服務(wù)端渲染的流程,那么接下來(lái)就要講一下如何給其加入路由。在服務(wù)端的時(shí)候是需要生成Html的,而不同的訪問路徑對(duì)著不同的組件,因此服務(wù)端是需要有一個(gè)路由層去幫助我們找到該訪問路徑所對(duì)應(yīng)的react組件。其次,客戶端會(huì)進(jìn)行一個(gè)hydrate的操作,也是需要根據(jù)訪問路徑去匹配到對(duì)應(yīng)的react組件的。綜上所述,服務(wù)端和客戶端都是需要路由體現(xiàn)的??蛻舳说穆酚上嘈挪挥迷僮鲞^(guò)多的贅述了,這里我們就講一下服務(wù)端是如何添加路由的。
StaticRouter在客戶端渲染的項(xiàng)目中,react-router提供了BrowserRouter和HashRouter可供我們選擇,而這兩個(gè)router都有一個(gè)共同點(diǎn)就是需要讀取地址欄的url,但是在服務(wù)端的環(huán)境中是沒有地址欄的,也就是說(shuō)沒有window.location這個(gè)對(duì)象,那么BrowserRouter和HashRouter就不能在服務(wù)端的環(huán)境中去使用,那么這就無(wú)解了嗎,當(dāng)然不是!react-router給我們提供了另外一種router叫做StaticRouter,react-router官網(wǎng)給它的描述中有這么一句話。
This can be useful in server-side rendering scenarios when the user isn’t actually clicking around, so the location never actually changes.
我們畫一下重點(diǎn)“be useful in server-side rendering scenarios”,意思很明確,就是為了服務(wù)端渲染而打造的。接下來(lái)我們就結(jié)合StaticRouter是去實(shí)現(xiàn)一下。
現(xiàn)在我們有兩個(gè)頁(yè)面Login和User,代碼如下:
Login:
import React from "react"; export default class Login extends React.PureComponent{ render(){ return登陸} }
User:
import React from "react"; export default class User extends React.PureComponent{ render(){ return用戶} }
服務(wù)端代碼:
import express from "express"; import React from "react"; import {renderToString} from "react-dom/server"; import {StaticRouter,Route} from "react-router"; import Login from "@/pages/login"; import User from "@/pages/user"; const app = express(); app.use(express.static("dist")) app.get("*",function(req,res){ const content = renderToString(); res.send(`ssr ${content}`); }); app.listen(3000);
最后的效果:
/user:
/login:
通過(guò)上面的小實(shí)驗(yàn),我們已經(jīng)掌握了如何在服務(wù)端去添加路由,接下來(lái)我們需要處理的就是前后端路由同構(gòu)的問題。由于前后端都需要添加路由,如果兩端都各自寫一遍的話,費(fèi)時(shí)費(fèi)力不說(shuō),維護(hù)還很不方便,所以我們希望一套路由可以在兩端都跑通。接下來(lái)我們就去實(shí)現(xiàn)一下。
思路這里先說(shuō)下思路,首先我們肯定不會(huì)在服務(wù)器端寫一遍路由,然后在去客戶端寫一遍路由,這樣費(fèi)時(shí)費(fèi)力不說(shuō),而且不易維護(hù)。相信大多數(shù)人和我一樣都希望少寫一些代碼,少寫代碼的第一要義就是把通用的部分給抽出來(lái)。那么接下來(lái)我們就找一下通用的部分,仔細(xì)觀察不難發(fā)現(xiàn)不管是服務(wù)端路由還是客戶端路由,路徑和組件之間的關(guān)系是不變的,a路徑對(duì)應(yīng)a組件,b路徑對(duì)應(yīng)b組件,所以這里我們希望路徑和組件之間的關(guān)系可以用抽象化的語(yǔ)言去描述清楚,也就是我們所說(shuō)路由配置化。最后我們提供一個(gè)轉(zhuǎn)換器,可以根據(jù)我們的需要去轉(zhuǎn)換成服務(wù)端或者客戶端路由。
代碼routeConf.js
import Login from "@/pages/login"; import User from "@/pages/user"; import NotFound from "@/pages/notFound"; export default [{ type:"redirect", exact:true, from:"/", to:"/user" },{ type:"route", path:"/user", exact:true, component:User },{ type:"route", path:"/login", exact:true, component:Login },{ type:"route", path:"*", component:NotFound }]
router生成器
import React from "react"; import { createBrowserHistory } from "history"; import {Route,Router,StaticRouter,Redirect,Switch} from "react-router"; import routeConf from "./routeConf"; const routes = routeConf.map((conf,index)=>{ const {type,...otherConf} = conf; if(type==="redirect"){ return; }else if(type ==="route"){ return ; } }); export const createRouter = (type)=>(params)=>{ if(type==="client"){ const history = createBrowserHistory(); return }else if(type==="server"){ const {location} = params; return {routes} } } {routes}
客戶端
createRouter("client")()
服務(wù)端
const context = {}; createRouter("server")({location:req.url,context}) //req.url來(lái)自node服務(wù)
這里給的只是單層路由的實(shí)現(xiàn),在實(shí)際項(xiàng)目中我們會(huì)更多的使用嵌套路由,但是二者原理是一樣的,嵌套路由的話,小伙伴私下可以實(shí)現(xiàn)一下哦!
重定向問題 問題描述上面講完前后端路由同構(gòu)之后,我們發(fā)現(xiàn)一個(gè)小問題,雖然當(dāng)url為“/”時(shí),路由重定向到了“/user”了,但是我們打開控制臺(tái)會(huì)發(fā)現(xiàn),返回的內(nèi)容跟瀏覽器顯示的內(nèi)容是不一致的。從而我們可以得出這個(gè)重定向應(yīng)該是客戶端的路由幫我們做的重定向,但是這樣是有問題的,我們想要的應(yīng)該是要有兩個(gè)請(qǐng)求,一個(gè)請(qǐng)求響應(yīng)的狀態(tài)碼為302,告訴瀏覽器重定向到“/user”,另一個(gè)是瀏覽器請(qǐng)求“/user”下的資源。因此我們?cè)诜?wù)端我們需要做個(gè)改造。
改造代碼import express from "express"; import React from "react"; import {renderToString} from "react-dom/server"; import {createRouter} from "@/router"; const app = express(); app.use(express.static("dist")) app.get("*",function(req,res){ const context = {}; const content = renderToString({createRouter("server")({ location:req.url, context })}); /** * ------重點(diǎn)開始 */ //當(dāng)Redirect被使用時(shí),context.url將包含重新向的地址 if(context.url){ //302 res.redirect(context.url); }else{ //200 res.send(`ssr ${content}`); } /** * ------重點(diǎn)結(jié)束 */ }); app.listen(3000);
這里我們只加了一層判斷,檢查context.url是否存在,存在則重定向到context.url,反之則正常渲染。至于為什么這么判斷,因?yàn)檫@是react-router官方文檔提供的判斷是否有重定向的方式,有興趣的小伙伴可以看一下文檔以及源碼。文檔地址如下:
https://reacttraining.com/rea...404問題
雖然我在routeConf.js中配置了404頁(yè)面,但是會(huì)有一問題,我們來(lái)看一下這張圖。
雖然頁(yè)面正常顯示404,但是狀態(tài)碼卻是200,這顯然是不符合我們的要求的,因此我們需要改造一下。這里我的思路是借助StaticRouter的context,context里面我會(huì)放一個(gè)常量NOT_FOUND來(lái)代表是否需要設(shè)置狀態(tài)碼404,放置的時(shí)機(jī)我選擇在Route的render方法里面去設(shè)置,具體代碼如下。
import express from "express"; import React from "react"; import {renderToString} from "react-dom/server"; import {createRouter} from "@/router"; const app = express(); app.use(express.static("dist")) app.get("*",function(req,res){ const context = {}; const content = renderToString({createRouter("server")({ location:req.url, context })}); //當(dāng)Redirect被使用時(shí),context.url將包含重新向的地址 if(context.url){ //302 res.redirect(context.url); }else{ if(context.NOT_FOUND) res.status(404);//判斷是否設(shè)置狀態(tài)碼為404 res.send(`ssr ${content}`); } }); app.listen(3000);
主要就是加了這行代碼,通過(guò)context.NOT_FOUND是否為true來(lái)選擇是否設(shè)置狀態(tài)碼為404。
if(context.NOT_FOUND) res.status(404);//判斷是否設(shè)置狀態(tài)碼為404routeConf.js
import Login from "@/pages/login"; import User from "@/pages/user"; import NotFound from "@/pages/notFound"; export default [{ type:"redirect", exact:true, from:"/", to:"/user" },{ type:"route", path:"/user", exact:true, component:User, loadData:User.loadData },{ type:"route", path:"/login", exact:true, component:Login },{ type:"route", path:"*", //將component替換成render render:({staticContext})=>{ if (staticContext) staticContext.NOT_FOUND = true; return} }]
這里我沒有選擇直接在NotFound組件constructor生命周期上修改,主要考慮這個(gè)404組件日后可能會(huì)給其他客戶端渲染的項(xiàng)目用,盡量保持組件的通用性。
//改造前 component:NotFound //改造 render:({staticContext})=>{ if (staticContext) staticContext.NOT_FOUND = true; return加入redux}
第一次接觸服務(wù)端渲染的小伙伴們可能會(huì)不太理解這里為什么要多帶帶講一下如何將redux集成到服務(wù)端渲染的項(xiàng)目中去。因?yàn)榍懊嬖谥v原理的時(shí)候,我們已經(jīng)很清楚服務(wù)端的作用了,那就是根據(jù)訪問路徑生成對(duì)應(yīng)的html,而redux是javaScript的狀態(tài)容器,服務(wù)端渲染生成html也不需要這玩意兒啊,說(shuō)白了就是客戶端的東西,按照客戶端的方式集成不就完了,干嘛還非得多帶帶拎出來(lái)講一下?
這里我就要解釋一下了,以掘金的文章詳情頁(yè)為例,假設(shè)掘金是服務(wù)端渲染的項(xiàng)目,那么每一篇文章的網(wǎng)頁(yè)源代碼里面應(yīng)該是要包含該文章的完整內(nèi)容的,這也就意味著接口請(qǐng)求是在服務(wù)端渲染html之前就去請(qǐng)求的,而不是在客戶端接管頁(yè)面之后再去請(qǐng)求的,所以服務(wù)端拿到請(qǐng)求數(shù)據(jù)之后得找個(gè)地方以供后續(xù)的html渲染使用。我們先看看客戶端拿到請(qǐng)求數(shù)據(jù)是如何存儲(chǔ)的,一般來(lái)說(shuō)無(wú)外乎這兩種方式,一個(gè)就是組件的state,另一個(gè)就是redux。再回到服務(wù)端渲染,這兩種方式我們一個(gè)個(gè)看一下,首先是放在組件的state里面,這種方式顯然不可取的,都還沒有renderToString呢,哪來(lái)的state,所以只剩下第二條路redux,redux的createStore第二參數(shù)就是用于傳入初始化state的,我們可以通過(guò)這個(gè)方法實(shí)現(xiàn)數(shù)據(jù)注入,大致的流程圖如下。
基本框架首先先展示一下基本目錄結(jié)構(gòu):
頁(yè)面user下面一個(gè)redux文件夾,里面有這三個(gè)文件:
actions.js (集合redux-thunk將dispatch封裝到一個(gè)個(gè)函數(shù)里面,解決action類型的記憶問題)
actionTypes.js (reducer所需用的action的類型)
reducer.js (常規(guī)reducer文件)
有人可能會(huì)喜歡以下這種結(jié)構(gòu),把redux相關(guān)的東西放到一個(gè)文件夾里,然后分幾個(gè)大類。但是我個(gè)人比較推崇將redux的東西隨頁(yè)面放在一起,這樣做的好處就是找起來(lái)比較方便,易于維護(hù)??梢愿鶕?jù)個(gè)人喜好來(lái),我只是提供一種我的方式。
actions.js
import {CHANGE_USERS} from "./actionTypes"; export const changeUsers = (users)=>(dispatch,getState)=>{ dispatch({ type:CHANGE_USERS, payload:users }); }
actionTypes.js
export const CHANGE_USERS = "CHANGE_USERS";
reducer.js
import {CHANGE_USERS} from "./actionTypes"; const initialState = { users:[] } export default (state = initialState, action)=>{ const {type,payload} = action; switch (type) { case CHANGE_USERS: return { ...state, users:payload } default: return state; } }
/store/index.js
這個(gè)文件的作用就是對(duì)多有reducer做一個(gè)整合,向外暴露創(chuàng)建store的方法。
import { createStore, applyMiddleware,combineReducers } from "redux"; import thunk from "redux-thunk"; import user from "@/pages/user/redux/reducer"; const rootReducer = combineReducers({ user }); export default () => { return createStore(rootReducer,applyMiddleware(thunk)) };
至于為啥不直接暴露store,原因很簡(jiǎn)單。主要原因是由于這是單例模式,如果服務(wù)端對(duì)這個(gè)store的數(shù)據(jù)做了修改,那么這個(gè)修改將會(huì)一直被保留下來(lái)。簡(jiǎn)單來(lái)說(shuō)a用戶的訪問會(huì)對(duì)b用戶的訪問產(chǎn)生影響,所以直接暴露store是不可取的。
數(shù)據(jù)的獲取以及注入 路由改造routeConf.js
export default [{ type:"redirect", exact:true, from:"/", to:"/user" },{ type:"route", path:"/user", exact:true, component:User, loadData:User.loadData //服務(wù)端獲取數(shù)據(jù)的函數(shù) },{ type:"route", path:"/login", exact:true, component:Login },{ type:"route", path:"*", component:NotFound }]
首先我們要對(duì)前面提到的routeConf.js進(jìn)行改造,改造的方式很簡(jiǎn)單,就是加上一個(gè)loadData方法,提供給服務(wù)端獲取數(shù)據(jù),至于User.loadData方法的具體實(shí)現(xiàn)我們放到后面再講。看到這里或許有小伙伴會(huì)有這樣的疑問,通過(guò)component就可以拿到User組件,拿到User組件不就可以拿到loadData方法了嗎?為啥還要多帶帶配一個(gè)loadData呢?針對(duì)這個(gè)疑問,我在這里做一下說(shuō)明,就拿User為例,首先你不一定會(huì)用component也有可能會(huì)用render,其次component對(duì)應(yīng)組件未必就是User,有可能會(huì)用類似react-loadable這樣的庫(kù)對(duì)User進(jìn)行一個(gè)包裝從而形成一個(gè)異步加載組件,這樣就無(wú)法通過(guò)component去拿到User.loadData方法了。鑒于component的不確定性,保險(xiǎn)起見還是多帶帶配一個(gè)loadData更為穩(wěn)妥一些。
頁(yè)面改造在路由改造中,我們提到了需要加一個(gè)loadData方法,接下來(lái)我們就實(shí)現(xiàn)一下。
import React from "react"; import {Link} from "react-router-dom"; import {bindActionCreators} from "redux"; import {connect} from "react-redux"; import axios from "axios"; import * as actions from "./redux/actions"; @connect( state=>state.user, dispatch=>bindActionCreators(actions,dispatch) ) class User extends React.PureComponent{ static loadData=(store)=>{ //axios本身就是基于Promise封裝的,因此axios.get()返回的就是一個(gè)Promise對(duì)象 return axios.get("http://localhost:3000/api/users").then((response)=>{ const {data} = response; const {changeUsers} = bindActionCreators(actions,store.dispatch); changeUsers(data); }); } render(){ const {users} = this.props; return} } export default User;
{ users.map((user)=>{ const {name,birthday,height} = user; return 姓名 身高 生日 }) } {name} {birthday} {height}
render部分很簡(jiǎn)單,單純地顯示一個(gè)表格,表格有三列分別為姓名、身高、生日這三列。其中表格的數(shù)據(jù)來(lái)源于props的users,當(dāng)通過(guò)接口獲取到數(shù)據(jù)后通過(guò)changeUsers方法來(lái)修改props的users的值(這個(gè)說(shuō)法其實(shí)不準(zhǔn)確,users實(shí)際來(lái)源于store,然后通過(guò)connect方法注入到porps中,為了方便那么理解姑且這么說(shuō))。整個(gè)頁(yè)面的主邏輯大致就是這樣,接下來(lái)我們著重講一下loadData需要注意的地方。
loadData必須有一個(gè)參數(shù)接受store,返回必須是一個(gè)Promise對(duì)象必須要有一個(gè)參數(shù)接受store,這個(gè)比較好理解,根據(jù)前面畫的流程圖,我們是需要修改store里面的state的值的,沒有store,修改state值就無(wú)從談起。返回Promise對(duì)象主要因?yàn)閖avascript是異步的,但是我們需要等待數(shù)據(jù)請(qǐng)求完畢之后才能渲染react組件去生成html。當(dāng)然這里選擇callbak也可以實(shí)現(xiàn),但是不推薦,Promise在處理異步上要比callback好太多了,網(wǎng)上有很多文章做了Promise和callback的對(duì)比,有興趣的小伙伴們可以自行查閱,這里就不討論了。除了Promise比callback在處理異步上更好之外,最主要的一點(diǎn),同時(shí)也是callback的致命傷,就是在嵌套路由的情況下,我們需要調(diào)用多個(gè)組件的loadData,比如說(shuō)下面這個(gè)例子。
import React from "react"; import {Switch,Route} from "react-router"; import Header from "@/component/header"; import Footer from "@/component/footer"; import User from "@/pages/User"; import Login from "@/pages/Login"; class App extends React.PureComponent{ static loadData = ()=>{ //請(qǐng)求數(shù)據(jù) } render(){ const {menus,data} = this.props; return} }
當(dāng)路徑為/user時(shí),我們不僅要調(diào)用User.loadData,還要調(diào)用App.loadData。如果是Promise,我們可以利用Promise.all來(lái)輕松解決多個(gè)異步任務(wù)的完成響應(yīng)問題,相反用callback則變得非常復(fù)雜。
必須有store.dispatch這個(gè)步驟這點(diǎn)其實(shí)也比較好理解,修改store的state的值只能通過(guò)調(diào)用store.dispatch來(lái)完成。但是這里你可以直接調(diào)用store.dispatch,或者像我集合第三方的redux中間件來(lái)實(shí)現(xiàn),我這里用的redux-thunk,這里我將store.dispatch的操作封裝在changeUsers中去了。
import {CHANGE_USERS} from "./actionTypes"; export const changeUsers = (users)=>(dispatch,getState)=>{ dispatch({ type:CHANGE_USERS, payload:users }); }服務(wù)端改造
import express from "express"; import React from "react"; import {renderToString} from "react-dom/server"; import {Provider} from "react-redux"; import {createRouter,routeConfs} from "@/router"; import { matchPath } from "react-router-dom"; import getStore from "@/store"; const app = express(); app.use(express.static("dist")) app.get("/api/users",function(req,res){ res.send([{ "name":"吉澤明步", "birthday":"1984-03-03", "height":"161" },{ "name":"大橋未久", "birthday":"1987-12-24", "height":"158" },{ "name":"香澄優(yōu)", "birthday":"1988-08-04", "height":"158" },{ "name":"愛音麻里亞", "birthday":"1996-02-22", "height":"165" }]); }); app.get("*",function(req,res){ const context = {}; const store = getStore(); const promises = []; routeConfs.forEach((route)=> { const match = matchPath(req.path, route); if(match&&route.loadData){ promises.push(route.loadData(store)); }; }); Promise.all(promises).then(()=>{ const content = renderToString({createRouter("server")({ location:req.url, context })} ); if(context.url){ res.redirect(context.url); }else{ res.send(`ssr ${content}`); } }); }); app.listen(3000);
ps:這邊加了一個(gè)接口“/api/users”是為了后續(xù)演示用的,不在改造范圍內(nèi)
集合上面的代碼,我們來(lái)說(shuō)一下需要改動(dòng)哪幾點(diǎn):
根據(jù)路徑找到所有需要調(diào)用的loadData方法,接著傳人store調(diào)用去獲取Promise對(duì)象,然后加入到promises數(shù)組中。這里使用的是react-router-dom提供的matchPath方法,因?yàn)檫@里是單級(jí)路由,matchPath方法足夠了。但是如果多級(jí)路由的話,可以使用react-router-config這個(gè)包,具體使用方法我就不贅述了,官方文檔介紹的肯定比我介紹的更加詳細(xì)全面。另外有些小伙伴們的路由配置規(guī)則可能跟官方提供的不一樣,就比如我自己在公司項(xiàng)目中的路由配置方式就跟官方的不一樣,對(duì)于這種情況就需要自己寫一個(gè)匹配函數(shù)了,不過(guò)也簡(jiǎn)單,一個(gè)遞歸的應(yīng)用而已,相信難不倒大家。
加入Promise.all,將渲染react組件生成html等操作放入其then中。前面我們講過(guò)在多級(jí)路由中,會(huì)存在需要調(diào)用多個(gè)loadData的情況,用Promise.all可以非常好地解決多個(gè)異步任務(wù)完成響應(yīng)的問題。
在html中加入一個(gè)script,使新的state掛載到window對(duì)象上。根據(jù)流程圖,客戶端創(chuàng)建store的時(shí)候,得傳入一個(gè)初始state,以達(dá)到數(shù)據(jù)注入的效果,這里掛載到window這個(gè)全局對(duì)象上也是為了方便客戶端去獲取這個(gè)state。
客戶端改造const getStore = (initialState) => { return createStore(rootReducer,initialState,applyMiddleware(thunk)) }; const store = getStore(window.INITIAL_STATE);
客戶端的改造相對(duì)簡(jiǎn)單了很多,主要就兩點(diǎn):
getStore支持傳入一個(gè)初始state。
調(diào)用getStore,并傳入window.INITIAL_STATE,從而獲得一個(gè)注入數(shù)據(jù)的store。
最終效果圖 node層加入接口代理在講“加入redux”這個(gè)模塊的時(shí)候,用到了一個(gè)“/api/users”接口,這個(gè)接口在寫在當(dāng)前服務(wù)上的,但是在實(shí)際項(xiàng)目中,我們更多地會(huì)是調(diào)用其他服務(wù)的接口。此外除了在服務(wù)端調(diào)用接口,客戶端同樣也有需要調(diào)用接口,而客戶端調(diào)用接口就要面臨著跨域的問題。因此在node層加入接口代理,不光可以實(shí)現(xiàn)多個(gè)服務(wù)的調(diào)用,也能解決跨域問題,一舉兩得。接口代理我選用http-proxy-middleware這個(gè)包,它可以作為express的中間件使用,具體的使用可以查看官方文檔了,我這里就不贅述了,我就給個(gè)示例供大家參考一下。
準(zhǔn)備工作在使用http-proxy-middleware之前,先做點(diǎn)準(zhǔn)備工作,目前“/api/users”是在當(dāng)前服務(wù)上,為了方便演示,我先搭了一個(gè)基于json-server的簡(jiǎn)單mock服務(wù),端口選用了8000,與當(dāng)前服務(wù)的3000端口做一下區(qū)分。最后我們?cè)L問 “http://localhost:8000/api/users” 可以獲取我們想要的數(shù)據(jù),效果如下。
開始配置操作很簡(jiǎn)單,就是在我們服務(wù)端加入一行代碼就可以了。
import proxy from "http-proxy-middleware"; app.use("/api",proxy({target: "http://localhost:8000", changeOrigin: true }))
如果是多個(gè)服務(wù)的話,可以這么做。
import proxy from "http-proxy-middleware"; const findProxyTarget = (path)=>{ console.log(path.split("/")); switch(path.split("/")[1]){ case "a": return "http://localhost:8000"; case "b": return "http://localhost:8001"; default: return "http://localhost:8002" } } app.use("/api",function(req,res,next){ proxy({ target:findProxyTarget(req.path), pathRewrite:{ "^/api/a":"/api", "^/api/b":"/api" }, changeOrigin: true })(req,res,next); })
/api/a/users => http://localhost:8000/api/users
/api/b/users => http://localhost:8001/api/users
/api/users => http://localhost:8002/api/users
不同的服務(wù)可以用不同的前綴來(lái)區(qū)分,通過(guò)這個(gè)額外的前綴就可以得出對(duì)應(yīng)的target,另外記得用pathRewrite把額外的前綴給去掉,否則就會(huì)出現(xiàn)代理錯(cuò)誤。
處理css樣式 解決報(bào)錯(cuò)module:{ rules:[{ test:/.css$/, use: [ "style-loader", { loader: "css-loader", options: { modules: true } }] }] }
上面這段webpack配置相信大家并不陌生,是用來(lái)處理css樣式的,但是這段配置要是放在服務(wù)端的webpack配置便出現(xiàn)下面這個(gè)問題。
從上圖的報(bào)錯(cuò)來(lái)看,提示window未定義,因?yàn)槭欠?wù)端是node環(huán)境,因此也就沒有window對(duì)象。解決問題的辦法也很簡(jiǎn)單,因?yàn)閺膱D可知這個(gè)報(bào)錯(cuò)是來(lái)自style-loader的,只要style-loader替換掉即可,替換成isomorphic-style-loader即可。如是你要處理scss、less等等這些文件的話,方法也是一樣的,替換style-loader為isomorphic-style-loader即可。
module:{ rules:[{ test:/.css$/, use: [ "isomorphic-style-loader", { loader: "css-loader", options: { modules: true } }] }] }潛在問題 問題描述
從上面兩張圖,我們發(fā)現(xiàn)一個(gè)問題,控制臺(tái)我們是可以看到style標(biāo)簽的,但是網(wǎng)頁(yè)源代碼卻沒有,這也就意味這個(gè)style標(biāo)簽是js幫我們生成的。這樣不好的一點(diǎn)就是頁(yè)面會(huì)閃一下,用戶體驗(yàn)不是很好,希望網(wǎng)頁(yè)源代碼可以有這段css,如何實(shí)現(xiàn)呢?下面我們就來(lái)講一下。
isomorphic-style-loader的官方文檔上提供了這個(gè)方法,大家可以去isomorphic-style-loader的官方文檔查閱一下。這里我說(shuō)一下需要注意的點(diǎn)。
webpackwebpack配置需要注意兩點(diǎn):
webpack.client.conf.js(客戶端)和webpack.server.conf.js(服務(wù)端)style-loader必須替換成isomorphic-style-loader。
css-loader必須開啟CSS Modules,及其options.modules=true。
組件import withStyles from "isomorphic-style-loader/withStyles"; import style from "./style.css"; export default withStyles(style)(User);
操作步驟:
引入withStyles方法。
引入css文件。
用withStyles包裹需要導(dǎo)出的組件。
服務(wù)端import StyleContext from "isomorphic-style-loader/StyleContext"; app.get("*",function(req,res){ const css = new Set() const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss())) const content = renderToString(); res.send(` {createRouter("server")({ location:req.url, context })} ssr ${content}`); })
操作步驟:
引入StyleContext。
新建Set對(duì)象css(為了保證唯一性這里選用Set)
定義一個(gè)insertCss方法,內(nèi)部邏輯很簡(jiǎn)單,調(diào)用每一個(gè)style對(duì)象的_getCss方法獲取css內(nèi)容并加到之前定義的Set對(duì)象css中去。
加入StyleContext.Provider,其value是一個(gè)對(duì)象,該對(duì)象里面有一個(gè)insertCss屬性,該屬性對(duì)應(yīng)的值就是前面定義的insertCss方法。
在渲染的模板中加入style標(biāo)簽,其內(nèi)容為[...css].join("")
客戶端import React from "react"; import {hydrate} from "react-dom"; import StyleContext from "isomorphic-style-loader/StyleContext" import App from "./app"; const insertCss = (...styles) => { const removeCss = styles.map(style => style._insertCss()) return () => removeCss.forEach(dispose => dispose()) } hydrate(, document.getElementById("root") );
操作步驟:
引入StyleContext
定義insertCss方法,內(nèi)部邏輯為先拿到傳入的每一個(gè)style對(duì)象中_insertCss執(zhí)行過(guò)后的返回值,最后返回一個(gè)函數(shù),該函數(shù)作用就是將前面拿到返回值再每一個(gè)執(zhí)行一遍。
加入StyleContext.Provider,其value是一個(gè)對(duì)象,該對(duì)象里面有一個(gè)insertCss屬性,該屬性對(duì)應(yīng)的值就是前面定義的insertCss方法
最終效果:
react-helmet說(shuō)到seo優(yōu)化,有一點(diǎn)大家一定可以答上來(lái),那就是在head標(biāo)簽里加入title標(biāo)簽以及兩個(gè)meta標(biāo)簽(keywords、description)。在單頁(yè)面應(yīng)用中,title和meta都是固定的,但是在多頁(yè)應(yīng)用中,不同頁(yè)面的title和meta可能是不一樣的,因此服務(wù)端渲染項(xiàng)目是需要支持title和meta的修改的。react生態(tài)圈已經(jīng)有人做了一個(gè)庫(kù)來(lái)幫助我們?nèi)?shí)現(xiàn)這個(gè)功能,這個(gè)庫(kù)就是react-helmet。這里我們說(shuō)一下其基本使用,更多使用方法大家可以查看其官方文檔。
組件import {Helmet} from "react-helmet"; class User extends React.PureComponent{ render(){ const {users} = this.props; return} }用戶頁(yè) user.name).join(",")} />
操作步驟:
在render方法中入一個(gè)React元素Helmet。
Helmet內(nèi)部加入title、meta等數(shù)據(jù)。
服務(wù)端import {Helmet} from "react-helmet"; app.get("*",function(req,res){ const content = renderToString(); const helmet = Helmet.renderStatic(); res.send(` ${helmet.title.toString()} ${helmet.meta.toString()} {createRouter("server")({ location:req.url, context })} ${content}`); })
操作步驟:
執(zhí)行Helmet.renderStatic()獲取title、meta等數(shù)據(jù)。
將數(shù)據(jù)綁定到html模版上。
注意事項(xiàng):Helmet.renderStatic一定要在renderToString方法之后調(diào)用,否則無(wú)法獲取到數(shù)據(jù)。結(jié)語(yǔ)
看完本篇文章你需要掌握以下內(nèi)容:
服務(wù)端渲染的基本流程。
react同構(gòu)概念。
如何添加路由?
如何解決重定向和404問題?
如何添加redux?
如何基于redux完成數(shù)據(jù)的脫水和注水?
node層如何配置代理?
如何實(shí)現(xiàn)網(wǎng)頁(yè)源代碼中有css樣式?
react-helmet的使用
關(guān)于服務(wù)端渲染,網(wǎng)上也有不少聲音覺得這個(gè)東西非常的雞肋。服務(wù)端渲染具有兩大優(yōu)勢(shì):
良好的SEO
較短的白屏?xí)r間
認(rèn)為這兩個(gè)優(yōu)勢(shì)根本不算優(yōu)勢(shì),首先是第一個(gè)優(yōu)勢(shì),單頁(yè)應(yīng)用可以可以通過(guò)prerender技術(shù)來(lái)解決其seo問題,具體實(shí)現(xiàn)就是在webpack配置文件中加一個(gè)prerender-spa-plugin插件。其次是首屏渲染時(shí)間,服務(wù)端渲染由于是需要提前加載數(shù)據(jù)的,這里的白屏?xí)r間就需要加上數(shù)據(jù)等待時(shí)間,如果遇到等待時(shí)間較長(zhǎng)的接口,這體驗(yàn)絕對(duì)是不如客戶端渲染。另外客戶端渲染有很多措施可以減少白屏?xí)r間,比如異步組件、js拆分、cdn等等技術(shù)。這一來(lái)二去“較短的白屏?xí)r間”這個(gè)優(yōu)勢(shì)就不復(fù)存在了。我個(gè)人認(rèn)為服務(wù)端渲染還是有應(yīng)用場(chǎng)景的,就比如那種活動(dòng)頁(yè)項(xiàng)目。這種活動(dòng)頁(yè)項(xiàng)目是非常在意白屏?xí)r間,越短的白屏?xí)r間越能留住用戶去瀏覽,活動(dòng)頁(yè)數(shù)據(jù)請(qǐng)求很少,也就是說(shuō)服務(wù)端渲染的數(shù)據(jù)等待時(shí)間基本上可以忽略不計(jì)的,其次活動(dòng)頁(yè)項(xiàng)目的特點(diǎn)就是數(shù)量比較多,當(dāng)數(shù)量多到一定程度之后,客戶端渲染那些減少白屏?xí)r間的手段的效果就不那么明顯了。總結(jié)一下什么樣的項(xiàng)目適合服務(wù)端渲染,這個(gè)項(xiàng)目要具有以下兩個(gè)條件:
比較在意白屏?xí)r間
接口的等待時(shí)間較短
頁(yè)面數(shù)量很多,而且未來(lái)有很大的增長(zhǎng)空間
個(gè)人認(rèn)為SEO不能作為選擇服務(wù)端渲染的首要原因,首先服務(wù)端渲染項(xiàng)目要比客戶端渲染項(xiàng)目復(fù)雜不少,其次客戶端有技術(shù)可以解決SEO問題,再者服務(wù)端渲染所帶來(lái)的SEO提升并不是很明顯,想要SEO好,花錢是少不了的。說(shuō)出來(lái)你可能不信,百度搜索“外賣”關(guān)鍵詞,第一頁(yè)居然沒有美團(tuán),前三條的廣告既沒有餓了么也沒有美團(tuán)。
在實(shí)際項(xiàng)目中還是建議大家使用Next.js這種服務(wù)端渲染的框架,Next.js已經(jīng)非常完善了,簡(jiǎn)單易用,又有良好的文檔,而且還有中文版本哦!這可是不太愛看英文文檔的小伙伴們的福音。不太建議大家手動(dòng)搭一個(gè)服務(wù)端渲染項(xiàng)目,服務(wù)端渲染相對(duì)來(lái)說(shuō)比較復(fù)雜,未必能面面俱到,本篇文章也只是講了一些比較核心的點(diǎn),很多細(xì)節(jié)點(diǎn)還是沒有涉及到的。其次,項(xiàng)目交接給別人也比較費(fèi)時(shí)間。當(dāng)然,如果是為了更深入地了解服務(wù)端渲染,自己手動(dòng)搭一個(gè)最好不過(guò)了。最后附上代碼地址:
本篇文章示例代碼:https://github.com/ruichengpi...
完整的服務(wù)端渲染項(xiàng)目:https://github.com/ruichengpi...
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://systransis.cn/yun/106193.html
摘要:從零開始最小實(shí)現(xiàn)服務(wù)器渲染前言最近在寫的時(shí)候想到,如果我部分代碼提供,部分代碼支持,那我應(yīng)該如何寫呢不想拆成個(gè)服務(wù)的情況下而且最近寫的項(xiàng)目里面也用過(guò)一些服務(wù)端渲染,如,自己也搭過(guò)的項(xiàng)目,確實(shí)開發(fā)體驗(yàn)都非常友好,但是友好歸友好,具體又是如何實(shí) showImg(https://segmentfault.com/img/bVMbjB?w=1794&h=648); 從零開始最小實(shí)現(xiàn) react...
摘要:前端每周清單半年盤點(diǎn)之與篇前端每周清單專注前端領(lǐng)域內(nèi)容,以對(duì)外文資料的搜集為主,幫助開發(fā)者了解一周前端熱點(diǎn)分為新聞熱點(diǎn)開發(fā)教程工程實(shí)踐深度閱讀開源項(xiàng)目巔峰人生等欄目。與求同存異近日,宣布將的構(gòu)建工具由遷移到,引發(fā)了很多開發(fā)者的討論。 前端每周清單半年盤點(diǎn)之 React 與 ReactNative 篇 前端每周清單專注前端領(lǐng)域內(nèi)容,以對(duì)外文資料的搜集為主,幫助開發(fā)者了解一周前端熱點(diǎn);分為...
摘要:今天這篇文章顯然不是討論這兩個(gè)詞語(yǔ)的,我們要嘗試使用最新版,構(gòu)件一個(gè)簡(jiǎn)單的服務(wù)端渲染應(yīng)用。這樣取代了完全由客戶端渲染前后端分離方式模式。在場(chǎng)景下,我們可以使用自身的完成服務(wù)端初次渲染。這也是它在推出短短時(shí)間以來(lái),便迅速走紅的原因之一。 參加或留意了最近舉行的JSConf CN 2017的同學(xué),想必對(duì) Next.js 不再陌生, Next.js 的作者之一到場(chǎng)進(jìn)行了精彩的演講。其實(shí)在更早...
摘要:今天這篇文章顯然不是討論這兩個(gè)詞語(yǔ)的,我們要嘗試使用最新版,構(gòu)件一個(gè)簡(jiǎn)單的服務(wù)端渲染應(yīng)用。這樣取代了完全由客戶端渲染前后端分離方式模式。在場(chǎng)景下,我們可以使用自身的完成服務(wù)端初次渲染。這也是它在推出短短時(shí)間以來(lái),便迅速走紅的原因之一。 參加或留意了最近舉行的JSConf CN 2017的同學(xué),想必對(duì) Next.js 不再陌生, Next.js 的作者之一到場(chǎng)進(jìn)行了精彩的演講。其實(shí)在更早...
摘要:新聞熱點(diǎn)國(guó)內(nèi)國(guó)外,前端最新動(dòng)態(tài)蘋果開源了版近日,蘋果開源了一款基于事件驅(qū)動(dòng)的跨平臺(tái)網(wǎng)絡(luò)應(yīng)用程序開發(fā)框架,它有點(diǎn)類似,但開發(fā)語(yǔ)言使用的是。蘋果稱的目標(biāo)是幫助開發(fā)者快速開發(fā)出高性能且易于維護(hù)的服務(wù)器端和客戶端應(yīng)用協(xié)議。 showImg(https://segmentfault.com/img/remote/1460000013677379); 前端每周清單專注大前端領(lǐng)域內(nèi)容,以對(duì)外文資料的...
閱讀 902·2023-04-26 01:37
閱讀 3376·2021-09-02 15:40
閱讀 970·2021-09-01 10:29
閱讀 2900·2019-08-29 17:05
閱讀 3428·2019-08-28 18:02
閱讀 1187·2019-08-28 18:00
閱讀 1494·2019-08-26 11:00
閱讀 2620·2019-08-26 10:27