vue-vite淺析

你們好,我是小雨小雨,致力於分享有趣的、實用的文章。javascript

內容分爲原創和翻譯,若是有問題,歡迎隨時評論或私信,很樂意和你們一塊兒探討,一塊兒進步。css

分享不易,但願可以獲得你們的支持和關注。html


vite出了很久了,也出了好多相關文章,我也想出,而後我就寫了。:)vue

該文檔對應的vite版本:2.0.0-beta.4
vite文檔

總體流程

筆者認爲,vite是站在巨人肩膀上的一個創新型dev構建工具,分別繼承於:java

其中洋蔥模型若是將next()放到函數最底部的話,和rollup的插件驅動是相似的。node

也就是說可插拔架構是vite的總體思想,不只能夠編寫內部插件,將內部插件原子化,還能夠藉助npm上各類已有的插件。很是靈活。git

爲何採用es module呢?github

vite採用的es module進行模塊導入,這是現代瀏覽器原生支持的,當import一個模塊時,會發出一個請求,正因如此,只能在服務中使用es module。並且import的模塊路徑發生變化的時候,會從新發送請求,路徑變化包括query。web


下面咱們進入總體typescript

vite採用monorepo架構,咱們要關心的代碼主要兩部分:

先從vite cli提及。

這裏是vite的入口:

const { createServer } = await import('./server')
try {
  const server = await createServer(
    {
      root,
      mode: options.mode,
      logLevel: options.logLevel,
      server: cleanOptions(options) as ServerOptions
    },
    options.config
  )
  await server.listen()
複製代碼

簡單粗暴,經過createServer建立一個服務,而後開始監聽,咱們直接一個瞎子摸葫蘆,打開createServer看看。

export async function createServer( inlineConfig: UserConfig & { mode?: string } = {}, configPath?: string | false ): Promise<ViteDevServer> {
  // 代碼太多不放了,放點方便看的,有興趣的話能夠打開代碼一邊看這裏的註釋一邊看代碼
  
  // 配置相關,好比記載本地配置文件、集成插件,環境變量等等

  // 利用connect初始化服務,connect是一個使用中間件爲node提供可擴展服務的http框架,有興趣能夠去看看

  // 建立webSocket服務

  // 利用chokidar進行文件監聽
  
  // vite繼承rollup實現了一個迷你版的構解析構建工具
  
  // 建立一個圖來維護模塊之間的關係
  
  // 當文件發生變化的時候進行hmr相關操做,後續會介紹
  
  // 接入各類各樣的中間件,好比接口代理的、靜態服務的、解析請求資源的、重定向、處理html的等,其中最重要的就是解析請求資源的了,下面具體來扣一下這塊 
  
  // 調用插件中的configureServer,這一步能夠將vite中全部內容暴露給用戶,好比node服務app,配置,文件監聽器,socket等等,很大膽,很壞,可是我好喜歡
  
  // 返回node服務,供listen
}
複製代碼

運行完這一堆後,咱們就啓動了一個服務,咱們發現,vite到目前爲止,並無任何關於打包的代碼,那他快在哪裏呢?

其實沒有打包就是vite快的緣由之一,而他的打包作到了真正的按需。

啓動服務後,咱們訪問頁面會發送一個個的請求,這些請求會通過中間件處理,而中間件,就會進行打包,注入等相關操做。

核心內容其實就是上面註釋中寫的解析請求資源這個中間件,vite中叫作transformMiddleware

export function transformMiddleware( server: ViteDevServer ): Connect.NextHandleFunction {
  const {
    config: { root, logger },
    moduleGraph
  } = server

  return async (req, res, next) => {
      // 其餘代碼
      
      // Only apply the transform pipeline to:
      // - requests that initiate from ESM imports (any extension)
      // - CSS (even not from ESM)
      // - Source maps (only for resolving)
      if (
        isJSRequest(url) || // 指定的(j|t)sx?|mjs|vue這類文件,或者沒有後綴
        isImportRequest(url) || // import來的
        isCSSRequest(url) || // css
        isHTMLProxy(url) || // html-proxy
        server.config.transformInclude(withoutQuery) // 命中須要解析的
      ) {
        // 移除import的query,例: (\?|$)import=xxxx
        url = removeImportQuery(url)

        // 刪調idprefix,importAnalysis生成的不合法的瀏覽器說明符被預先解析id
        if (url.startsWith(VALID_ID_PREFIX)) {
          url = url.slice(VALID_ID_PREFIX.length)
        }

        // for CSS, we need to differentiate between normal CSS requests and
        // imports
        // 處理css連接
        if (isCSSRequest(url) && req.headers.accept?.includes('text/css')) {
          url = injectQuery(url, 'direct')
        }

        // check if we can return 304 early
        const ifNoneMatch = req.headers['if-none-match']
        // 命中瀏覽器緩存,利用瀏覽器的特性
        if (
          ifNoneMatch &&
          (await moduleGraph.getModuleByUrl(url))?.transformResult?.etag ===
            ifNoneMatch
        ) {
          res.statusCode = 304
          return res.end()
        }

        // 解析vue js css 等文件的關鍵
        const result = await transformRequest(url, server)
        if (result) {
          const type = isDirectCSSRequest(url) ? 'css' : 'js'
          const isDep =
            DEP_VERSION_RE.test(url) ||
            url.includes(`node_modules/${DEP_CACHE_DIR}`)
          return send(
            req,
            res,
            result.code,
            type,
            result.etag,
            // allow browser to cache npm deps!
            isDep ? 'max-age=31536000,immutable' : 'no-cache',
            result.map
          )
        }
      }
    } catch (e) {
      return next(e)
    }

    next()
  }
}
複製代碼

