vite2.x 源码解析一 ——启动阶段

你是否曾经因为项目过于复杂,每次启动需要等待半天而烦恼?你是否曾为修改一次代码,浏览器迟迟不更新而愤怒?别担心!使用 Vite ,这些问题都能解决。

简介

Vite(读音类似于[weɪt],法语,快的意思),一个基于浏览器原生 ES Modules 的开发服务器。其主要包含两部分:

  • 一个开发服务器,它基于原生 ES 模块提供了 丰富的内建功能,如速度快到惊人的模块热更新。
  • 一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。

前置概念

ES Module

ES Module 是浏览器自身支持的模块系统,目前大多数主流浏览器均已支持。

image.png

要使用 ES Module 时,script 标签需要带上 type="module" 的标识。

<script type="module" src="https://juejin.cn/index.ts"></script>
复制代码

浏览器会把 import 语法作为一次请求处理,以下图为例,import App from './App.ts' 执行时,浏览器会根据相对路径请求 App.ts 这个文件来获得对应的模块内容。

image.png

ESBuild

ESBuild 是一款 js 打包工具,支持 babel、压缩等功能,他的特点是快(比 rollup 等工具会快上几十倍)!

image.png

为什么是 Vite

相比于旧的开发模式(使用 webpack 等编译打包工具),使用 Vite 会有更好的开发体验,主要体现在以下几个方面:

  1. 更快的启动——启动阶段除了依赖的预编译以外,不会进行任何其他编译操作
  2. 更快的编译——使用 ESBuild 进行编译
  3. 更快的热更新——每次更新不必分析依赖,重新打包 bundle.js

源码分析

Vite 的 github 仓库地址:github.com/vitejs/vite

启动 API

Vite 项目在启动时,只是执行了 vite 这个指令,定位到 packages/vite/src/node/cli.ts 文件,可以看出 Vite 只是根据用户传入的参数执行了 createServer 这个 API。

const server = await createServer({
    root,
    base: options.base,
    mode: options.mode,
    configFile: options.config,
    logLevel: options.logLevel,
    clearScreen: options.clearScreen,
    server: cleanOptions(options) as ServerOptions
})
await server.listen()
复制代码

createServer 的代码位于 packages/vite/src/node/server/index.ts 中。

配置整合

首先会将用户相关的配置做一个整合,赋值给 config 这个变量。

const config = await resolveConfig(inlineConfig, 'serve', 'development')
复制代码

resolveConfig 将读取配置文件中的配置项,与传入的参数做合并。

if (configFile !== false) {
    const loadResult = await loadConfigFromFile(
      configEnv,
      configFile,
      config.root,
      config.logLevel
    )
    if (loadResult) {
      config = mergeConfig(loadResult.config, config)
      configFile = loadResult.path
      configFileDependencies = loadResult.dependencies
    }
  }
复制代码

可以通过 vite --config xxx.ts 来指定配置文件,不然会依次寻找根目录下的 vite.config.js | vite.config.mjs | vite.config.ts 作为默认配置文件。

if (configFile) {
    resolvedPath = path.resolve(configFile)
} else {
    const jsconfigFile = path.resolve(configRoot, 'vite.config.js')
    if (fs.existsSync(jsconfigFile)) {
      resolvedPath = jsconfigFile
    }

    if (!resolvedPath) {
      const mjsconfigFile = path.resolve(configRoot, 'vite.config.mjs')
      if (fs.existsSync(mjsconfigFile)) {
        resolvedPath = mjsconfigFile
      }
    }

    if (!resolvedPath) {
      const tsconfigFile = path.resolve(configRoot, 'vite.config.ts')
      if (fs.existsSync(tsconfigFile)) {
        resolvedPath = tsconfigFile
      }
    }
}

if (!resolvedPath) {
    debug('no config file found.')
    return null
}
复制代码

读取到的配置文件内容与传入的 config 做深合并,并保存下配置文件路径。

config = mergeConfig(loadResult.config, config)
configFile = loadResult.path
复制代码

取出配置内容中的插件,按照插件类型进行排序。

const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(
    rawUserPlugins
)
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
复制代码

执行插件的 config 钩子方法。

for (const p of userPlugins) {
    if (p.config) {
      const res = await p.config(config, configEnv)
      if (res) {
        config = mergeConfig(config, res)
      }
    }
}
复制代码

