詳解React+Koa實現(xiàn)服務(wù)端渲染(SSR)
React是目前前端社區(qū)最流行的UI庫之一,它的基于組件化的開發(fā)方式極大地提升了前端開發(fā)體驗,React通過拆分一個大的應(yīng)用至一個個小的組件,來使得我們的代碼更加的可被重用,以及獲得更好的可維護性,等等還有其他很多的優(yōu)點...
通過React, 我們通常會開發(fā)一個單頁應(yīng)用(SPA),單頁應(yīng)用在瀏覽器端會比傳統(tǒng)的網(wǎng)頁有更好的用戶體驗,瀏覽器一般會拿到一個body為空的html,然后加載script指定的js, 當(dāng)所有js加載完畢后,開始執(zhí)行js, 最后再渲染到dom中, 在這個過程中,一般用戶只能等待,什么都做不了,如果用戶在一個高速的網(wǎng)絡(luò)中,高配置的設(shè)備中,以上先要加載所有的js然后再執(zhí)行的過程可能不是什么大問題,但是有很多情況是我們的網(wǎng)速一般,設(shè)備也可能不是最好的,在這種情況下的單頁應(yīng)用可能對用戶來說是個很差的用戶體驗,用戶可能還沒體驗到瀏覽器端SPA的好處時,就已經(jīng)離開網(wǎng)站了,這樣的話你的網(wǎng)站做的再好也不會有太多的瀏覽量。
但是我們總不能回到以前的一個頁面一個頁面的傳統(tǒng)開發(fā)吧,現(xiàn)代化的UI庫都提供了服務(wù)端渲染(SSR)的功能,使得我們開發(fā)的SPA應(yīng)用也能完美的運行在服務(wù)端,大大加快了首屏渲染的時間,這樣的話用戶既能更快的看到網(wǎng)頁的內(nèi)容,與此同時,瀏覽器同時加載需要的js,加載完后把所有的dom事件,及各種交互添加到頁面中,最后還是以一個SPA的形式運行,這樣的話我們既提升了首屏渲染的時間,又能獲得SPA的客戶端用戶體驗,對于SEO也是個必須的功能。
OK,我們大致了解了SSR的必要性,下面我們就可以在一個React App中來實現(xiàn)服務(wù)端渲染的功能,BTW, 既然我們已經(jīng)處在一個到處是async/await的環(huán)境中,這里的服務(wù)端我們使用koa2來實現(xiàn)我們的服務(wù)端渲染。
初始化一個普通的單頁應(yīng)用SPA
首先我們先不管服務(wù)端渲染的東西,我們先創(chuàng)建一個基于React和React-Router的SPA,等我們把一個完整的SPA創(chuàng)建好后,再加入SSR的功能來最大化提升app的性能。
首先進入app入口 App.js:
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
const Home = () => <div>Home</div>;
const Hello = () => <div>Hello</div>;
const App = () => {
return (
<Router>
<Route exact path="/" component={Home} />
<Route exact path="/hello" component={Hello} />
</Router>
)
}
ReactDOM.render(<App/>, document.getElementById('app'))
上面我們?yōu)槁酚? 和 /hello創(chuàng)建了2個只是渲染一些文字到頁面的組件。但當(dāng)我們的項目變得越來越大,組件越來越多,最終我們打包出來的js可能會變得很大,甚至變得不可控,所以呢我們第一步需要優(yōu)化的是代碼拆分(code-splitting),幸運的是通過webpack dynamic import 和 react-loadable,我們可以很容易做到這一點。
用React-Loadable來時間代碼拆分
使用之前,先安裝 react-loadable:
npm install react-loadable # or yarn add react-loadable
然后在你的 javascript中:
//...
import Loadable from 'react-loadable';
//...
const AsyncHello = Loadable({
loading: <div>loading...</div>,
//把你的Hello組件寫到單獨的文件中
//然后使用webpack的 dynamic import
loader: () => import('./Hello'),
})
//然后在你的路由中使用loadable包裝過的組件:
<Route exact path="/hello" component={AsyncHello} />
很簡單吧,我們只需要import react-loadable, 然后傳一些option進去就行了,其中的loading選項是當(dāng)動態(tài)加載Hello組件所需的js時,渲染loading組件,給用戶一種加載中的感覺,體驗也會比什么都不加好。
好了,現(xiàn)在如果我們訪問首頁的話,只有Home組件依賴的js才會被加載,然后點擊某個鏈接進入hello頁面的話,會先渲染loading組件,并同時異步加載hello組件依賴的js,加載完后,替換掉loading來渲染hello組件。通過基于路由拆分代碼到不同的代碼塊,我們的SPA已經(jīng)有了很大的優(yōu)化,cheers🍻。更叼的是react-loadable同樣支持SSR,所以你可以在任意地方使用react-loadable,不管是運行在前端還是服務(wù)端,要讓react-loadable在服務(wù)端正常運行的話我們需要做一些額外的配置,本文后面會講到,先不急🏃。
到這里我們已經(jīng)創(chuàng)建好一個基本的React SPA,加上代碼拆分,我們的app已經(jīng)有了不錯的性能,但是我們還可以更加極致的優(yōu)化app的性能,下面我們通過增加SSR的功能來進一步提升加載速度,順便解決一下SPA中的SEO問題🎉。
加入服務(wù)端渲染(SSR)功能
首先我們先搭建一個最簡單的koa web服務(wù)器:
npm install koa koa-router
然后在koa的入口文件app.js中:
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
router.get('*', async (ctx) => {
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR</title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/bundle.js"></script>
</body>
</html>
`;
});
app.use(router.routes());
app.listen(3000, '0.0.0.0');
上面*路由代表任意的url進來我們都默認渲染這個html,包括html中打包出來的js,你也可以用一些服務(wù)端模板引擎(如:nunjucks)來直接渲染html文件,在webpack打包時通過html-webpack-plugin來自動插入打包出來的js/css資源路徑。
OK, 我們的簡易koa server好了,接下來我們開始編寫React SSR的入口文件AppSSR.js,這里我們需要使用StaticRouter來代替之前的BrowserRouter,因為在服務(wù)端,路由是靜態(tài)的,用BrowserRouter的話是不起作用的,后面還會做一些配置來使得react-loadable運行在服務(wù)端。
提示: 你可以把整個node端的代碼用ES6/JSX風(fēng)格編寫,而不是部分commonjs,部分JSX, 但這樣的話你需要用webpack把整個服務(wù)端的代碼編譯成commonjs風(fēng)格,才能使得它運行在node環(huán)境中,這里的話我們把React SSR的代碼單獨抽出去,然后在普通的node代碼里去require它。因為可能在一個現(xiàn)有的項目中,之前都是commonjs的風(fēng)格,把以前的node代碼一次性轉(zhuǎn)成ES6的話成本有點大,但是可以后期一步步的再遷移過去
OK, 現(xiàn)在在你的 AppSRR.js中:
import React from 'react';
//使用靜態(tài) static router
import { StaticRouter } from 'react-router-dom';
import ReactDOMServer from 'react-dom/server';
import Loadable from 'react-loadable';
//下面這個是需要讓react-loadable在服務(wù)端可運行需要的,下面會講到
import { getBundles } from 'react-loadable/webpack';
import stats from '../build/react-loadable.json';
//這里吧react-router的路由設(shè)置抽出去,使得在瀏覽器跟服務(wù)端可以共用
//下面也會講到...
import AppRoutes from 'src/AppRoutes';
//這里我們創(chuàng)建一個簡單的class,暴露一些方法出去,然后在koa路由里去調(diào)用來實現(xiàn)服務(wù)端渲染
class SSR {
//koa 路由里會調(diào)用這個方法
render(url, data) {
let modules = [];
const context = {};
const html = ReactDOMServer.renderToString(
<Loadable.Capture report={moduleName => modules.push(moduleName)}>
<StaticRouter location={url} context={context}>
<AppRoutes initialData={data} />
</StaticRouter>
</Loadable.Capture>
);
//獲取服務(wù)端已經(jīng)渲染好的組件數(shù)組
let bundles = getBundles(stats, modules);
return {
html,
scripts: this.generateBundleScripts(bundles),
};
}
//把SSR過的組件都轉(zhuǎn)成script標簽扔到html里
generateBundleScripts(bundles) {
return bundles.filter(bundle => bundle.file.endsWith('.js')).map(bundle => {
return `<script type="text/javascript" src="${bundle.file}"></script>\n`;
});
}
static preloadAll() {
return Loadable.preloadAll();
}
}
export default SSR;
當(dāng)編譯這個文件的時候,在webpack配置里使用target: "node" 和 externals,并且在你的打包前端app的webpack配置中,需要加入react-loadable的插件,app的打包需要在ssr打包之前運行,不然拿不到react-loadable需要的各組件信息,先來看app的打包:
//webpack.config.dev.js, app bundle
const ReactLoadablePlugin = require('react-loadable/webpack')
.ReactLoadablePlugin;
module.exports = {
//...
plugins: [
//...
new ReactLoadablePlugin({ filename: './build/react-loadable.json', }),
]
}
在.babelrc中加入loadable plugin:
{
"plugins": [
"syntax-dynamic-import",
"react-loadable/babel",
["import-inspector", {
"serverSideRequirePath": true
}]
]
}
上面的配置會讓react-loadable知道哪些組件最終在服務(wù)端被渲染了,然后直接插入到html script標簽中,并在前端初始化時把SSR過的組件考慮在內(nèi),避免重復(fù)加載,下面是SSR的打包:
//webpack.ssr.js
const nodeExternals = require('webpack-node-externals');
module.exports = {
//...
target: 'node',
output: {
path: 'build/node',
filename: 'ssr.js',
libraryExport: 'default',
libraryTarget: 'commonjs2',
},
//避免把node_modules里的庫都打包進去,此ssr js會直接運行在node端,
//所以不需要打包進最終的文件中,運行時會自動從node_modules里加載
externals: [nodeExternals()],
//...
}
然后在koa app.js, require它,并且調(diào)用SSR的方法:
//...koa app.js
//build出來的ssr.js
const SSR = require('./build/node/ssr');
//preload all components on server side, 服務(wù)端沒有動態(tài)加載各個組件,提前先加載好
SSR.preloadAll();
//實例化一個SSR對象
const s = new SSR();
router.get('*', async (ctx) => {
//根據(jù)路由,渲染不同的頁面組件
const rendered = s.render(ctx.url);
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app">${rendered.html}</div>
<script type="text/javascript" src="/runtime.js"></script>
${rendered.scripts.join()}
<script type="text/javascript" src="/app.js"></script>
</body>
</html>
`;
ctx.body = html;
});
//...
以上是個簡單的實現(xiàn)React SSR到koa web server, 為了使react-loadable知道哪些組件在服務(wù)端渲染了,rendered里面的scripts數(shù)組里面包含了SSR過的組件組成的各個script標簽,里面調(diào)用了SSR#generateBundleScripts()方法,在插入時需要確保這些script標簽在runtime.js之后((通過 CommonsChunkPlugin 來抽出來)),并且在app bundle之前(也就是初始化的時候應(yīng)該已經(jīng)知道之前的哪些組件已經(jīng)渲染過了)。更多react-loadable服務(wù)端支持,參考這里.
上面我們還把react-router的路由都單獨抽出去了,使得它可以運行在瀏覽器跟服務(wù)端,以下是AppRoutes組件:
//AppRoutes.js
import Loadable from 'react-loadable';
//...
const AsyncHello = Loadable({
loading: <div>loading...</div>,
loader: () => import('./Hello'),
})
function AppRoutes(props) {
<Switch>
<Route exact path="/hello" component={AsyncHello} />
<Route path="/" component={Home} />
</Switch>
}
export default AppRoutes
//然后在 App.js 入口中
import AppRoutes from './AppRoutes';
// ...
export default () => {
return (
<Router>
<AppRoutes/>
</Router>
)
}
服務(wù)端渲染的初始狀態(tài)
目前為止,我們已經(jīng)創(chuàng)建了一個React SPA,并且能在瀏覽器端跟服務(wù)端共同運行🍺,社區(qū)稱之為universal app 或者 isomophic app。但是我們現(xiàn)在的app還有一個遺留問題,一般來說我們app的數(shù)據(jù)或者狀態(tài)都需要通過遠端的api來異步獲取,拿到數(shù)據(jù)后我們才能開始渲染組件,服務(wù)端SSR也是一樣,我們要動態(tài)的獲取初始數(shù)據(jù),然后才能扔給React去做SSR,然后在瀏覽器端我們還要初始化就能同步獲取這些SSR時的初始化數(shù)據(jù),避免瀏覽器端初始化時又重新獲取了一遍。
下面我們簡單從github獲取一些項目的信息作為頁面初始化的數(shù)據(jù), 在koa的app.js中:
//...
const fetch = require('isomorphic-fetch');
router.get('*', async (ctx) => {
//fetch branch info from github
const api = 'https://api.github.com/repos/jasonboy/wechat-jssdk/branches';
const data = await fetch(api).then(res => res.json());
//傳入初始化數(shù)據(jù)
const rendered = s.render(ctx.url, data);
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app">${rendered.html}</div>
<script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify(data)}</script>
<script type="text/javascript" src="/runtime.js"></script>
${rendered.scripts.join()}
<script type="text/javascript" src="/app.js"></script>
</body>
</html>
`;
ctx.body = html;
});
然后在你的Hello組件中,你需要checkwindow里面(或者在App入口中統(tǒng)一判斷,然后通過props傳到子組件中)是否存在window.__INITIAL_DATA__,有的話直接用來當(dāng)做初始數(shù)據(jù),沒有的話我們在componentDidMount生命周期函數(shù)中再去來數(shù)據(jù):
export default class Hello extends React.Component {
constructor(props) {
super(props);
this.state = {
//這里直接判斷window,如果是父組件傳入的話,通過props判斷
github: window.__INITIAL_DATA__ || [],
};
}
componentDidMount() {
//判斷沒有數(shù)據(jù)的話,再去請求數(shù)據(jù)
//請求數(shù)據(jù)的方法也可以抽出去,以讓瀏覽器及服務(wù)端能統(tǒng)一調(diào)用,避免重復(fù)寫
if (this.state.github.length <= 0) {
fetch('https://api.github.com/repos/jasonboy/wechat-jssdk/branches')
.then(res => res.json())
.then(data => {
this.setState({ github: data });
});
}
}
render() {
return (
<div>
<ul>
{this.state.github.map(b => {
return <li key={b.name}>{b.name}</li>;
})}
</ul>
</div>
);
}
}
好了,現(xiàn)在如果頁面被服務(wù)端渲染過的話,瀏覽器會拿到所有渲染過的html, 包括初始化數(shù)據(jù),然后通過這些SSR的內(nèi)容配合加載的js,再組成一個完整的SPA,就像一個普通的SPA一樣,但是我們得到了更好的性能,更好的SEO😎。
React-v16 更新
在React的最新版v16中,SSR的API做了很多的優(yōu)化,并且提供了新的基于流的API來更好的提升性能,通過streaming api, 服務(wù)端可以邊渲染邊把前面渲染好的html發(fā)到瀏覽器,瀏覽器端也可以提前開始渲染頁面而不是等服務(wù)端所有組件都渲染完成后才能開始瀏覽器端的初始化,提升了性能也降低了服務(wù)端資源的消耗。還有一個在瀏覽器端需要注意的是需要使用ReactDOM.hydrate()來代替之前的ReactDOM.render(),更多的更新參考medium文章whats-new-with-server-side-rendering-in-react-16.
💖要查看完整的demo, 參考koa-web-kit, koa-web-kit是一個現(xiàn)代化的基于React/Koa的全棧開發(fā)框架,包括React SSR支持,可以直接用來測試服務(wù)端渲染的功能😀
結(jié)論
好了,以上就是React-SSR + Koa的簡單實踐,通過SSR,我們既提升了性能,又很好的滿足了SEO的要求,Best of the Both Worlds🍺。
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
詳解react native頁面間傳遞數(shù)據(jù)的幾種方式
這篇文章主要介紹了詳解react native頁面間傳遞數(shù)據(jù)的幾種方式,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-11-11
React中useCallback useMemo到底該怎么用
在React函數(shù)組件中,當(dāng)組件中的props發(fā)生變化時,默認情況下整個組件都會重新渲染。換句話說,如果組件中的任何值更新,整個組件將重新渲染,包括沒有更改values/props的函數(shù)/組件。在react中,我們可以通過memo,useMemo以及useCallback來防止子組件的rerender2023-02-02
React報錯Function?components?cannot?have?string?refs
這篇文章主要為大家介紹了React報錯Function?components?cannot?have?string?refs解決方案詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-12-12
深入React?18源碼useMemo?useCallback?memo用法及區(qū)別分析
這篇文章主要為大家介紹了React?18源碼深入分析useMemo?useCallback?memo用法及區(qū)別,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-04-04
使用Axios在React中請求數(shù)據(jù)的方法詳解
這篇文章主要給大家介紹了初學(xué)React,如何規(guī)范的在react中請求數(shù)據(jù),主要介紹了使用axios進行簡單的數(shù)據(jù)獲取,加入狀態(tài)變量,優(yōu)化交互體驗,自定義hook進行數(shù)據(jù)獲取和使用useReducer改造請求,本文主要適合于剛接觸React的初學(xué)者以及不知道如何規(guī)范的在React中獲取數(shù)據(jù)的人2023-09-09
React如何使用refresh_token實現(xiàn)無感刷新頁面
本文主要介紹了React如何使用refresh_token實現(xiàn)無感刷新頁面,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-04-04
VSCode 配置React Native開發(fā)環(huán)境的方法
本篇文章主要介紹了VSCode 配置React Native開發(fā)環(huán)境的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-12-12

