前端构建工具vite进阶系列(八) -- vite本地服务器、esbuild、rollup以及跨域的处理

3,129 阅读16分钟

前言

create-vue快速生成项目,到底是怎么做的一文中,我们只讲了通过npm create vue@latest来创建一个项目,并生成以下指令: image.png 但是并没有讲到后面pnpm dev,也就是npm run dev,这里需要对vite本地服务器有一定足够的了解之后,才能去讲,并且vite在开发环境中使用的是esbuild,生产环境中用的是rollup、那么本章就讲一讲vite的指令解析、esbuildrollup相关。

vite是如何来判断环境的呢

在项目的package.json里面会有这些指令。

// 只讲dev和build、preview,其他的操作先删除
"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview",
},
  • 执行npm run dev之后,vite会创建一个本地服务器。
  • 执行npm run build之后,vite会进行文件打包。
  • 执行npm run preview之后,vite会....

不知道vite会干嘛,那就来读一下源码: 我们直接跳到

function start() {
  return import("../dist/node/cli.js");
}

这个start函数才是vite的核心入口函数,在这个函数中通过require导入了cli.js文件,在这个文件里面创建了cli对象,这个对象长这样:

image.png

通过cli对象注册dev命令,和buildpreview命令

cli
    .command('[root]', 'start dev server') // default command
    .alias('serve') // the command is called 'serve' in Vite's API
    .alias('dev') // alias to align with the script name
    ...
  
cli
    .command('build [root]', 'build for production')
    ...
    
cli
    .command('preview [root]', 'locally preview production build')
    ...
    
// 通过cli注册的其他命令,请查看源码

下面分别来讲一下,他们的action回调:

cli.command('dev')

cli
    .command('[root]', 'start dev server') // default command
    .alias('serve') // the command is called 'serve' in Vite's API
    .alias('dev') // alias to align with the script name
    .(...)
    .action(async (root, options) => {
    
    // 处理options
    filterDuplicateOptions(options);
    // 导入createServer函数,创建一个本地服务器
    const { createServer } = await import('./server')
    
    // 创建server对象
    try {
        const server = await createServer({
            root,
            base: options.base,
            mode: options.mode,
            configFile: options.config,
            logLevel: options.logLevel,
            clearScreen: options.clearScreen,
            optimizeDeps: { force: options.force },
            server: cleanOptions(options),
        });
        // 创建失败,就抛错
        if (!server.httpServer) {
            throw new Error('HTTP server not available');
        }
        // 监听端口,启动服务
        await server.listen();
        const info = server.config.logger.info;
        
        // vite的启动时间
        const viteStartTime = global.__vite_start_time ?? false;
        
        // 计算并展示,vite启动服务所耗费时间
        const startupDurationString = viteStartTime
            ? picocolorsExports.dim(
            `ready in ${picocolorsExports.reset(picocolorsExports.bold(Math.ceil(
            performance.now() - viteStartTime)))} ms`)
            : '';
            
        // 打印    
        info(`\n  ${picocolorsExports.green(`$
        {picocolorsExports.bold('VITE')} v${VERSION}`)}  
        ${startupDurationString}\n`, { clear: !server.config.logger.hasWarned });
        
        // 打印
        server.printUrls();
        //   VITE v4.1.3  ready in 1157 ms
        //   ➜  Local:   http://localhost:5173/
        //   ➜  Network: use --host to expose
        //   ➜  press h to show help
        
        // 处理简单指令,就比如在控制台按r就重新启动服务,具体的命令如下:
        // 为了好看,下面扩展简化了
        bindShortcuts(server, {...});
    }
    catch (e) {
        const logger = createLogger(options.logLevel);
        logger.error(picocolorsExports.red(`error when starting dev server:\n${e.stack}`), {
            error: e,
        });
        stopProfiler(logger.info);
        process.exit(1);
    }
});

扩展:上面简单的指令如下所示,能够按照指令,对服务进行重启、打印url、打开浏览器、清空控制台、终止进程等操作。

let BASE_SHORTCUTS = [
  {
    key: 'r',
    description: 'restart the server',
    async action(server) {
      await server.restart()
    }
  },
  {
    key: 'u',
    description: 'show server url',
    action(server) {
      server.config.logger.info('')
      server.printUrls()
    }
  },
  {
    key: 'o',
    description: 'open in browser',
    action(server) {
      const url = server.resolvedUrls?.local[0]
      if (!url) {
        server.config.logger.warn('No URL available to open in browser')
        return
      }
      openBrowser(url, true, server.config.logger)
    }
  },
  {
    key: 'c',
    description: 'clear console',
    action(server) {
      server.config.logger.clearScreen('error')
    }
  },
  {
    key: 'q',
    description: 'quit',
    async action(server) {
      await server.close().finally(() => process.exit())
    }
  }
]

cli.command('build')

cli
    .command('build [root]', 'build for production')
    .(...) 
    .action(async (root, options) => {
    
    // 处理options
    filterDuplicateOptions(options);
    
    // 引入build函数,进行打包
    const { build } = await import('./build')
    
    // 调用cleanOptions函数,删除掉打包配置一些属性
    const buildOptions = cleanOptions(options);
    
    // 调用build函数,打包
    try {
        await build({
            root,
            base: options.base,
            mode: options.mode,
            configFile: options.config,
            logLevel: options.logLevel,
            clearScreen: options.clearScreen,
            optimizeDeps: { force: options.force },
            build: buildOptions,
        });
    }
    catch (e) {
        createLogger(options.logLevel).error(picocolorsExports.red(`
        error during build:\n${e.stack}`), { error: e });
        process.exit(1);
    }
    finally { 
        // 最后在控制台输出打包的结果
        stopProfiler((message) => createLogger(options.logLevel).info(message));
    }
});

cli.command('preview')

cli
    .command('preview [root]', 'locally preview production build')
    .(...)
    .action(async (root, options) => {
    
    // 处理options
    filterDuplicateOptions(options);
    
    // 导入preview函数
    const { preview } = await import('./preview')
    
    // 执行preview函数
    try {
        const server = await preview({
            root,
            base: options.base,
            configFile: options.config,
            logLevel: options.logLevel,
            mode: options.mode,
            build: {
                outDir: options.outDir,
            },
            preview: {
                port: options.port,
                strictPort: options.strictPort,
                host: options.host,
                https: options.https,
                open: options.open,
            },
        });
        // 打印,与dev类似
        server.printUrls();
    }
    catch (e) {
        createLogger(options.logLevel).error(
        picocolorsExports.red(`error when starting preview server:\n${e.stack}`), { error: e });
        process.exit(1);
    }
    finally {
        // 最后在控制台输出打包的结果
        stopProfiler((message) => createLogger(options.logLevel).info(message));
    }
});

根据上面的三个指令,我们大致清楚了这些内容:

  • dev命令,通过createServer函数创建了一个服务器,再注册了一些指令来辅助开发。
  • build命令,通过build函数,进行打包。
  • preview命令,通过preview函数,开启一个本地服务器,模拟production环境。

vite本地服务器

我们在上面看到了通过createServer来创建一个vite本地服务器,服务器长这样:

image.png

并且vite服务器是借助于中间件connect服务器实现的:

const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
  ? null
  : await resolveHttpServer(serverConfig, middlewares, httpsOptions)

resolveHttpServer

通过connect中间件,传入resolveHttpServer,创建一个web服务器

export async function resolveHttpServer(
  { proxy }: CommonServerOptions,
  app: Connect.Server,
  httpsOptions?: HttpsServerOptions,
): Promise<HttpServer> {
  if (!httpsOptions) {
    const { createServer } = await import('node:http')
    return createServer(app)
  }

  // 通过http模块,创建代理服务器
  if (proxy) {
    const { createServer } = await import('node:https')
    return createServer(httpsOptions, app)
  } else {
    const { createSecureServer } = await import('node:http2')
    return createSecureServer( // http2
      {
        // Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM
        // errors on large numbers of requests
        maxSessionMemory: 1000,
        ...httpsOptions,
        allowHTTP1: true,
      },
      // @ts-expect-error TODO: is this correct?
      app,
    ) as unknown as HttpServer
  }
}

我们可以看到,viteDevServer上挂载了很多静态方法与属性,其中listen方法也被挂在了上面,之后通过调用server.listen()方法启动服务器,说白了就是connect.listen()

async listen(port?: number, isRestart?: boolean) {
  await startServer(server, port) // 启动服务器
  if (httpServer) {
    // 搞到启动的url
    server.resolvedUrls = await resolveServerUrls(
      httpServer,
      config.server,
      config,
    )
    // 根据简短指令与config的配置,是否自动打开浏览器
    if (!isRestart && config.server.open) server.openBrowser()
  }
  return server
},

createServer函数中,还创建了一个ws服务来做HMR

...
const ws = createWebSocketServer(httpServer, config, httpsOptions)

具体的流程可以参照前端构建工具vite进阶系列(五) -- vite的热更新(HMR)机制的实践与原理,当然了,connect本地服务器,你可以认为它是一种express服务,因为express就是在connect的上层做了封装而已。

vite与esbuild

在开发环境中,vite是怎么去要求esbuild能够做vite具体想做的事情呢?因为在vite中很多地方都用到了esbuild,比如预编译、转换TypeScript、转换JsxTsx等,那么这里我们再来深入了解一下预编译,你也可以查看前端构建工具vite进阶系列(二) -- vite的依赖预构建与配置文件相关处理,为什么要做依赖预解析。预构建的入口是initDepsOptimizer函数,我们一起来看看吧。

initDepsOptimizer

initDepsOptimizer函数里面,调用createDepsOptimizer创建了一个依赖分析器,并且通过loadCachedDepOptimizationMetadata获取了上一次预构建的产物cachedMetadata

// 获取预构建的依赖,元信息
const cachedMetadata = loadCachedDepOptimizationMetadata(config, ssr)

loadCachedDepOptimizationMetadata

export function loadCachedDepOptimizationMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  force = config.optimizeDeps.force,
  asCommand = false,
): DepOptimizationMetadata | undefined {
  const log = asCommand ? config.logger.info : debug

  // Before Vite 2.9, dependencies were cached in the root of the cacheDir
  // For compat, we remove the cache if we find the old structure
  if (fs.existsSync(path.join(config.cacheDir, '_metadata.json'))) {
    emptyDir(config.cacheDir)
  }

  const depsCacheDir = getDepsCacheDir(config, ssr)

  if (!force) { // 强制重新预构建
    let cachedMetadata: DepOptimizationMetadata | undefined
    try {
      // 读取_metadata.json信息
      const cachedMetadataPath = path.join(depsCacheDir, '_metadata.json')
      cachedMetadata = parseDepsOptimizerMetadata(
        fs.readFileSync(cachedMetadataPath, 'utf-8'),
        depsCacheDir,
      )
    } catch (e) {}
    // 读取_metadata.json文件中的hash与当前hash比较
    // 一致则不需要重新预构建
    if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
      log('Hash is consistent. Skipping. Use --force to override.')
      return cachedMetadata
    }
  } else {
    config.logger.info('Forced re-optimization of dependencies')
  }

  // 不一致则重新预构建
  fs.rmSync(depsCacheDir, { recursive: true, force: true })
}

_metadata.json

{
  "hash": "bbce761b", // 预构建之后生成的hash
  "browserHash": "b92f51d8", // 每个文件后面会带上
  "optimized": {
    "pinia": {
      "src": "../../pinia/dist/pinia.mjs",
      "file": "pinia.js",
      "fileHash": "763eaad0",
      "needsInterop": false
    },
    "vue": {
      "src": "../../vue/dist/vue.runtime.esm-bundler.js",
      "file": "vue.js",
      "fileHash": "fc2e66ef",
      "needsInterop": false
    },
    "vue-router": {
      "src": "../../vue-router/dist/vue-router.mjs",
      "file": "vue-router.js",
      "fileHash": "edbaad58",
      "needsInterop": false
    }
  },
  "chunks": {
    "chunk-5OBJFL24": {
      "file": "chunk-5OBJFL24.js"
    },
    "chunk-3NMN3MUW": {
      "file": "chunk-3NMN3MUW.js"
    }
  }
}

在此文件中,记录了预解析之后的源文件路径,并且会以强缓存的形式缓存这些依赖。 预构建流程在源码中分为两部分:

  • 第一种是:通过命令行vite optimize来手动预解析,调用optimizeDeps函数。
optimizeDep
export async function optimizeDeps(
  config: ResolvedConfig,          // 接收一个 ResolvedConfig 类型的配置对象作为参数
  force = config.optimizeDeps.force,  // 默认情况下,force 参数的值等于 config.optimizeDeps.force 的值
  asCommand = false,              // 默认情况下,asCommand 参数为 false
): Promise<DepOptimizationMetadata> {  // 返回一个 Promise 对象,其泛型类型为 DepOptimizationMetadata
  const log = asCommand ? config.logger.info : debug  // 根据 asCommand 的值确定日志输出函数
  
  // 根据配置文件中的 command 和 build.ssr 判断是否开启 SSR
  const ssr = config.command === 'build' && !!config.build.ssr  
  
  // 读取上一次预构建的产物
  const cachedMetadata = loadCachedDepOptimizationMetadata(  
    config, 
    ssr,
    force,
    asCommand,
  )
  if (cachedMetadata) {  // 如果缓存中已存在该对象,直接返回
    return cachedMetadata
  }

  const deps = await discoverProjectDependencies(config).result  // 获取当前项目的依赖项

  const depsString = depsLogString(Object.keys(deps))  // 生成依赖项的日志输出字符串
  log(colors.green(`Optimizing dependencies:\n  ${depsString}`))  // 输出日志
  
  // 根据配置文件中手动指定的依赖项,手动添加到 deps 对象中
  await addManuallyIncludedOptimizeDeps(deps, config, ssr)  
  
  // 将 deps 对象转换成 DiscoveredDependencyInfo 类型的对象
  const depsInfo = toDiscoveredDependencies(config, deps, ssr)  
  
  // 打包依赖,返回打包结果
  const result = await runOptimizeDeps(config, depsInfo).result  

  await result.commit()  // 将优化结果写入缓存

  return result.metadata  // 返回优化结果的 metadata 属性,即 DepOptimizationMetadata 对象
}
  1. Vite 在 optimizeDeps 函数中调用 loadCachedDepOptimizationMetadata 函数,读取上一次预构建的产物,如果产物存在,则直接return
  2. 如果不存在则调用discoverProjectDependencies对依赖进行扫描,获取到项目中的所有依赖,并返回一个deps
  3. 然后通过toDiscoveredDependencies函数把依赖包装起来,再通过runOptimizeDeps进行依赖打包。
  4. 返回metadata产物。
  • 第二种是:在createServer函数中,调用initDepsOptimizer -> createDepsOptimizer -> loadCachedDepOptimizationMetadata -> discoverProjectDependencies -> toDiscoveredDependencies -> runOptimizeDeps 这个流程进行预构建的,上面已经分析过了,不再复述。

上面两种其实流程都一样,只是optimize函数,抽离了createServer函数的部分预构建的逻辑。那我们继续来看一下discoverProjectDependencies是怎么获取到deps的。

discoverProjectDependencies

通过scanImports来扫描依赖,而scanImports则是通过esbuild插件esbuildScanPlugin来工作的。

export function discoverProjectDependencies(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<Record<string, string>>
} {
  // scanImports中调用prepareEsbuildScanner注册插件esbuildScanPlugin
  const { cancel, result } = scanImports(config)

  return {
    cancel,
    result: result.then(({ deps, missing }) => {
      const missingIds = Object.keys(missing) // 缺失依赖
      if (missingIds.length) {
        throw new Error(
          `The following dependencies are imported but could not be resolved:\n\n  ${missingIds
            .map(
              (id) =>
                `${colors.cyan(id)} ${colors.white(
                  colors.dim(`(imported by ${missing[id]})`),
                )}`,
            )
            .join(`\n  `)}\n\nAre they installed?`,
        )
      }

      return deps // 返回deps
    }),
  }
}

scanImports

scanImports会通过computeEntries方法获取入口文件,其中核心方法就是entries = await globEntries('**/*.html', config)

export function scanImports(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<{
    deps: Record<string, string>
    missing: Record<string, string>
  }>
} {
  // Only used to scan non-ssr code

  const start = performance.now()
  const deps: Record<string, string> = {}
  const missing: Record<string, string> = {}
  let entries: string[]

  const scanContext = { cancelled: false }

  // 获取入口文件
  const esbuildContext: Promise<BuildContext | undefined> = computeEntries(
    config,
  ).then((computedEntries) => {
    entries = computedEntries // 赋值

    if (!entries.length) {
      ...
    }
    // 取消就return
    if (scanContext.cancelled) return

    debug(`Crawling dependencies using entries:\n  ${entries.join('\n  ')}`)
    return prepareEsbuildScanner(config, entries, deps, missing, scanContext)
  })
}
// 省略一部分代码...

prepareEsbuildScanner

