背景
最近遇到一個更新了 package,但是本地編譯打包后沒有更新代碼的情況,先來復現下這個 case 的流程:
A 同學在 npm 上發布了0.1.0版本的 package;
B 同學開發了一個新的 feature,並發布0.2.0版本;
C 同學將本地的0.1.0版本升級到0.2.0版本,並執行npm run deploy,代碼經過 webpack 本地編譯后發布到測試環境。但是測試環境的代碼並不是最新的 package 的內容。但是在 node_modules 當中的 package 確實是最新的版本。
這個問題其實在社區里面有很多同學已經遇到了:
issue-4438
issue-3635
issue-2450
TL;DR(流程分析較復雜,可一拉到底)
發現 & 分析問題
翻了那些 issue 后,基本知道了是由於 webpack 在編譯代碼過程中走到 cache-loader 然后命中了緩存,這個緩存是之前編譯的老代碼,既然命中了緩存,那么就不會再去編譯新的代碼,於是最終編譯出來的代碼並不是我們所期望的。所以這個時候 cd node_modules && rm -rf .cache && npm run deploy,就是進入到 node_modules 目錄,將 cache-loader 緩存的代碼全部清除掉,並重新執行部署的命令,這些編譯出來的代碼肯定是最新的。
既然知道了問題的所在,那么就開始着手去分析這個問題的來龍去脈。這里我也簡單的介紹下 cache-loader 的 workflow 是怎么進行的:
在 cache-loader 上部署了 pitch 方法(有關 loader pitch function 的用法可戳我),在 pitch 方法內部會根據生成的 cacheKey(例如abc) 去尋找 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 處理文本文件的流程。
cache-loader 在決定是否使用緩存內容時是通過緩存內容當中記錄的所有的依賴文件的 mtime 與對應文件最新的 mtime 做對比來看是否發生了變化,如果沒有發生變化,即命中緩存,讀取緩存內容並跳過后面的 loader 的處理,否則走正常的 loader 處理流程。
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); } ); }) }
通過 @vue/cli 初始化的項目內部會通過腳手架去完成 webpack 相關的配置,其中針對 vue SFC 文件當中的script block及template block在代碼編譯構建的流程當中都利用了 cache-loader 進行了緩存相關的配置工作。
vi設計http://www.maiqicn.com 辦公資源網站大全https://www.wode007.com
// @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 具體是因為什么原因導致的呢?
事實上在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 生成的策略當中: