文章首发于我的blog,欢迎关注~javascript
最近遇到一个更新了 package,可是本地编译打包后没有更新代码的状况,先来复现下这个 case 的流程:vue
0.1.0
版本的 package;0.2.0
版本;0.1.0
版本升级到0.2.0
版本,并执行npm run deploy
,代码通过 webpack 本地编译后发布到测试环境。可是测试环境的代码并非最新的 package 的内容。可是在 node_modules 当中的 package 确实是最新的版本。这个问题其实在社区里面有不少同窗已经遇到了:java
TL;DR(流程分析较复杂,可一拉到底)node
翻了那些 issue 后,基本知道了是因为 webpack 在编译代码过程当中走到 cache-loader 而后命中了缓存,这个缓存是以前编译的老代码,既然命中了缓存,那么就不会再去编译新的代码,因而最终编译出来的代码并非咱们所指望的。因此这个时候 cd node_modules && rm -rf .cache && npm run deploy
,就是进入到 node_modules 目录,将 cache-loader 缓存的代码所有清除掉,并从新执行部署的命令,这些编译出来的代码确定是最新的。webpack
既然知道了问题的所在,那么就开始着手去分析这个问题的前因后果。这里我也简单的介绍下 cache-loader 的 workflow 是怎么进行的:git
node_modules/.cache
文件夹下的缓存的 json 文件(abc.json)。其中 cacheKey 的生成支持外部传入 cacheIdentifier 和 cacheDirectory 具体参见官方文档。// cache-loader 内部定义的默认的 cacheIdentifier 及 cacheDirectory const defaults = { cacheContext: '', cacheDirectory: findCacheDir({ name: 'cache-loader' }) || os.tmpdir(), cacheIdentifier: `cache-loader:${pkg.version} ${env}`, cacheKey, compare, precision: 0, read, readOnly: false, write } function cacheKey(options, request) { const { cacheIdentifier, cacheDirectory } = options; const hash = digest(`${cacheIdentifier}\n${request}`); return path.join(cacheDirectory, `${hash}.json`); }
若是缓存文件(abc.json)当中记录的全部依赖以及这个文件都没发生变化,那么就会直接读取缓存当中的内容,并返回且跳事后面的 loader 的正常执行。一旦有依赖或者这个文件发生变化,那么就正常的走接下来的 loader 上部署的 pitch 方法,以及正常的 loader 处理文本文件的流程。github
cache-loader 在决定是否使用缓存内容时是经过缓存内容当中记录的全部的依赖文件的 mtime 与对应文件最新的 mtime 作对比来看是否发生了变化,若是没有发生变化,即命中缓存,读取缓存内容并跳事后面的 loader 的处理,不然走正常的 loader 处理流程。web
function pitch(remainingRequest, prevRequest, dataInput) { ... // 根据 cacheKey 的标识获取对应的缓存文件内容 readFn(data.cacheKey, (readErr, cacheData) => { async.each( cacheData.dependencies.concat(cacheData.contextDependencies), // 遍历全部依赖文件路径 (dep, eachCallback) => { // Applying reverse path transformation, in case they are relatives, when // reading from cache const contextDep = { ...dep, path: pathWithCacheContext(options.cacheContext, dep.path), }; // fs.stat 获取对应文件状态 FS.stat(contextDep.path, (statErr, stats) => { if (statErr) { eachCallback(statErr); return; } // When we are under a readOnly config on cache-loader // we don't want to emit any other error than a // file stat error if (readOnly) { eachCallback(); return; } const compStats = stats; const compDep = contextDep; if (precision > 1) { ['atime', 'mtime', 'ctime', 'birthtime'].forEach((key) => { const msKey = `${key}Ms`; const ms = roundMs(stats[msKey], precision); compStats[msKey] = ms; compStats[key] = new Date(ms); }); compDep.mtime = roundMs(dep.mtime, precision); } // 对比当前文件最新的 mtime 和缓存当中记录的 mtime 是否一致 // If the compare function returns false // we not read from cache if (compareFn(compStats, compDep) !== true) { eachCallback(true); return; } eachCallback(); }); }, (err) => { if (err) { data.startTime = Date.now(); callback(); return; } ... callback(null, ...cacheData.result); } ); }) }
script block
及template block
在代码编译构建的流程当中都利用了 cache-loader 进行了缓存相关的配置工做。// @vue/cli-plugin-babel module.export = (api, options) => { ... api.chainWebpack(webpackConfig => { const jsRule = webpackConfig.module .rule('js') .test(/\.m?jsx?$/) .use('cache-loader') .loader(require.resolve('cache-loader')) .options(api.genCacheConfig('babel-loader', { '@babel/core': require('@babel/core/package.json').version, '@vue/babel-preset-app': require('@vue/babel-preset-app/package.json').version, 'babel-loader': require('babel-loader/package.json').version, modern: !!process.env.VUE_CLI_MODERN_BUILD, browserslist: api.service.pkg.browserslist }, [ 'babel.config.js', '.browserslistrc' ])) .end() jsRule .use('babel-loader') .loader(require.resolve('babel-loader')) }) ... } // @vue/cli-serive/lib/config module.exports = (api, options) => { ... api.chainWebpack(webpackConfig => { const vueLoaderCacheConfig = api.genCacheConfig('vue-loader', { 'vue-loader': require('vue-loader/package.json').version, /* eslint-disable-next-line node/no-extraneous-require */ '@vue/component-compiler-utils': require('@vue/component-compiler-utils/package.json').version, 'vue-template-compiler': require('vue-template-compiler/package.json').version }) webpackConfig.module .rule('vue') .test(/\.vue$/) .use('cache-loader') .loader(require.resolve('cache-loader')) .options(vueLoaderCacheConfig) .end() .use('vue-loader') .loader(require.resolve('vue-loader')) .options(Object.assign({ compilerOptions: { whitespace: 'condense' } }, vueLoaderCacheConfig)) ... }) }
即:算法
script block
来讲通过babel-loader
的处理后经由cache-loader
,若以前没有进行缓存过,那么新建本地的缓存 json 文件,若命中了缓存,那么直接读取通过babel-loader
处理后的 js 代码;template block
来讲通过vue-loader
转化成 renderFunction 后经由cache-loader
,若以前没有进行缓存过,那么新建本地的缓存 json 文件,若命中了缓存,那么直接读取 json 文件当中缓存的 renderFunction。上面对于 cache-loader 和 @vue/cli 内部工做原理的简单介绍。那么在文章一开始的时候提到的那个 case 具体是由于什么缘由致使的呢?vue-cli
事实上在npm 5.8+
版本,npm 将发布的 package 当中包含的文件的 mtime 都统一置为了1985-10-26T08:15:00.000Z
(可参见 issue-20439)。
A 同窗(npm版本为6.4.1)发布了0.1.0
的版本后,C 同窗安装了0.1.0
版本,本地构建后生成缓存文件记录的文件 mtime 为1985-10-26T08:15:00.000Z
。B 同窗(npm版本为6.2.1)发布了0.2.0
,C 同窗安装0.2.0
版本,本地开始构建,可是经由 cache-loader 的过程中,cache-loader 经过对比缓存文件记录的依赖的 mtime 和新安装的 package 的文件的 mtime,可是发现都是1985-10-26T08:15:00.000Z
,这样也就命中了缓存,即直接获取上一次缓存文件当中所包含的内容,而不会对新安装的 package 的文件进行编译。
针对这个问题,@vue/cli 在19年4月的3.7.0
版本(具体代码变动的内容请戳我)当中也作了相关的修复性的工做,主要是将:package-lock.json
、yarn.lock
、pnpm-lock.yaml
,这些作版本控制文件也加入到了 hash 生成的策略当中:
// @vue/cli-service/lib/PluginAPI.js class PluginAPI { ... genCacheConfig(id, partialIdentifier, configFiles = []) { ... if (!Array.isArray(configFiles)) { configFiles = [configFiles] } configFiles = configFiles.concat([ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml' ]) const readConfig = file => { const absolutePath = this.resolve(file) if (!fs.existsSync(absolutePath)) { return } if (absolutePath.endsWith('.js')) { // should evaluate config scripts to reflect environment variable changes try { return JSON.stringify(require(absolutePath)) } catch (e) { return fs.readFileSync(absolutePath, 'utf-8') } } else { // console.log('the absolute path is:', fs.readFileSync(absolutePath, 'utf-8')) return fs.readFileSync(absolutePath, 'utf-8') } } // 获取版本控制文件的文本内容 for (const file of configFiles) { const content = readConfig(file) if (content) { variables.configFiles = content.replace(/\r\n?/g, '\n') break } } // 将带有版本控制文件的内容加入到 hash 算法当中,生成新的 cacheIdentifier // 并传入 cache-loader(缓存文件的 cacheKey 依据这个 cacheIdentifier 来生成,👆上文有说明) const cacheIdentifier = hash(variables) return { cacheDirectory, cacheIdentifier } } }
这样来作的核心思想就是:当你升级了某个 package 后,相应的版本控制文件也会对应的更新(例如 package-lock.json),那么再一次进行编译流程时,所生成的缓存文件的 cacheKey 就会是最新的,由于也就不会命中缓存,仍是走正常的全流程的编译,最终打包出来的代码也就是最新的。
不过此次升级后,仍是有同窗在社区反馈命中缓存,代码没有更新的问题,并且出现的 case 是 package 当中须要走 babel-loader 的 js 会遇到命中缓存不更新的状况,可是 package 当中被项目代码引用的 vue 的 template 文件不会出现这种状况。后来我调试了下@vue/cli-service/lib/PluginAPI.js
的代码,发现代码在读取多个配置文件的过程当中,一旦获取到某个配置文件的内容后就再也不读取后面的配置文件的内容了,这样也就致使就算package-lock.json
发生了更新,可是由于在编译流程当中并未读取package-lock.json
这个文件的最新的内容话,那么也就不会生成新的 cacheKey,仍然会出现命中缓存的问题:
// 针对须要走 babel-loader 流程的配置文件为: ['babel.config.js', '.browserslistrc', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] // 针对须要缓存的 vue template 的配置文件为: ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] // @vue/cli-service/lib/PluginAPI.js class PluginAPI { ... genCacheConfig(id, partialIdentifier, configFiles = []) { ... if (!Array.isArray(configFiles)) { configFiles = [configFiles] } configFiles = configFiles.concat([ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml' ]) const readConfig = file => { ... } // 一旦获取到某个配置文件的内容后,就直接跳出了 for ... of 的循环 // 那么也就不会继续获取其余配置文件的内容, // 因此对于处理 js 文件的流程来讲,由于读取了 babel.config.js 的内容,那么也就不会再去获取更新后的 packge-lock.json 文件内容 // 可是对于处理 vue template 的流程来讲,配置文件当中第一项就位 package-lock.json,这种状况下会获取最新的 package-lock.json 文件,因此对于 vue template 的不会出现升级了 package 内容,可是会由于命中缓存,致使编译代码不更新的状况。 for (const file of configFiles) { const content = readConfig(file) if (content) { variables.configFiles = content.replace(/\r\n?/g, '\n') break } } const cacheIdentifier = hash(variables) return { cacheDirectory, cacheIdentifier } } }
不过就在前几天,@vue/cli 的做者也从新看了下这个有关 vue template 正常,可是对于 js 命中缓存的缘由,并针对这个问题进行了修复(具体代码内容变动请戳我),此次的代码变动就是经过 map 循环(而非 for ... of 循环读取到内容后直接 break),这样去确保全部的配置文件都被获取获得:
variables.configFiles = configFiles.map(file => { const content = readConfig(file) return content && content.replace(/\r\n?/g, '\n') }
目前在@vue/cli-service@4.1.2
版本中已经进行了修复。
以上就是经过 @vue/cli 初始化的项目,在升级 package 的过程当中,cache-loader 命中缓存,新一轮代码编译生成非最新代码问题的分析。
cache-loader 使用缓存文件(node_modules/.cache
)记录了不一样依赖文件的 mtime,并经过对比缓存记录的 mtime 和最新文件的 mtime 是否发生了变化来以为是否使用缓存。因为npm@5.8.0
以后,每次新发布的 package 内部所包含的文件的 mtime 都被重置为1985-10-26T08:15:00.000Z
,致使 cache-loader 这个对比 mtime 的策略失效。由于 @vue/cli-service 从3.7.0
(19年4月)版本针对这个问题进行了第一次的修复,核心思想就是将package-lock.json
这样的版本控制文件的内容归入到了生成缓存文件的 cacheKey 的 hash 算法当中,每次升级 package 后,package-lock.json
也会随之变化,这样会生成新的 cacheKey,进而不会命中缓存策略,这样也就解开了因为 npm 重置 mtime 而带来的重复命中缓存的问题,可是3.7.0
版本的修复是有bug的,主要就是有些项目当中package-lock.json
(由项目结构决定)这样的版本控制文件根本就没有被读取,致使 cache-loader 生成的 cacheKey 依然没有变化。而后在前几天(2020年1月28日),@vue/cli 的做者从新针对这个问题进行优化,确保package-lock.json
版本控制文件能被读取到,从而避免 cacheKey 不变的问题,于@vue/cli-service@4.1.2
版本中彻底修复了重复命中缓存的问题。
这里比较有意思的一点就是这个问题的出现须要知足2个条件:
好比我一直使用的 node 版本为 8.11.0,对应的 npm 版本为 5.6.0,那么经由我去修改发布的全部 package 所包含的文件的 mtime 都是被修改的那一刻,其余人升级到我发布的版本后,是不会出现重复命中缓存的问题。
不过既然问题被梳理清楚后,那么本地编译的过程避免出现这个问题的解决方式:
node_module/.cache
文件夹,例如将本地编译构建的npm script
修改成rm -rf node_module/.cache && vue-cli-service build
。(不过对于大型的项目来讲,少了这部分的缓存内容的话,编译速度仍是会受到必定的影响的。)