1. 程式人生 > >基於webpack實現react元件的按需載入

基於webpack實現react元件的按需載入

隨著web應用功能越來越複雜,模組打包後體積越來越大,如何實現靜態資源的按需載入,最大程度的減小首頁載入模組體積和首屏載入時間,成為模組打包工具的必備核心技能。

webpack作為當下最為流行的模組打包工具,成為了react、vue等眾多熱門框架的官方推薦打包工具。其提供的Code Splitting(程式碼分割)特性正是實現模組按需載入的關鍵方式。

什麼是Code Splitting

官方定義

Code splitting is one of the most compelling features of webpack. It allows you to split your code into various bundles which you can then load on demand — like when a user navigates to a matching route, or on an event from the user. This allows for smaller bundles, and allows you to control resource load prioritization, which if used correctly, can have a major impact on your application load time

翻譯過來大概意思就是:

程式碼分割功能允許將web應用程式碼分割為多個獨立模組,當用戶導航到一個匹配的路由或者派發某些特定事件時,來按需載入這些模組。這樣就可以將大型的模組分割為多個小型的模組,實現系統資源載入的最大優化,如果使用得當,能夠極大的減少我們的應用首屏載入時間。

Code splitting 分類

一、快取和並行載入的資源分割

這種方法是將某些第三方基礎框架模組(例如:moment、loadash)或者多個頁面的公用模組(js、css)拆分出來獨立打包載入,通常這些模組改動頻率很低,將其與業務功能模組拆分出來並行載入,一方面可以最大限度的利用瀏覽器快取,另一方面也可以大大降低多頁面系統的程式碼冗餘度。

按照資源型別不同又可以分為js公共資源分割css資源分割兩類。

1、js公共資源分割

例如:系統應用入口檔案index.js中使用日期功能庫 momentjs

index.js

 

1

2

 

var moment = require('moment');

console.log(moment().format());

webpack.config.js

定義多個entry入口

  • main為主入口模組檔案
  • vendor為公共基礎庫模組,名字可隨意設定。稱為initial chunk
 

1

2

3

4

5

6

7

8

9

10

11

12

 

var path = require('path');

module.exports = {

entry: {

main: './index.js',

vendor: ['moment']

},

output: {

filename: '[name].js',

path: path.resolve(__dirname, 'dist')

}

}

執行webpack打包命令:

 

1

 

webpack --progress --hide-modules

可以看到最終打包為兩個js檔案 main.js 、vendor.js,但如果檢查者兩個檔案會發現moment模組程式碼被重複打包到兩個檔案中,而這肯定不是我們想要的,這時候就需要 webpack的plugin發揮作用了。

vendo

使用CommonsChunkPlugin

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

 

var webpack = require('webpack');

var path = require('path');

module.exports = {

entry: {

main: './index.js',

vendor: ['moment']

},

output: {

filename: '[chunkhash:8].[name].js',

path: path.resolve(__dirname, 'dist')

},

plugins: [

new webpack.optimize.CommonsChunkPlugin({

// vendor是包括公共的第三方程式碼,稱為initial chunk

name: 'vendor'

})

]

}

執行webpack打包命令,我們發現moment只被打包進vendor.js中。

vendo

webpack執行時模組(manifest)

  • 在前面的步驟2當中webpack在瀏覽器中載入js模組的執行時程式碼塊也打包進了vendor.js,如果為打包的js檔案新增chunkhash,則每次修改 index.js後再次編譯打包,由於執行時程式碼需要重新編譯生成,導致vendor.js重新打包並生成新的chunkhash

webpack執行時程式碼塊部分:

webpackjsonp

  • 實際專案中我們希望修改業務功能後打包時只重新打包業務模組,而不打包第三方公共基礎庫。這裡我們可以將webpack的執行時程式碼提取到獨立的manifest檔案中,這樣每次修改業務程式碼只重新打包生成業務程式碼模組main.js和執行時程式碼模組manifest.js,就實現了業務模組和公共基礎庫模組的分離。

manifest1

  • names欄位支援以陣列格式來指定基礎庫模組名稱執行時程式碼模組名稱
 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

 

module.exports = {

entry: {

main: './index.js',

vendor: 'moment'

},

output: {

filename: '[chunkhash:8].[name].js',

path: path.resolve(__dirname, 'dist')

},

plugins: [

new webpack.optimize.CommonsChunkPlugin({

// manifest是包括webpack執行時runtime的塊,可以稱為entry chunk

names: ['vendor', 'manifest']

})

]

}

