基於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發揮作用了。
使用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
中。
webpack執行時模組(manifest)
- 在前面的
步驟2
當中webpack在瀏覽器中載入js模組的執行時程式碼塊也打包進了vendor.js
,如果為打包的js檔案新增chunkhash
,則每次修改index.js
後再次編譯打包,由於執行時程式碼需要重新編譯生成,導致vendor.js
重新打包並生成新的chunkhash
。
webpack執行時程式碼塊部分:
- 實際專案中我們希望修改業務功能後打包時只重新打包業務模組,而不打包第三方公共基礎庫。這裡我們可以將webpack的
執行時程式碼
提取到獨立的manifest
檔案中,這樣每次修改業務程式碼只重新打包生成業務程式碼模組main.js
和執行時程式碼模組manifest.js
,就實現了業務模組和公共基礎庫模組的分離。
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 }) ] ...... |
二、按需載入程式碼分割
-
前面介紹的
靜態資源分離打包
需要開發者在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打包為一個獨立的chunk
。import()
以模組名稱作為引數名並且返回一個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-loader是bundle-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-router
的getComponent
方法就可以實現React元件的按需載入,具體可參照以下文章:
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元件的懶載入示例
參考資料
- 基於Webpack 2的React元件懶載入
- react-router動態路由與Webpack分片thunks
- Lazy Loading - React
- es6-promise-loader
- promise-loader
- bundle-loader
- es6-modules-overview
- Implicit Code Splitting and Chunk Loading with React Router and Webpack
- 在Webpack中使用Code Splitting實現按需載入
- Code Splitting - Using require.ensure
- Code Splitting with ES2015
- 深入理解 React 高階元件
- ES7 Decorator 裝飾者模式
- ES Decorators簡介
- Higher-Order Components
原文https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises