SSR 有兩種模式,單頁面和非單頁面模式,第一種是後端首次渲染的單頁面應用,第二種是徹底使用後端路由的後端模版渲染模式。他們區別在於使用後端路由的程度。javascript
爲何說首次加載快呢。 一個普通的單頁面應用,首次加載的時候須要把全部相關的靜態資源加載完畢,而後核心 JS 纔會開始執行,這個過程就會消耗必定的時間,接着還會請求網絡接口,最終才能徹底渲染完成。html
注意:頁面能很快的展現出來,可是因爲當前返回的只是單純展現的 DOM、CSS,其中的 JS 相關的事件等在客戶端其實並無綁定,因此最終仍是須要 JS 加載完之後,對當前的頁面再進行一次渲染,稱爲同構。 因此 SSR 就是更快的先展現出頁面的內容,先讓用戶可以看到。前端
爲何 SEO 友好呢,由於搜索引擎爬蟲在爬取頁面信息的時候,會發送 HTTP 請求來獲取網頁內容,而咱們服務端渲染首次的數據是後端返回的,返回的時候已是渲染好了 title,內容等信息,便於爬蟲抓取內容。java
import Index from "../pages/index";
import List from "../pages/list";
const routers = [
{ exact: true, path: "/", component: Index },
{ exact: true, path: "/list", component: List }
];複製代碼
咱們在 client 的 router 文件夾中創建兩個 JS 文件 index 和 pages:node
pages 裏配置路由路徑和組件的映射,代碼大體以下,使其能被客戶端路由和服務端路由同時使用。react
import Index from "../pages/index";
import List from "../pages/list";
const routers = [
{ exact: true, path: "/", component: Index },
{ exact: true, path: "/list", component: List }
];
//註冊頁面和引入組件,存在對象中,server路由匹配後渲染
export const clientPages = (() => {
const pages = {};
routers.forEach(route => {
pages[route.path] = route.component;
});
return pages;
})();
export default routers;複製代碼
在 server 路由中代碼大體是這樣的,在服務端獲取到get請求之後,匹配路徑,若是路徑 path 是有映射頁面組件的,獲取到此組件並渲染,這就是咱們的第一步:後端攔截路由,根據路徑找到須要渲染的 react 頁面組件。 webpack
import { clientPages } from "./../../client/router/pages";
router.get("*", (ctx, next) => {
let component = clientPages[ctx.path];
if (component) {
const data = await component.getInitialProps();
//由於component是變量,因此須要create
const dom = renderToString(
React.createElement(component, {
ssrData: data
})
)
}
})複製代碼
這一步比較重要,爲何咱們須要一個靜態方法,而不是直接把請求寫在 willmount 中呢。 由於在服務端使用 renderToString 渲染組件時,生命週期只會執行到 willmount 以後第一次 render,在 willmount 內部,請求是異步的,第一次 render 完成的時候,異步的數據都沒有獲取到,這個時候 renderToString 就已經返回了。 那咱們頁面的初始化數據就沒有了,返回的 HTML 不是咱們所指望的。 所以定義了一個靜態方法,在組件實例化以前獲取到這個方法,同步執行,數據獲取完成後,經過 props 把數據傳入給組件進行渲染。 git
那麼這個方法是如何實現的呢? 咱們根據代碼截圖來看 base.js:github
import React from "react";
export default class Base extends React.Component {
//override 獲取須要服務端首次渲染的異步數據
static async getInitialProps() {
return null;
}
static title = "react ssr";
//page組件中不要重寫constructor
constructor(props) {
super(props);
//若是定義了靜態state,按照生命週期,state應該優先於ssrData
if (this.constructor.state) {
this.state = {
...this.constructor.state
};
}
//若是是首次渲染,會拿到ssrData
if (props.ssrData) {
if (this.state) {
this.state = {
...this.state,
...props.ssrData
};
} else {
this.state = {
...props.ssrData
};
}
}
}
async componentWillMount() {
//客戶端運行時
if (typeof window != "undefined") {
if (!this.props.ssrData) {
//非首次渲染,也就是單頁面路由狀態改變,直接調用靜態方法
//咱們不肯定有沒有異步代碼,若是getInitialProps直接返回一個初始化state,這樣會形成自己應該同步執行的,由於await沒有同步執行,形成狀態混亂
//因此建議初始化state須要寫在class屬性中,用static靜態方法定義,constructor時會將其合併到實例中。
//爲何不直接寫state屬性而要加static,由於默認屬性會執行在constructor以後,這樣會覆蓋constructor定義的state
const data = await this.constructor.getInitialProps(); //靜態方法,經過構造函數獲取
if (data) {
this.setState({ ...data });
}
}
//設置標題
document.title = this.constructor.title;
}
}
}複製代碼
首先在 client 的 pages 裏新建一個 base 組件,base 繼承 React.Component,全部 pages 裏的頁面組件都須要繼承這個 base,base 有一個靜態方法 getInitialProps,此方法主要是返回組件初始化須要的異步數據。 若是有初始化的 ajax 請求,就應該重寫在此方法裏,而且 return 數據對象。 web
constructor 判斷了頁面組件是否有初始化定義的 state 靜態屬性,有的話傳遞給組件實例化的 state 對象,若是 props 有傳入 ssrData,把 ssrData 傳遞值給組件 state 對象。
base 中的 componentWillMount 會判斷是否還須要去執行 getInitialProps 方法,若是在服務端渲染的時候,數據已經在組件實例化以前同步獲取並傳入了 props,因此忽略。
若是在客戶端環境,分兩種狀況。
第一種:用戶第一次進到頁面,這時候是服務端去請求的數據,服務端獲取到數據後在服務端渲染組件,同時也會把數據存放在 HTML 的 script 代碼中,定義一個全局變量 ssrData,以下圖,react 在註冊單頁面應用而且同構的時候會把全局 ssrData 傳遞給頁面組件,這個時候頁面組件在客戶端同構渲染的時候,就能夠延續使用服務端以前的數據,這樣也保持了同構的一致性,也避免了一次重複請求。
第二種狀況:就是當前用戶在單頁面之中切換路由,這樣就沒有服務端渲染,那麼就執行 getInitialProps 方法,把數據直接返回給 state,幾乎等同於在 willmount 中執行請求。 這樣封裝咱們就能夠用一套代碼兼容服務端渲染和單頁面渲染。
client/app.js
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}
}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);複製代碼
再看看如何寫頁面組件,下面是頁面組件 Index 的截圖,Index 繼承 Base,定義了靜態 state,組件 constructor 方法會把此對象傳遞給組件實例化的 state 對象中,之因此用靜態方法來寫默認數據,是想保證定義的默認 state 先傳遞給實例對象的 state,接口請求傳遞的props 數據後傳遞給實例對象的 state。
爲何不直接寫 state 屬性而要加 static,由於 state 屬性會執行在 constructor 以後,這樣會覆蓋 constructor 定義的 state,也就是會覆蓋咱們 getInitialProps 返回的數據。
export default class Index extends Base {
//注意看看:base關於getInitialProps的註釋
static state = {
desc: "Hello world~"
};
//替代componentWillMount
static async getInitialProps() {
let data;
const res = await request.get("/api/getData");
if (!res.errCode) data = res.data;
return {
data
};
}
}複製代碼
注意:在服務端渲染環境下,執行 renderToString 的時候,組件會被實例化,而且返回字符串形式的 DOM,這個過程 react 組件的生命週期只會執行到 willmount 以後的 render。
3)咱們寫好一個 HTML 文件,大體以下。 當前已經渲染出了相應的節點字符串,後端須要返回 HTML 文本,內容應該包含標題,節點和最後須要加載的打包好的 JS,依次去替換 HTML 佔位部分。
<!DOCTYPE html>
<html lang="en"> <head> <title>/*title*/</title> </head> <body> <div id="root">$$$$</div> <script> /*getInitialProps*/ </script> <script src="/*app*/"></script> <script src="/*vendor*/"></script> </body> </html>複製代碼
server/router.js
indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
"/*getInitialProps*/",
`window.ssrData=${JSON.stringify(data)};window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();複製代碼
4)最後客戶端 JS 加載完成後,會運行 react,而且執行同構方法 ReactDOM.hydrate,而不是平時用的 ReactDOM.render。
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);複製代碼
如下是首次渲染過程大體流程圖,點擊查看大圖
如今咱們已經完成了最核心的邏輯,可是有一個問題。 我發如今後端渲染組件的時候,style-loader 會報錯,style-loader 會找到組件依賴的 CSS,並在組件加載時,把 style 載入到 HTML header 中,可是咱們在服務端渲染的時候,沒有 window 對象,所以 style-loader 內部代碼會報錯。
服務端 webpack 須要移除 style-loader,用其餘方法代替,後來我把樣式賦值給組件靜態變量,而後經過服務端渲染一併返回給前端,可是有個問題,我只能拿到當前組件的樣式,子組件的樣式沒辦法拿到,若是要給子組件再添加靜態方法,再想辦法去取,那就太麻煩了。
後來我找到了一個庫 isomorphic-style-loader 能夠支持咱們想要的功能,看了下它的源碼和使用方法,經過高階函數把樣式賦值給組件,而後利用 react 的 Context,拿到當前須要渲染的全部組件的樣式,最後把 style 插入到 HTML 中,這樣解決了子組件樣式沒法導入的問題。 可是我以爲有點麻煩,首先須要定義全部組件的高階函數和引入這個庫,而後在 router 之中須要寫相關代碼收集 style,最後插入到 HTML 中。
以後我定義了一個 ProcessSsrStyle 方法,入參是 style 文件,邏輯是判斷環境,若是是服務端把 style 加載到當前組件的 DOM 中,若是是客戶端就不處理(由於客戶端有style-loader)。 實現和使用很是簡單,以下:
ProcessSsrStyle.js
import React from "react";
export default style => {
if (typeof window != "undefined") {
//客戶端
return;
}
return <style>{style}</style>;
};複製代碼
使用:
render() {
return (
<div className="index"> {ProcessSsrStyle(style)} </div>
);
}複製代碼
服務端返回 HTML 的內容以下,用戶立刻可以看到完整的頁面樣式,而當客戶端 react 同構完成後,DOM 會被替換爲純 DOM,由於 ProcessSsrStyle 方法在客戶端不會輸出 style,最終style-loader 執行後 header 中也會有樣式,,頁面不會出現不一致的變化,對於用戶來講這一切都是無感的。
至此,最核心的功能已經實現,可是在後來的開發中,我發現事情還並無那麼簡單,由於開發環境彷佛太不友好了,開發效率低,須要手動重啓。
先說說最初的開發環境如何工做:
webpack 打包後,啓動了兩個服務,一個是服務端的 app 應用、端口爲 9999,一個是客戶端的 dev-server、端口爲 8888,dev-server 會監聽和打包 client 代碼,能夠在客戶端代碼更新的時候,實時熱更新前端代碼。
當訪問 localhost:9999時,server 會返回 HTML,咱們的 server 返回的 HTML 中的 JS 腳本路徑是指向的 dev-serve 端口的地址,以下圖。 也就是說,客戶端的程序和服務端的程序被分別打包,而且運行兩個不一樣的端口服務。
在生產環境下,由於不須要 dev-server 去監聽和熱更新,所以只一個服務就足夠, 以下圖,服務端註冊靜態資源文件夾:
server/app.js
app.use(
staticCache("dist/client", {
cacheControl: "no-cache,public",
gzip: true
})
);複製代碼
目前的構建系統,區分了生產環境和開發環境,如今的開發環境構建是沒有什麼問題的。 可是開發環境問題就比較明顯,存在的最大問題是服務端沒有熱更新或者從新打包重啓。 這樣會致使不少問題,最嚴重的就是前端已經更新了組件,可是服務端並無更新,因此在同構的時候會出現不一致,就會致使報錯,有些報錯會影響運行,解決辦法只有重啓。 這樣的開發體驗是沒法忍受的。 後來我開始考慮作服務端的熱更新。
最初個人方法是監聽修改,打包而後重啓應用。 還記得咱們的 client/router/pages.js 文件嗎,客戶端和服務端的路由都引入了這個文件,因此服務端和客戶端的打包依賴都有pages.js,所以全部 pages 的組件相關的依賴均可以被客戶端和服務端監聽,當一個組件更新了,dev-server 已經幫助咱們監聽和熱更新了客戶端代碼,如今咱們要本身來處理如下如何更新和重啓服務端代碼。
其實方法很簡單,就是在服務端打包配置裏開啓監聽,而後在插件配置中,寫一個重啓的插件,插件代碼以下:
plugins: [
new function() {
this.apply = compiler => {
//自定義註冊鉤子函數,watch監聽修改並編譯完成後,done被觸發,callback必須執行,不然不會執行後續流程
compiler.hooks.done.tap(
"recomplie_complete",
(compilation, callback) => {
if (serverChildProcess) {
console.log("server recomplie completed");
serverChildProcess.kill();
}
serverChildProcess = child_process.spawn("node", [
path.resolve(cwd, "dist/server/bundle.js"),
"dev"
]);
serverChildProcess.stdout.on("data", data => {
console.log(`server out: ${data}`);
});
serverChildProcess.stderr.on("data", data => {
console.log(`server err: ${data}`);
});
callback && callback();
}
);
};
}()
]複製代碼
當 webpack 首次運行以後,插件會啓動一個子進程,運行 app.js,當文件發生變更後,再次編譯,判斷是否有子進程,若是有殺掉子進程,而後重啓子進程,這樣就實現了自動重啓。 由於客戶端和服務端是兩個不一樣的打包服務和配置,當文件被修改,他們同時會從新編譯,爲了保證編譯後運行符合預期,要保證服務端先編譯完成,客戶端後編譯完成,因此在客戶端的 watch 配置裏,增長一點延遲,以下圖,默認是 300 毫秒,因此服務端是 300 毫秒後執行編譯,而客戶端是 1000 毫秒後執行編譯。
watchOptions: {
ignored: ["node_modules"],
aggregateTimeout: 1000 //優化,儘可能保證後端從新打包先執行完
}複製代碼
如今解決了重啓問題,可是我以爲還不夠,由於在開發的大部分時間裏 pages.js 中組件,也就是展現端的代碼更新頻率會很高,若是總是去重啓編譯後端的代碼,我以爲效率過低。 所以我以爲再作一次優化。
流程應該是這樣的,增長一個 webpack.server-dev-pages.js 配置文件,單獨監聽和打包出 dist/pages,服務端代碼判斷若是是開發環境,在路由監聽方法中每次執行都從新獲取dist/pages 包,服務端監聽配置忽略 client 文件夾。
看起來有點懵逼,其實最終的效果就是當 pages 中依賴的組件發生了更新,webpack.server-dev-pages.js 從新編譯並打包到 dist/pages中,服務端app不編譯和重啓,只須要在服務端app路由中從新獲取最新的 dist/pages 包,就保證了服務應用更新了全部客戶端組件,而服務端應用並不會編譯和重啓。 當服務端自己的代碼發生了修改,仍是會自動編譯和重啓。
因此最終咱們的開發環境須要啓動3個打包配置
server/router,如何清除和更新 pages 包
const path = require("path");
const cwd = process.cwd();
delete __non_webpack_require__.cache[
__non_webpack_require__.resolve(
path.resolve(cwd, "dist/pages/pages.js")
)];
component = __non_webpack_require__(
path.resolve(cwd, "dist/pages/pages.js")
).clientPages[ctx.path];複製代碼
至此,比較滿意的開發環境基本實現了。 後來又以爲每次更新 CSS 都須要去從新打包後端的pages 也沒有必要,加上同構的時候 CSS 不一致,僅僅只有警告,沒有實質影響,所以我在server-dev-pages 中忽略了 less 文件(由於我用的 less)。 這樣會致使一個問題,由於沒有更新pages,因此頁面會刷新時會先展現舊的樣式,而後同構完成又立馬變成新樣式,在開發環境中這一瞬間是能夠接受的,也不影響什麼。 可是避免了無謂的編譯。
watchOptions: {
ignored: ["**/*.less", "node_modules"] //忽略less,樣式修改並不會影響同構
}複製代碼
最初作本身小站的目的是學習,加上本身使用,所以有太多個性的東西。 從本身的小站中抽離了出來,已經刪去了不少包和代碼,只爲了讓他人更能快速理解其中的核心代碼。 代碼中有不少註釋都能幫助他人理解,若是你們想使用當前庫開發一個本身的小站,是徹底能夠的,也能夠幫助你們更好的理解它。 若是是用於商業項目,推薦 nextjs。
CSS 沒有作做用域控制,所以若是想隔離做用域,手動添加上層 CSS 隔離,好比 .index{ ..... } 包裹一層,或者嘗試本身引入三方包。
webpack 通用的配置能夠封裝成一個文件,而後在每一個文件裏引入,再個性修改。 可是以前看其餘代碼的時候發現,這種方法,會增長閱讀難度,加上自己配置內容很少,因此不作封裝,看起來更直觀。
開發環境下,圖片路徑會出現不一致,好比客戶端地址請求地址是 localhost...assets/xx.jpg,而服務端是 assets/xx.jpg,可能會有警告,可是不影響。 由於只是一個是絕對路徑,一個是相對路徑。
對於此次的 SSR 服務端渲染的實現仍是挺滿意的,也花費了挺多時間。 感覺下加載速度吧,歡迎訪問大詩人小站,dashiren.cn/ 。 部分頁面有接口請求,好比dashiren.cn/space,加載速度依然很快。
倉庫已經準備好,下載下來試試吧,安裝依賴後,運行命令便可。github.com/zimv/react-…
碼字不易,點個贊吧~