React服務端渲染(程式碼分割和資料預取)

code_mcx發表於2018-10-18

前幾節已經把專案基本骨架和路由搭建好了,但作為實際專案開發,這些還是不夠的。隨著業務的增大,應用層序程式碼也隨之增大,如果把所有程式碼都打包到一個檔案裡面,首次載入會導致時間相對變長,增加流量(對移動端來說)。應用程式包含很多頁面,某一時刻使用者只會訪問一個頁面,使用者未訪問的頁面程式碼在訪問之前不應該被載入,只有在使用者訪問時才應改載入頁面所需資源。之前搭建好的專案暫不涉及資料互動,業務最核心的東西就是資料,本節將會介紹基於路由的程式碼分割、資料互動和同步

上一節:前後端路由同構

原始碼地址見文章末尾

本節部分程式碼已進行重寫,詳情請戳這裡

程式碼分割

路由懶載入

在做程式碼分割的時候有很多解決方案,如react-loadablereact-async-componentloadable-components,三者都支援Code Splitting和懶載入,而且都支援服務端渲染。react-loadable和react-async-component在做服務端渲染時,步驟十分繁瑣,loadable-components提供了簡單的操作來支援服務端渲染,這裡選用loadable-components

如果你使用webpack4,loadable-components請使用新的版本這裡是使用新版本重寫的完整例子

安裝loadable-components

npm install loadable-components
複製程式碼

將路由配置中的元件改成動態匯入

src/router/index.js

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-polyfill

為了解析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

React服務端渲染(程式碼分割和資料預取)

當點選其它路由就會下載對應的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打包

React服務端渲染(程式碼分割和資料預取)

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

React服務端渲染(程式碼分割和資料預取)

可以看到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基本專案結構

React服務端渲染(程式碼分割和資料預取)

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又會執行一次資料載入,這一次資料載入是多餘的,看下圖

React服務端渲染(程式碼分割和資料預取)

訪問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,客戶端少了一次資料請求。如下圖

React服務端渲染(程式碼分割和資料預取)

總結

本節利用webpack動態匯入的特性對路由進行懶載入,以減少打包後的檔案大小,做到按需載入,利用webpack自帶的CommonsChunkPlugin外掛分離第三方模組,讓客戶端更好的快取。一般的客戶端都是在DOM掛載以後獲取資料,而服務端渲染就要在服務端提前載入資料,然後把資料返回給客戶端,客戶端獲取服務端返回的資料,保證前後端資料是一致的

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

最後

服務端渲染涉及到了後端領域,實際專案中除了客戶端優化外,還需要服務端做相應的優化。如果你在生產中使用服務端渲染,使用者量大時需要做伺服器端負載,選擇明智的快取策略

原始碼

相關文章