React服務端渲染(代碼分割和數據預取)

前幾節已經把項目基本骨架和路由搭建好了,但做爲實際項目開發,這些仍是不夠的。隨着業務的增大,應用層序代碼也隨之增大,若是把全部代碼都打包到一個文件裏面,首次加載會致使時間相對變長,增長流量(對移動端來講)。應用程序包含不少頁面,某一時刻用戶只會訪問一個頁面,用戶未訪問的頁面代碼在訪問以前不該該被加載,只有在用戶訪問時才應改加載頁面所需資源。以前搭建好的項目暫不涉及數據交互,業務最核心的東西就是數據,本節將會介紹基於路由的代碼分割、數據交互和同步javascript

上一節:先後端路由同構css

源碼地址見文章末尾html

本節部分代碼已進行重寫,詳情請戳這裏java

代碼分割

路由懶加載

在作代碼分割的時候有不少解決方案,如react-loadablereact-async-componentloadable-components,三者都支持Code Splitting和懶加載,並且都支持服務端渲染。react-loadable和react-async-component在作服務端渲染時,步驟十分繁瑣,loadable-components提供了簡單的操做來支持服務端渲染,這裏選用loadable-componentsnode

若是你使用webpack4,loadable-components請使用新的版本這裏是使用新版本重寫的完整例子react

安裝loadable-componentswebpack

npm install loadable-components
複製代碼

將路由配置中的組件改爲動態導入ios

src/router/index.jsgit

import Loadable from "loadable-components";

const router = [
  {
    path: "/bar",
    component: Loadable(() => import("../views/Bar"))
  },
  {
    path: "/baz",
    component: Loadable(() => import("../views/Baz"))
  },
  {
    path: "/foo",
    component: Loadable(() => import("../views/Foo"))
  },
  {
    path: "/top-list",
    component: Loadable(() => import("../views/TopList")),
    exact: true
  }
];
複製代碼

import()動態導入是從Webpack2開始支持的語法,本質上是使用了promise,若是要在老的瀏覽器中運行須要es6-promisepromise-polyfilles6

爲了解析import()語法,須要配置babel插件syntax-dynamic-import,而後單頁面應用中就能夠工做了。這裏使用loadable-components來作服務端渲染,babel配置以下

"plugins": [
  "loadable-components/babel"
]
複製代碼

注意:這裏使用babel6.x的版本

在客戶端使用loadComponents方法加載組件而後進行掛載。客戶端入口修改以下

src/entry-client.js

import { loadComponents } from "loadable-components";
import App from "./App";

// 開始渲染以前加載所需的組件
loadComponents().then(() => {
  ReactDOM.hydrate(<App />, document.getElementById("app"));
});
複製代碼

服務端調用getLoadableState()而後將狀態插入到html片斷中

src/server.js

const { getLoadableState } = require("loadable-components/server");

...

let component = createApp(context, req.url);
// 提取可加載狀態
getLoadableState(component).then(loadableState => {
  let html = ReactDOMServer.renderToString(component);

  if (context.url) {  // 當發生重定向時,靜態路由會設置url
    res.redirect(context.url);
    return;
  }

  if (!context.status) {  // 無status字段表示路由匹配成功
    // 獲取組件內的head對象,必須在組件renderToString後獲取
    let head = component.type.head.renderStatic();
    // 替換註釋節點爲渲染後的html字符串
    let htmlStr = template
    .replace(/<title>.*<\/title>/, `${head.title.toString()}`)
    .replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()}`)
    .replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>\n${loadableState.getScriptTag()}`);
    // 將渲染後的html字符串發送給客戶端
    res.send(htmlStr);
  } else {
    res.status(context.status).send("error code:" + context.status);
  }
});
複製代碼

調用getLoadableState()傳入根組件,等待狀態加載完成後進行渲染並調用loadableState.getScriptTag()把返回的腳本插入到html模板中

服務端渲染須要modules選項

const AsyncComponent = loadable(() => import('./MyComponent'), {
  modules: ['./MyComponent'],
})
複製代碼

這個選項不須要手動編寫,使用loadable-components/babel插件便可。import()語法在node中並不支持,因此服務端還須要配置一個插件dynamic-import-node

安裝dynamic-import-node

npm install babel-plugin-dynamic-import-node --save-dev
複製代碼

客戶端不須要這個插件,接下來修改webpack配置,客戶端使用.babelrc文件,服務端經過loader的options選項指定babel配置

webpack.config.base.js中的如下配置移到webpack.config.client.js