其中最重要的是transformRequest,該方法進行了緩存,請求資源解析,加載,轉換操做。

export async function transformRequest( url: string, { config: { root }, pluginContainer, moduleGraph, watcher }: ViteDevServer ): Promise<TransformResult | null> {
  url = removeTimestampQuery(url)
  const prettyUrl = isDebug ? prettifyUrl(url, root) : ''

  // 檢查上一次的transformResult,這個東西會在hmr中被主動移除掉
  const cached = (await moduleGraph.getModuleByUrl(url))?.transformResult
  if (cached) {
    isDebug && debugCache(`[memory] ${prettyUrl}`)
    return cached
  }

  // resolve
  const id = (await pluginContainer.resolveId(url))?.id || url
  const file = cleanUrl(id)

  let code = null
  let map: SourceDescription['map'] = null

  // load
  const loadStart = Date.now()
  const loadResult = await pluginContainer.load(id)
  // 加載失敗,直接讀文件
  if (loadResult == null) {
    // try fallback loading it from fs as string
    // if the file is a binary, there should be a plugin that already loaded it
    // as string
    try {
      code = await fs.readFile(file, 'utf-8')
      isDebug && debugLoad(`${timeFrom(loadStart)} [fs] ${prettyUrl}`)
    } catch (e) {
      if (e.code !== 'ENOENT') {
        throw e
      }
    }
    if (code) {
      map = (
        convertSourceMap.fromSource(code) ||
        convertSourceMap.fromMapFileSource(code, path.dirname(file))
      )?.toObject()
    }
  } else {
    isDebug && debugLoad(`${timeFrom(loadStart)} [plugin] ${prettyUrl}`)
    if (typeof loadResult === 'object') {
      code = loadResult.code
      map = loadResult.map
    } else {
      code = loadResult
    }
  }
  if (code == null) {
    throw new Error(`Failed to load url ${url}. Does the file exist?`)
  }

  // 將當前處理請求地址添加到維護的圖中
  const mod = await moduleGraph.ensureEntryFromUrl(url)
  // 監聽
  if (mod.file && !mod.file.startsWith(root + '/')) {
    watcher.add(mod.file)
  }

  // transform
  const transformStart = Date.now()
  // 全部的插件都被閉包保存了,而後調用pluginContainer上的某個鉤子函數,該函數會loop插件進行具體操做
  const transformResult = await pluginContainer.transform(code, id, map)
  if (
    transformResult == null ||
    (typeof transformResult === 'object' && transformResult.code == null)
  ) {
    // no transform applied, keep code as-is
    isDebug &&
      debugTransform(
        timeFrom(transformStart) + chalk.dim(` [skipped] ${prettyUrl}`)
      )
  } else {
    isDebug && debugTransform(`${timeFrom(transformStart)} ${prettyUrl}`)
    if (typeof transformResult === 'object') {
      code = transformResult.code!
      map = transformResult.map
    } else {
      code = transformResult
    }
  }

  // 返回並緩存當前轉換結果
  return (mod.transformResult = {
    code,
    map,
    etag: getEtag(code, { weak: true })
  } as TransformResult)
}
複製代碼

主要涉及插件提供的三個鉤子函數:

  • pluginContainer.resolveId
  • pluginContainer.load
  • pluginContainer.transform

resolveIdload將請求的url解析成對應文件中的內容供transform使用

transform會調用插件提供的transform方法對不一樣文件代碼進行轉換操做,好比vite提供的plugin-vue,就對vue進行了轉換,提供的plugin-vue-jsx,就對jsx寫法進行了支持。若是要支持其餘框架語言,也能夠自行添加。

到這裏,vite的大體流程就結束了。

可能光看代碼不是很直觀,這邊提供一個簡單的例子:

<!DOCTYPE html>
<html lang="en">

<head>
    <title>Vite App</title>
</head>

<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
</body>

</html>
複製代碼
// main.js
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')
複製代碼
// app.vue
<template>
    <div>hello world</div>
</template>
複製代碼

瀏覽器中看到的app.vue中的內容是這樣的:

除了render相關函數,還有createHotContext、import.meta.hot.accept這類內容,這是和hmr相關的,下面會講到。

hmr

hmr在咱們的開發工程中也提到舉足輕重的做用,那vite是怎麼作的呢?

涉及部分:

  • client提供hmr上下文環境,其中包含當前文件對應的更新方法,ws通知時會調用

  • importsAnalysisimport模塊的時候對模塊進行圖依賴更新、拼接等操做,好比針對hmr模塊注入client中提供的hmr api

  • plugin-vue注入vue上下文環境,而且將client中的方法拼接到當前模塊中

當咱們import一個模塊時,會發送一個請求,當前請求在transformMiddleware中間件處理的時候,當前請求url會被添加到圖中,而後被各類插件的transform處理,其中就包括importsAnalysis插件,importsAnalysis會經過es-module-lexer解析import的export,將當前模塊插入到模塊圖中,而且將當前importe和被引入的importedModules創建依賴關係。

// importsAnalysis.ts
if (!isCSSRequest(importer)) {
    const prunedImports = await moduleGraph.updateModuleInfo(
      importerModule, // 當前解析的主體
      importedUrls, // 被引入的文件
      normalizedAcceptedUrls,
      isSelfAccepting
    )
    if (hasHMR && prunedImports) {
      handlePrunedModules(prunedImports, server)
    }
}
複製代碼

而且會爲當前請求的文件中加入hmr api。

// importsAnalysis.ts
if (hasHMR) {
    // inject hot context
    str().prepend(
      `import { createHotContext } from "${CLIENT_PUBLIC_PATH}";` +
        `import.meta.hot = createHotContext(${JSON.stringify( importerModule.url )});`
    )
  }
複製代碼

除了importsAnalysis插件外,還有plugin-vue插件的transform,插入的是re-render方法。

// /plugin-vue/src/main.ts
if (devServer && !isProduction) {
    output.push(`_sfc_main.__hmrId = ${JSON.stringify(descriptor.id)}`)
    output.push(
      `__VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)`
    )
    // check if the template is the only thing that changed
    if (prevDescriptor && isOnlyTemplateChanged(prevDescriptor, descriptor)) {
      output.push(`export const _rerender_only = true`)
    }
    output.push(
      `import.meta.hot.accept(({ default: updated, _rerender_only }) => {`,
      ` if (_rerender_only) {`,
      ` __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)`,
      ` } else {`,
      ` __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)`,
      ` }`,
      `})`
    )
}
複製代碼

其中__VUE_HMR_RUNTIME__爲vue runtime暴露的,已經在main.js中引入過了,下面的import.meta.hot.accept則是client暴露的方法,import.meta爲es module當前模塊的元數據。

而client就是瀏覽器端hmr相關的邏輯了,也是上面插件注入的方法的依賴。

// client.ts
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
    // hotModulesMap被閉包保存了
    // ownerPath是當importsAnalysis實例化hmr上下文的時候傳入的當前模塊的id地址
    const mod: HotModule = hotModulesMap.get(ownerPath) || {
      id: ownerPath,
      callbacks: []
    }
    mod.callbacks.push({
      deps,
      fn: callback
    })
    hotModulesMap.set(ownerPath, mod)
  }
  // 經過importsAnalysis添加在文件中
  // plugin-vue插件會使用該方法添加模塊(mod),而且會添加一些vue相關的內容,好比:
  // 添加vue render方法,以供hmr調用
const hot = {
    // 調用的時候給callback增長刷新方法
    accept(deps: any, callback?: any) {
      if (typeof deps === 'function' || !deps) {
        // self-accept: hot.accept(() => {})
        acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
      } else if (typeof deps === 'string') {
        // explicit deps
        acceptDeps([deps], ([mod]) => callback && callback(mod))
      } else if (Array.isArray(deps)) {
        acceptDeps(deps, callback)
      } else {
        throw new Error(`invalid hot.accept() usage.`)
      }
    },
    // ...
}
複製代碼

