《vite技术揭秘、还原与实战》第6节–defineConfig引发的问题思考与解决方案分析

前言

上一节我们提供的svite.config.ts配置文件没有相应的TypeScript类型定义,这对开发者是不友好的

本节我们通过提供一个defineConfig函数来优化这个问题

源码获取

传送门

更新进度

公众号:更新至第15

博客:更新至第6

尝试

这似乎很简单,在packages\vite\src\node\config.ts中导出defineConfig函数,该函数用作向用户提供类型支持

export function defineConfig(config: UserConfig): UserConfig {
  return config;
}

为了达到这个目的,还需要在packages\vite\src\node\index.ts中导出UserConfig

export * from "./config";

接着在playground\config\svite.config.ts中导入并使用,理论上就大功告成了

import { defineConfig } from "svite";
export default defineConfig({
  server: {},
  root: "",
});

此时启用该用例,会发现如下报错

Dynamic require of "fs" is not supported

原因分析

该报错说明我们正在esm模块的执行过程中使用require语法,这似乎有点不可思议,因为我们在packages\vite\rollup.config.ts文件中定义的output.format确实是esm格式,打包结果如下所示

import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { build } from 'esbuild';

const DEFAULT_CONFIG_FILES = ["svite.config.ts"];

async function buildBoundle(fileName) {
    ...
}
async function loadConfigFromBoundled(code, resolvedPath) {
    ...
}
function defineConfig(config) {
    return config;
}
async function parseConfigFile(conf) {
    ...
}
async function resolveConfig(userConf) {
    ...
}

export { defineConfig, parseConfigFile, resolveConfig };
//# sourceMappingURL=index.js.map

如上,我们的三个import也都是符合esm语法的,其中fspath模块是node内置的,node 本身又支持esm,理论上来说不可能是它们导致的。那问题貌似出现在esbuild

我们找到node_modules下的esbuild文件夹,并根据package.json定位到入口为packages\vite\node_modules\esbuild\lib\main.js的文件,在其源码中发现了这两句代码

...
var fs = require("fs");
var os = require("os");
...

还记得我们上一节是如何处理svite.config.ts文件的吗?我们为了能在文件中引入外部模块,使用esbuild进行了打包,而build行为会分析模块中的import并将其打包进一个boundle,这就意味着var fs = require("fs");这行代码将会被打包到我们最终的boundleCode

import * as any from "some-pkg";
const fs = require("fs");

而对于boundleCode我们使用的是new Function的形式,至此,真相大白

const dynamicImport = new Function("file", "return import(file)");

如何解决

既然原因是对esbuild进行了分析打包,那是否可以跳过对esbuild包的build行为呢?

首先,我们找到在packages\vite\package.json中的dependencies,如下

"dependencies": {
    "esbuild": "^0.18.8",
    "magic-string": "^0.30.0",
    "rollup": "^3.21.0"
}

由于esbuilddependencies的一员,则意味着,我们一定能在用户项目的node_modules中找到该依赖包,这意味着使用import { defineConfig } from 'svite 时对入口的加载过程不会抛出错误

同时由于svite已经被打包过,因此如果我们能直接从node_modules引入则问题能够被解决,并且为了更安全的找到对应的包,我们使用绝对路径更为妥当

import { defineConfig } from 'XXX/node_modules/svite/dist/node/index.js'

那么问题就在于,svite如何被转换为XXX/node_modules/svite/dist/node/index.js呢?

源码分析

我们在原因分析中已经找到了问题出在打包处,并且也提出了解决方案:

1-将裸依赖从打包中排除

2-将裸依赖导出地址替换为绝对路径

故将代码定位到bundleConfigFile函数的名称为externalize-depsesbuild plugin中,源码简化如下

// packages/vite/src/node/config.ts

async function bundleConfigFile(
  fileName: string,
  isESM: boolean,
): Promise<{ code: string; dependencies: string[] }> {
  ...
  const result = await build({
    ...
    plugins: [
      {
        name: 'externalize-deps',
        setup(build) {
          ...
          // 排除裸依赖
          build.onResolve(
            { filter: /^[^.].*/ },
            async ({ path: id, importer, kind }) => {
              ...
              return {
                path: idFsPath,
                external: true,
              }
            },
          )
        },
      },
      ...
    ],
  })
  const { text } = result.outputFiles[0]
  return {
    code: text,
    ...
  }
}

如上,vite通过onResolve钩子来完成对模块路径的替换和模块的排除工作,这分别对应着返回对象的pathexternal属性,当为external设置为true后,esbuild将会自动将模块从打包结果中排除,因此,我们的重点是分析模块路径替换是怎么实现的,也即pathvalue值是如何生成的

// 获取裸模块的本地文件路径
idFsPath = resolveByViteResolver(id, importer, !isImport)
// 将本地文件路径转换为可用于网络访问的URL
idFsPath = pathToFileURL(idFsPath).href

进入resolveByViteResolver函数,并按顺序找到tryNodeResolve函数,如下是笔者简化后的代码

// packages/vite/src/node/plugins/resolve.ts

export function tryNodeResolve(...): PartialResolvedId | undefined {
  const { preserveSymlinks, packageCache,... } = options
  ...
  // 获取目录,即vite.config.ts所在的目录,一般为用户项目根目录
  const basedir = path.dirname(importer)
  // 获取裸模块包的信息
  const pkg = resolvePackageData(pkgId, basedir, preserveSymlinks, packageCache)
  ...
  // 模块导入函数
  const resolveId = deepMatch ? resolveDeepImport : resolvePackageEntry
  ...
  // 从模块信息中分析入口文件地址
  const resolved = resolveId(unresolvedId, pkg, targetWeb, options)
  ...
  return resolved
}

进入resolvePackageData函数,可以看到,vitenpm包的package.json开始查找,一般来说,在第一次while循环过程中就能找到,若实在找不到就依次到上一级

// packages/vite/src/node/packages.ts
export function resolvePackageData(...): PackageData | null {
  ...
  const originalBasedir = basedir
  while (basedir) {
    ...
    // 找到导入npm包的package.json文件
    const pkg = path.join(basedir, 'node_modules', pkgName, 'package.json')
    try {
      if (fs.existsSync(pkg)) {
        // 读取package.json
        const pkgPath = preserveSymlinks ? pkg : safeRealpathSync(pkg)
        const pkgData = loadPackageData(pkgPath)
        ...
        return pkgData
      }
    } catch {}
    // 进入上一级目录查找
    const nextBasedir = path.dirname(basedir)
    if (nextBasedir === basedir) break
    basedir = nextBasedir
  }

  return null
}

pkgPath这一行,根据preserveSymlinks取值来决定是否使用符号链接,符号链接其实类似于一种别名,通过读取A可直接获取源文件B,而非符号链接则必须找到实际的源文件B的路径才能进行内容的读取

safeRealpathSync的实现又根据操作系统的不同有差异

// packages/vite/src/node/utils.ts
export let safeRealpathSync = isWindows
  ? fs.realpathSync
  : fs.realpathSync.native

总而言之,vite获取到了一个指向npm包的绝对路径,下一步使用loadPackageData来进行文件内容的读取,如下,其本质上就是fs的文件读取操作

export function loadPackageData(pkgPath: string): PackageData {
  const data = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  ...
  return data
}

现在我们返回tryNodeResolve函数

对于resolveId的取值,则分别对应如下两种导入方式

import xxx from 'x'
import yyy from 'x/y'

笔者不打算在此处对这两个函数进行展开,因为在svite的实现中笔者并不打算完全采用该方式。感兴趣的读者可以自己按提示找到对应的文件定位到代码处进行查看。实际上其实现思路也很简单:找到package.json中的文件导出字段如exports,然后进行匹配拼接即可

原文链接:https://juejin.cn/post/7329718699341709338 作者:潘苏苏

(0)
上一篇 2024年1月30日 下午4:11
下一篇 2024年1月30日 下午4:21

相关推荐

发表回复

登录后才能评论