6

vue-vite浅析

 3 years ago
source link: https://www.daqianduan.com/19632.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

大家好,我是小雨小雨,致力于分享有趣的、实用的文章。

内容分为原创和翻译,如果有问题,欢迎随时评论或私信,很乐意和大家一起探讨,一起进步。

分享不易,希望能够得到大家的支持和关注。

vite出了好久了,也出了好多相关文章,我也想出,然后我就写了。

该文档对应的vite版本:2.0.0-beta.4

vite文档

整体流程

笔者认为,vite是站在巨人肩膀上的一个创新型dev构建工具,分别继承于:

其中洋葱模型如果将next()放到函数最底部的话,和rollup的插件驱动是类似的。

也就是说可插拔架构是vite的整体思想,不仅可以编写内部插件,将内部插件原子化,还可以借助npm上各种已有的插件。非常灵活。

为什么采用es module呢?

vite采用的es module进行模块导入,这是现代浏览器原生支持的,当import一个模块时,会发出一个请求,正因如此,只能在服务中使用es module。而且import的模块路径发生变化的时候,会重新发送请求,路径变化包括query。

下面我们进入整体

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中的内容是这样的:

YfmAn2.png

除了render相关函数,还有createHotContext、import.meta.hot.accept这类内容,这是和hmr相关的,下面会讲到。

hmr

hmr在我们的开发工程中也提到举足轻重的作用,那vite是怎么做的呢?

涉及部分:

  • client 提供hmr上下文环境,其中包含当前文件对应的更新方法,ws通知时会调用

  • importsAnalysis import模块的时候对模块进行图依赖更新、拼接等操作,比如针对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简版流程图

VNNbue.png

总结

vite对es module的使用让人惊艳,一下子解决了大项目build所有内容的痛点,而且与rollup完美集结合,任何rollup插件都可以在vite中使用。

当然,vite的这种思想不是首例,很早之前snowpack利用es module也是名噪一时。

vite目前主要解决的是dev环境的问题,生产环境还是需要build才能使用,vite使用esbuild进行生产环境打包,esbuild使用go开发,原生到原生,感兴趣的朋友可以去看一看,这里就不班门弄斧了。

最后感谢大家的内心阅读,如果觉得不错,可以通过关注,点赞,转发多多支持~

祝大家工作顺利,节节高升

#感谢您访问本站#
#本文转载自互联网,若侵权,请联系删除,谢谢!657271#qq.com#

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK