怎么打包公共代码才能避免重复?
前言
与单页应用相比,多页应用存在多个入口(每个页面即一个入口),每一个入口(页面)都意味着一套完整的js代码(包括业务逻辑和加载的第三方库/框架等)。在文章《webpack配置常用部分有哪些?》中,我介绍了如何配置多页应用的入口(entry),然而,如果仅仅如此操作,带来的后果就是,打包生成出来的每一个入口文件都会完整包含所有代码。你也许会说:"咱们以前写页面不也是每个页面都会加载所有的代码吗?浏览器会缓存,没事的啦"。其实问题在于,以前写代码都是单个单个js来加载的,一个页面加载下来的确所有页面都能共享到缓存;而到了webpack这场景,由于对于每一个页面来说,所有的js代码都打包成唯一一个js文件了,而浏览器是无法分辨出该文件内的公共代码并加以缓存的,所以,浏览器就没办法实现公共代码在页面间的缓存了(当前页面的缓存还是OK的,也就是说刷新不需要重新加载)。
想智能判断并打包公共代码?CommonsChunkPlugin能帮到你
CommonsChunkPlugin的效果是:在你的多个页面(入口)所引用的代码中,找出其中满足条件(被多少个页面引用过)的代码段,判定为公共代码并打包成一个独立的js文件。至此,你只需要在每个页面都加载这个公共代码的js文件,就可以既保持代码的完整性,又不会重复下载公共代码了(多个页面间会共享此文件的缓存)。
再提一下使用Plugin的方法
大部分Plugin的使用方法都有一个固定的套路:
- 利用Plugin的初始方法并传入Plugin预设的参数进行初始化,生成一个实例。
- 将此实例插入到webpack配置文件中的
plugins
参数(数组类型)里即可。
CommonsChunkPlugin的初始化常用参数有哪些?
name
,给这个包含公共代码的chunk命个名(唯一标识)。filename
,如何命名打包后生产的js文件,也是可以用上[name]
、[hash]
、[chunkhash]
这些变量的啦(具体是什么意思,请看我上一篇文章中关于filename的那一节)。minChunks
,公共代码的判断标准:某个js模块被多少个chunk加载了才算是公共代码。chunks
,表示需要在哪些chunk(也可以理解为webpack配置中entry的每一项)里寻找公共代码进行打包。不设置此参数则默认提取范围为所有的chunk。
实例分析
实例来自于我的脚手架项目webpack-seed
,我是这样初始化一个CommonsChunkPlugin的实例:
var commonsChunkPlugin = new webpack.optimize.CommonsChunkPlugin({
name: 'commons', // 这公共代码的chunk名为'commons'
filename: '[name].bundle.js', // 生成后的文件名,虽说用了[name],但实际上就是'commons.bundle.js'了
minChunks: 4, // 设定要有4个chunk(即4个页面)加载的js模块才会被纳入公共代码。这数目自己考虑吧,我认为3-5比较合适。
});
最终生成文件的路径是根据webpack配置中的ouput.path和上面CommonsChunkPlugin的filename参数来拼的,因此想控制目录结构的,直接在filename参数里动手脚即可,例如:filename: 'commons/[name].bundle.js'
总结
整体来说,这套方案还是相当简单的,而从效果上说,也算是比较均衡的,比较适合项目初期使用。
老式jQuery插件还不能丢,怎么兼容?
前言
目前前端虽处于百花齐放阶段,angular/react/vue竞相角逐,但毕竟尚未完全成熟,有些需求还是得依靠我们的老大哥jQuery的。
我个人对jQuery并不反感,但我对jQuery生态的停滞不前相当无奈,比如说赫赫有名的bootstrap(特指3代),在webpack上打包还得靠个loader的,太跟不上时势了。况且,bootstrap还算好的,有些jquery插件都有一两年没更新了,连NPM都没上架呢,可偏偏就是找不到它们的替代品,项目又急着要上,这可咋办呐?
别急,今天就教你适配兼容老式jQuery插件。
老式jQuery插件为和不能直接用webpack打包?
如果你把jQuery看做是一个普通的js模块来加载(要用到jQuery的模块统统先require后再使用),那么,当你加载老式jQuery插件时,往往会提示找不到jQuery实例(有时候是提示找不到$
),这是为啥呢?
要解释这个问题,就必须先稍微解释一下jQuery插件的机制:jQuery插件是通过jQuery提供的jQuery.fn.extend(object)
和jQuery.extend(object)
这俩方法,来把插件本身实现的方法挂载到jQuery
(也即$
)这个对象上的。传统引用jQuery及其插件的方式是先用<script>
加载jQuery本身,然后再用同样的方法来加载其插件;jQuery会把jQuery
对象设置为全局变量(当然也包括了$
),既然是全局变量,那么插件们很容易就能找到jQuery
对象并挂载自身的方法了。
而webpack作为一个遵从模块化原则的构建工具,自然是要把各模块的上下文环境给分隔开以减少相互间的影响;而jQuery也早已适配了AMD/CMD等加载方式,换句话说,我们在requirejQuery
的时候,实际上并不会把jQuery
对象设置为全局变量。说到这里,问题也很明显了,jQuery插件们找不到jQuery
对象了,因为在它们各自的上下文环境里,既没有局部变量jQuery
(因为没有适配AMD/CMD,所以就没有相应的require语句了),也没有全局变量jQuery
。
怎么来兼容老式jQuery插件呢?
方法有不少,下面一个一个来看。
ProvidePlugin
+ expose-loader
首先来介绍我最为推荐的方法:ProvidePlugin
+ expose-loader
,在我公司的项目,以及我个人的脚手架开源项目webpack-seed
里使用的都是这一种方法。
ProvidePlugin的配置是这样的:
var providePlugin = new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
'window.jQuery': 'jquery',
'window.$': 'jquery',
});
ProvidePlugin的机制是:当webpack加载到某个js模块里,出现了未定义且名称符合(字符串完全匹配)配置中key的变量时,会自动require配置中value所指定的js模块。
如上述例子,当某个老式插件使用了jQuery.fn.extend(object)
,那么webpack就会自动引入jquery
(此处我是用NPM的版本,我也推荐使用NPM的版本)。
另外,使用ProvidePlugin还有个好处,就是,你自己写的代码里,再!也!不!用!require!jQuery!啦!毕竟少写一句是一句嘛哈哈哈。
接下来介绍expose-loader,这个loader的作用是,将指定js模块export的变量声明为全局变量。下面来看下expose-loader的配置:
/*
很明显这是一个loader的配置项,篇幅有限也只能截取相关部分了
看不明白的麻烦去看本系列的另一篇文章《webpack多页应用架构系列(二):webpack配置常用部分有哪些?》:https://segmentfault.com/a/1190000006863968
*/
{
test: require.resolve('jquery'), // 此loader配置项的目标是NPM中的jquery
loader: 'expose?$!expose?jQuery', // 先把jQuery对象声明成为全局变量`jQuery`,再通过管道进一步又声明成为全局变量`$`
},
你或许会问,有了ProvidePlugin为嘛还需要expose-loader?问得好,如果你所有的jQuery插件都是用webpack来加载的话,的确用ProvidePlugin就足够了;但理想是丰满的,现实却是骨感的,总有那么些需求是只能用<script>
来加载的。
externals
externals是webpack配置中的一项,用来将某个全局变量“伪装”成某个js模块的exports,如下面这个配置:
externals: {
'jquery': 'window.jQuery',
},
那么,当某个js模块显式地调用var $ = require('jquery')
的时候,就会把window,jQuery
返回给它。
与上述ProvidePlugin + expose-loader
的方案相反,此方案是先用<script>
加载的jQuery满足老式jQuery插件的需要,再通过externals将其转换成符合模块化要求的exports。
我个人并不太看好这种做法,毕竟这就意味着jQuery脱离NPM的管理了,不过某些童鞋有其它的考虑,例如为了加快每次打包的时间而把jQuery这些比较大的第三方库给分离出去(直接调用公共CDN的第三方库?),也算是有一定的价值。
imports-loader
这个方案就相当于手动版的ProvidePlugin,以前我用requireJS的时候也是用的类似的手段,所以我一开始从requireJS迁移到webpack的时候用的也是这种方法,后来知道有ProvidePlugin就马上换了哈。
这里就不详细说明了,放个例子大家看看就懂:
// ./webpack.config.js
module.exports = {
...
module: {
loaders: [
{
test: require.resolve("some-module"),
loader: "imports?$=jquery&jQuery=jquery", // 相当于`var $ = require("jquery");var jQuery = require("jquery");`
}
]
}
};
总结
以上的方案其实都属于shimming,并不特别针对jQuery,请举一反三使用。另外,上述方案并不仅用于shimming,比如用上ProvidePlugin
来写少几个require,自己多多挖掘,很有乐趣的哈~~
补充
误用externals(2016-10-17更新)
有童鞋私信我,说用了我文章的方案依然提示$ is not a function
,在我仔细分析后,发现:
- 他用的是我推荐的
ProvidePlugin
+expose-loader
方案,也就是说,他已经把jquery打包进来了。 - 但是他又不明就里得配了externals:
externals: {
jquery: 'window.jQuery',
},
- 然而实际上他并没有直接用
<script>
来引用jQuery,因此window.jQuery是个null。 - 结果,他的jquery插件获得的
$
就是个null了。
这里面我们可以看出,externals是会覆盖掉ProvidePlugin
的。
但这里有个问题,expose-loader
的作用就是设置好window.jQuery和window.$,那window.jQuery怎么会是null呢?我的猜想是:externals在expose-loader
设置好window.jQuery
前就已经取了window.jQuery
的值(null
)了。
说了这么多,其实关键意思就是,不要手贱不要手贱不要手贱(重要的事情说三遍)!
开发环境、生产环境傻傻分不清楚?
前言
开发环境与生产环境分离的原因如下:
- 在开发时,不可避免会产生大量debug又或是测试的代码,这些代码不应出现在生产环境中(也即不应提供给用户)。
- 在把页面部署到服务器时,为了追求极致的技术指标,我们会对代码进行各种各样的优化,比如说混淆、压缩,这些手段往往会彻底破坏代码本身的可读性,不利于我们进行debug等工作。
- 数据源的差异化,比如说在本地开发时,读取的往往是本地mock出来的数据,而正式上线后读取的自然是API提供的数据了。
如果硬是要在开发环境和生产环境用完全一样的代码,那么必然会付出沉重的代价,这点想必也不用多说了。
下面主要针对两点来介绍如何分离开发环境和生产环境:一是如何以不同的方式进行编译,也即如何分别形成开发环境及生产环境的webpack配置文件;二是在业务代码中如何根据环境的不同而做出不同的处理。
如何分离开发环境和生产环境的webpack配置文件
如果同时把一份完整的开发环境配置文件和一份完整的生产环境配置文件列在一起进行比较,那么会出现以下三种情况:
- 开发环境有的配置,生产环境不一定有,比如说开发时需要生成sourcemap来帮助debug,又或是热更新时使用到的
HotModuleReplacementPlugin
。 - 生产环境有的配置,开发环境不一定有,比如说用来混淆压缩js用的
UglifyJsPlugin
。 - 开发环境和生产环境都拥有的配置,但在细节上有所不同,比如说
output.publicPath
,又比如说css-loader
中的minimize
和autoprefixer
参数。
更重要的是,实际上开发环境和生产环境的配置文件的绝大部分都是一致的,对于这一致的部分来说,我们坚决要消除冗余,否则后续维护起来不仅麻烦,而且还容易出错。
怎么做呢?
答案很简单:分拆webpack配置文件成N个小module。原先我们是一个完整的配置文件,有好几百行,从头看到尾都头大了,更别说分离不分离的了。下面来看看我分离的结果:
├─webpack.dev.config.js # 开发环境的webpack配置文件(无实质内容,仅为组织整理)
├─webpack.config.js # 生产环境的webpack配置文件(无实质内容,仅为组织整理)
├─webpack-config # 存放分拆后的webpack配置文件
├─entry.config.js # webpack配置中的各个大项,这一级目录里的文件都是
├─module.config.js
├─output.config.js
├─plugins.dev.config.js # 俩环境配置中不一致的部分,此文件由开发环境配置文件webpack.dev.config.js来加载
├─plugins.product.config.js # 俩环境配置中不一致的部分,此文件由生产环境配置文件webpack.config.js来加载
├─resolve.config.js
│
├─base # 主要是存放一些变量
│ ├─dir-vars.config.js
│ ├─page-entries.config.js
│
├─inherit # 存放生产环境和开发环境相同的部分,以供继承
│ ├─plugins.config.js
│
└─vendor # 存放webpack兼容第三方库所需的配置文件
├─eslint.config.js
├─postcss.config.js
文件目录结构看过了,接下来看一下我是如何组织整理最后的配置文件的:
/* 开发环境webpack配置文件webpack.dev.config.js */
module.exports = {
entry: require('./webpack-config/entry.config.js'),
output: require('./webpack-config/output.config.js'),
module: require('./webpack-config/module.config.js'),
resolve: require('./webpack-config/resolve.config.js'),
plugins: require('./webpack-config/plugins.dev.config.js'),
eslint: require('./webpack-config/vendor/eslint.config.js'),
postcss: require('./webpack-config/vendor/postcss.config.js'),
};
这样,你就可以很轻松地处理开发/生产环境配置文件中相同与不同的部分了。
如何分别调用开发/生产环境的配置文件呢?
还记得我在《[webpack多页应用架构系列(二):webpack配置常用部分有哪些?][3]》里讲过,我们在控制台调用webpack命令来启动打包时,可以添加上--config
参数来指定webpack配置文件的路径吗?我们可以配合上npm scripts
来使用,在package.json里定义:
"scripts": {
"build": "node build-script.js && webpack --progress --colors",
"dev": "node build-script.js && webpack --progress --colors --config ./webpack.dev.config.js",
"watch": "webpack --progress --colors --watch --config ./webpack.dev.config.js"
},
这样一来,当我们开发的时候就可以使用npm run dev
或npm run watch
,而到要上线打包的时候就运行npm run build
。
业务代码如何判断生产/开发环境
在业务代码里要判断生产/开发环境其实很简单,只需一个变量即可:
if (IS_PRODUCTION) {
// 做生产环境该做的事情
} else {
// 做开发环境该做的事情
}
这么一来,关键就在于这变量IS_PRODUCTION
是怎么来的了。
在我还没分离开发和生产环境时,我用的办法是,开发时在业务代码所使用的配置文件中把这变量设为false
,而在最后打包上线时就手动改为true
。这种方法我用过一段时间,非常繁琐,而且经常上线后发现,我嘞个去怎么ajax读的是我本地的mock服务器。
怎么做呢?
我参考了许多文章,先粗略讲讲我没有采用的方法:
- 用
EnvironmentPlugin
引入process.env,这样就可以在业务代码中靠process.env.NODE_ENV
来判断了。 - 用
ProvidePlugin
来控制在不同环境里加载不同的配置文件(业务代码用的)。
那我用的是什么方法呢?我最后选用的是[DefinePlugin
][4]。
举个官方例子,其大概用法是这样的:
new webpack.DefinePlugin({
PRODUCTION: JSON.stringify(true),
VERSION: JSON.stringify("5fa3b9"),
BROWSER_SUPPORTS_HTML5: true,
TWO: "1+1",
"typeof window": JSON.stringify("object")
})
DefinePlugin
可能会被误认为其作用是在webpack配置文件中为编译后的代码上下文环境设置全局变量,但其实不然。它真正的机制是:DefinePlugin
的参数是一个object,那么其中会有一些key-value
对。在webpack编译的时候,会把业务代码中没有定义(使用var/const/let来预定义的)而变量名又与key
相同的变量(直接读代码的话的确像是全局变量)替换成value
。例如上面的官方例子,PRODUCTION
就会被替换为true
;VERSION
就会被替换为'5fa3b9'
(注意单引号);BROWSER_SUPPORTS_HTML5
也是会被替换为true
;TWO
会被替换为1+1
(相当于是一个数学表达式);typeof window
就被替换为'object'
了。
再举个例子,比如你在代码里是这么写的:
if (!PRODUCTION)
console.log('Debug info')
if (PRODUCTION)
console.log('Production log')
那么在编译生成的代码里就会是这样了:
if (!true)
console.log('Debug info')
if (true)
console.log('Production log')
而如果你用了UglifyJsPlugin
,则会变成这样:
console.log('Production log')
如此一来,只要在俩环境的配置文件里用DefinePlugin
分别定义好IS_PRODUCTION
的值,我们就可以在业务代码里进行判断了:
/* global IS_PRODUCTION:true */
if (!IS_PRODUCTION) {
console.log('如果你看到这个Log,那么这个版本实际上是开发用的版本');
}
需要注意的是,如果你在webpack里整合了ESLint,那么,由于ESLint会检测没有定义的变量(ESLint要求使用全局变量时要用window.xxxxx
的写法),因此需要一个global
注释声明(/* global IS_PRODUCTION:true */
)IS_PRODUCTION是一个全局变量(当然在本例中并不是)来规避warning。