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