react同構(gòu)直出方案

react同構(gòu)直出方案

@(Tech)[React技術(shù)棧]

同構(gòu)直出的好處

  1. SEO,讓搜索引擎更容易讀取頁(yè)面內(nèi)容
  2. 首屏渲染速度更快(重點(diǎn)),無(wú)需等待js文件下載執(zhí)行的過(guò)程
  3. 更易于維護(hù),服務(wù)端和客戶端可以共享某些代碼

關(guān)鍵技術(shù)棧

  • react v16
  • react-router-dom v4
  • redux v4
  • webpack v3
  • express v4
  • react-loadable v5
  • eslint
  • prettier

主要問(wèn)題

  1. 如何實(shí)現(xiàn)組件同構(gòu)?
  2. 如何保持前后端應(yīng)用狀態(tài)一致?
  3. 如何解決前后端路由匹配問(wèn)題?
  4. 如何處理服務(wù)端對(duì)靜態(tài)資源的依賴?
  5. 如何配置兩套不同的環(huán)境(開發(fā)環(huán)境和產(chǎn)品環(huán)境)?
  6. 如何劃分更合理的項(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)境

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容