2、CSS程式碼分割

  • 實際專案開發當中經常使用webpack的css-loader來將css樣式匯入到js模組中,再使用style-loader將css樣式以<style>標籤的形式插入到頁面當中,但這種方法的缺點就是無法單獨載入並快取css樣式檔案,頁面展現必須依賴於包含css樣式的js模組,從而造成頁面閃爍的不佳體驗。

  • 因此有必要將js模組當中import的css模組提取出來,這時候就需要用到extract-text-webpack-plugin

注意webpack2.x需要使用相應版本的plugin。

 

1

 

npm i --save-dev [email protected]

index.js

 

1

2

3

4

 

import moment from 'moment';

import './index.css';

console.log('moment:', moment().format());

webpack.config.js

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

 

var ExtractTextPlugin = require('extract-text-webpack-plugin');

......

module: {

rules: [{

test: /\.css$/,

exclude: /node_modules/,

use: ExtractTextPlugin.extract({

loader: 'css-loader',

options: {}

})

}]

},

plugins: [

new ExtractTextPlugin({

filename: 'bundle.css',

disable: false,

allChunks: true

})

]

......

extract

二、按需載入程式碼分割

  • 前面介紹的靜態資源分離打包需要開發者在webpack配置檔案中明確分割點來提取獨立的公共模組,這種方式適合提取第三方公共基礎庫(vue、react、moment等)以及webpack 的執行時程式碼模組

  • 除此之外webpack還提供了按需載入的程式碼分割功能,常用於在web應用路由或者使用者行為事件邏輯中動態按需載入特定的功能模組chunk,這就是我們本文中後面要重點介紹的。

Code splitting with require.ensure

webpack1提供了CommonJS風格的 require.ensure()實現模組chunk的非同步載入,通過require.ensure()在js程式碼中建立分割點,編譯打包時webpack會將此分割點所指定的程式碼模組都打包為一個程式碼模組chunk,然後通過jsonp的方式來按需載入打包後的模組chunk

  • require.ensure()語法
 

1

2

3

4

5

6

7

8

9

10

11

12

 

// 空引數

require.ensure([], function(require){

var = require('module-b');

});

// 依賴模組 "module-a", "module-b",會和'module-c'打包成一個chunk來載入

// 不同的分割點可以使用同一個chunkname,這樣可以保證不同分割點的程式碼模組打包為一個chunk

require.ensure(["module-a", "module-b"], function(require) {

var a = require("module-a");

var b = require("module-b");

var c = require('module-c');

},"custom-chunk-name");

Code Splitting with ES2015

webpack2 的ES2015 loader中提供了import()方法在執行時動態按需載入ES2015 Module

webpack將import()看做一個分割點並將其請求的module打包為一個獨立的chunkimport()以模組名稱作為引數名並且返回一個Promise物件。

  • import() 語法
 

1

2

3

4

5

 

import("./module").then(module => {

return module.default;

}).catch(err => {

console.log("Chunk loading failed");

});

  • import()使用須知

    • import()目前還是處於TC39 proposal階段。
    • 在Babel中使用import()方法,需要安裝 dynamic-import外掛並選擇使用babel-preset-stage-3處理解析錯誤。
  • 動態表示式 Dynamic expressions

    import()中的傳參可支援部分表示式的寫法了,如果之前有接觸過CommonJS中require()表示式寫法,應該不會對此感到陌生。

    它的操作其實和 CommonJS 類似,給所有可能的檔案建立一個環境,當你傳遞那部分程式碼的模組還不確定的時候,webpack 會自動生成所有可能的模組,然後根據需求載入。這個特性在前端路由的時候很有用,可以實現按需載入資源。

    import()會針對每一個讀取到的module建立獨立的chunk

     

    1

    2

    3

    4

     

    function route(path, query) {

    return import(`./routes/${path}/route`)

    .then(route => new route.Route(query));

    }

bundle-loader

bundle-loader 是webpack官方提供的loader,其作用就是對require.ensure的抽象封裝為一個wrapper函式來動態載入模組程式碼,從而避免require.ensure將分割點所有模組程式碼打包為一個chunk體積過大的問題。

  • 使用語法:
 

1

2

3

4

5

6

7

8

9

10

 

// 在require bundle時,瀏覽器會立即載入

var waitForChunk = require("bundle!./file.js");

