利用 React/Redux/React-Router 4/webpack 開發大型 web 專案時如何按需載入

GitChat的部落格發表於2018-04-12
  1. 如何設計一個大型 web 專案?
  2. React + webpack 如何按需載入?
  3. React + React-Router 4 + webpack 如何按需載入?
  4. React + Redux + React-Router 4 + webpack 如何按需載入?

實錄提要:

  • bundle-loader 和 Webpack 內建的 import() 有什麼區別?
  • 按需載入能否支援通過請求後臺資料,動態配置頁面的的應用場景?
  • 參與過幾個 React 專案,被依賴包搞的暈暈的,不知道該怎麼選擇?
  • 什麼包應該放到 devDependencies 裡面?什麼包放到 depedencies 裡面?
  • 為什麼是 react-router-redux 而不是傳統的 react-redux,其優勢是什麼?
  • 按需載入時,每個單獨的 bundle 都挺大的,為什麼?
  • ECMAScript 每年出一個版本,對應的 babel 也有一大堆,應該如何選擇?
  • 單頁專案過大,怎麼拆分不同模組頁面到不同 js 來動態載入?

題外話

經驗尚淺,尚不足以教導,若理解有誤,望能指導三分,語言若有偏激,請理解我年輕氣盛。

之所以寫這篇文章,是因為我最近一陣子經歷了一個部門的技術選型->專案實施這些技術->二次技術選型->技術版本升級的一個過程。開發業務應用為主的我們,很少有時間去研究某項技術的原始碼,不加班趕專案進度就已經很慶幸了,大部分時間都花在瞭如何靈活使用市面上的一些技術體系。在這篇文章中,不涉及原始碼範圍,我也沒去研究過原始碼。寫這篇文章的初衷是分享我的想法和程式碼示例,同時也希望看這篇文章的你能夠給予寶貴的意見,讓我得以進步。

web應用讓人驚歎是從Gmail開始的,流暢的桌面版體驗吸引了很多人,從此web專案開始蓬勃發展。隨後,web應用也越來越複雜,為了能讓web應用如同桌面版應用一樣流暢,出現了SPA。這就是今天我想說的,react/redux等等一系列的產品的出現都是為了實現體驗度更佳的SPA。

兩年前,我開發web專案,都只是用javaweb,使用模板引擎,後端渲染出頁面。對於訪問量不是很大、單個頁面複雜度不是很高、專案的迭代週期不頻繁、二次開發的次數很少的系統,這種模式無疑很適用、效能也沒什麼大的影響,一個java程式設計師就可以做到全棧。而事實上,我手頭的web專案並非這麼簡單,隨著週期的迭代,專案越來越臃腫,後端程式碼和前端程式碼摻雜在一起混亂不堪,當時我自認為自己技術不錯,程式碼寫的自己都認識,然而我離職之後發現一個很多人都知道的道理,一個技術真正好的程式設計師,寫出來的程式碼是要能夠讓他人讀的懂,最起碼要讓接替你繼續這個專案的人讀得懂,技術不行那是例外,當時我沒有做到。而後,進入新的公司,讓我能夠有機會去對部門進行技術選型,主要是前端部分。我果斷選擇了前後端分離模式,我不想以前不好的地方繼續發生在以後。人員安排最好是這樣,專職的人做專職的事,全棧人員要能夠補位。設計一個優質的web專案,最重要的是人,而不是技術!

設計web最好是前端設計成SPA、後端設計成微服務,有很多企業使用react、vue、angular這些,結果是多頁應用,增加複雜度,降低頁面切換的流暢度。我真不知道他們是怎麼想的,首先多頁應用是不可取的,如果他們是開發webapp,封裝成apk,那就更不可取了!web專案設計成SPA,很多人會想,隨著程式碼量的增大,首次載入的檔案就會增大,沒錯,這時就需要用到code splitting。這也就是我今天要講的實際專案中如何進行按需載入。我見過有些開發人員將一個js檔案拆分成多個js檔案,而每個頁面都載入這些js檔案,這顯然是不可取的,這樣子的不需要拆分。隨著現在網速的提升,首次載入檔案稍微大點都是可以接受的。首次載入後快取在瀏覽器處,下次載入的時候會更快。引入第三方UI元件,基礎元件要單一,不可以引入antd了再去引用bootstrap,還有用了react這些就不要在專案中出現jQuery,要純粹!

廢話就講到這裡,下面介紹我將一個專案重構三次的過程,這三個過程裡有三種不同的按需載入方式。示例是我將實際專案刪減過後可執行的例子。code splitting和react/react-router是沒有直接關係的。

一、react(v.0.14.8) / react-router(v.1.0.3) / webpack(v.1.13.3)

相容IE8+及現代瀏覽器,示例程式碼地址->https://github.com/love-fay/fay-webpack-redux-code-splitting/tree/master/react