这里需要注意,config 钩子是在整合插件之后执行的,所以我们自己写插件的时候如果在 config 钩子中处理 plugins 这个字段是不会生效的

将用户配置的插件与 Vite 默认插件做整合,默认包含的插件可以到 packages/vite/src/node/plugins/index.ts 中的 resolvePlugins 方法查看。

(resolved.plugins as Plugin[]) = await resolvePlugins(
    resolved,
    prePlugins,
    normalPlugins,
    postPlugins
)
复制代码

到这一步配置项基本就不会再发生改变了,此时会执行插件中的 configResolved 钩子。

await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))
复制代码

启动服务

拿到配置后,先初始化 httpwebsocket 服务,http 主要用于启动本地服务器,websocket 主要用于开发阶段热更新。

const middlewares = connect() as Connect.Server
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(serverConfig, middlewares)
const ws = createWebSocketServer(httpServer, config)
复制代码

接着会加载一系列的中间件,此处暂不做详细赘述。

middlewares.use(proxyMiddleware(httpServer, config))
// ...
middlewares.use(baseMiddleware(server))
//...
middlewares.use('/__open-in-editor', launchEditorMiddleware())
//...
middlewares.use('/__vite_ping', (_, res) => res.end('pong'))
//...
middlewares.use(decodeURIMiddleware())
//...
middlewares.use(servePublicMiddleware(config.publicDir))
//...
middlewares.use(transformMiddleware(server))
//...
middlewares.use(serveRawFsMiddleware())
//...
middlewares.use(serveStaticMiddleware(root, config))
//...
middlewares.use(indexHtmlMiddleware(server))
//...
复制代码

packages/vite/src/node/server/index.ts 中的 startServer 用于启动服务,端口号 port 默认 3000,默认启动 host127.0.0.1

async function startServer(
  server: ViteDevServer,
  inlinePort?: number,
  isRestart: boolean = false
): Promise<ViteDevServer> {
    //...
    return new Promise((resolve, reject) => {
        //...
        httpServer.listen(port, options.host, () => {
            //...
        });
    });
}
复制代码

httpServer.listen 方法经过了重新,执行后会执行所有插件的 buildStart 钩子以及 runOptimize 依赖的预打包。

const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
    try {
        await container.buildStart({})
        await runOptimize()
    } catch (e) {
        httpServer.emit('error', e)
        return
    }
    return listen(port, ...args)
}) as any
复制代码

依赖预打包

做依赖预打包的的原因主要是以下 2 点:

  1. CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。

  2. Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

依赖收集

Vite 会默认寻找项目中的 html 文件作为 entry,如果想要修改也可以在配置文件中设置 optimizeDeps.entries 或者 build.rollupOptions?.input

const explicitEntryPatterns = config.optimizeDeps?.entries
const buildInput = config.build.rollupOptions?.input

if (explicitEntryPatterns) {
  entries = await globEntries(explicitEntryPatterns, config)
} else if (buildInput) {
  const resolvePath = (p: string) => path.resolve(config.root, p)
  if (typeof buildInput === 'string') {
    entries = [resolvePath(buildInput)]
  } else if (Array.isArray(buildInput)) {
    entries = buildInput.map(resolvePath)
  } else if (isObject(buildInput)) {
    entries = Object.values(buildInput).map(resolvePath)
  } else {
    throw new Error('invalid rollupOptions.input value.')
  }
} else {
  entries = await globEntries('**/*.html', config)
}
复制代码

根据 entries 执行一次 esbuild 的编译:

const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
await Promise.all(
  entries.map((entry) =>
    build({
      write: false,
      entryPoints: [entry],
      bundle: true,
      format: 'esm',
      logLevel: 'error',
      plugins: [...plugins, plugin],
      ...esbuildOptions
    })
  )
 )
复制代码

相比于生产环境的的 build ,此处会多一个 esbuildScanPluginesbuild 插件来收集依赖。

function esbuildScanPlugin(
  config: ResolvedConfig,
  container: PluginContainer,
  depImports: Record<string, string>,
  missing: Record<string, string>,
  entries: string[]
): Plugin {
  // ...
  return {
    name: 'vite:dep-scan',
    setup(build) {
      // ...
    }
  }
}
复制代码

如果 entry 是个 html 文件,会将其中的 script 标签抽出改造成 js 形式的入口文件,例如 html 文件中有如下 script 标签:

<script src="/main.tsx" />
复制代码

那么将会被改造成:

import "/main.tsx";

export default {};
复制代码