咱們調用import.meta.hot.accept的時候,好比傳入方法,那麼會以importer模塊爲key將更新方法添加到一個hotModulesMap中。記錄當前待更新模塊。

接下來,ws會在在文件變化後發送message到瀏覽器端。這一步會涉及判斷是否爲自更新、(主要是根據accept方法主體內容判斷,具體邏輯可自行查看)是否有importer等邏輯決定hmr類型。

咱們以hmr類型爲js-update爲例子繼續往下說。

主要是兩個方法,一個是fetchUpdate,用來獲取即將更新的模塊,import模塊,返回一個調用re-render的方法,一個是queueUpdate,用於執行fetchUpdate返回的方法。

進入fetchUpdate後,會判斷是否更新的是當前模塊,是的話添加當前模塊到modulesToUpdate,不是的話將依賴的子模塊添加到待更新的記錄中modulesToUpdate,以後過濾出以前收集的待更新的模塊,循環進行import操做,可是會在import模塊的路徑上加上當前時間戳,以強制觸發http請求,用引入的新模塊替換以前的舊模塊,最後返回plugin-vue提供的re-render方法。

async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
  // 當前更新的模塊
  const mod = hotModulesMap.get(path)
  if (!mod) {
    return
  }

  const moduleMap = new Map()
  // 自更新
  const isSelfUpdate = path === acceptedPath

  // make sure we only import each dep once
  const modulesToUpdate = new Set<string>()
  if (isSelfUpdate) {
    // self update - only update self
    modulesToUpdate.add(path)
  } else {
    // dep update
    for (const { deps } of mod.callbacks) {
      deps.forEach((dep) => {
        if (acceptedPath === dep) {
          modulesToUpdate.add(dep)
        }
      })
    }
  }

  // determine the qualified callbacks before we re-import the modules
  // 符合標準的更新函數纔會留下來
  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
    return deps.some((dep) => modulesToUpdate.has(dep))
  })

  // 將modulesToUpdate變成對應模塊的更新函數
  await Promise.all(
    Array.from(modulesToUpdate).map(async (dep) => {
      const disposer = disposeMap.get(dep)
      if (disposer) await disposer(dataMap.get(dep))
      const [path, query] = dep.split(`?`)
      try {
        // 這裏又會發一個請求,而後新的模塊就下來了,可是dom樹還沒變化,下載下來的文件會有id,對應當前即將被更新的模塊
        const newMod = await import(
          /* @vite-ignore */
          path + `?t=${timestamp}${query ? `&${query}` : ''}`
        )
        moduleMap.set(dep, newMod)
      } catch (e) {
        warnFailedFetch(e, dep)
      }
    })
  )

  // 返回函數,函數內容是plugin-vue中的accept注入的,好比vue文件就是vue的render更新方法
  // 這裏會調用新文件中的render方法,進而在瀏覽器端進行模塊更新操做
  return () => {
    for (const { deps, fn } of qualifiedCallbacks) {
      fn(deps.map((dep) => moduleMap.get(dep)))
    }
    const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
    console.log(`[vite] hot updated: ${loggedPath}`)
  }
}
複製代碼

fetchUpdate的結果會流向queueUpdate,queueUpdate將更新任務放到微任務中,自動收集必定時間內的渲染。

async function queueUpdate(p: Promise<(() => void) | undefined>) {
  queued.push(p)
  if (!pending) {
    pending = true
    await Promise.resolve()
    pending = false
    const loading = [...queued]
    queued = []
    ;(await Promise.all(loading)).forEach((fn) => fn && fn())
  }
}
複製代碼

vite簡版流程圖

總結

vite對es module的使用讓人驚豔,一會兒解決了大項目build全部內容的痛點,並且與rollup完美集結合,任何rollup插件均可以在vite中使用。

固然,vite的這種思想不是首例,很早以前snowpack利用es module也是名聞遐邇。

vite目前主要解決的是dev環境的問題,生產環境仍是須要build才能使用,vite使用esbuild進行生產環境打包,esbuild使用go開發,原生到原生,感興趣的朋友能夠去看一看,這裏就不班門弄斧了。

最後感謝你們的心裏閱讀,若是以爲不錯,能夠經過關注,點贊,轉發多多支持~

祝你們工做順利,節節高升

相關文章
相關標籤/搜索