先貼下依賴:

依賴

因為業務的需求,需要相容到IE8,不得不被動地選擇低版本庫。處於對react的首次使用,並沒有加入redux相關技術。

在react-router 1.x版本中Route元件上擁有getComponent、onEnter引數(4.x之後被移除),getComponent是非同步的,所以我們可以在這個引數裡進行按需載入,getComponent這個函式有兩個引數nextState、callback,根據nextState.pathname可以獲取到路由地址,然後再利用webpack的require.ensure非同步載入所屬元件的js檔案,最後通過callback將該元件返回。示例程式碼如下:

<Route path="app" getComponent={this.getUumsComponent} onEnter={this.requireAuth}/>
getUumsComponent = (nextState, callback) ={    let pathname = nextState.pathname;    switch (pathname) {        case 'app':        require.ensure([], (require) ={            callback(null, require('../app/components/App'));                }, 'App');            break;        default :            historyConfig.pushState({nextPathname: pathname}, '/404');    }};

打包過後主要檔案的對比:

code splitting

code splitting

not code splitting

not code splitting

這種方式的按需載入就這些,很簡單~

二、react(v.15.6.1) / react-router(v.4.2.2)b / webpack(v.3.5.6)

相容IE9+及現代瀏覽器,示例程式碼地址->https://github.com/love-fay/fay-webpack-redux-code-splitting/tree/master/react-rr4

先貼下依賴:

依賴

這次決定拋棄IE8,甚至都不想相容IE。很多人口口聲聲說使用者體驗、使用者需求,卻一味地去支援IE8,甚至還有支援IE6的!其實使用者體驗和需求不是使用者單方面的要求,還有就是開發方需要去改變使用者習慣、引導使用者對未來的需求,在這基礎上不斷地提高使用者體驗。(你不告知你的使用者有個瀏覽器叫做谷歌瀏覽器,他這輩子就會覺得IE就是瀏覽器,瀏覽器就是IE。)

這次主要是將react/react-router/webpack進行了升級,並升級到最新(當時的最新)。

按需載入其實跟react-router沒多大關係,只不過需要藉助它更好的完成按需載入這項任務。react-router升級到4後,便沒有了getComponent這個引數,所以我們得換種方式,react-router4官方示例也提供了code splitting的方法,利用webpack結合bundle-loader,它是在require.ensure基礎上封裝的,更友好的實現非同步載入過程。

bundle-loader可以在webpack檔案中進行配置,這裡我就不介紹了,webpack官方文件都有寫。我這裡是寫在程式碼裡的。我簡單說下,基本跟react-router4官方文件說的差不多。

首先先寫一個bundle.js這個元件,程式碼如下:

import React, { Component } from 'react';import PropTypes from 'prop-types';class Bundle extends Component {    static propTypes = {        load: PropTypes.any,        children: PropTypes.any,    };    state = {        mod: null,    };    componentWillMount () {        this.load(this.props);    }    componentWillReceiveProps (nextProps) {        if (nextProps.load !== this.props.load) {            this.load(nextProps);        }    }    load (props) {        this.setState({            mod: null,        });        props.load((mod) ={            this.setState({                mod: mod['default'] ? mod['default'] : mod,            });        });    }    render () {        return this.state.mod ? this.props.children(this.state.mod) : <div></div>;    }}export default Bundle;

然後在用到需要按需載入的元件的元件中,引入的時候,在檔案路徑前面使用bundle-loader?lazy&name=[App]!,如下:

import loadApp from 'bundle-loader?lazy&name=[App]!../../app/components/App';

然後比如我這裡使用 <Route path="/app" component={App}/> 載入這個App元件,我們需要用到剛才自己寫的bundle元件:

import Bundle from '../bundle/components/Bundle';const App = (props) =(    <Bundle load={loadApp}>        {(App) ={            return <App {...props}/>;        }}    </Bundle>);

打包過後主要檔案的對比:

code splitting

code splitting

not code splitting

not code splitting

到這裡,這第二種方式介紹完了,很簡單~

二、react(v.16.1.1) / redux(v.3.7.2) / react-router(v.4.2.2) / webpack(v.3.8.1)

相容IE9+及現代瀏覽器,示例程式碼地址->https://github.com/love-fay/fay-webpack-redux-code-splitting/tree/master/react-rr4-redux

先貼下依賴:

enter image description here

這次又一次對引用的技術進行了更新,同時加入了redux,專案複雜度的提高,元件之間的交流變得複雜,此時就需要用到redux。有些開發人員會覺得好煩,不斷地升級,不斷地改造,很費時費力,什麼時候才能穩定,其實不然,專案的穩定不代表技術的不變,穩定是相對的。如果想要一勞永逸的話,就不要讓公司給你漲工資了,公司也想一勞永逸~以後人工智慧一旦鋪開到企業級開發中,將會導致大量在安逸中度過的程式設計師失業!學習是無止境的,學習也是人一輩子免費的技能,曾經後端Java一家獨大的時候,spring3穩定的時候,很多後端程式設計師就開始陷入了一勞永逸的幻覺當中,導致他們中的很多人一度抱怨前端是在瞎折騰~這就好比有自行車為什麼要造汽車的理論是一樣的~我是以Java程式設計師入行的,很清楚Java寫後端的時候,輪子很多,很多程式設計師就是使用CV大法,甚至很多專案經理啊什麼的就說程式設計師是搬運工,程式碼不就是增刪改查麼~

使用了redux後,全域性只有一個Store,而這個Store在頁面開啟的時候就已經宣告瞭,於是讓我很糾結如何按需載入。後來我瞭解到redux這個東西的存在,內部運用了react中的context,同時這個context算是隱藏著的祕密。利用它我可以改變全域性的Store。我這裡使用了react-redux,在頂級元件處加入。

import {Provider} from 'react-redux';<Provider store={store}>    ......</Provider>

然後在需要引入store資訊的子元件處利用它提供的connect方法將store派發下去,這裡派發是根據上下文context。專案中少不了用到路由,這時候,我使用了react-router-redux(一定要5.x版本npm i react-router-redux@next),在總的reducer中加入routerReducer,然後在寫路由元件的部分的頂級處使用。

import createBrowserHistory from 'history/createBrowserHistory';import { ConnectedRouter} from 'react-router-redux';const history = createBrowserHistory();<ConnectedRouter history={history}>    ......</ConnectedRouter>

讓我們再回到上一個程式碼片,其中的store來源如下:

import configureStore from '../Store';let store = configureStore();

Store.js

import {createStore, applyMiddleware, compose} from 'redux';import { createLogger } from 'redux-logger';const logger = createLogger();import { routerMiddleware } from 'react-router-redux';import createHistory from 'history/createBrowserHistory';import createSagaMiddleware from 'redux-saga';const history = createHistory();const rMiddleware = routerMiddleware(history);const win = window;export const sagaMiddleware = createSagaMiddleware();const middlewares = [rMiddleware, sagaMiddleware];if (process.env.NODE_ENV !== 'production') {    middlewares.push(require('redux-immutable-state-invariant').default());}const storeEnhancers = compose(    applyMiddleware(...middlewares, logger),    (win && win.devToolsExtension) ? win.devToolsExtension() : (f) =f,);import createReducer from './reducers';export function injectAsyncStore(store, asyncReducers, sagas) {    asyncReducers && injectAsyncReducers(store, asyncReducers);    sagas && injectAsyncSagas(store, sagas);}function injectAsyncReducers(store, asyncReducers) {    let flag = false;    for (let key in asyncReducers) {        if(Object.prototype.hasOwnProperty.call(asyncReducers, key)) {            if (!store.asyncReducers[key]) {                store.asyncReducers[key] = asyncReducers[key];                flag = true;            }        }    }    flag && store.replaceReducer(createReducer(store.asyncReducers));}function injectAsyncSagas(store, sagas) {    for (let key in sagas) {        if(Object.prototype.hasOwnProperty.call(sagas, key)) {            if (!store.asyncSagas[key]) {                store.asyncSagas[key] = sagas[key];                store.sagaMiddleware.run(sagas[key]);            }        }    }}export default function configureStore() {    let store = createStore(createReducer(), {}, storeEnhancers);    store.asyncReducers = {};    store.asyncSagas = {};    store.sagaMiddleware = sagaMiddleware;    return store;}

reducers.js

import { combineReducers } from 'redux';import { routerReducer } from 'react-router-redux';export default function createReducer(asyncReducers) {    const reducers = {        ...asyncReducers,        router: routerReducer    };    return combineReducers(reducers);}

我沒有進行刪減,主要是動態改變store中兩個東西,一個是reducer還有一個就是saga。非同步請求這塊我用的是redux-saga,雖然官方文件上露臉的是redux-thunk和redux-promise,但是後起之秀redux-saga做到低耦合,在專案中作為獨立的一層出現,不與action creator和reducer耦合。還有就是它強大的非同步流程控制。

再來看看路由部分是怎麼寫的:

<Provider store={store}>    <ConnectedRouter history={history}>        <Switch>            <Route path='/app' component={App}/>        </Switch>    </ConnectedRouter></Provider>

這裡的App元件便是我們要按需載入的元件。我是按照模組來組織我的程式碼的,先來看下App模組的程式碼排版:

app

這張圖中sagas.js是用來處理非同步請求的,bundle.js和lazy.js以及公用的bundle.js是用來完成code splitting的。

lazy.js【需要懶載入的檔案】