async function prepareEsbuildScanner(
  config: ResolvedConfig, // Vite 的配置项
  entries: string[], // 用于构建的入口文件路径列表
  deps: Record<string, string>, // 依赖关系的映射表
  missing: Record<string, string>, // 缺失的依赖关系的映射表
  scanContext?: { cancelled: boolean }, // 扫描上下文
): Promise<BuildContext | undefined> { // 返回一个 esbuild 构建环境
  // 创建插件容器
  const container = await createPluginContainer(config)

  // 如果扫描上下文被取消了,直接返回
  if (scanContext?.cancelled) return

  // 生成一个 esbuild 扫描插件
  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

  // 获取 esbuild 的插件和构建选项
  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  // 调用 esbuild 的 context 函数,返回依赖图上下文对象
  return await esbuild.context({
    absWorkingDir: process.cwd(),
    write: false,// 不输出文件,因为第一次调用esbuild的时候只是做一个扫描
    stdin: {
      // 拿到入口,拼成js字符串
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [...plugins, plugin],
    ...esbuildOptions,
  })

扩展:为什么要使用插件:

  • 因为esbuild不认识html,所以需要插件来处理。
  • esbuild插件大致如下图用法:
import * as esbuild from 'esbuild'

let envPlugin = {
  name: 'env',
  setup(build) {
    // Intercept import paths called "env" so esbuild doesn't attempt
    // to map them to a file system location. Tag them with the "env-ns"
    // namespace to reserve them for this plugin.
    // 路径重写
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    // Load paths tagged with the "env-ns" namespace and behave as if
    // they point to a JSON file containing the environment variables.
    // 内容处理
    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

// 使用
await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [envPlugin],
})

esbuild在读取文件的时候就会调用一次扫描插件esbuildScanPlugin,插件里面根据build.onResolvebuild.onLoad来选择性处理文件,比如处理html文件他就会走到这里:

image.png 这里的resolve就是处理路径的核心方法,我们一起来看一下。

resolve

const seen = new Map<string, string | undefined>() // 路径映射表
const resolve = async (
  id: string, // 相对或者绝对路径,或者三方模块路径
  // 相对路径 : ./
  // 决对路径 : /
  // 三方包路径(裸模块):'vue'
  importer?: string, // 引用者
  options?: ResolveIdOptions,
) => {
  // 路径处理
  const key = id + (importer && path.dirname(importer))
    if (seen.has(key)) { // 去重
      return seen.get(key)
    }
    // 调用container.resolveId处理路径补全。
    // container 为插件容器,在createServer函数里面创建
    // 此时会遍历执行vite插件,并挂到container上,以供后续调用
    const resolved = await container.resolveId(
      id,
      importer && normalizePath(importer),
      {
        ...options,
        scan: true,
      },
    )
    const res = resolved?.id
    seen.set(key, res)
    return res // 返回处理好的路径
}

resolveId

我们可以通过container找到resolveId插件,它长这样:

image.png 在这个插件里面可以看到对relativeabsolutebare的处理,比如文件所在项目的根目录为root:'/Users/mac/Desktop/my-project'

  • absolute

比如import Function from '/Module1',则路径会被处理成:

import Function from '/Module1' => import Function from '/Users/mac/Desktop/my-project/Module'

  • relative 对于relative有两种,请看代码:
// 如果模块被人引用,则基础路径为引用者,否则就是npm run xx 的当前目录
const basedir = importer ? path.dirname(importer) : process.cwd()

比如引用者的路径为importerPath:'/Users/mac/Desktop/my-project/importerPath',则路径会被处理成:

import Function from '/Module2' => /Users/mac/Desktop/my-project/importerPath/Module2'

  • bare module bare module 也就是第三方模块,我们以react为例,关于这个的路径处理是这样的。
    • 先去node_modules里面去找到react路径,记录为:path=node_module/react/

    • 找到react路径下的package.json文件中的exports字段的default属性,如果没有则会去找module字段。

    • 拼接pathexports或者module组成完整路径。

image.png

比如import React from 'react',则路径会被处理成:

import React from 'react' => import React from '/Users/mac/Desktop/my-project/node_module/react/index.js'

路径处理完毕之后,就会return出去,就会被onLoad接收,就像这面这样:

image.png

onLoad函数中,就会读取html内容:

let raw = fs.readFileSync(path, 'utf-8')

读取html内容之后,就会匹配出scriptsrc属性,找到js模块,就会触发build.OnResolve解析js模块,从而找到模块中的裸模块,也就是第三方依赖。

扩展:这里分享几个实用的正则,感兴趣的可以看一下:

const scriptModuleRE =
  /(<script\b[^>]+type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gis
export const scriptRE = /(<script(?:\s[^>]*>|>))(.*?)<\/script>/gis
export const commentRE = /<!--.*?-->/gs
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i

找到了第三方模块之后调用runOptimizeDeps进行打包,我们来看一下源码:

runOptimizeDeps

export function runOptimizeDeps(
  resolvedConfig: ResolvedConfig,
  depsInfo: Record<string, OptimizedDepInfo>,
  ssr: boolean = resolvedConfig.command === 'build' &&
    !!resolvedConfig.build.ssr,
): {
  ...
  // 获取.vite/deps路径
  const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr)
  const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig, ssr)

  // 先处理一下.vite/deps目录
  if (fs.existsSync(processingCacheDir)) {
    // 存在就置空
    emptyDir(processingCacheDir)
  } else {
    // 不存在就创建
    fs.mkdirSync(processingCacheDir, { recursive: true })
  }

  // 写入package.json 指定ESM规范
  writeFile(
    path.resolve(processingCacheDir, 'package.json'),
    JSON.stringify({ type: 'module' }),
  )
  
  // 获得元数据
  const metadata = initDepsOptimizerMetadata(config, ssr)
  
  // 写入browserHash
  metadata.browserHash = getOptimizedBrowserHash(
    metadata.hash,
    depsFromOptimizedDepInfo(depsInfo),
  )
  // 省略一部分代码

  const start = performance.now() // 记录时间

  // 调用prepareEsbuildOptimizerRun里面的esbuild.context打包
  const preparedRun = prepareEsbuildOptimizerRun(
    resolvedConfig,
    depsInfo,
    ssr,
    processingCacheDir,
    optimizerContext,
  )
  // 省略了一些代码 ...

prepareEsbuildOptimizerRun

prepareEsbuildOptimizerRun函数做了几件事

  • 先把deps拍平成一维数组。
  • 调用build.context进行打包。
  • 输出打包的映射对象result,对象长这样:

image.png

  • 遍历outputs,把打包结果输出到processingCacheDir,也就是.vite/deps
const dataPath = path.join(processingCacheDir, '_metadata.json')
writeFile(
    dataPath, // 写入元数据
    stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)
  • 通过esbuild插件扫描和esbuild.context打包,最后得到的优化依赖的结果会被缓存起来,并且写到了node_module/.vite/deps目录下。

此过程就是预解析的过程,esbuild是一个打包器,在开发阶段全权依赖于它。

esbuild处理TypeScript、Jsx、Tsx

esbuild内置了对TypeScriptJSX/TSX的原生支持,无需安装额外的插件或配置。当esbuild发现一个文件的扩展名是.ts.tsx.jsx或者.mjs时,会自动启用TypeScriptJSX/TSX支持,并将其转换为JavaScript

在转换过程中,esbuild会首先进行预处理(preprocessing)和解析(parsing),然后进行类型检查(type-checking),最后将其转换为JavaScript代码。

需要注意的是,esbuildTypeScriptJSX/TSX支持还没有完全与标准的TypeScriptJSX/TSX语法保持一致,尤其是在一些高级特性和边缘情况下可能会有一些不一致的行为。因此在使用esbuild进行TypeScriptJSX/TSX编译时,比如:

  • babel-plugin-macros,它是一个Babel插件,允许你使用JavaScript代码来生成代码,这种技术被称为“宏”。使用宏可以提供更好的开发体验,减少样板代码并提高代码可读性。

在使用esbuild时,babel-plugin-macros是不支持的,因为它是一个Babel插件,而esbuild不会执行Babel插件。在这种情况下,你需要找到其他解决方案,或者使用其他工具来替代esbuild

  • 另一个例子是WebAssemblyesbuild在处理WebAssembly时,需要依赖一个第三方插件esbuild-wasm。如果你在使用esbuild时需要处理WebAssembly,那么你需要确保已经安装了这个插件,并且已经配置好了esbuild的相关设置。

vite与rollup

image.png这是vite官方文档的原话,其实你可以理解,为什么vite在开发环境用esbuild,就是因为快,有多快?看下图:

image.png

但是在生产环境,对代码质量的要求更高,而不是更快。那关于打包是调用的build函数,来扒一扒它的源码。

build

export async function build(
  inlineConfig: InlineConfig = {},
): Promise<RollupOutput | RollupOutput[] | RollupWatcher> {
  // 从inlineConfig和vite.config.json里面解析依赖,并合并
  const config = await resolveConfig(
    inlineConfig,
    'build',
    'production',
    'production',
  )
  // 获取构建配置
  const options = config.build
  
  // 判断是否需要生成 SSR 相关的代码
  const ssr = !!options.ssr
  
  // 如果是构建库,则获取库构建相关的配置
  const libOptions = options.lib

  // 在控制台打印,正在构建...
  config.logger.info(
    colors.cyan(
      `vite v${VERSION} ${colors.green(
        `building ${ssr ? `SSR bundle ` : ``}for ${config.mode}...`,
      )}`,
    ),
  )

  // 定义 resolve 函数用于解析路径
  const resolve = (p: string) => path.resolve(config.root, p)
  
  // 获取入口文件
  const input = libOptions
    ? options.rollupOptions?.input ||
      (typeof libOptions.entry === 'string'
        ? resolve(libOptions.entry)
        : Array.isArray(libOptions.entry)
        ? libOptions.entry.map(resolve)
        : Object.fromEntries(
            Object.entries(libOptions.entry).map(([alias, file]) => [
              alias,
              resolve(file),
            ]),
          ))
    : typeof options.ssr === 'string'
    ? resolve(options.ssr)
    : options.rollupOptions?.input || resolve('index.html')

  // 如果是SSR并且入口文件是html就抛错
  if (ssr && typeof input === 'string' && input.endsWith('.html')) {
    throw new Error(
      `rollupOptions.input should not be an html file when building for SSR. ` +
        `Please specify a dedicated SSR entry.`,
    )
  }

  // 获取输出目录
  const outDir = resolve(options.outDir)

  // 如果是 SSR 构建,则在插件中注入 ssr 参数
  const plugins = (
    ssr ? config.plugins.map((p) => injectSsrFlagToHooks(p)) : config.plugins
  ) as Plugin[]

  // 获取用户配置的 external
  const userExternal = options.rollupOptions?.external
  let external = userExternal

  // 如果构建 SSR,且配置了 legacy.buildSsrCjsExternalHeuristics,则需要对 external 进行处理
  if (ssr && config.legacy?.buildSsrCjsExternalHeuristics) {
    external = await cjsSsrResolveExternal(config, userExternal)
  }
  
  // 如果启用了依赖优化,则初始化依赖优化
  if (isDepsOptimizerEnabled(config, ssr)) {
    await initDepsOptimizer(config)
  }

  // 配置 RollupOptions
  const rollupOptions: RollupOptions = {
    context: 'globalThis',
    preserveEntrySignatures: ssr
      ? 'allow-extension'
      : libOptions
      ? 'strict'
      : false,
    cache: config.build.watch ? undefined : false,
    ...options.rollupOptions,
    input,
    plugins,
    external,
    onwarn(warning, warn) {
      onRollupWarning(warning, warn, config)
    },
  }

  ...

  // 定义bundle.js
  let bundle: RollupBuild | undefined
  try {
    const buildOutputOptions = (output: OutputOptions = {}): OutputOptions => {
      // 省略了一些判断...
      const ssrNodeBuild = ssr && config.ssr.target === 'node'
      const ssrWorkerBuild = ssr && config.ssr.target === 'webworker'
      const cjsSsrBuild = ssr && config.ssr.format === 'cjs'

      const format = output.format || (cjsSsrBuild ? 'cjs' : 'es')
      const jsExt =
        ssrNodeBuild || libOptions
          ? resolveOutputJsExtension(format, getPkgJson(config.root)?.type)
          : 'js'
      return {  // 生成buildOutputOptions对象
        dir: outDir,
        // Default format is 'es' for regular and for SSR builds
        format,
        exports: cjsSsrBuild ? 'named' : 'auto',
        sourcemap: options.sourcemap, // 启用sourceMap
        name: libOptions ? libOptions.name : undefined, // name命名规则
        generatedCode: 'es2015', // 目标代码版本呢
        
        // 文件名、chunks名,生成规则
        entryFileNames: ssr
          ? `[name].${jsExt}`
          : libOptions
          ? ({ name }) =>
              resolveLibFilename(libOptions, format, name, config.root, jsExt)
          : path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`),
        chunkFileNames: libOptions
          ? `[name]-[hash].${jsExt}`
          : path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`),
        assetFileNames: libOptions
          ? `[name].[ext]`
          : path.posix.join(options.assetsDir, `[name]-[hash].[ext]`),
        inlineDynamicImports:
          output.format === 'umd' ||
          output.format === 'iife' ||
          (ssrWorkerBuild &&
            (typeof input === 'string' || Object.keys(input).length === 1)),
        ...output,
      }
    }

    // 多入口打包处理、或者单入口打包处理
    const outputs = resolveBuildOutputs(
      options.rollupOptions?.output,
      libOptions,
      config.logger,
    )
    const normalizedOutputs: OutputOptions[] = []

    if (Array.isArray(outputs)) {
      // 如果打包结果有多个  
      for (const resolvedOutput of outputs) {
        // 就获取单个的打包结果
        normalizedOutputs.push(buildOutputOptions(resolvedOutput))
      }
    } else {
      // 否则直接拿到输出对象
      normalizedOutputs.push(buildOutputOptions(outputs))
    }
    
    // 获取到输出路径,取到dir,一般是dist
    const outDirs = normalizedOutputs.map(({ dir }) => resolve(dir!))

    // 用rollup监听文件变化
    if (config.build.watch) {
      config.logger.info(colors.cyan(`\nwatching for file changes...`))

      const resolvedChokidarOptions = resolveChokidarOptions(
        config,
        config.build.watch.chokidar,
      )

      const { watch } = await import('rollup')
      const watcher = watch({
        ...rollupOptions,
        output: normalizedOutputs,
        watch: {
          ...config.build.watch,
          chokidar: resolvedChokidarOptions,
        },
      })
      
      // 监听打包开始、结束、异常
      watcher.on('event', (event) => {
        if (event.code === 'BUNDLE_START') {
          config.logger.info(colors.cyan(`\nbuild started...`))
          if (options.write) {
            // 校验输出目录规则
            prepareOutDir(outDirs, options.emptyOutDir, config)
          }
        } else if (event.code === 'BUNDLE_END') {
          event.result.close()
          // 计算打包耗时
          config.logger.info(colors.cyan(`built in ${event.duration}ms.`))
        } else if (event.code === 'ERROR') {
          // 异常,调用outputBuildError,输出异常信息
          outputBuildError(event.error)
        }
      })

      return watcher
    }

    // 通过rollup打包生成文件
    const { rollup } = await import('rollup')
    bundle = await rollup(rollupOptions)

    if (options.write) {
      // 校验输出目录规则
      prepareOutDir(outDirs, options.emptyOutDir, config)
    }

    const res = []
    for (const output of normalizedOutputs) {
      // 通过bundle = await rollup(rollupOptions),返回一个Promise对象
      // 根据write或者generate指令,相应的对输出文件dist目录写入,和对象代码的生成
      res.push(await bundle[options.write ? 'write' : 'generate'](output))
    }
    return Array.isArray(outputs) ? res : res[0]
  } catch (e) {
    // 异常报错
    outputBuildError(e)
    throw e
  } finally {
    // 打包结束。终止进程
    if (bundle) await bundle.close()
  }
}

build函数,就通过找查入口文件,交给rollup进行打包,并且监听每个文件的变化和打包开始,结束,异常三种情况,然后通过prepareOutDir函数,进行输出目录的校验,之后遍历执行每个bundle,交给rollup执行,通过控制指令(write或者generate)来控制文件写入或者代码生成,把包含Promise的结果放进res数组里面并返回。

prepareOutDir
function prepareOutDir(
  outDirs: string[], // 接收一个字符串数组参数,表示输出目录
  emptyOutDir: boolean | null, // 表示是否清空输出目录,为布尔值或 null
  config: ResolvedConfig, // 表示已解析的配置
) {
  const nonDuplicateDirs = new Set(outDirs) // 创建一个 Set 对象,用于存储不重复的输出目录
  let outside = false // 是否有输出目录位于项目根目录外
  if (emptyOutDir == null) { // 如果未指定是否清空输出目录
    for (const outDir of nonDuplicateDirs) { // 遍历不重复的输出目录
      if (
        fs.existsSync(outDir) && // 判断输出目录是否存在
        !normalizePath(outDir).startsWith(config.root + '/') // 判断输出目录是否在项目根目录下
      ) {
        // 如果输出目录不在项目根目录下,发出警告,并将 outside 设为 true
        config.logger.warn(
          colors.yellow(
            `\n${colors.bold(`(!)`)} outDir ${colors.white(
              colors.dim(outDir),
            )} is not inside project root and will not be emptied.\n` +
              `Use --emptyOutDir to override.\n`,
          ),
        )
        outside = true
        break // 跳出循环
      }
    }
  }
  for (const outDir of nonDuplicateDirs) { // 再次遍历不重复的输出目录
    if (!outside && emptyOutDir !== false && fs.existsSync(outDir)) {
      // 如果没有输出目录位于项目根目录外,且不禁止清空输出目录,且输出目录存在
      const skipDirs = outDirs
        .map((dir) => {
          const relative = path.relative(outDir, dir) // 获取当前目录到其他目录的相对路径
          if (
            relative && // 相对路径非空
            !relative.startsWith('..') && // 相对路径不以 '..' 开头
            !path.isAbsolute(relative) // 相对路径不是绝对路径
          ) {
            return relative // 返回相对路径
          }
          return '' // 返回空字符串
        })
        .filter(Boolean) // 过滤掉空字符串
      emptyDir(outDir, [...skipDirs, '.git']) // 清空输出目录(除了指定的目录)
    }
    if (
      config.build.copyPublicDir && // 如果配置中指定了 copyPublicDir
      config.publicDir && // public 目录存在
      fs.existsSync(config.publicDir) // public 目录存在于磁盘中
    ) {
      copyDir(config.publicDir, outDir) // 复制 public 目录到输出目录
    }
  }
}

vite preview

vite preview真的没什么好讲的,就是通过nodehttp模块,在本地启动一个http服务器,然后对dist目录文件路径处理,能够在新开的端口访问到项目,模拟production环境而已。

vite的跨域配置

能看这篇文章的人,我默认为你对跨域概念以及跨域的常见解决办法都掌握了,这里就讲一下vite中跨域是怎么做的。先来看vite给我们做的一个优化:

一般的用fetch请求一个接口,如果接口是崩的,那么就会报错,比如:

// 没有后端,也没有mock
fetch('/api').then(res=>{
  console.log(res)
})

image.png 竟然没有报错?404 not found?这就是vite给我们做的优化,当请求的地址没有带baseUrl的时候,他默认给你补全localhost,这算啥优化啊? emmmm...

如果使用了代理,现在我们来尝试一下访问百度的首页。

export default defineConfig({
    server:{
       proxy:{
         '/api':{
           target:"https://www.baidu.com",
           changeOrigin:true,
           rewrite:(path)=>path.replace(/^\/api/, '')
         },
         ...
      }
    }
})

效果:

image.png

能加载出来,但是css可能缺失了...,同样的,在生产环境你这个代理服务器就不能使用啦,关于server的更多配置,请查看vite开发服务器文档

总结

这篇文章讲了vite在开发环境与打包的一些指令解析,还有一些流程,和跨域的一些配置,这篇文章比较偏向源码型的,当然可能还有一些没有顾到的点,在后续可能会进行补充。下一章 >>> 前端构建工具vite进阶系列(九) -- 总结与展望