vite编写ts文件的动态导入插件

29 阅读4分钟

在一个纯TypeScript(以下简称TS)开发的前端项目收尾阶段,测试环节突发功能异常。经过初步定位,发现问题根源并非业务逻辑错误,而是TS文件在通过Vite编译时出现异常处理,部分核心功能依赖的TS文件未被正常编译,反而被当作静态资源加载,最终导致相关功能无法执行。这一问题直接阻碍了项目交付,需要快速定位并解决。

该项目采用纯TS开发,未引入任何前端框架,构建工具选用Vite以提升开发与构建效率。项目中为实现复杂计算任务的并行处理,引入了Web Worker技术,通过多线程避免主线程阻塞,提升页面响应速度。开发过程中,为满足动态加载Worker的需求,采用了“先获取文件URL,再动态导入”的实现方式,具体代码为通过import worker from "xx.worker.ts?url"语法获取Worker文件的URL路径,随后通过import(worker)完成动态导入。在开发环境中,该实现方式运行正常,Worker相关功能稳定,因此未提前发现潜在的编译问题。

项目完成后执行生产环境构建,部署后首次测试便出现功能失效。打开浏览器开发者工具查看报错信息,发现控制台提示“无法解析模块”,同时网络请求中显示xx.worker.ts文件以静态资源形式被请求,响应类型为“text/plain”,而非编译后的JavaScript文件。这一现象表明,Vite在编译过程中未将该TS文件识别为需要编译的脚本文件,而是按照普通静态资源(如图片、文本)的处理逻辑进行了打包,导致浏览器无法将其作为JavaScript模块解析执行。

为解决该问题,首先需要明确Vite对TS文件及动态导入的处理规则。Vite本身对TS具有原生支持,但这种支持依赖于正确的文件识别和导入方式。通过查阅Vite官方文档得知,当使用?url查询参数时,Vite会将目标文件视为资源文件,仅对其进行路径处理,不会执行编译转换操作。在开发环境中,Vite的开发服务器会实时处理文件请求,即便添加了?url参数,也会隐式对TS文件进行编译;但在生产环境构建时,Vite会严格按照配置规则处理文件,此时?url参数会强制将文件归为资源类型,从而跳过TS编译流程,这便是问题产生的核心原因。

针对上述原因,制定了两种解决思路:一是调整Worker文件的导入方式,移除导致文件被识别为资源的?url参数,让Vite正常编译TS文件;二是通过Vite配置自定义文件处理规则,确保带有?url参数的TS文件仍能被编译。考虑到项目中Worker动态导入的场景需求,最终选择第一种更简洁、更符合Vite设计理念的方案。

通过实现vite插件来ts文件的动态导入的问题

import type { Plugin } from 'vite'
import path from 'path'
import esbuild from 'esbuild'
import fs from 'fs/promises'
import { existsSync } from 'fs'

/**
 * 自定义正则转义函数(兼容低版本Node.js)
 */
function escapeRegExp(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/**
 * 延迟函数(确保文件写入完成)
 */
function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
 * 移动文件夹工具函数(带重试机制)
 */
async function moveFolder(source: string, target: string, retry = 3, interval = 500) {
  // 检查源目录是否存在(带重试,应对文件写入延迟)
  let exists = false
  for (let i = 0; i < retry; i++) {
    if (existsSync(source)) {
      exists = true
      break
    }
    console.log(
      `[vite-plugin-force-compile-ts-url] 源目录 ${source} 暂未生成,等待${interval}ms后重试(${i + 1}/${retry})`
    )
    await delay(interval)
  }

  if (!exists) {
    console.warn(`[vite-plugin-force-compile-ts-url] 重试${retry}次后仍未找到源目录 ${source},跳过移动`)
    return
  }

  // 创建目标目录的父级目录(确保assets存在)
  await fs.mkdir(path.dirname(target), { recursive: true })

  // 先删除已存在的目标目录(避免冲突)
  if (existsSync(target)) {
    console.log(`[vite-plugin-force-compile-ts-url] 目标目录 ${target} 已存在,先删除`)
    await fs.rm(target, { recursive: true, force: true })
  }

  try {
    // 移动目录
    await fs.rename(source, target)
    console.log(`[vite-plugin-force-compile-ts-url] 成功将 ${source} 移动到 ${target}`)
  } catch (error: any) {
    if (retry > 0) {
      console.log(`[vite-plugin-force-compile-ts-url] 移动目录失败,${interval}ms后重试(${retry}次):`, error.message)
      await delay(interval)
      return moveFolder(source, target, retry - 1, interval)
    }
    throw error
  }
}

/**
 * 字符串转 base64(处理 UTF-8 编码,避免中文乱码)
 */
function stringToBase64(str: string): string {
  return Buffer.from(str, 'utf8').toString('base64')
}

/**
 * Vite插件:强制编译TS URL导入(支持依赖打包版)
 * 编译输出到根目录workers,所有文件写入完成后自动移动到assets/workers
 */
export default function forceCompileTsUrlPlugin(
  options: {
    /** 匹配的worker文件后缀(默认:.worker, .work) */
    matchSuffix?: string[]
    /** esbuild编译选项(可覆盖默认配置) */
    esbuildOptions?: esbuild.BuildOptions
    /** 编译输出目录(默认:workers,根目录下) */
    outputDir?: string
    /** 目标移动目录(默认:assets/workers) */
    targetDir?: string
    /** 外部依赖排除列表 */
    external?: string[]
    /** 移动重试次数(默认:3) */
    moveRetry?: number
    /** 重试间隔(默认:500ms) */
    retryInterval?: number
    /** 是否内联 */
    isInline?: boolean
  } = {}
): Plugin {
  const {
    matchSuffix = ['.worker', '.work'],
    esbuildOptions = {},
    outputDir = 'workers', // 编译时输出到根目录workers
    targetDir = 'assets/workers', // 最终目标目录
    external = ['electron', 'node:*', 'opencascade.js'],
    moveRetry = 3,
    retryInterval = 500,
    isInline = true
  } = options

  // 缓存已编译+转base64的代码(绝对路径 -> base64字符串)
  const compiledCache = new Map<string, string>()
  // 生成匹配正则:支持多个后缀 + ?url查询参数(包括?worker&url等组合)
  const matchRegex = new RegExp(`^(.+?(${matchSuffix.map(escapeRegExp).join('|')}))(\\?(?:worker&)?url)(&.+)?$`)

  return {
    name: 'vite-plugin-force-compile-ts-url-import',
    enforce: 'pre', // 优先于Vite内置插件处理
    async resolveId(id: string, importer: string | undefined) {
      const isDev = process.env.NODE_ENV !== 'production'
      if (isDev) {
        return null
      }
      // 1. 匹配目标导入格式(xxx.worker?url / xxx.work?url)
      const match = id.match(matchRegex)
      if (!match) return null

      const [, sourcePath, ext, , query = ''] = match

      // 2. 解析原文件绝对路径(处理别名、相对路径等)
      const resolved = await this.resolve(sourcePath, importer, {
        skipSelf: true,
        custom: { 'vite-plugin-force-compile-ts-url-import': true }
      })
      if (!resolved || resolved.external) return null
      const absolutePath = resolved.id

      // 3. 安全访问config + 缓存处理

      if (compiledCache.has(absolutePath)) {
        return `\0worker-url:${absolutePath}${query}`
      }

      try {
        // 5. 使用esbuild.build进行依赖打包
        const buildResult = await esbuild.build({
          entryPoints: [absolutePath],
          bundle: true, // 启用依赖打包
          write: false, // 不写入文件系统,我们手动处理
          target: 'es2022',
          platform: 'browser',
          format: 'esm',
          sourcemap: false,
          treeShaking: true,
          minify: true,
          external: [...external, ...(esbuildOptions.external || [])],
          ...esbuildOptions
        })

        if (!buildResult.outputFiles || buildResult.outputFiles.length === 0) {
          throw new Error(`esbuild failed to generate output for ${absolutePath}`)
        }

        const rawCode = buildResult.outputFiles[0].text

        if (!isInline) {
          //  生成文件名+hash的输出路径(编译到根目录workers)
          const fileName = path.basename(absolutePath, ext)
          const timestamp = Date.now().toString(36)
          const random = Math.random().toString(36).substring(2, 8)
          const hash = `${timestamp}-${random}`
          const outputFileName = `${outputDir}/${fileName}-${hash}.js` // 原输出路径不变

          // 发射静态资源(输出到根目录workers)
          const assetId = this.emitFile({
            type: 'asset',
            source: rawCode,
            fileName: outputFileName
          })
          compiledCache.set(absolutePath, assetId)
        } else {
          const base64Code = stringToBase64(rawCode)
          compiledCache.set(absolutePath, base64Code)
        }

        return `\0worker-url:${absolutePath}${query}`
      } catch (error) {
        console.error(`[vite-plugin-force-compile-ts-url] Failed to compile ${absolutePath}:`, error)
        throw error
      }
    },

    // 加载虚拟模块:返回合法URL(适配移动后的路径)
    load(id: string) {
      // 匹配虚拟模块ID(\0worker-url:xxx)
      const match = id.match(/^\0worker-url:([^?]+)(\?.*)?$/)
      if (!match) return null

      const [, absolutePath] = match
      // 从缓存获取 base64 编码后的代码
      if (isInline) {
        const base64Code = compiledCache.get(absolutePath) || ''
        // 直接导出 data:base64 URL 字符串(极简写法)
        return `export default 'data:application/javascript;base64,${base64Code}';`
      } else {
        const assetId = compiledCache.get(absolutePath)!
        const assetFileName = this.getFileName(assetId)

        // 处理路径特殊字符,确保URL合法
        const encodedFileName = assetFileName.split(path.posix.sep).map(encodeURIComponent).join('/')

        // 导出ESM格式URL
        return `export default new URL('${encodedFileName}', import.meta.url).href;`
      }
    },

    // 关键修改:使用writeBundle钩子(所有文件写入磁盘后触发)
    async writeBundle() {
      // 只在生产环境执行(开发环境不输出dist,无需移动)
      if (process.env.NODE_ENV !== 'production') return
      if (isInline) return 

      // 获取Vite的输出根目录(支持用户自定义outDir)
      const outDir = 'web'
      // 源路径:dist/workers(编译输出目录)
      const sourcePath = path.resolve(process.cwd(), outDir, outputDir)
      // 目标路径:dist/assets/workers(最终目录)
      const targetPath = path.resolve(process.cwd(), outDir, targetDir)

      try {
        // 执行移动(带重试机制,应对文件写入延迟)
        await moveFolder(sourcePath, targetPath, moveRetry, retryInterval)
      } catch (error) {
        console.error(`[vite-plugin-force-compile-ts-url] 移动目录最终失败:`, error)
        throw error
      }
    }
  }
}

修改完成后,重新执行生产环境构建并部署测试。通过浏览器开发者工具验证,发现xx.worker.ts文件已被成功编译为JavaScript文件,网络请求中响应类型变为“application/javascript”,Worker相关功能正常执行,之前的报错信息完全消失。为避免后续出现类似问题,进一步在项目中补充了生产环境预构建测试环节,每次开发完成后先执行构建命令并本地模拟部署,提前发现编译层面的问题。

此次问题的解决,暴露了开发过程中对构建工具环境差异的忽视。开发环境中Vite的便捷性容易掩盖一些潜在的配置问题,而生产环境的严格编译规则会将这些问题放大。同时也明确了Vite中?url等查询参数的具体作用机制,以及TS文件编译依赖正确的识别规则。后续在使用Vite开发纯TS项目时,需更加注重导入方式与构建规则的匹配,针对Worker、动态导入等特殊场景,优先参考官方推荐实现方案,减少自定义语法的使用,确保开发环境与生产环境的一致性,提升项目交付的稳定性。

插件的优化

import type { Plugin } from "vite";
import path from "path";

/**
 * 自定义正则转义函数(兼容低版本Node.js)
 */
function escapeRegExp(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
 * Vite插件:强制编译TS URL导入(支持依赖打包版)
 * 编译输出到根目录workers,所有文件写入完成后自动移动到assets/workers
 */
export default function forceCompileTsUrlPlugin(
  options: {
    /** 匹配的worker文件后缀(默认:.worker, .work) */
    matchSuffix?: string[];
  } = {},
): Plugin {
  const { matchSuffix = [".worker", ".work"] } = options;

  // 缓存已编译的内容
  // 内联模式:绝对路径 -> base64字符串
  // 非内联模式:绝对路径 -> assetId字符串
  const compiledCache = new Map<string, string>();
  // 生成匹配正则:支持多个后缀 + ?url查询参数(包括?worker&url等组合)
  // 注意:后缀可能后面还有 .ts 或其他扩展名
  const matchRegex = new RegExp(
    `^(.+?(${matchSuffix.map(escapeRegExp).join("|")})(?:\\.\\w+)?)(\\?(?:worker&)?url)(&.+)?$`,
  );

  return {
    name: "vite-plugin-force-compile-ts-url-import",
    enforce: "pre", // 优先于Vite内置插件处理
    async resolveId(id: string, importer: string | undefined) {
      const isDev = process.env.NODE_ENV !== "production";
      if (isDev) {
        return null;
      }
      //  匹配目标导入格式(xxx.worker?url / xxx.work?url)
      const match = id.match(matchRegex);
      if (!match) return null;

      const [, sourcePath, ext, , query = ""] = match;

      //  解析原文件绝对路径(处理别名、相对路径等)
      const resolved = await this.resolve(sourcePath, importer, {
        skipSelf: true,
        custom: { "vite-plugin-force-compile-ts-url-import": true },
      });
      if (!resolved || resolved.external) return null;
      const absolutePath = resolved.id;

      //  安全访问config + 缓存处理

      if (compiledCache.has(absolutePath)) {
        return `\0worker-url:${absolutePath}${query}`;
      }

      try {
        const assetId = this.emitFile({
          type: "chunk",
          id: `${absolutePath}`, // 唯一ID(避免冲突)
          preserveSignature: "strict", // 严格保留导出结构(Worker场景无影响,确保兼容性)
          importer: importer, // 标记导入者,帮助Vite追踪依赖
        });

        compiledCache.set(absolutePath, assetId);

        return `\0worker-url:${absolutePath}${query}`;
      } catch (error) {
        console.error(
          `[vite-plugin-force-compile-ts-url] Failed to compile ${absolutePath}:`,
          error,
        );
        throw error;
      }
    },

    // 加载虚拟模块:返回合法URL(适配移动后的路径)
    load(id: string) {
      const isDev = process.env.NODE_ENV !== "production";
      if (isDev) {
        return null;
      }
      // 匹配虚拟模块ID(\0worker-url:xxx)
      const match = id.match(/^\0worker-url:([^?]+)(\?.*)?$/);
      if (!match) return null;

      const [, absolutePath] = match;

      const assetId = compiledCache.get(absolutePath)!;

      // 关键修复:在 generateBundle 阶段获取文件名,而不是在 load 阶段
      // 这里返回一个占位符,实际文件名会在 generateBundle 阶段替换
      return `export default __WORKER_ASSET_PLACEHOLDER_${assetId}__;`;
    },

    // 在 generateBundle 阶段替换占位符为实际文件名
    async generateBundle(options, bundle) {
      const isDev = process.env.NODE_ENV !== "production";
      if (isDev) {
        return;
      }

      // 遍历所有文件,找到包含占位符的文件
      for (const [fileName, file] of Object.entries(bundle)) {
        if ("code" in file) {
          let code = file.code;

          // 替换所有占位符为实际文件名
          for (const [absolutePath, assetId] of compiledCache.entries()) {
            const placeholder = `__WORKER_ASSET_PLACEHOLDER_${assetId}__`;
            if (!code.includes(placeholder)) continue;

            while (code.includes(placeholder)) {
              const assetFileName = this.getFileName(assetId);
              const encodedFileName = assetFileName
                .split(path.posix.sep)
                .map(encodeURIComponent)
                .join("/");
              const actualUrl = `new URL('/${encodedFileName}', import.meta.url).href`;

              code = code.replace(
                new RegExp(escapeRegExp(placeholder), "g"),
                actualUrl,
              );
            }
          }

          file.code = code;
        }
      }
    },
  };
}