1. 前言
隨著前端項目的不斷擴大锻煌,一個原本簡單的網(wǎng)頁應(yīng)用所引用的js文件可能變得越來越龐大妓布。尤其在近期流行的單頁面應(yīng)用中,越來越依賴一些打包工具(例如webpack)宋梧,通過這些打包工具將需要處理匣沼、相互依賴的模塊直接打包成一個單獨的bundle文件,在頁面第一次載入時捂龄,就會將所有的js全部載入释涛。但是,往往有許多的場景倦沧,我們并不需要在一次性將單頁應(yīng)用的全部依賴都載下來唇撬。例如:我們現(xiàn)在有一個帶有權(quán)限的"訂單后臺管理"單頁應(yīng)用,普通管理員只能進入"訂單管理"部分展融,而超級用戶則可以進行"系統(tǒng)管理"窖认;或者,我們有一個龐大的單頁應(yīng)用告希,用戶在第一次打開頁面時扑浸,需要等待較長時間加載無關(guān)資源。這些時候燕偶,我們就可以考慮進行一定的代碼拆分(code splitting)喝噪。
2. 實現(xiàn)方式
2.1 簡單的按需加載
代碼拆分的核心目的,就是實現(xiàn)資源的按需加載杭跪∠陕撸考慮這么一個場景,在我們的網(wǎng)站中涧尿,右下角有一個類似聊天框的組件系奉,當(dāng)我們點擊圓形按鈕時,頁面展示聊天組件姑廉。
btn.addEventListener('click', function(e) {
// 在這里加載chat組件相關(guān)資源 chat.js
});
從這個例子中我們可以看出缺亮,通過將加載chat.js的操作綁定在btn點擊事件上,可以實現(xiàn)點擊聊天按鈕后聊天組件的按需加載桥言。而要動態(tài)加載js資源的方式也非常簡單(方式類似熟悉的jsonp)萌踱。通過動態(tài)在頁面中添加<scrpt>
標(biāo)簽,并將src
屬性指向該資源即可号阿。
btn.addEventListener('click', function(e) {
// 在這里加載chat組件相關(guān)資源 chat.js
var ele = document.createElement('script');
ele.setAttribute('src','/static/chat.js');
document.getElementsByTagName('head')[0].appendChild(ele);
});
代碼拆分就是為了要實現(xiàn)按需加載所做的工作并鸵。想象一下,我們使用打包工具扔涧,將所有的js全部打包到了bundle.js這個文件园担,這種情況下是沒有辦法做到上面所述的按需加載的届谈,因此,我們需要講按需加載的代碼在打包的過程中拆分出來弯汰,這就是代碼拆分艰山。那么,對于這些資源咏闪,我們需要手動拆分么曙搬?當(dāng)然不是,還是要借助打包工具鸽嫂。下面就來介紹webpack中的代碼拆分纵装。
3. 代碼拆分
這里回到應(yīng)用場景,介紹如何在webpack中進行代碼拆分溪胶。在webpack有多種方式來實現(xiàn)構(gòu)建是的代碼拆分搂擦。
3.1 import()
這里的import不同于模塊引入時的import,可以理解為一個動態(tài)加載的模塊的函數(shù)(function-like)哗脖,傳入其中的參數(shù)就是相應(yīng)的模塊瀑踢。例如對于原有的模塊引入import react from 'react'
可以寫為import('react')
。但是需要注意的是才避,import()
會返回一個Promise
對象橱夭。因此,可以通過如下方式使用:
btn.addEventListener('click', e => {
// 在這里加載chat組件相關(guān)資源 chat.js
import('/components/chart').then(mod => {
someOperate(mod);
});
});
可以看到桑逝,使用方式非常簡單棘劣,和平時我們使用的Promise
并沒有區(qū)別。當(dāng)然楞遏,也可以再加入一些異常處理:
btn.addEventListener('click', e => {
import('/components/chart').then(mod => {
someOperate(mod);
}).catch(err => {
console.log('failed');
});
});
當(dāng)然茬暇,由于import()
會返回一個Promise
對象,因此要注意一些兼容性問題寡喝。解決這個問題也不困難糙俗,可以使用一些Promise
的polyfill來實現(xiàn)兼容≡蓿可以看到巧骚,動態(tài)import()
的方式不論在語意上還是語法使用上都是比較清晰簡潔的。
3.2 require.ensure()
在webpack 2的官網(wǎng)上寫了這么一句話:
require.ensure() is specific to webpack and superseded by import().
所以格二,在webpack 2里面應(yīng)該是不建議使用require.ensure()
這個方法的劈彪。但是目前該方法仍然有效,所以可以簡單介紹一下顶猜。包括在webpack 1中也是可以使用沧奴。下面是require.ensure()
的語法:
require.ensure(dependencies: String[], callback: function(require), errorCallback: function(error), chunkName: String)
require.ensure()
接受三個參數(shù):
第一個參數(shù)
dependencies
是一個數(shù)組,代表了當(dāng)前require
進來的模塊的一些依賴长窄;第二個參數(shù)
callback
就是一個回調(diào)函數(shù)滔吠。其中需要注意的是远寸,這個回調(diào)函數(shù)有一個參數(shù)require
,通過這個require
就可以在回調(diào)函數(shù)內(nèi)動態(tài)引入其他模塊屠凶。值得注意的是,雖然這個require
是回調(diào)函數(shù)的參數(shù)肆资,理論上可以換其他名稱矗愧,但是實際上是不能換的,否則webpack就無法靜態(tài)分析的時候處理它郑原;第三個參數(shù)
errorCallback
比較好理解唉韭,就是處理error的回調(diào);第四個參數(shù)
chunkName
則是指定打包的chunk名稱犯犁。
因此属愤,require.ensure()
具體的用法如下:
btn.addEventListener('click', e => {
require.ensure([], require => {
let chat = require('/components/chart');
someOperate(chat);
}, error => {
console.log('failed');
}, 'mychat');
});
3.3 Bundle Loader
除了使用上述兩種方法,還可以使用webpack的一些組件酸役。例如使用Bundle Loader:
npm i --save bundle-loader
使用require("bundle-loader!./file.js")
來進行相應(yīng)chunk的加載住诸。該方法會返回一個function
,這個function
接受一個回調(diào)函數(shù)作為參數(shù)涣澡。
let chatChunk = require("bundle-loader?lazy!./components/chat");
chatChunk(function(file) {
someOperate(file);
});
和其他loader類似贱呐,Bundle Loader也需要在webpack的配置文件中進行相應(yīng)配置。Bundle-Loader的代碼也很簡短入桂,如果閱讀一下可以發(fā)現(xiàn)奄薇,其實際上也是使用require.ensure()
來實現(xiàn)的,通過給Bundle-Loader返回的函數(shù)中傳入相應(yīng)的模塊處理回調(diào)函數(shù)即可在require.ensure()
的中處理抗愁,代碼最后也列出了相應(yīng)的輸出格式:
/*
Output format:
var cbs = [],
data;
module.exports = function(cb) {
if(cbs) cbs.push(cb);
else cb(data);
}
require.ensure([], function(require) {
data = require("xxx");
var callbacks = cbs;
cbs = null;
for(var i = 0, l = callbacks.length; i < l; i++) {
callbacks[i](data);
}
});
*/
4. react-router v4 中的代碼拆分
最后馁蒂,回到實際的工作中,基于webpack蜘腌,在react-router4中實現(xiàn)代碼拆分沫屡。react-router 4相較于react-router 3有了較大的變動。其中逢捺,在代碼拆分方面谁鳍,react-router 4的使用方式也與react-router 3有了較大的差別。
在react-router 3中劫瞳,可以使用Route
組件中getComponent
這個API來進行代碼拆分倘潜。getComponent
是異步的,只有在路由匹配時才會調(diào)用志于。但是涮因,在react-router 4
中并沒有找到這個API,那么如何來進行代碼拆分呢伺绽?
在react-router 4官網(wǎng)上有一個代碼拆分的例子养泡。其中嗜湃,應(yīng)用了Bundle Loader來進行按需加載與動態(tài)引入
import loadSomething from 'bundle-loader?lazy!./Something'
然而,在項目中使用類似的方式后澜掩,出現(xiàn)了這樣的警告:
Unexpected '!' in 'bundle-loader?lazy!./component/chat'. Do not use import syntax to configure webpack loaders import/no-webpack-loader-syntax
Search for the keywords to learn more about each error.
在webpack 2中已經(jīng)不能使用import這樣的方式來引入loader了(no-webpack-loader-syntax)
Webpack allows specifying the loaders to use in the import source string using a special syntax like this:
var moduleWithOneLoader = require("my-loader!./my-awesome-module");
This syntax is non-standard, so it couples the code to Webpack. The recommended way to specify Webpack loader configuration is in a Webpack configuration file.
我的應(yīng)用使用了create-react-app作為腳手架购披,屏蔽了webpack的一些配置。當(dāng)然肩榕,也可以通過運行npm run eject
使其暴露webpack等配置文件刚陡。然而,是否可以用其他方法呢株汉?當(dāng)然筐乳。
這里就可以使用之前說到的兩種方式來處理:import()
或require.ensure()
。
和官方實例類似乔妈,我們首先需要一個異步加載的包裝組件Bundle蝙云。Bundle的主要功能就是接收一個組件異步加載的方法,并返回相應(yīng)的react組件:
export default class Bundle extends Component {
constructor(props) {
super(props);
this.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) : null;
}
}
在原有的例子中路召,通過Bundle Loader來引入模塊:
import loadArticleDetail from 'bundle-loader?lazy!./functions/ArticleDetial'
const ArticleDetail = (props) => (
<Bundle load={ loadArticleDetail}>
{(ArticleDetail) => <About {...props}/>}
</Bundle>
)
注意: webpack 2 還是可以用Bundle Loader的
由于不再使用Bundle Loader勃刨,我們可以使用import()
對該段代碼進行改寫:
const ArticleDetail = (props) => (
<Bundle load={ () => import('./functions/ArticleDetail')}>
{ (ArticleDetail) => <ArticleDetail {...props} /> }
</Bundle>
)
需要注意的是,由于import()
會返回一個Promise
對象优训,因此Bundle
組件中的代碼也需要相應(yīng)進行調(diào)整
export default class Bundle extends Component {
constructor(props) {
super(props);
this.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
});
//注意這里朵你,使用Promise對象; mod.default導(dǎo)出默認
props.load().then((mod) => {
this.setState({
mod: mod.default ? mod.default : mod
});
});
}
render() {
return this.state.mod ? this.props.children(this.state.mod) : null;
}
}
路由部分沒有變化
<Route exact path="/post/:id" component={ArticleDetail}/>
這時候,執(zhí)行npm run start
揣非,可以看到在載入最初的頁面時加載的資源如下
而當(dāng)點擊觸發(fā)到/post路徑時抡医,可以看到
動態(tài)加載了2.chunk.js
這個js文件,如果打開這個文件查看早敬,就可以發(fā)現(xiàn)這個就是我們剛才動態(tài)import()
進來的模塊忌傻。
當(dāng)然,除了使用import()
仍然可以使用require.ensure()
來進行模塊的異步加載搞监。相關(guān)示例代碼如下:
const ArticleDetail = (props) => (
<Bundle load={ (cb) => {
require.ensure([],require=>{
cb(require('./function/ArticleDetail'))
});
}}>
{ (ArticleDetail) => <ArticleDetail {...props} /> }
</Bundle>
);
export default class Bundle extends Component {
constructor(props) {
super(props);
this.state = {
mod: null
};
}
load = props => {
this.setState({
mod: null
});
props.load(mod => {
this.setState({
mod: mod ? mod : null
});
});
}
componentWillMount() {
this.load(this.props);
}
render() {
return this.state.mod ? this.props.children(this.state.mod) : null
}
}
此外水孩,如果是直接使用webpack config的話,也可以進行如下配置
output: {
// The build folder.
path: paths.appBuild,
// There will be one main bundle, and one file per asynchronous chunk.
filename: 'static/js/[name].[chunkhash:8].js',
chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
},
5. 運用react-loadable庫
5.1 背景
當(dāng)你的項目足夠大時琐驴,把所有代碼打包到一個bundle中的啟動時間就會成為問題俘种。這時就需要把app拆分為若干個bundle,然后根據(jù)需求動態(tài)加載它們绝淡。
一個大bundle VS 若干個小bundle:
那如何把一個bundle拆成幾個呢宙刘?這個問題其實已經(jīng)被 Browserify 和 Webpack 這些工具解決得很好了。
但還要做的是在項目中找到合適的地方拆分bundle牢酵,然后異步去加載悬包。所以當(dāng)項目中有東西在加載時的需要一種通信機制。
5.2 基于路由拆分 vs 基于組件拆分
通常的推薦做法就是把app根據(jù)路由進行拆分馍乙,然后異步地去加載每一個布近〉媸停看上去這種做法對于大多數(shù)app已經(jīng)足夠好,例如點擊一個連接然后加載一個新頁面撑瞧,這種體驗還不賴棵譬。
但是,我們可以做得更好预伺。
其實在大多數(shù)React的路由管理工具中茫船,路由以組件的形式存在。它們并沒有什么非常特別的地方扭屁。所以假設(shè)我們圍繞著組件優(yōu)化而不是把責(zé)任推給路由會怎么樣?這樣會給我們帶來什么涩禀?
基于路由 VS 基于組件代碼拆分:
這會有很多結(jié)果料滥。相比只是簡單根據(jù)路由拆分app,這樣做會有更多地方可以拆分艾船。例如 Modals葵腹、tabs ,還有很多在用戶做相應(yīng)操作之前隱藏內(nèi)容的組件屿岂。
更別說那些需要推遲到高優(yōu)先級內(nèi)容加載完成后才加載的內(nèi)容了践宴。一個在頁面底部而且依賴了一大串類庫的組件為什么要和頁面頂部的內(nèi)容同時加載呢?
你大可依然在路由只是簡單組件時拆分他們爷怀。對于你的app阻肩,不管黑貓白貓,捉到老鼠就是好貓运授。
但我們需要在讓組件層面拆分app像在路由層面拆分一樣簡單烤惊。簡單得只要改幾行代碼,其他的事就自動OK吁朦。
5.3 React Loadable簡介
React Loadable 是一個很小的庫柒室,是作者thejameskyle厭煩了你們總說這個很難做 之后寫出來的。
Loadable 是一個高階組件(創(chuàng)建組件的function)用來輕易地在組件層面拆分bundle逗宜。
我們試想一下有兩個組件雄右,其中一個引入并渲染了另一個。
import AnotherComponent from './another-component';
class MyComponent extends React.Component {
render() {
return <AnotherComponent/>;
}
}
此時我們依賴了AnotherComponent并且通過import關(guān)鍵字同步引入纺讲。我們需要一種讓它異步加載的方法擂仍。
使用ECMA中動態(tài)引用(一個T39提案,目前stage3 )的特性來修改我們的組件使之異步加載AnotherComponent刻诊。
此時我們依賴了AnotherComponent并且通過import關(guān)鍵字同步引入防楷。我們需要一種讓它異步加載的方法。
使用ECMA中動態(tài)引用(一個T39提案则涯,目前stage3 )的特性來修改我們的組件使之異步加載AnotherComponent复局。
class MyComponent extends React.Component {
state = {
AnotherComponent: null
};
componentWillMount() {
import('./another-component').then(AnotherComponent => {
this.setState({ AnotherComponent });
});
}
render() {
let {AnotherComponent} = this.state;
if (!AnotherComponent) {
return <div>Loading...</div>;
} else {
return <AnotherComponent/>;
};
}
}
然而冲簿,這只是手動做法,并不適用大量其他各種各樣的場景亿昏。比如說當(dāng)import()失敗的情況峦剔,以及服務(wù)端渲染的情況。
作為替代角钩,你可以使用 Loadable 把問題抽象出來吝沫。Loadable的用法很簡單。你僅僅要做的就是把要加載的組件和當(dāng)你加載組件時的“Loading”組件傳入一個方法中递礼。
import Loadable from 'react-loadable';
function MyLoadingComponent() {
return <div>Loading...</div>;
}
const LoadableAnotherComponent = Loadable(
() => import('./another-component'),
MyLoadingComponent
);
class MyComponent extends React.Component {
render() {
return <LoadableAnotherComponent/>;
}
}
但是如果組件加載失敗怎么辦惨险,我們還需要一個錯誤狀態(tài)提示。 為了讓你最大化控制要顯示的東西脊髓,錯誤提示只是簡單地作為LoadingComponent的一個prop傳入辫愉。
function MyLoadingComponent({ error }) {
if (error) {
return <div>Error!</div>;
} else {
return <div>Loading...</div>;
}
}
5.4 基于import()的自動代碼拆分
import()的牛X之處在于 Webpack 2 可以自動拆分代碼,不論你在何時加入新代碼将硝,都不用做其他額外的工作恭朗。
這意味著你在使用 React Loadable 時,你可以通過切換 import() 位置來輕易試驗代碼拆分點依疼,以便讓你的app達到最佳性能痰腮。你可以在這查看示例工程溜族≡俟茫或者查看 Webpack 2 文檔(提示:一些相關(guān)文檔在require.ensure() 一節(jié)中)
5.5 避免組件加載閃爍
有時組件加載非常快(<200ms)栅葡,這時加載中的樣式就會一閃而過误辑。
有大量用戶研究表明虫腋,這樣會讓用戶感覺到比實際加載更長的等待時間。如果什么都不顯示的話稀余,用戶會感覺更快悦冀。所以Loading組件需要接收一個pastDelay prop。
這樣你的Loading組件只在加載時間比設(shè)定delay時間長時才會顯示睛琳。
export default function MyLoadingComponent({ error, pastDelay }) {
if (error) {
return <div>Error!</div>;
} else if (pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
這個 delay 默認200ms盒蟆,但你也可以給Loadable傳入第三個參數(shù)用來自定義這個值。
5.6
作為優(yōu)化师骗,你也可以在組件渲染之前對它進行預(yù)加載历等。舉個例子,當(dāng)你需要在點擊按鈕時加載一個新組建辟癌,可能需要用戶hover在按鈕上時就預(yù)加載它寒屯。
Loadable 創(chuàng)建的組件向外暴露了一個用于預(yù)加載的靜態(tài)方法,具體如下:
let LoadableMyComponent = Loadable(
() => import('./another-component'),
MyLoadingComponent,
);
class MyComponent extends React.Component {
state = { showComponent: false };
onClick = () => {
this.setState({ showComponent: true });
};
onMouseOver = () => {
LoadableMyComponent.preload();
};
render() {
return (
<div>
<button onClick={this.onClick} onMouseOver={this.onMouseOver}>
Show loadable component
</button>
{this.state.showComponent && <LoadableMyComponent/>}
</div>
)
}
}
5.7 服務(wù)端渲染
Loadable 通過控制最后一個參數(shù)同樣支持服務(wù)端渲染。服務(wù)端運行時寡夹,通過傳入要動態(tài)加載模塊的絕對路徑來允許 Loadable 同步 reqire() 模塊处面。
import path from 'path';
const LoadableAnotherComponent = Loadable(
() => import('./another-component'),
MyLoadingComponent,
200,
path.join(__dirname, './another-component')
);
這意味著你的“異步加載”和“代碼拆分”模塊在服務(wù)端都是同步渲染。
此時在客戶端遇到的問題回來了菩掏。我們可以在服務(wù)端完整渲染應(yīng)用魂角,但在客戶端,我們同一時間只需要加載一個bunle智绸。
設(shè)想一下如果我們能弄清楚服務(wù)端bundling進程中哪些bundle是我們所需的會怎樣野揪?這樣我們就可以把這些bundle一下傳給客戶端并且?guī)戏?wù)端渲染的確切狀態(tài)。
今天你其實離這個目標(biāo)很近了瞧栗。
因為我們在Loadable中掌握了所有server端依賴的路徑斯稳,我們可以添加一個新的flushServerSideRequires方法用來返回所有在服務(wù)端渲染的路徑。然后用webpack –json命令迹恐,我們就可以獲得一個匹配了對應(yīng)文件的bundle(我的具體代碼)平挑。
6. 結(jié)束
代碼拆分在單頁應(yīng)用中非常常見,對于提高單頁應(yīng)用的性能與體驗具有一定的幫助系草。我們通過將第一次訪問應(yīng)用時,并不需要的模塊拆分出來唆涝,通過scipt
標(biāo)簽動態(tài)加載的原理找都,可以實現(xiàn)有效的代碼拆分。在實際項目中廊酣,使用webpack中的import()
能耻、require.ensure()
或者一些loader
(例如Bundle Loader)來做代碼拆分與組件按需加載。
后續(xù)打算弄一個腳手架出來
本項目地址: geekjc-antd-mobile