{
  test: /\.(js|jsx)$/,
  loader: ["babel-loader", "eslint-loader"],
  exclude: /node_modules/
}
複製代碼

webpack.config.client.js

rules: [
  {
    test: /\.(js|jsx)$/,
    loader: ["babel-loader", "eslint-loader"],
    exclude: /node_modules/
  },
  ...util.styleLoaders({
    sourceMap: isProd ? true : false,
    usePostCSS: true,
    extract: isProd ? true : false
  })
]
複製代碼

服務端打包配置修改以下

webpack.config.server.js

rules: [
  {
    test: /\.(js|jsx)$/,
    use: [
      {
        loader: "babel-loader",
        options: {
          babelrc: false,
          presets: [
            "react",
            [
              "env",
              { "targets": { "node": "current" } }
            ]
          ],
          "plugins": [ "dynamic-import-node", "loadable-components/babel" ]
        }
      },
      { loader: "eslint-loader" }
    ],
    exclude: /node_modules/
  },
  ...util.styleLoaders({
    sourceMap: true,
    usePostCSS: true,
    extract: true
  })
]
複製代碼

運行npm run dev,打開瀏覽器輸入http://localhost:3000,在network面板中能夠看到先下載app.b73b88f66d1cc5797747.js,而後下載當前bar頁面所需的js(下圖中的3.b73b88f66d1cc5797747.js

當點擊其它路由就會下載對應的js而後執行

Webpack打包優化

實際使用中,隨着應用的迭代更新,打包文件後的文件會愈來愈大,其中主要腳本文件app.xxx.js包含了第三方模塊和業務代碼,業務代碼會隨時變化,而第三方模塊在必定的時間內基本不變,除非你對目前使用的框架或庫進行升級。app.xxx.js中的xxx使用chunkhash命名,chunkhash表示chunk內容的hash,第三方模塊的chunk不會變化,咱們將其分離出來,便於瀏覽器緩存

關於output.filename更多信息請戳這裏

爲了提取第三方模塊,須要使用webpack自帶的CommonsChunkPlugin插件,同時爲了更好的緩存咱們將webpack引導模塊提取到一個單獨的文件中

webpack.config.client.js

plugins: [
  ...
  new webpack.optimize.CommonsChunkPlugin({
    name: "vendor",
    minChunks: function(module) {
      // 阻止.css文件資源打包到vendor chunk中
      if(module.resource && /\.css$/.test(module.resource)) {
        return false;
      }
      // node_modules目錄下的模塊打包到vendor chunk中
      return module.context && module.context.includes("node_modules");
    }
  }),
  // 分離webpack引導模塊
  new webpack.optimize.CommonsChunkPlugin({
    name: "manifest",
    minChunks: Infinity
  })
]
複製代碼

經過以上配置會打包出包含第三方模塊的vendor.xxx.jsmanifest.xxx.js

注意:這裏使用webpack3.x的版本,CommonsChunkPlugin在webpack4中已移除。webpack4請使用SplitChunksPlugin

項目中在生產模式下才使用了chunkhash,接下來運行npm run build打包

修改src/App.jsx中的代碼,再進行打包

能夠看到vender.xxx.js文件名沒有產生變化,app.xxx.js變化了,4個異步組件打包後的文件名沒有變化,mainfest.xxx.js發生了變化

數據預取和同步

服務端渲染須要把頁面內容由服務端返回給客戶端,若是某些內容是經過調用接口請求獲取的,那麼就要提早加載數據而後渲染,再調用ReactDOMServer.renderToString()渲染出完整的頁面,客戶端渲染出來的html內容要和服務端返回的html內容一致,這就須要保證客戶端的數據和服務端的數據是一致的

數據管理這裏選用Redux,Redux在作服務端渲染時,每次請求都要建立一個新的Store,而後初始化state返回給客戶端,客戶端拿到這個state建立一個新的Store

Redux服務端渲染示例

加入Redux

安裝相關依賴

npm install redux redux-thunk react-redux
複製代碼

首先搭建Redux基本項目結構

actionTypes.js

export const SET_TOP_LIST = "SET_TOP_LIST";

export const SET_TOP_DETAIL = "SET_TOP_DETAIL";
複製代碼

actions.js

import { SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes";

export function setTopList(topList) {
  return { type: SET_TOP_LIST, topList };
}

export function setTopDetail(topDetail) {
  return { type: SET_TOP_DETAIL, topDetail };
}
複製代碼

reducers.js

import { combineReducers } from "redux";
import * as ActionTypes from "./actionTypes";

const initialState = {
  topList: [],
  topDetail: {}
}

function topList(topList = initialState.topList, action) {
  switch (action.type) {
    case ActionTypes.SET_TOP_LIST:
      return action.topList;
    default:
      return topList;
  }
}

function topDetail(topDetail = initialState.topDetail, action) {
  switch (action.type) {
    case ActionTypes.SET_TOP_DETAIL:
      return action.topDetail;
    default:
      return topDetail;
  }
}

const reducer = combineReducers({
  topList,
  topDetail
});

export default reducer;
複製代碼

store.js

import { createStore, applyMiddleware } from "redux";
import thunkMiddleware from "redux-thunk";
import reducer from "./reducers";

// 導出函數,以便客戶端和服務端根據初始state建立store
export default (store) => {
  return createStore(
    reducer,
    store,
    applyMiddleware(thunkMiddleware) // 容許store能dispatch函數
  );
}
複製代碼

這裏請求數據須要使用異步Action,默認Store只能dispatch對象,使用redux-thunk中間件就能夠dispatch函數了

接下來在action.js中編寫異步Action建立函數

import { getTopList, getTopDetail } from "../api";

...

export function fatchTopList() {
  // dispatch由thunkMiddleware傳入
  return (dispatch, getState) => {
    return getTopList().then(response => {
      const data = response.data;
      if (data.code === 0) {
        // 獲取數據後dispatch,存入store
        dispatch(setTopList(data.data.topList));
      }
    });
  }
}

export function fetchTopDetail(id) {
  return (dispatch, getState) => {
    return getTopDetail(id).then(response => {
      const data = response.data;
      if (data.code === 0) {
        const topinfo = data.topinfo;
        const top = {
          id: topinfo.topID,
          name: topinfo.ListName,
          pic: topinfo.pic,
          info: topinfo.info
        };
        dispatch(setTopDetail(top));
      }
    });
  }
}
複製代碼

上述代碼中Action建立函數返回一個帶有異步請求的函數,這個函數中能夠dispatch其它action。在這裏這個函數中調用接口請求,請求完成後把數據經過dispatch存入到state,而後返回Promise,以便異步請求完成後作其餘處理。在異步請求中須要同時支持服務端和客戶端,你可使用axios或者在瀏覽器端使用fetch API,node中使用node-fetch

在這裏使用了QQ音樂的接口做爲數據來源,服務端使用axios,客戶端不支持跨域使用了jsonpsrc/api/index.js中的代碼看起來像下面這樣

import axios from "axios";
import jsonp from "jsonp";

const topListUrl = "https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg";

if (process.env.REACT_ENV === "server") {
  return axios.get(topListUrl + "?format=json");
} else {
  // 客戶端使用jsonp請求
  return new Promise((resolve, reject) => {
    jsonp(topListUrl + "?format=jsonp", {
      param: "jsonpCallback",
      prefix: "callback"
    }, (err, data) => {
      if (!err) {
        const response = {};
        response.data = data;
        resolve(response);
      } else {
        reject(err);
      }
    });
  });
}
複製代碼

若是你想了解更多QQ音樂接口請戳這裏

讓React展現組件訪問state的方法就是使用react-redux模塊的connect方法鏈接到Store,編寫容器組件TopList

src/containers/TopList.jsx

import { connect } from "react-redux"
import TopList from "../views/TopList";

const mapStateToProps = (state) => ({
    topList: state.topList
});

export default connect(mapStateToProps)(TopList);
複製代碼

src/router/index.js中把有原來的import("../views/TopList"))改爲import("../containers/TopList"))

{
  path: "/top-list",
  component: Loadable(() => import("../containers/TopList")),
  exact: true
}
複製代碼

在展現組件TopList中經過props訪問state

class TopList extends React.Component {
  render() {
    const { topList } = this.props;
    return (
      <div>
        ...
        <ul className="list-wrapper">
          {
            topList.map(item => {
              return <li className="list-item" key={item.id}>
                {item.title}
              </li>;
            })
          }
        </ul>
      </div>
    )
  }
}
複製代碼

接下來在服務端入口文件entry-server.js中使用Provider包裹StaticRouter,並導出createStore函數

src/entry-server.js

import createStore from "./redux/store";
...

const createApp = (context, url, store) => {
  const App = () => {
    return (
      <Provider store={store}>
        <StaticRouter context={context} location={url}>
          <Root setHead={(head) => App.head = head}/>  
        </StaticRouter>
      </Provider>
    )
  }
  return <App />;
}

module.exports = {
  createApp,
  createStore
};
複製代碼

server.js中獲取createStore函數建立一個沒有數據的Store

let store = createStore({});

// 存放組件內部路由相關屬性,包括狀態碼,地址信息,重定向的url
let context = {};
let component = createApp(context, req.url, store);
複製代碼

客戶端一樣使用Provider包裹,建立一個沒有數據的Store並傳入

src/App.jsx

import createStore from "./redux/store";
...

let App;
if (process.env.REACT_ENV === "server") {
  // 服務端導出Root組件
  App = Root;
} else {
  const Provider = require("react-redux").Provider;
  const store = createStore({});
  App = () => {
    return (
      <Provider store={store}>
        <Router>
          <Root />
        </Router>
      </Provider>
    );
  };
}
export default App;
複製代碼

預取數據

獲取數據有兩種作法第一種是把加載數據的方法放到路由上,就像下面這樣

const routes = [
  {
    path: "/",
    component: Root,
    loadData: () => getSomeData()
  }
  ...
];
複製代碼

另外一種作法就是把加載數據的方法放到對應的組件上定義成靜態方法,這種作法更直觀

本例採用第二種作法在TopList組件中定義一個靜態方法asyncData,傳入store用來dispatch異步Action,這裏定義成靜態方法是由於組件渲染以前尚未被實例化沒法訪問this

static asyncData(store) {
  return store.dispatch(fatchTopList());
}
複製代碼

fatchTopList返回的函數被redux-thunk中間件調用,redux-thunk中間件會把調用函數的返回值看成dispatch方法的返回值傳遞

如今須要在請求的時候獲取路由組件的asyncData方法並調用,react-router在react-router-config模塊中爲咱們提供了matchRoutes方法,根據路由配置來匹配路由

爲了在服務端使用路由匹配,路由配置要從entry-server.js中導出

src/entry-server.js

import { router } from "./router";
...

module.exports = {
  createApp,
  createStore,
  router
};
複製代碼

server.js中獲取router路由配置,當全部異步組件加載完成後調用matchRoutes()進行路由匹配,調用全部匹配路由的asyncData方法後進行渲染

let promises;
getLoadableState(component).then(loadableState => {
  // 匹配路由
  let matchs = matchRoutes(router, req.path);
  promises = matchs.map(({ route, match }) => {
    const asyncData = route.component.Component.asyncData;
    // match.params獲取匹配的路由參數
    return asyncData ? asyncData(store, Object.assign(match.params, req.query)) : Promise.resolve(null);
  });

  // resolve全部asyncData
  Promise.all(promises).then(() => {
    // 異步數據請求完成後進行服務端render
    handleRender();
  }).catch(error => {
    console.log(error);
    res.status(500).send("Internal server error");
  });
  ...
}
複製代碼

上述代碼中使用route.component獲取的是loadable-components返回的異步組件,route.component.Component纔是真正的路由組件,必須在調用getLoadableState()後才能獲取。若是組件存在asyncData方法就放到promises數組中,不存在就返回一個resolve好的Promise,而後將全部Promise resolve。有些url相似/path/:idmatch.params就是用來獲取該url中的:id表示的參數,若是某些參數以?形似傳遞,能夠經過req.query獲取,合併到match.params中,傳給組件處理

注意:matchRoutes中第二個參數請用req.pathreq.path獲取的url中不包含query參數,這樣才能正確匹配

同步數據

服務端預先請求數據並存入Store中,客戶端根據這個state初始化一個Store實例,只要在服務端加載數據後調用getState()獲取到state並返回給客戶端,客戶端取到這個這個state便可

server.js中獲取初始的state,經過window.__INITIAL_STATE__保存在客戶端

src/server.js

let preloadedState = {};
...

// resolve全部asyncData
Promise.all(promises).then(() => {
  // 獲取預加載的state,供客戶端初始化
  preloadedState = store.getState();
  // 異步數據請求完成後進行服務端render
  handleRender();
}).catch(error => {
  console.log(error);
  res.status(500).send("Internal server error");
});

...
let htmlStr = template
.replace(/<title>.*<\/title>/, `${head.title.toString()}`)
.replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()}
  <script type="text/javascript">
    window.__INITIAL_STATE__ = ${JSON.stringify(preloadedState)}
  </script>
`)
.replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>\n${loadableState.getScriptTag()}`);
複製代碼

App.jsx中獲取window.__INITIAL_STATE__

// 獲取服務端初始化的state,建立store
const initialState = window.__INITIAL_STATE__;
const store = createStore(initialState);
複製代碼

此時客戶端和服務端數據能夠同步了

客戶端數據獲取

對於客戶端路由跳轉,是在瀏覽器上完成的,這個時候客戶端也須要請求數據