// 使用lazy模式,瀏覽器並不立即載入,只在呼叫wrapper函式才載入

var waitForChunk = require("bundle?lazy!./file.js");

// 等待載入,在回撥中使用

waitForChunk(function(file) {

var file = require("./file.js");

});

  • wrapper函式:

預設普通模式wrapper:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

 

var cbs = [],data;

module.exports = function(cb) {

if(cbs) cbs.push(cb);

else cb(data);

},

require.ensure([], function(require) {

data = require('./file.js');

var callbacks = cbs;

cbs = null;

for(var i = 0, l = callbacks.length; i < l; i++) {

callbacks[i](data);

}

});

lazy模式wrapper:

 

1

2

3

4

5

6

 

module.exports = function (cb) {

require.ensure([], function(require) {

var app = require('./file.js');

cb(app);

});

};

使用bundle-loader在程式碼中require檔案的時候只是引入了wrapper函式,而且因為每個檔案都會產生一個分離點,導致產生了多個打包檔案,而打包檔案的載入只有在條件命中的情況下才產生,也就可以按需載入。

  • 支援自定義Chunk名稱:
 

1

 

require("bundle-loader?lazy&name=my-chunk!./file.js");

promise-loader

promise-loaderbundle-loader的lazy模式的變種,其核心就是使用Promise語法來替代原先的callback回撥機制。與bundle-loader類似,require模組的時候只是引入了wrapper函式,不同之處在於呼叫函式時得到的是一個對模組引用的promise物件,需要在then方法中獲取模組物件,並可以使用catch方法來捕獲模組載入中的錯誤。

promise-loader支援使用第三方Promise基礎庫(如:bluebird)或者使用global引數來指定使用執行環境已經存在的Promise庫。

  • 使用語法:
 

1

2

3

4

5

6

7

8

9

 

// 使用Bluebird promise庫

var load = require("promise?bluebird!./file.js");

// 使用全域性Promise物件

var load = require("promise?global!./file.js");

load().then(function(file) {

});

  • wrapper函式:
 

1

2

3

4

5

6

7

8

9

 

var Promise = require('bluebird');

module.exports = function (namespace) {

return new Promise(function (resolve) {

require.ensure([], function (require) {

resolve(require('./file.js')[namespace]));

});

});

}

es6-promise-loader

es6-promise-loader相比 promise-loader區別就在於使用原生的ES6 Promise物件。

  • 使用語法:
 

1

2

3

4

5

 

var load = require("es6-promise!./file.js");

load(namespace).then(function(file) {

console.log(file);

});

  • wrapper函式:
 

1

2

3

4

5

6

7

 

module.exports = function (namespace) {

return new Promise(function (resolve) {

require.ensure([], function (require) {

resolve(require('./file.js')[namespace]));

});

});

}

React按需載入實現方案

React router動態路由

react-router的 標籤有一個叫做getComponent的非同步的方法去獲取元件。他是一個function接受兩個引數,分別是location和callback。當react-router執行回撥函式 callback(null, ourComponent)時,路由只渲染指定元件ourComponent

  • getComponent非同步方法

使用語法:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

 

<Router history={history}>

<Route

path="/"

getComponent={(nextState, callback) => {

callback(null, HomePage)

}}

/>

<Route

path="/faq"

getComponent={(nextState, callback) => {

callback(null, FAQPage);

}}

/>

</Router>

這些元件會在需要的時候非同步載入。這些元件仍然會在同一個檔案中,並且你的應用看起來不會有任何不同。

  • require.ensure

webpack提供的require.ensure可以定義分割點來打包獨立的chunk,再配合react-routergetComponent方法就可以實現React元件的按需載入,具體可參照以下文章:

  1. 基於Webpack 2的React元件懶載入
  2. react-router動態路由與Webpack分片thunks

React懶載入元件

文章前面提到使用React動態路由來按需載入react元件,但實際專案開發中很多時候不需要或者沒法引入react-router,我們就可以使用webpack官方提供的React懶載入元件來動態載入指定的React元件,原始碼見 LazilyLoad

LazilyLoad懶載入元件

  • LazilyLoad使用:

webpack2.x import方法非同步載入ES2015模組檔案,返回一個Promise物件。

 

1

2

3

4

5

6

7

8

9

10

11

12

13

 

<LazilyLoad modules={{

TodoHandler: () => importLazy(import('./components/TodoHandler')),

TodoMenuHandler: () => importLazy(import('./components/TodoMenuHandler')),

TodoMenu: () => importLazy(import('./components/TodoMenu')),

}}>

{({TodoHandler, TodoMenuHandler, TodoMenu}) => (

<TodoHandler>

<TodoMenuHandler>

<TodoMenu />

</TodoMenuHandler>

</TodoHandler>

)}

</LazilyLoad>

webpack 1.x 使用前文中提到的promise-loader或者es6-promise-loader封裝按需載入元件。

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

 

class App extends React.Component {

render() {

return (

<div>

<LazilyLoad modules={{

LoadedLate: () => require('es6-promise!./lazy/LoadedLate')(),

LoadedLate2: () => require('es6-promise!./lazy/LoadedLate2')()

}}>

{({LoadedLate,LoadedLate2}) => (

<div>

<LoadedLate />

<LoadedLate2 />

</div>

)}

</LazilyLoad>

</div>

);

}

  • importLazy方法是為了相容Babel/ES2015模組,返回模組的default屬性。
 

1

2

3

 

export const importLazy = (promise) => (

promise.then((result) => result.default || result)

);

React高階元件懶載入

高階元件 (Higher Order Component)就是一個 React 元件包裹著另外一個 React 元件。

可參考React官方文件說明。

a higher-order component is a function that takes a component and returns a new component

這種模式通常使用工廠函式來實現。

封裝懶載入元件LazilyLoad的高階元件工廠函式

LazilyLoadFactory

 

1

2

3

4

5

6

7

 

export const LazilyLoadFactory = (Component, modules) => {

return (props) => (

<LazilyLoad modules={modules}>

{(mods) => <Component {...mods} {...props} />}

</LazilyLoad>

);

};

使用高階元件實現按需載入

webpack 2.x

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

 

class Load_jQuery extends React.Component {

componentDidMount() {

console.log('Load_jQuery props:', this.props);

}

render() {

return (

<div ref={(ref) => this.props.$(ref).css('background-color', 'red')}>

Hello jQuery

</div>

);

}

};

// 使用工廠函式封裝Load_jQuery為高階元件,將非同步載入的jQuery模組物件以props的形式來獲取並使用

export default LazilyLoadFactory(Load_jQuery, {

$: () => import('jquery')

});

webpack 1.x

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

 

class Load_jQuery extends React.Component {

render() {

return (

<div ref={(ref) => this.props.$(ref).css('background-color', 'red')}>

Hello jQuery

</div>

);

}

};

export default LazilyLoadFactory(Load_jQuery, {

$: () => require('es6-promise!jquery')()

});

ES Decorator

除了工廠函式方式擴充套件實現高階元件,還可通過 ES草案中的 Decorator(https://medium.com/google-developers/exploring-es7-decorators-76ecb65fb841) 語法來實現。Decorator 可以通過返回特定的 descriptor 來”修飾” 類屬性,也可以直接”修飾”一個類。即傳入一個已有的類,通過 Decorator 函式”修飾”成了一個新的類。

  • 使用方法:
 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

 

// ES Decorators函式實現高階元件封裝

// 參考 http://technologyadvice.github.io/es7-decorators-babel6/

const LazilyLoadDecorator = (Component) => {

return LazilyLoadFactory(Component, {

$: () => require('jquery')(),

});

};

// ES Decorators語法

// 需要依賴babel-plugin-transform-decorators-legacy

// babel-loader配置使用plugins: ["transform-decorators-legacy"]

@LazilyLoadDecorator

export default class Load_jQuery extends React.Component {

componentDidMount() {

console.log('Load_jQuery props:', this.props);

}

render() {

return (

<div ref={(ref) => this.props.$(ref).css('background-color', 'red')}>

Hello jQuery

</div>

);

}

};

引用被高階元件包裹的普通元件

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

 

import Load_jQuery from './js/Load_jQuery';

class App extends React.Component {

constructor() {

super(...arguments);

this.state = {

load: false,

};

this.handleClick = this.handleClick.bind(this);

}

handleClick() {

this.setState({

load: !this.state.load,

});

}

render() {

return (

<div>

<p>

<a

style={{ color: 'blue', cursor: 'pointer' }}

onClick={this.handleClick}>點選載入jQuery</a>

</p>

{this.state.load ?

<div><Load_jQuery /></div>

: null

}

</div>

);

}

基於webpack 1.x實現react元件的懶載入示例

lazyloaded

參考資料

原文https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises