@vue/cli 項目編譯重複命中緩存問題解析

@vue/cli 項目編譯重複命中緩存問題解析

文章首發於我的blog,歡迎關注~javascript

背景

最近遇到一個更新了 package,可是本地編譯打包後沒有更新代碼的狀況,先來複現下這個 case 的流程:vue

  1. A 同窗在 npm 上發佈了0.1.0版本的 package;
  2. B 同窗開發了一個新的 feature,併發布0.2.0版本;
  3. C 同窗將本地的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

  1. 在 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 處理文本文件的流程。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);
      }
    );
  })
}
  1. 經過 @vue/cli 初始化的項目內部會經過腳手架去完成 webpack 相關的配置,其中針對 vue SFC 文件當中的script blocktemplate 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.jsonyarn.lockpnpm-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個條件:

  1. 發佈 package 的同窗使用的 npm 的版本須要高於 5.8.0;
  2. 使用 package 的同窗使用的 @vue/cli-service 的版本要低於 4.1.2 版本

好比我一直使用的 node 版本爲 8.11.0,對應的 npm 版本爲 5.6.0,那麼經由我去修改發佈的全部 package 所包含的文件的 mtime 都是被修改的那一刻,其餘人升級到我發佈的版本後,是不會出現重複命中緩存的問題。

不過既然問題被梳理清楚後,那麼本地編譯的過程避免出現這個問題的解決方式:

  1. 若是你的項目使用 @vue/cli@4.x 初始化的,那麼直接升級 @vue/cli-service 到 4.1.2 版本便可;
  2. 若是你不想升級 @vue/cli-service 的版本(特別是你是使用 @vue/cli@3.x 版本初始化項目的同窗,可能會出現兼容性問題,具體可自行測試),那麼能夠在每次本地編譯開始前,刪除掉node_module/.cache文件夾,例如將本地編譯構建的npm script修改成rm -rf node_module/.cache && vue-cli-service build。(不過對於大型的項目來講,少了這部分的緩存內容的話,編譯速度仍是會受到必定的影響的。)
相關文章
相關標籤/搜索