V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
kev1nzh
V2EX  ›  Node.js

Vite-babysitter 像月嫂?保姆?照顾孩子一般为你讲解 Vite 源码。

  •  
  •   kev1nzh · 2021-06-02 12:47:29 +08:00 · 726 次点击
    这是一个创建于 1052 天前的主题,其中的信息可能已经有所发展或是发生改变。

    在老家 v2 发个看看,求点小星星!

    之前刷到有帖子介绍 Vite,我就去看了下源码然后记录下来分享一下,

    然后发现不能全部 copy 过来超字数了。。看线上文档吧。

    查看线上文档体验更佳 查看文档 Powered by dumi

    前言

    该项目如名,像月嫂?保姆?照顾孩子一般为你讲解Vite源码。

    • NPM 依赖解析和预构建: 全面提升页面重载速度和强缓存依赖。

    • Plugins 插件:可以利用 Rollup 插件的强大生态系统,同时根据需要也能够扩展开发服务器和 SSR 功能。

    • 动态模块热重载( HMR ):Vite 提供了一套原生 ESM 的 HMR API 。 具有 HMR 功能的框架可以利用该 API 提供即时、准确的更新,而无需重新加载页面或删除应用程序状态。

    目前 [依赖解析和预构建] 章节已经施工完毕,插件和 HMR 的源码解析将在两周内上线。

    Vite 的版本为 2.3.3 。

    看完有帮助的可以进入github给我一个🌟小星星 谢谢!

    NPM 依赖解析和预构建

    目录

    1. 代码入口
    2. 预构建对象和前期准备
    3. 构建和插件
    4. 最后

    1. 代码入口

    在 cli.ts 文件中,接收命令行的运行参数。

    // 命令行输入命令启动 vite
    npm run dev
    // 根据 package 调用 vite 并获取命令参数 如--force build...
    vite xxxx xxx xxx
    

    vite 运行的第一步,获取命令参数,最后创建 server 并运行 listen 函数。

    //cli.ts
    
    .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
            const { createServer } = await import('./server')
            try {
                    const server = await createServer({
                    ...
                    })
                    await server.listen()
            } catch (e) {
                    ...
            }
    })
    

    listen 函数中,runOptimize 函数就是预构建的核心代码。

    // server/index.ts => listen
    if (!middlewareMode && httpServer) {
      // overwrite listen to run optimizer before server start
      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;
      ...
    } else {
      await container.buildStart({});
      await runOptimize();
    }
    
    // server/index.ts
    import { DepOptimizationMetadata, optimizeDeps } from '../optimizer'
    
    const runOptimize = async () => {
      if (config.cacheDir) {
        server._isRunningOptimizer = true;
        try {
          server._optimizeDepsMetadata = await optimizeDeps(config);
        } finally {
          server._isRunningOptimizer = false;
        }
        server._registerMissingImport = createMissingImporterRegisterFn(server);
      }
    };
    
    // server/index.ts
    import { DepOptimizationMetadata, optimizeDeps } from '../optimizer'
    
    const runOptimize = async () => {
      if (config.cacheDir) {
        server._isRunningOptimizer = true;
        try {
          server._optimizeDepsMetadata = await optimizeDeps(config);
        } finally {
          server._isRunningOptimizer = false;
        }
        server._registerMissingImport = createMissingImporterRegisterFn(server);
      }
    };
    

    入口代码很简单,获取了 vite 命令行参数后,创建内部 server,触发各个功能的构建。

    接下来进入详解 optimizeDeps 的章节。

    预构建对象和前期准备

    首先获取预缓存(metadata.json)的路径,以及预构建的 hash 值,以便后续比对。

    这个 json 文件为 vite 处理后导出的数据信息,当此文件存在时,会比对 hash 值,如果相同就会直接读取此文件中的依赖。

    // /optimizer.ts
    async function optimizeDeps(
      config: ResolvedConfig,
      force = config.server.force,
      asCommand = false,
      newDeps?: Record<string, string>,
    ) {
      const { root, logger, cacheDir } = config
       // 这边第三个 args 为 asCommand, 是否是命令行运行的
       // 为了讲述的流畅性,在上一章节代码入口没有提到, 在 vite --force 后,会直接运行 optimizeDeps 函数,因此需要区分 log 的输出方式
       // vite --force    =>    await optimizeDeps(config, options.force, true)
      const log = asCommand ? logger.info : debug
    
      if (!cacheDir) {
        log(`No cache directory. Skipping.`)
        return null
    
      //这边首先获取 预构建模块路径
      const dataPath = path.join(cacheDir, '_metadata.json'); //预缓存路径
      // /.../my-vue-app/node_modules/.vite/_metadata.json
      const mainHash = getDepHash(root, config);
      // 创建一个 data 的对象,后面会用到
      const data: DepOptimizationMetadata = {
        hash: mainHash,
        browserHash: mainHash,
        optimized: {},
      };
    

    如何获取 hash 值?

    首先获取了预构建模块的路径,默认情况为 node_modules/.vite 。

    以下为 metadata.json 的数据结构, 后续会说到。

    // node_modules/.vite/_metadata.json
    {
      "hash": "9a4fa980",
      "browserHash": "6f00d484",
      "optimized": {
        "vue": {
          "file": "/.../my-vue-app/node_modules/.vite/vue.js",
          "src": "/.../my-vue-app/node_modules/vue/dist/vue.runtime.esm-bundler.js",
          "needsInterop": false
        },
        "axios": {
          "file": "/.../new/my-vue-app/node_modules/.vite/axios.js",
          "src": "/.../new/my-vue-app/node_modules/axios/index.js",
          "needsInterop": true
        }
      }
    }
    

    接着我们看 getDepHash 函数。 官方文档中描述,Vite 在预构建之前,根据以下源来确定是否要重新运行预构建。

    • package.json 中的 dependencies 列表
    • 包管理器的 lockfile,例如 package-lock.json, yarn.lock,或者 pnpm-lock.yaml
    • 可能在 vite.config.js 相关字段中配置过的

    以下代码中,变量 lockfileFormats 就是包管理器的 locakfile 。

    // /optimizer.ts 
    const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
    
    // /optimizer.ts => getDepHash
    let cachedHash: string | undefined;
    
    function getDepHash(root: string, config: ResolvedConfig): string {
      if (cachedHash) {
        return cachedHash;
      }
      let content = lookupFile(root, lockfileFormats) || ''; //往下滑会有 lookupFile 函数的解释。
      // 这边已经获取了所有 local file array 内的文件内容
    
      // also take config into account
      // only a subset of config options that can affect dep optimization
    
      content += JSON.stringify(
        {
          mode: config.mode,
          root: config.root,
          resolve: config.resolve,
          assetsInclude: config.assetsInclude,
          plugins: config.plugins.map((p) => p.name),
          optimizeDeps: {
            include: config.optimizeDeps?.include, // null
            exclude: config.optimizeDeps?.exclude, //null
          },
        },
        (_, value) => {
          if (typeof value === 'function' || value instanceof RegExp) {
            return value.toString();
          }
          return value;
        },
      );
      //这里不说了  最终返回 "9a4fa980" 八位数 hash 值。
      return createHash('sha256').update(content).digest('hex').substr(0, 8);
    }
    
    // /optimizer.ts => lookupFile
    function lookupFile(
      dir: string,
      formats: string[],
      pathOnly = false,
    ): string | undefined {
      for (const format of formats) {
        const fullPath = path.join(dir, format); //获取 root + format 路径
        // 路径对象是否存在 并且是文件
        // pathOnly 为 true 就只返回路径,不然就都默认返回 utf-8 的文件内容
        if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
          return pathOnly ? fullPath : fs.readFileSync(fullPath, 'utf-8');
        }
      }
      const parentDir = path.dirname(dir);
      if (parentDir !== dir) {
        return lookupFile(parentDir, formats, pathOnly);
      }
    }
    

    是否强制优化并处理.vite 文件夹

    获取了预构建的 hash 值后,让我退回到 optimizeDeps 函数中,继续往下看。

    通过参数 force 来判断是否需要强制优化,如果不需要那就对比老 hash 值,如果相等就返回老的 metadata.json 文件内容。

    最后处理.vite 文件夹,为后续做准备。

    // /optimizer.ts
    ...
    const data: DepOptimizationMetadata = {
        hash: mainHash, //"9a4fa980"
        browserHash: mainHash, //"9a4fa980"
        optimized: {},
      };
    
    
    // 是否强制预先优化 不管是否已经更改。
    // force = config.server.force 来源于 cli.ts ,获取命令行参数中是否有 --force
    if (!force) {
      let prevData;
      try {
        // 尝试解析已经存在的 metadata 数据, 获取 /.vite/metadata.json 中的内容
        prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
      } catch (e) {}
      // hash is consistent, no need to re-bundle
      // 如果预 dep 数据的 hash 相同,那就直接跳过,如果需要覆盖就使用 --force
      if (prevData && prevData.hash === data.hash) {
        log('Hash is consistent. Skipping. Use --force to override.');
        return prevData;
      }
    }
    //如果 node_modules/.vite 存在,那就清空。
    if (fs.existsSync(cacheDir)) {
      emptyDir(cacheDir);
    } else {
      // 要不然就创建文件夹, 并且 recursive:true 返回创建文件夹的路径
      fs.mkdirSync(cacheDir, { recursive: true });
    }
    

    获取需要编译依赖关系的模块路径

    解决.vite 文件夹后,我们跟着代码处理.vite 中的内容文件。

    这边创建了两个变量 deps 和 missing 。

    deps: 需要处理依赖关系的路径对象。

    missing: 需要处理依赖关系但在 node_modules 中没有找到来源的数组对象。

    //deps
    {
      "vue": "/.../my-vue-app/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "axios": "/.../my-vue-app/node_modules/axios/index.js"
    }
    

    需要提前知道的是,newDeps 这个 args 参数区分了第一次编译和已启动后遇到新依赖关系导入重写运行的编译。

    // /optimizer.ts
    
    let deps: Record<string, string>, missing: Record<string, string>;
    // 在服务器已经启动之后,如果遇到一个新的依赖关系导入,
    // 而这个依赖关系还没有在缓存中,Vite 将重新运行依赖构建进程并重新加载页面。
    // 如上官方文档所述,最终会得出 deps 和 missing
    if (!newDeps) {
      // scanImports 这里就不展开了,他的作用就是获取导入源,用正则检测后,使用 esbuild 编译所有的入口依赖( entries)
      ({ deps, missing } = await scanImports(config));
    } else {
      deps = newDeps;
      missing = {};
    }
    // 重写更新了浏览器的哈希
    // update browser hash
    data.browserHash = createHash('sha256')
      .update(data.hash + JSON.stringify(deps))
      .digest('hex')
      .substr(0, 8);
    

    没有找到来源的模块处理(missing)

    下面代码很简单,处理在 node_modules 中没有找到来源的模块。

    // /optimizer.ts
    
    // missing 是一个储存需要处理依赖关系但在 node_modules 中没有找到来源的数组对象,如果有的话直接 error 提醒一波。
    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) =>
              `${chalk.cyan(id)} ${chalk.white.dim(
                `(imported by ${missing[id]})`,
              )}`,
          )
          .join(`\n  `)}\n\nAre they installed?`,
      );
    }
    

    获取并导入 自定义的强制预构建(include)

    接着处理在 vite.config.js 中 optimizeDeps.include 。

    如官方文档 API 所述,

    optimizeDeps.include: 默认情况下,不在 node_modules 中的,链接的包不会被预构建。使用此选项可强制预构建链接的包

    // /optimizer.ts
    
    //config 中是否有需要强制构建的依赖项, 处理后再 deps 中加入
    const include = config.optimizeDeps?.include;
    if (include) {
      const resolve = config.createResolver({ asSrc: false });
      for (const id of include) {
        if (!deps[id]) {
          const entry = await resolve(id);
          if (entry) {
            deps[id] = entry;
          } else {
            throw new Error(
              `Failed to resolve force included dependency: ${chalk.cyan(id)}`,
            );
          }
        }
      }
    }
    

    命令行打印需要构建模块的信息

    // /optimizer.ts
    
    const qualifiedIds = Object.keys(deps);
    //不用说很简单,没有需要依赖的 dep 就跳过
    if (!qualifiedIds.length) {
      writeFile(dataPath, JSON.stringify(data, null, 2));
      log(`No dependencies to bundle. Skipping.\n\n\n`);
      return data;
    }
    
    // 这里也不用解释太多,基本上就是打印出信息的逻辑,然后绿色高亮告诉你要预缓存巴拉巴拉
    const total = qualifiedIds.length;
    const maxListed = 5;
    const listed = Math.min(total, maxListed);
    const extra = Math.max(0, total - maxListed);
    const depsString = chalk.yellow(
      qualifiedIds.slice(0, listed).join(`\n  `) +
        (extra > 0 ? `\n  (...and ${extra} more)` : ``),
    );
    if (!asCommand) {
      if (!newDeps) {
        // This is auto run on server start - let the user know that we are
        // pre-optimizing deps
        logger.info(
          chalk.greenBright(`Pre-bundling dependencies:\n  ${depsString}`),
        );
        logger.info(
          `(this will be run only when your dependencies or config have changed)`,
        );
      }
    } else {
      logger.info(chalk.greenBright(`Optimizing dependencies:\n  ${depsString}`));
    }
    

    创建预构建对象

    使用 es-module-lexer 模块获取每个 deps 中的预构建模块文件,输出引入和导出的数据并保存。

    // /optimizer.ts
    
    import { ImportSpecifier, init, parse } from 'es-module-lexer';
    
    // esbuild generates nested directory output with lowest common ancestor base
    // this is unpredictable and makes it difficult to analyze entry / output
    // mapping. So what we do here is:
    // 1. flatten all ids to eliminate slash
    // 2. in the plugin, read the entry ourselves as virtual files to retain the
    //    path.
    const flatIdDeps: Record<string, string> = {};
    const idToExports: Record<string, ExportsData> = {};
    const flatIdToExports: Record<string, ExportsData> = {};
    // 运行 es-module-lexer 的初始化函数,后续会用到
    await init;
    
    for (const id in deps) {
      // 替换 id 中的斜杠变成下划线 node/abc => node_abc
      const flatId = flattenId(id);
      flatIdDeps[flatId] = deps[id];
      // 获取每个依赖源的文件内容
      //{ vue: '/.../my-vue-app/node_modules/vue/dist/vue.runtime.esm-bundler.js',
      // 'element-plus': '/.../my-vue-app/node_modules/element-plus/lib/index.esm.js',
      //  axios: '/.../my-vue-app/node_modules/axios/index.js' }
      const entryContent = fs.readFileSync(deps[id], 'utf-8');
      // parse 出自 es-module-lexer,这个包是一个 js 模块语法词法分析器,体积非常小
      // 解析出后的 ExportsData 是一个数组,[0]是 imports, [1]是 exports
      const exportsData = parse(entryContent) as ExportsData;
    
      /*
        ss/se => statement start/end 缩写, {number} import 的开始和结束 index
        这里以 vue 举例,parse 返回的值 =>  ss = 0 se = 60
        entryContent.slice(0, 60) => "import { initCustomFormatter, warn } from '@vue/runtime-dom'"
        entryContent.slice(62, 94) => "export * from '@vue/runtime-dom"
        最后标注需要特殊处理的 export from
      */
      for (const { ss, se } of exportsData[0]) {
        const exp = entryContent.slice(ss, se);
        if (/export\s+\*\s+from/.test(exp)) {
          exportsData.hasReExports = true; //待定
        }
      }
      // 分别记录以 id flatId 的 exportsData
      // exportsData 数据太多这里就不贴了,总之里面包含每个构建模块中的 import 和 export 的数据。
      idToExports[id] = exportsData;
      flatIdToExports[flatId] = exportsData;
    
    }
    

    总结

    上述描述代码中,我们理一下当前的逻辑。

    1. 获取了预构建模块的内容( hash 值,优化对象等)。
    2. 获取包管理器的 lockfile 转换的 hash 值,判断是否需要重新运行预构建。
    3. 获取需要编译依赖关系的模块路径( deps )和需要编译但没找到来源的模块( missing)。
    4. 处理 missing 数组,打印 error 提示是否已安装来源。
    5. 获取 vite.config.js 中自定义强制预构建的模块路径(include),加入 deps 对象中。
    6. 命令行打印需要构建模块的信息。
    7. 创建预构建对象,获取预构建对象中的引入导出数据并记录。

    处理完各种琐事之后,我们获取了需要构建的 deps 对象,接下来进入下一章节来解析 deps 对象。

    3. 构建和插件

    此章节准备介绍构建和 vite 的自定义插件。

    构建(build)

    需要注意的几个参数:

    1. format设为esm,是 Vite 的目的之一,将所有的代码视为原生 ES 模块。

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

    2. splitting设为true,仅适用于 esm 输出下,拆分多个文件引入的模块至单独文件,浏览页面 a 时,加载了 axios,再进入页面 b 时,直接调用已经加载后的 axios,省去了再次请求 axios 的操作。

      Code shared between multiple entry points is split off into a separate shared file that both entry points import. That way if the user first browses to one page and then to another page, they don't have to download all of the JavaScript for the second page from scratch if the shared part has already been downloaded and cached by their browser.

      Code referenced through an asynchronous import() expression will be split off into a separate file and only loaded when that expression is evaluated. This allows you to improve the initial download time of your app by only downloading the code you need at startup, and then lazily downloading additional code if needed later.

    3. plugins含有 Vite 插件esbuildDepPlugin: 下面会详细解释此插件。

    4. treeShaking设为ignore-annotations, 文档中提到的忽略无用的代码,以便减轻模块的体积。

    // /optimizer/index.ts
    
    // 最核心的地方,使用 esBuild 打包了
    const result = await build({
      entryPoints: Object.keys(flatIdDeps),
      bundle: true, //任何导入的依赖一起打包
      format: 'esm', // 符合 vite 转换成 esm
      external: config.optimizeDeps?.exclude, //不需要处理的模块
      logLevel: 'error', //日志级别,只显示错误
      //拆分代码,简单来说就是拆分入口内的共享 import 文件,在访问 a 页面时加载了 axios,
      //进入了 b 页面直接使用 a 页面加载的 axios 省去了再次请求的过程。
      splitting: true,
      sourcemap: true, //这个不用多说哈
      outdir: cacheDir, //vite 自定义的默认缓存文件夹,node_modules/.vite
      //修剪树枝? 默认删除无用的代码,ignore-annotations 的话指忽略那些删掉会损坏包的无用代码
      treeShaking: 'ignore-annotations',
      metafile: true, // 生成 meta json
      define, // 替换标识符
      plugins: [...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config)],
      ...esbuildOptions,
    });
    

    esbuild 插件

    了解 esbuild 的插件的可以直接跳过这节,此节简单解释了下插件的结构:

    (1) esbuild plugin 是一个包含namesetup的对象结构。 name为插件名,setup是一个接收build的函数。

    (2) 主要的逻辑在setup函数中,分别为build.onResolvebuild.onLoad

    build.onResolve: 此函数拦截相应的导入路径,修改路径并标记特定的命名空间。

    build.onLoad: 此函数接收并筛选所有标记命名空间为env-ns的传入项,告诉 esbuild 该如何处理。

    let envPlugin = {
      name: 'env',
      setup(build) {
        // 第一个参数为拦截规则。如下示例,用正则拦截了名为`env`的路径。
        // 第二个参数为函数,返回对象中包含路径(这里可以对路径修改并返回)和标记`env-ns`命名空间。
        build.onResolve({ filter: /^env$/ }, (args) => ({
          path: args.path,
          namespace: 'env-ns',
        }));
    
        // 第一个参数为接收命名空间为 env-ns 的路径并通过 filter 筛选。
        // 第二个参数为函数,告诉 esbuild 在 env-ns 命名空间中要返回 json 格式的环境变量。
        build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
          contents: JSON.stringify(process.env),
          loader: 'json',
        }));
      },
    };
    
    require('esbuild')
      .build({
        entryPoints: ['app.js'],
        bundle: true,
        outfile: 'out.js',
        plugins: [envPlugin],
      })
      .catch(() => process.exit(1));
    

    esbuildDepPlugin

    首先需要看下 Vite 插件的一些用到的函数:

    // /optimizer/esbuildDepPlugin.ts
    
    export function esbuildDepPlugin(
      qualified: Record<string, string>,
      exportsData: Record<string, ExportsData>,
      config: ResolvedConfig,
    ): Plugin;
    

    (1) 创建了两个解析器,分别对应 esmcommonjs

    // /optimizer/esbuildDepPlugin.ts
    
    // default resolver which prefers ESM
    const _resolve = config.createResolver({ asSrc: false });
    
    // cjs resolver that prefers Node
    const _resolveRequire = config.createResolver({
      asSrc: false,
      isRequire: true,
    });
    

    (2) 创建 resolve 函数,主要用来解决判断是什么类型的模块,并且返回相应的解析器结果。

    // /optimizer/esbuildDepPlugin.ts
    
    const resolve = (
      id: string,
      importer: string,
      kind: ImportKind,
      resolveDir?: string,
    ): Promise<string | undefined> => {
      let _importer;
      // explicit resolveDir - this is passed only during yarn pnp resolve for
      // entries
      // 传如果传入文件夹,那就获取绝对路径的文件夹路径
      if (resolveDir) {
        _importer = normalizePath(path.join(resolveDir, '*'));
      } else {
        // map importer ids to file paths for correct resolution
        /**
         * mporter 是否在外部传入的 flatIdDeps 中,
         * {
         *  vue: '/Users/kev1nzh/Desktop/new/my-vue-app/node_modules/vue/dist/vue.runtime.esm-bundler.js',
         *  axios: '/Users/kev1nzh/Desktop/new/my-vue-app/node_modules/axios/index.js'
         * }
         * 如果在获取 value 的路径
         */
        _importer = importer in qualified ? qualified[importer] : importer;
      }
      //判断是否时以 require 开头,为了筛选出 kind 为 require-resolve, require-call 的模块,调用 resolveRequire
      const resolver = kind.startsWith('require') ? _resolveRequire : _resolve;
      // 返回解决完的路径,这个函数的代码后续会有章节详细讲
      return resolver(id, _importer);
    };
    

    (3) 创建resolveEntry函数,根据传入类型返回命名空间。

    function resolveEntry(id: string, isEntry: boolean, resolveDir: string) {
      const flatId = flattenId(id);
      if (flatId in qualified) {
        return isEntry
          ? {
              path: flatId,
              namespace: 'dep',
            }
          : {
              path: require.resolve(qualified[flatId], {
                paths: [resolveDir],
              }),
            };
      }
    }
    
    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2870 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 14:15 · PVG 22:15 · LAX 07:15 · JFK 10:15
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.