import appSagas from './sagas';import appReducer from './reducer';import view from './views/app';const reducer = {    appReducer: appReducer};const sagas = {    appSagas: appSagas};export {sagas, reducer, view};

bundle.js【code splitting】

import React from 'react';import Bundle from '../../bundle/views/bundle';import load from 'bundle-loader?lazy&name=[App]!./bundle';import {injectAsyncStore} from '../../Store';export default (props) ={    return (        <Bundle load={(store, cb) ={            load((target) ={                const {reducer, view, sagas} = target;                injectAsyncStore(store, reducer, sagas);                cb(view);            })        }}>            {(View) ={                return <View {...props}/>            }}        </Bundle>    );};

公用的bundle.js【對其進行了改造,加入了store】

import React, { Component } from 'react';import PropTypes from 'prop-types';class Bundle extends Component {    static propTypes = {        load: PropTypes.any,        children: PropTypes.any,    };    static contextTypes = {        store: PropTypes.object    };    state = {        mod: null,    };    componentWillMount () {        this._isMounted = true;        this.load(this.props);    }    componentWillUnmount() {        this._isMounted = false;    }    componentWillReceiveProps (nextProps) {        if (nextProps.load !== this.props.load) {            this.load(nextProps);        }    }    load (props) {        this.setState({            mod: null,        });        props.load(this.context.store, (mod) ={            if (this._isMounted) {                this.setState({                    mod: mod['default'] ? mod['default'] : mod,                });            }        });    }    render () {        return this.state.mod ? this.props.children(this.state.mod) : <div>元件載入中...</div>;    }}export default Bundle;

index.js【對外暴露的元件】

import view from './bundle';export {view};

code splitting就完成了~當然我又進行了更改,下文有講。

這裡要說下公用的bundle.js中的this.context.store,這裡一定要定義contextTypes,不然獲取不到this.context,當然官方沒有提供這個api,也不推薦使用,但是按需載入就得需要它,並且我們要謹慎使用它即可,因為this.context一旦改變,它關聯的上下文就會重新render,所以載入某個頁面的時候,把它所要使用到的reducer和sagas也都關聯進去,這樣載入這個頁面其他元件的時候就已經存在相關的reducer和sagas,不需要再改變上下文的store了。還有元件設計很重要,如果不合理會導致頁面不可控。lazy.js中的reducer和sagas是個物件,比如app這個元件中如果巢狀了其他元件,而這些其他元件中需要引入reducer和sagas,這時可以將這些reducer和sagas結合到app模組的lazy.js中。不加入也可以,這時需要靈活運用shouldComponentUpdate這個生命週期來控制頁面。

從上面的程式碼中,可以發現每個模組的bundle.js中存在類似的程式碼,這樣我們可以給其剝離出來,這不是必須的,因為剝離出來後,我們需要約定好每個模組lazy.js中必須是export {sagas, reducer, view},當然也可以約定其他,一致就行。這樣程式碼進過改造後,每個模組的bundle.js程式碼就可以分離到公共的bundle.js和模組中的index.js中,程式碼如下。

bundle.js中改動的程式碼片:

load (props) {    this.setState({        mod: null,    });    props.load((mod) ={        const {reducer, view, sagas} = mod;        injectAsyncStore(this.context.store, reducer, sagas);        if (this._isMounted) {            this.setState({                mod: view['default'] ? view['default'] : view,            });        }    });}

index.js【以app模組為例】

import React from 'react';import Bundle from '../../bundle/views/bundle';import load from 'bundle-loader?lazy&name=[App]!./lazy';const view = (props) ={    return (        <Bundle load={load}>            {(View) ={                return <View {...props}/>            }}        </Bundle>    );};export {view};

打包過後主要檔案的對比:

code splitting

code splitting

not code splitting

not code splitting

關於元件設計,使用reactjs的時候元件設計一定要足夠的扁平化,也就是平級,這樣不僅提高了計算的效率,同時也會很少出現父元件中巢狀子元件,而父元件更新的時候,子元件也跟著更新,實際上子元件並不想更新。當然遇到逼不得已巢狀的情況的時候,可以使用shouldComponentUpdate這個元件存在時期的生命週期來控制子元件是否render。可喜的是react16版本中B元件不巢狀在A元件中,渲染後出現在A元件裡,也可以掛載到任何一個元件裡,這就是portals

看到這裡,你會發現其實code splitting跟react和react-router沒多大關係,直接的聯絡是redux和webpack,所以這種方式同時也適用於其他使用redux和webpack這種類似的技術體系。

react技術棧是目前前端最美的技術棧。


本文首發於GitChat,未經授權不得轉載,轉載需與GitChat聯絡。

閱讀全文: http://gitbook.cn/gitchat/activity/5a0015e2d6f4ca0a7ab25690

一場場看太麻煩?成為 GitChat 會員,暢享 1000+ 場 Chat !點選檢視

相關文章