实现代码

build.onLoad(
  { filter: htmlTypesRE, namespace: 'html' },
  async ({ path }) => {
    let raw = fs.readFileSync(path, 'utf-8')
    const isHtml = path.endsWith('.html')
    const regex = isHtml ? scriptModuleRE : scriptRE
    regex.lastIndex = 0
    let js = ''
    let loader: Loader = 'js'
    let match
    while ((match = regex.exec(raw))) {
        const [, openTag, htmlContent, scriptContent] = match
        const content = isHtml ? htmlContent : scriptContent
        const srcMatch = openTag.match(srcRE)
        if (srcMatch) {
          const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
          js += `import ${JSON.stringify(src)}\n`
        } else if (content.trim()) {
          js += content + '\n'
        }
     }

    if (!js.includes(`export default`)) {
        js += `\nexport default {}`
    }

    return {
      loader,
      contents: js
    }
  }
)
复制代码

每次编译时,都会检查编译目标的路径,如果是来自 node_modules 或者设置了 optimizeDeps.include,都会存储到 depImports 中,准备后期预打包。

if (resolved.includes('node_modules') || include?.includes(id)) {
  if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
    depImports[id] = resolved
  }
  return externalUnlessEntry({ path: id })
} else {
  return {
    path: path.resolve(resolved)
  }
}
复制代码

这里有个小技巧,如果在开发阶段引用了某个包,显示这个包里面导出的变量不存在,大概率是因为这个包没有被预编译,可以在 optimizeDeps.include 里头加上这个包名,可以暂时解决问题

依赖预打包

通过依赖分析,我们已经拿到了需要进行预打包的 npm 包。

{ deps, missing } = await scanImports(config)
复制代码

将转换后的依赖入口作为 esbuild 的 entryPoints 进行打包,输出的结果会放置在 node_modules/.vite 目录下:

const result = await build({
    entryPoints: Object.keys(flatIdDeps),
    bundle: true,
    format: 'esm',
    external: config.optimizeDeps?.exclude,
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: cacheDir,
    treeShaking: 'ignore-annotations',
    metafile: true,
    define,
    plugins: [
      ...plugins,
      esbuildDepPlugin(flatIdDeps, flatIdToExports, config)
    ],
    ...esbuildOptions
})
复制代码

其中最关键的点在 esbuildDepPlugin 这个插件,其主要工作是生成依赖的入口内容:

let contents = ''
const data = exportsData[id]
const [imports, exports] = data
if (!imports.length && !exports.length) {
   // cjs
  contents += `export default require("${relativePath}");`
} else {
  if (exports.includes('default')) {
    contents += `import d from "${relativePath}";export default d;`
  }
  if (
    data.hasReExports ||
    exports.length > 1 ||
    exports[0] !== 'default'
  ) {
    contents += `\nexport * from "${relativePath}"`
  }
}
复制代码

如果依赖的包内容是 cjs 模式,会将其转换成 ES6 形式,假设依赖的某个包叫 func, 其入口内容如下:

// func
const func = () => { console.log('Hello') };
module.exports = func;
复制代码

那么经过转换后,真实打包的入口内容变成:

export default require("./node_modules/func/index.js");
复制代码

如果是 ES6 的模块,则编译的入口内容只是将其引入并导出,同样以 func 为例:

// func
const func = () => { console.log('Hello') };
export default func;
复制代码

转换后真实的打包入口内容:

import d from "./node_modules/func/index.js";
export default d;
复制代码

防止二次预打包

二次启动的时候,会根据项目 package.json 的内容生成 contentHash,将生成的 hash 值与上一次打包生成的 node_modules/.vite/_metadata.json 中的 hash 进行对比,如果没发生变化则不会打包:

// 根据 package.json 内容生成 hash
function getDepHash(root: string, config: ResolvedConfig): string {
    // ...
    let content = lookupFile(root, lockfileFormats) || ''
    // ...
    return createHash('sha256').update(content).digest('hex').substr(0, 8)
}
复制代码

比较 hash 变化:

const dataPath = path.join(cacheDir, '_metadata.json')
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
    hash: mainHash,
    browserHash: mainHash,
    optimized: {}
}
// ...
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))

if (prevData && prevData.hash === data.hash) {
  log('Hash is consistent. Skipping. Use --force to override.')
  return prevData
}
复制代码

总结

Vite 项目在启动阶段主要流程大致如下:

vite start.png

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享