TopList組件的componentDidMount生命週期函數中dispatch異步Action建立函數fatchTopList的返回值

componentDidMount() {
  this.props.dispatch(fatchTopList());
}
複製代碼

這裏組件已經被實例化,因此能夠經過this訪問Store的dispatch,同時這個函數只會在客戶端執行

你可能會想要在componentWillMountdispatch異步Action,官方已經對生命週期函數作了更改(請戳這裏),16.x版本中啓用對componentWillMountcomponentWillReceivePropscomponentWillUpdate過時警告,17版本中會移除這三個周期函數,推薦在componentDidMount中獲取數據(請戳這裏

有一種狀況若是服務端提早加載了數據,當客戶端掛載DOM後執行了componentDidMount又會執行一次數據加載,這一次數據加載是多餘的,看下圖

訪問http://localhost:3000/top-list,服務端已經預取到數據並把結果HTML字符串渲染好了,紅色方框中是客戶端DOM掛載之後發送的請求。爲了不這種狀況,新增一個state叫clientShouldLoad默認值爲true,表示客戶端是否加載數據,爲clientShouldLoad編寫好actionType、action建立函數和reducer函數

actionTypes.js

export const SET_CLIENT_LOAD = "SET_CLIENT_LOAD";
複製代碼

actions.js

import { SET_CLIENT_LOAD, SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes";

export function setClientLoad(clientShouldLoad) {
  return { type: SET_CLIENT_LOAD, clientShouldLoad };
}
複製代碼

reducers.js

const initialState = {
  clientShouldLoad: true,
  topList: [],
  topDetail: {}
}

function clientShouldLoad(clientShouldLoad = initialState.clientShouldLoad, action) {
  switch (action.type) {
    case ActionTypes.SET_CLIENT_LOAD:
      return action.clientShouldLoad;
    default:
      return clientShouldLoad;
  }
}
...

const reducer = combineReducers({
  clientShouldLoad,
  topList,
  topDetail
});
複製代碼

容器組件TopList中對clientShouldLoad進行映射

src/containers/TopList.jsx

const mapStateToProps = (state) => ({
    clientShouldLoad: state.clientShouldLoad,
    topList: state.topList
});
複製代碼

當服務端預取數據後修改clientShouldLoadfalse,客戶端掛載後判斷clientShouldLoad是否爲true,若是爲true就獲取數據,爲false就將clientShouldLoad改成true,以便客戶端跳轉到其它路由後獲取的clientShouldLoadtrue,進行數據獲取

在異步Action建立函數中,當前運行的是服務端數據,請求完成後dispatch

actions.js

export function fatchTopList() {
  // dispatch由thunkMiddleware傳入
  return (dispatch, getState) => {
    return getTopList().then(response => {
      const data = response.data;
      if (data.code === 0) {
        // 獲取數據後dispatch,存入store
        dispatch(setTopList(data.data.topList));
      }
      if (process.env.REACT_ENV === "server") {
        dispatch(setClientLoad(false));
      }
    });
  }
}
複製代碼

TopList組件中增長判斷

TopList.jsx

componentDidMount() {
  // 判斷是否須要加載數據
  if (this.props.clientShouldLoad === true) {
    this.props.dispatch(fatchTopList());
  } else {
    // 客戶端執行後,將客戶端是否加載數據設置爲true
    this.props.dispatch(setClientLoad(true));
  }
}
複製代碼

此時訪問http://localhost:3000/top-list,客戶端少了一次數據請求。以下圖

總結

本節利用webpack動態導入的特性對路由進行懶加載,以減小打包後的文件大小,作到按需加載,利用webpack自帶的CommonsChunkPlugin插件分離第三方模塊,讓客戶端更好的緩存。通常的客戶端都是在DOM掛載之後獲取數據,而服務端渲染就要在服務端提早加載數據,而後把數據返回給客戶端,客戶端獲取服務端返回的數據,保證先後端數據是一致的

搭建服務端渲染是一個很是繁瑣而又困難的過程,一篇文章是介紹不完實際開發所須要的點,本系列文章從起步再到接近實際項目介紹瞭如何搭建服務端渲染,其中涉及的技術點很是多。對於服務端渲染官方也沒有一套完整的案例,所以作法也不是惟一的

最後

服務端渲染涉及到了後端領域,實際項目中除了客戶端優化外,還須要服務端作相應的優化。若是你在生產中使用服務端渲染,用戶量大時須要作服務器端負載,選擇明智的緩存策略

源碼

相關文章
相關標籤/搜索