本文旨在记录定位问题的过程及用到的方法论,整体篇幅较长。如想快速了解可以直接阅读总结部分。
1. 场景说明
在前端日常开发及维护*.vue文件时,我们的项目中会使用webpack的watch模式去监听文件变化,并自动重新编译打包。然而,最近发现在导入一个新的模块(项目vue组件/项目js文件/第三方js库)时,webpack能够正常重新打包,但页面却会发生白屏的情况,需要重新启动webpack的watch服务才能继续进行开发调试。整个重启的过程需要大概1.5分钟。
事实上,上述导入模块的操作属于开发中的高频行为,这一过程出现问题会在一定程度上影响开发者的心情和效率。作为一名热爱划水的前端开发,在构建重启的空隙中,我经常会去刷刷微博,看看是否有新鲜事儿,经常刷得无法自拔,大大地影响了本就不高的开发效率。
2. 复现问题
-
随意在一个
*.vue的文件中使用import xxx from 'xxx'或者const xxx = require('xxx')的方法导入一个本地编写的模块或者一个node_modules中的模块,webpack打包无异常,但打开页面报错如下(极大几率出现,非必现): -
同样地,去掉一个原有
import进来的模块,也会报错如下:
-
不修改代码,终止当前的
watch模式,重新启动webpack的watch服务,报错不出现,页面正常显示。 -
用动态引入
ES6 Module的方式如下引入,则不会报错:const { debounce } = import('lodash');
3. 提出疑问
- 上述的报错发生在
__webpack_require__和normalizeComonent这两个函数中,webpack在检测到*.vue文件有新导入模块的过程中,发生了什么?这两个函数又承担了什么样的工作?为什么会报错? - 在
__webpack_require__函数中发生的报错仅仅是极大几率出现,非必现,这又是为什么呢? - 为什么只有动态引入
es6 modules时,才不会报错? webpack的watch模式是否本身存在问题,为何在检测到有模块导入时,重新打包的页面会运行出错。而当重启watch模式后,打包出来的页面又能正常运行?
4. 作出假设 & 实践验证
假设一: 当前项目的webpack版本的watch模式存在bug,webpack版本的最新版已经解决了这个问题。
难题: 我们的项目庞大,第三方依赖很多,升级webpack很容易牵一发而动全身,导致很多报错需要解决,花费时间太多,针对解决这个问题而言,性价比很低。其次,可能对于项目中的依赖/插件而言,还未能全部都完善支持webpack5,我们不一定能解决所有升级中遇到的报错,会做很多无用功。
解决方案: 根据当前项目的webpack版本以及必要的依赖,另外开启一个小型的webpack demo。先在demo中尝试能否复现问题。如果问题复现,在demo中尝试仅升级webpack版本到最新,开启watch模式看问题能否解决。
结果: demo结构如下:其中webpack.config.js和package.json中的内容与swan项目一致,排除因为依赖版本或wepack配置不同导致的打包结果存在偏差,无法复现问题。
在相同的webpack版本以及相同webpack配置下, demo在watch模式下导入模块会出现和swan项目一样的问题。探索进行到这里的结果让我感到非常兴奋,因为这证明模块导入bug的出现与swan项目架构无关,只是极大可能与webpack版本/配置有关。升级demo的webpack版本为最新版(v5.39.1)后,watch模式下导入模块不再出现此问题。
结论: 这个在watch模式下导入模块的bug在webpack5已经修复了,最直接的解决方案是升级webpack版本。
假设二: 这个问题仅出现在webpack的watch模式,但webpack还可以通过webpack-dev-server的方式监听文件改变并进行热更新, 如果在dev-server中不存在这个bug, 那我们就可以通过把watch模式改成dev-server来解决问题,不必升级webpack。
难题: watch模式能实际生成打包后的文件, 而dev-server的形式仅仅是把打包后的文件放在内存中。项目前后端不分离的属性导致了目前我们需要在PHP模版中如下引入打包好的js文件。这样一来,使用webpack-dev-server的形式后,需要手动去改变所有的js入口路径为swan:8080/js/xxx.js, 或者需要去修改nginx配置,对这些文件请求都进行路径转发,这无疑对项目造成的影响很大,为了解决这么一个导入的问题似乎有点小题大做。
解决方案: 删繁就简,先在demo里应用方案,看看webpack-dev-server方式下bug能否复现,再做进一步考虑。
结论: 在webpack-dev-server的文件监听下导入模块,得到了与watch模式相同的报错,证明报错与监听方式无关。
5. 更多探索
在验证完上述两个假设之后,似乎解决方案已经很明显了:要么升级到webpack5,要么继续忍耐这个小问题。但转念一想,webpack4有问题的话,这么久了应该被反馈且修复了才对,在github简略地搜了下也没查到有相关的open状态的issue。为了满足自己的好奇心,我决定从开始提到的几个问题出发,探究bug的原因出在何处。
- 问题一:
*.vue文件有新导入模块的过程中,发生了什么? - 问题二:报错发生在
__webpack_require__和normalizeComonent,这两个函数承担了什么样的工作?为什么会报错? - 问题三:如下动态引入
ES6 Module的方式来导入模块,为什么不会报错?
下面我们将来从vue文件的编译过程开始,一一探索上面提到的三个问题:**
1. 探索*.vue文件的编译过程
由于篇幅有限,下面只会简要地阐述一下vue-loader的工作流程,想要深入了解的同学可以移步到下面一篇文章:手把手带你撸一遍vue-loader源码(blog.csdn.net/vv_bug/arti…)
因为我们在webpack.config.js中配置了loader,而匹配规则中对*.vue类文件进行处理的正是vue-loader。 因此,在我们的*.vue文件发生变化(增加一行导入新模块的代码)时,webpack一定是用vue-loader来对其进行解析的。
问题会不会出在vue-loader身上呢 ? 我们来简要地分析下vue-loader的主要工作:
简单地说,vue-loader首先会把我们的SFC(single file component)分割成三个部分的代码: template(模版代码), script(js代码), style(样式代码)。接着,再把这三部分的代码交给不同的loader去处理。
下面以一个简单的文件header.vue来进行说明:
// header.vue
<template>
<div class="header">This is a header</div>
</template>
<script>
export default {
name: 'my-header',
}
</script>
<style lang="less" scoped>
.header {
color: yellow;
}
</style>
以我们的模版代码为例(<template>标签中的内容):
<template>
<div class="header">This is a header</div>
</template>
转换成两段解析:
//第一段: 用vue-loader的selector把vue文件中<template>标签中的内容抽出来
!../node_modules/vue-loader/lib/selector?type=template&index=0!./header.vue
//第二段: 用vue-loader中的template-compliler去编译抽出来的模版代码
!!../node_modules/vue-loader/lib/template-compiler/index?
最后我们的模版代码在webpack打包后的文件中大概是这样的:
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c("div", { staticClass: "header" }, [_vm._v("This is a header")])
}
var staticRenderFns = []
render._withStripped = true
对于我们的js和样式代码而言,处理的过程大致相同,利用selector把对应类型(template/script/style)的代码抽取出来,在交给我们配置好的loader去处理。如上面的template交给template-compliler, 在项目中,
- 我们的
script代码将会交给happypack,happypack将会以线程池的方式,使用babel-loader来对js代码进行打包。 - 我们的
style代码将会依次交给 'less-loader, 'css-loader','vue-style-loader'。
最后,webpack会将vue-loader处理后的生成的几个代码块合并到一起, 交给componentNormalizer去进行处理,最后把处理好的component给暴露出去。(下面的代码截取自小型webpack demo对header.vue的编译输出):
// template 部分
var headervue_type_template_id_29e8c3c6_scoped_true_render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c("div", { staticClass: "header" }, [_vm._v("This is a header")])
}
var headervue_type_template_id_29e8c3c6_scoped_true_staticRenderFns = []
headervue_type_template_id_29e8c3c6_scoped_true_render._withStripped = true;
// js部分
const headervue_type_script_lang_js_ = ({
name: 'my-header'
});
// style部分
var headervue_type_style_index_0_id_29e8c3c6_lang_less_scoped_true_ = __webpack_require__("./node_modules/vue-style-loader/index.js!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/less-loader/dist/cjs.js!./node_modules/vue-loader/lib/index.js??vue-loader-options!./src/components/header.vue?vue&type=style&index=0&id=29e8c3c6&lang=less&scoped=true&");
/* normalize component */
var component = normalizeComponent(
components_headervue_type_script_lang_js_,
headervue_type_template_id_29e8c3c6_scoped_true_render,
headervue_type_template_id_29e8c3c6_scoped_true_staticRenderFns,
false,
null,
"29e8c3c6",
null
)
/* hot reload */
if (false) { var api; }
component.options.__file = "src/components/header.vue"
/* harmony default export */ const header = (component.exports);
这里我们终于看到了控制台中报错的一个函数normalizeComponent。 通过断点调试,简单理解就是normalizeComponent会接收loader们处理好的template,script等代码块, 处理并组装成可导入的组件, 最后暴露给外部。
然而,这是一段没有报错的webpack输出代码,为了复现报错,接下来我们尝试开启watch模式进行监听, 接着在header.vue中引入一个新的模块,变成:
<template>
<div class="header">This is a header</div>
</template>
<script>
// 引入的新模块
import EXIF from 'exif-js';
export default {
name: 'my-header',
}
</script>
<style lang="less" scoped>
.header {
color: yellow;
}
</style>
webpack监听到变化之后,重新打包输出了文件,对比import前后webpack打包的两个输出文件:
- exif模块被正常地添加了
- 意外地发现,
header.vue经过处理之后的js代码入口,被异常地指向了新添加的模块exif
到了这里,报错的原因就不难解释了:webpack监听到变化后,重新打包了文件,但错误地把header.vue这个组件的入口指向了新加入的模块exif-js。紧接着,normalizeComponent函数接收了错误的header.vue入口文件,如常进行处理,导致访问了一个undefined的引用,最后抛出错误。
结合上面的过程进行分析,不论是vue-loader, template-complier,乃至babel-loader等中间者,都只是参与了把相应模块的代码分割出来,并且通过编译原理技术处理成浏览器可识别的其他代码形式而已。最后的代码块结合及导出是由webpack统一做处理的,也就是说,上述loader理论上说都不是报错的根源,接下来我们可以把目光聚焦在webpack本身了。
2. 探索webpack输出文件的玄机: 分析webpack的输出文件
经过上面的探索,我对一件事情非常疑惑:为什么webpack会把header.vue的入口指向了新添加的模块exif-js呢?
为此,我决定先从webpack构建的输出文件结构入手,先初步了解webpack的打包原理。第一步,我把demo中构建模块里面的内容全部去掉,仅留下构建后的代码整体框架如下:
为了让大家把代码结构看得清晰,我把里面所有的函数实现先去掉了。简单地再分析一下webpack构建输出代码的结构:
- 整份代码其实是一个IIFE( 立即调用函数表达式):里面会做一些基础工具函数的定义,最后
require一个入口文件
-
wabpack在初始化时对es6模块之间的依赖关系进行了分析,把需要引入的模块传入给自执行函数。这些模块通过__webpack_require__函数来引入。 -
核心函数
__webpack_require__的实现如下:检查当前模块是否已经有缓存,有则直接返回缓存。没有则分配一个模块id,并执行模块函数,标记模块为已加载,最后返回模块。
至此,我们上面的其中一个疑问__webpack_require__的作用也解决了。对于上面的其中一个报错提示:
我们不难猜测,这个报错的原因应该是__webpack_require__在传入的modules参数里面,无法找到新导入的模块,对于我们的示例代码而言,也就是下面的情况。
modules["exif-js"] // undefined
也就是说,在某些情况下,import一个新模块后,webpack检测到了代码的变化,并执行了编译后的代码,代码中需要引入一个新的模块如下:
const exif = __webpack_require__("exif-js")
但webpack却没有重新分析依赖关系,并把新添加的依赖加入到modules变量中,导致__webpack_require__在执行时因为访问了undefined下面的属性而抛出异常。只能也解释我上面备注的(这个报错非必现,只是极大可能发生)了。因为在代码中当新导入一个modules变量里面本来就有的模块时,__webpack_require__能正常执行,这个报错便不会发生。
webpack的scope hoisting(作用域提升)
经过上面的分析,webpack抛出错误的原因已经浮出水面了,但是我们依然没有很好的解决方案。经过再三的思考之后,最后我留意到了报错代码中的其中一个注释:CONCATENATED MODULE
于是我产生了几个疑问:
- 这个模块被标记为
CONCATENATED MODULE是什么意思?为什么别的模块没有这样的标记? - 凑巧的是,这个被标记为
CONCATENATED MODULE的模块,被错误地引用到了新导入的模块exif, 两者之间是否存在关系?如果这个模块没有被标记为CONCATENATED MODULE,会不会就能正常编译了呢?
带着这两个疑问,我又再进行了新一轮的探索。最后,我查到了这个名为CONCATENATED MODULE的标记来自于一个webpack的特性:scope hoisting (作用域提升)。我们知道,在js中,变量和函数的声明语句其实都是存在hoisting(developer.mozilla.org/zh-CN/docs/…)的,这意味着js解释器会在编译时把函数和变量的声明置顶。而wepack的scope hoisting特性其实与js的hoisting有异曲同工之妙。
前面说到,对于一个webpack打包输出后的文件,代码结构是类似于这样的:webpack 在打包的时候,为了隔离模块,会把每个模块都打包到一个函数中,再用__webpack_require__来进行引用。然而,这样其实也会增大每个模块的大小和性能开销。
而在启用了scope hoisting这一特性之后,webpack会分析模块间的依赖关系,尽可能将被打散的模块合并到一个函数中,但不能造成代码冗余,所以只有被引用一次的模块才能被合并。上述输出在启用了hoisting之后会变为类似如下的结构:
可以看出,这么优化后:
- 代码量明显减少
- 减少多个函数后内存占用减少
- 不用多次使用
__webpack_require__调用模块,运行速度也会得到提升
补充:由于需要分析模块间的依赖关系,所以源码必须是采用了ES6模块化的,否则在你使用非ES6模块或使用异步 import() 时,Webpack会降级处理不采用Scope Hoisting。模块依然会拆分开,不过具体代码会跟正常的引入有一点差异。
看到这里,我们前面提到的最后一个问题似乎也迎刃而解了,我们作出最后一个假设:
我们的项目中开启了scope hoisting的特性,而在webpack4的watch模式以及dev-server模式下,这个特性会使得webpack在监听到有新模块导入时,打包异常,导致页面崩溃。当我们使用异步import()来引入模块时,webpack自动进行了降级,没有采用scope hoisting,因此此时代码能够正常编译,页面不会报错白屏。
为了去验证这最后一个假设,我翻阅了webpack官方文档,在里面找到了关于scope hoisting的一些信息:
- 通过配置
optimization.concatenateModules: true, 可以开启scope hoisting
webpack4在mode为production时,默认会开启scope hoisting。
补充:为什么默认情况下不启用scope hoisting?事实上,使用scope hoisting来连接模块很酷,但是它增加了构建时间,并中断了热模块替换。这就是为什么应该只在生产中启用它。
回到项目的webpack配置中,正正就设置了optimization.concatenateModules: true。我们把这一行代码去掉,问题解决,watch模式下导入模块,热更新后页面也不会报错啦!同时,对比初次启动构建的时间,提高了大概10%。
至此,我们最后一个假设验证成立!完结撒花!
6. 总结
-
本文旨在解决
webpack4在watch模式下导入模块引发的页面报错问题,从复现错误开始,针对复现路径,把整个问题化成几个零碎,却又紧密相连的前端构建知识点,一步一步地在学习的过程中作出假说,并加以验证。 -
为了减少在验证假设中的具体工作量,本文作者重新搭建了一个
webpack demo,赋以相同的依赖版本及各类配置条件,所有假设及验证均在demo中试进行。 -
首先从宏观角度上,定位解决问题的大方向。
- 第一步:以
demo试行,验证了demo中同样存在问题,排除了问题是因为项目架构不合理/其他与业务强相关的因素产生的。 - 第二步:从
webpack版本出发,验证了webpack最新版不存在这个问题,证实问题与webpack版本有关。 - 第三步从文件的监听模式出发,验证了
watch模式和webpack-dev-server两种监听更新方式均存在问题,最终奠定问题根源在于webapck本身,不在于监听文件的模式机制。
- 第一步:以
-
其次从微观角度上,从零到一地分别分析了:
webpack编译打包文件的基础结构,报错的两个核心函数分别担任了何种工作webpack编译*.vue文件所使用的loader以及其简单原理- 发生报错时
webpack编译输出文件的前后特征对比,从差异和细节中发现问题,最后解决问题。
结论: webpack4在设置了optimization.concatenateModules: true的情况下,会开启scope hoisting特性。在watch模式以及webpack-dev-server下,这个特性会导致在*.vue文件中导入模块时,重新打包的js代码异常,页面报错。而webpack5似乎已经修复了这个问题。后面在webpack中找到了这样一个类似的issue,早早在webpack1的时期就被关闭了。但相同的提问一直持续到了今年1月,目前经过验证在webpack5中此问题应该已经被修复。
最后附上上述的issue链接和我的评论:github.com/webpack/web…