react同構(gòu)直出方案
@(Tech)[React技術(shù)棧]
同構(gòu)直出的好處
- SEO,讓搜索引擎更容易讀取頁(yè)面內(nèi)容
- 首屏渲染速度更快(重點(diǎn)),無(wú)需等待js文件下載執(zhí)行的過(guò)程
- 更易于維護(hù),服務(wù)端和客戶端可以共享某些代碼
關(guān)鍵技術(shù)棧
- react v16
- react-router-dom v4
- redux v4
- webpack v3
- express v4
- react-loadable v5
- eslint
- prettier
主要問(wèn)題
- 如何實(shí)現(xiàn)組件同構(gòu)?
- 如何保持前后端應(yīng)用狀態(tài)一致?
- 如何解決前后端路由匹配問(wèn)題?
- 如何處理服務(wù)端對(duì)靜態(tài)資源的依賴?
- 如何配置兩套不同的環(huán)境(開發(fā)環(huán)境和產(chǎn)品環(huán)境)?
- 如何劃分更合理的項(xiàng)目目錄結(jié)構(gòu)?
同構(gòu)方案
React本身是以Virtual DOM的形式存儲(chǔ)在內(nèi)存中。
對(duì)于客戶端,同構(gòu)ReactDOM.render方法把Virtual DOM轉(zhuǎn)換成真實(shí)DOM最后渲染到瀏覽器界面。
import ReactDOM from 'react-dom';
import App from './App'
ReactDOM.render(
<App/>,
document.getElementById('Root'),
);
對(duì)于服務(wù)端,通過(guò)ReactDOMServer.renderToString方法把Virtual DOM轉(zhuǎn)換成HTML字符串返回給客戶端,從而達(dá)到服務(wù)端渲染的目的。
import ReactDOMServer from 'react-dom/server';
import App from './App'
const html = ReactDOMServer.renderToString(<App/>);
res.render('home', {html:html});
狀態(tài)管理
我們使用Redux來(lái)管理應(yīng)用數(shù)據(jù)狀態(tài)。當(dāng)進(jìn)行服務(wù)端渲染時(shí),創(chuàng)建store實(shí)例后,將store的初始狀態(tài)回傳給客戶端,客戶端拿到初始狀態(tài)后,把它作為預(yù)加載狀態(tài)來(lái)創(chuàng)建store實(shí)例。這樣能夠保證客戶端和服務(wù)端生成的markup是一致的。
服務(wù)端
import { renderToString } from 'react-dom/server'
?
function handleRender(req, res) {
// Create a new Redux store instance
const store = createStore(counterApp)
?
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
?
// Grab the initial state from our Redux store
const preloadedState = store.getState()
?
res.render('home', {
html,
preloadedState: JSON.stringify(store.getState()).replace(/</g, '\\u003c')
});
}
handlebars
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React Isomorphic Boilerplate</title>
</head>
<body>
<div id="Root">{{{html}}}</div>
<script>
window.__PRELOADED_STATE__ = {{{preloadedState}}};
</script>
</body>
</html>
客戶端
import React from 'react'
import { hydrate } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './containers/App'
import counterApp from './reducers'
?
// Grab the state from a global variable injected into the server-generated HTML
const preloadedState = window.__PRELOADED_STATE__
?
// Allow the passed state to be garbage-collected
delete window.__PRELOADED_STATE__
?
// Create Redux store with initial state
const store = createStore(counterApp, preloadedState)
?
hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
路由方案
服務(wù)端渲染時(shí),使用無(wú)狀態(tài)的<StaticRouter>替代<BrowserRouter>。
當(dāng)客戶端使用<Redirect>時(shí),瀏覽器的history狀態(tài)會(huì)發(fā)生改變,我們會(huì)跳轉(zhuǎn)到新的頁(yè)面。在服務(wù)端,我們通過(guò)context屬性獲得服務(wù)端渲染的結(jié)果。如果context.url有值,則認(rèn)為應(yīng)用發(fā)生了跳轉(zhuǎn),此時(shí)服務(wù)端應(yīng)該進(jìn)行跳轉(zhuǎn)操作。同時(shí),我們也可以使用context跟蹤跳轉(zhuǎn)狀態(tài)碼。
RootComponent
import React from 'react';
import {
Route,
Link,
Redirect,
} from 'react-router-dom';
const RootComponent = () => (
<div>
<h2>React Test</h2>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/test">Test</Link>
</li>
<li>
<Link to="/h2">Hello2</Link>
</li>
</ul>
<hr/>
<Route exact path="/" render={() => <Redirect to="/home"/>}/>
<Route exact path="/home" component={TestContainer}/>}/>
<Route path="/test" component={LoadableTestContainer}/>
<Route path="/h2" component={LoadableHello2Component}/>
</div>
);
客戶端
import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import {BrowserRouter} from 'react-router-dom';
import configureStore from './redux/store';
import RootComponent from './RootComponent';
const render = (Component) => {
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<Component/>
</BrowserRouter>
</Provider>
document.getElementById('Root'),
);
};
render(RootComponent);
服務(wù)端
// This context object contains the results of the render
const context = {};
const appWidthRouter = (
<Provider store={store}>
<StaticRouter
location={req.url}
context={context}
>
<RootComponent/>
</StaticRouter>
</Provider>);
const html = ReactDOMServer.renderToString(appWidthRouter);
// context.url will contain the URL to redirect to if a <Redirect> was used
if (context.url) {
res.redirect(302, context.url);
} else {
res.render(viewName, {
html,
preloadedState: JSON.stringify(store.getState()).replace(/</g, '\\u003c')
});
}
靜態(tài)資源處理
客戶端代碼使用webpack打包已經(jīng)很常見(jiàn)了,我們可以把jsx語(yǔ)法、sass文件、圖片等等資源,最終通過(guò)webpack配合各種loader、plugin打包成相應(yīng)的瀏覽器端兼容的代碼。
而在服務(wù)端,不支持import、jsx這種語(yǔ)法,并且無(wú)法識(shí)別對(duì)css、image資源后綴的模塊引用,那么應(yīng)該怎么處理這些靜態(tài)資源呢?
開發(fā)環(huán)境
為了開發(fā)體驗(yàn)起見(jiàn),最好是一個(gè)在線執(zhí)行環(huán)境,那么在Node Web服務(wù)開始前,我們需要準(zhǔn)備以下操作:
- 首先引入babel-polyfill這個(gè)庫(kù)來(lái)提供regenerator運(yùn)行時(shí)和core-js來(lái)模擬全功能ES6環(huán)境。
- 引入babel-register,這是一個(gè)require鉤子,會(huì)自動(dòng)對(duì)require命令所加載的js文件進(jìn)行實(shí)時(shí)轉(zhuǎn)碼,需要注意的是,這個(gè)庫(kù)只適用于開發(fā)環(huán)境。
- 引入css-modules-require-hook,同樣是鉤子,只針對(duì)樣式文件,由于我們采用的是CSS Modules方案,并且使用SASS來(lái)書寫代碼,所以需要node-sass這個(gè)前置編譯器來(lái)識(shí)別擴(kuò)展名為.scss的文件,通過(guò)這個(gè)鉤子,自動(dòng)提取className哈希字符注入到服務(wù)端的React組件中。
- 引入asset-require-hook,來(lái)識(shí)別圖片資源。
// Provide custom regenerator runtime and core-js
require('babel-polyfill');
// // Node babel source map support
require('source-map-support').install();
// Javascript require hook
require('babel-register')();
// Css require hook
require('css-modules-require-hook')({
extensions: ['.scss'],
preprocessCss: (data, filename) =>
require('node-sass').renderSync({
data,
file: filename
}).css,
camelCase: true,
generateScopedName: '[local]___[hash:base64:5]'
});
// Image require hook
require('asset-require-hook')({
name: '/public/img/[name].[ext]',
extensions: ['jpg', 'png', 'gif', 'webp','svg'],
});
產(chǎn)品環(huán)境
在產(chǎn)品環(huán)境,我們使用webpack分別對(duì)客戶端和服務(wù)端代碼進(jìn)行打包。
服務(wù)端代碼打包,需要指定運(yùn)行環(huán)境為node,并且提供polyfill,設(shè)置 __filename 和 __dirname為true。
由于是采用CSS Modules,服務(wù)端只需獲取className,而無(wú)需加載樣式代碼,所以要使用css-loader/locals替代css-loader加載樣式文件。
使用externals處理不打包的依賴庫(kù),通過(guò)引入webpack-node-externals庫(kù),將忽略node_modules下的依賴庫(kù)。
設(shè)置libraryTarget值為commonjs2,bundle最終會(huì)以module.exports導(dǎo)出,適應(yīng)于Node環(huán)境運(yùn)行。
{
name: 'server',
context: path.resolve(__dirname, '..'),
entry: {
app: './server/server.prod',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../dist/server'),
chunkFilename: 'chunk.[name].js',
libraryTarget: 'commonjs2',
publicPath: '/public/'
},
target: 'node',
node: {
__filename: true,
__dirname: true
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
forceEnv: 'server',
}
}
},
{
test: /\.scss$/,
use: [
{
loader: 'css-loader/locals', // translates CSS into CommonJS
options: {
modules: true,
importLoaders: 1,
// localIdentName: '[path]___[name]__[local]___[hash:base64:5]',
localIdentName: '[local]___[hash:base64:5]'
}
},
{
loader: 'sass-loader' // compiles Sass to CSS
}
]
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: 'img/[name].[ext]?[hash:5]'
}
}
]
}
]
},
resolve: {
extensions: ['.js', '.json', '.scss'],
},
plugins: [
new CleanWebpackPlugin([path.resolve(__dirname, '../dist/server')], {root: path.join(__dirname, '../')}),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
},
}),
]
}
動(dòng)態(tài)加載
對(duì)于大型的Web應(yīng)用來(lái),所有代碼打包到一個(gè)文件不是一種優(yōu)雅的做法。用戶使用應(yīng)用時(shí),并不想下載整個(gè)應(yīng)用的代碼。通過(guò)webpack,babel-plugin-syntax-dynamic-import, 和react-loadable,可以非常靈活的實(shí)現(xiàn)動(dòng)態(tài)加載。
服務(wù)端渲染需要使用依賴babel-plugin-import-inspector。
.bashrc
"plugins": [
"syntax-dynamic-import",
["import-inspector", {
"serverSideRequirePath": true,
"webpackRequireWeakId": true
}]
]
eg.
import Loadable from 'react-loadable';
import Loading from './Loading';
const LoadableComponent = Loadable({
loader: () => import('./Dashboard'),
loading: Loading,
})
export default class LoadableDashboard extends React.Component {
render() {
return <LoadableComponent />;
}
}
優(yōu)化
- 提取第三方庫(kù),命名vendor
- 所有的js均以
chunkhash方式命名 - 所有的css均以
contenthash方式命名 - 基于
babel-runtime模擬ES6環(huán)境,在.bashrc中配置需要引入的模塊 - 提取公共模塊,manifest文件起過(guò)渡作用
- 圖片、字體庫(kù)、視頻類文件均帶hash后綴
{
test: /\.(png|svg|jpg|jpeg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: 'img/[name].[ext]?[hash:5]',
},
},
],
},
部署方案
對(duì)于客戶端代碼,將全部靜態(tài)資源上傳至CDN服務(wù)器;
對(duì)于服務(wù)端代碼,則采用pm2部署。
其他
提升開發(fā)體驗(yàn)
對(duì)于客戶端代碼,可以使用Hot Module Replacement技術(shù),并配合webpack-dev-middleware,webpack-hot-middleware兩個(gè)中間件,與傳統(tǒng)的BrowserSync不同的是,它可以使我們不用通過(guò)刷新瀏覽器的方式,讓js和css改動(dòng)實(shí)時(shí)更新反饋至瀏覽器界面中。
app.use(webpackDevMiddleware(compiler, {
noInfo: true,
publicPath: webpackConfig.output.publicPath,
}));
app.use(webpackHotMiddleware(compiler, {
path: '/__webpack_hmr',
}));
對(duì)于服務(wù)端代碼,則使用nodemon監(jiān)聽代碼改動(dòng),來(lái)自動(dòng)重啟node服務(wù)器。
nodemon ./server/server.dev.js --watch server --watch tools
代碼風(fēng)格約束
使用ESLint并配置ESLint規(guī)則,結(jié)合prettier、eslint-plugin-prettier、eslint-config-prettier來(lái)檢查和格式化代碼問(wèn)題。
日志記錄
TODO
參考鏈接
文章對(duì)應(yīng)的github源碼
教你如何搭建一個(gè)超完美的服務(wù)端渲染開發(fā)環(huán)境