重复工作自动化!真实案例,vite插件也就这样

本插件也已发布npm包,并开源到github(vite-plugin-dir2json),给star的都是帅哥美女,不给也是🫵

背景

经常做那些需要适配h5和pc端的页面的同学都知道,静态资源通常需要分两套,h5一套,pc一套

比如一个视频,pc上用的可能是1920 × 1080分辨率的,但考虑到文件大小和移动端网速一般差于pc情况,h5上可能用的是750 × 450分辨率的

那资源一多时,代码里就会有一堆 import,如下:

手动维护起来比较烦,凭着重复的工作自动化的精神,于是找下有没有简单方便的做法

  • 首先看到vite提供的 Glob 导入,但看了下结果,不太适合

  • 刚好不久前研究了vite,冒出了写个插件的想法,先来看看最终效果:

对于以下结构的目录

home
├── h5
│   └── home1
│       └── home1.mp4
└── pc
    └── home1
        └── home1.mp4

使用插件可以这么写:

// ...
import homeJson from "/path/to/home?dir2json";

console.log(homeJson);
// {
// h5: {
// home1: {
// home1: “/src/assets/home/h5/home1/home1.mp4”,
// },
// },
// pc: {
// home1: {
// home1: “/src/assets/home/pc/home1/home1.mp4”,
// },
// },
// };

export const homeVideo = isMobile() ? homeJson.h5 : homeJson.pc

还提供正确的ts类型提示,如下:

实现

功能

首先抛开一切,这个插件需要做的事情就是:输入一个目录路径,然后读取这个目录及子目录下所有支持文件的路径信息,并按目录结构组装成json数据返回。

把上面功能看作一个函数的话,用nodejs实现轻轻松松;但如果要做成vite插件的话,就要了解vite的插件工作流程,了解上面的每一步要怎么做,实际上就是了解哪些hook能提供我所要的信息,我能在哪些hook里实现这个功能

不太熟悉vite开发服务、插件系统的同学可以看我上篇文章,先有个大概了解,下面摘取部分:

vite整体工作流水线,主体大概就是

  1. 起一个 开发-服务器(就是一个简单的服务器,想一下express demo,能接收请求,自定义返回),然后根据浏览器的请求信息(定好的格式)开始 -> 2.(解析路径resolve、加载文件 load、代码转译transform)(完全可控) ->
  2. 返回处理结果(浏览器支持的es6模块/资源,不要被文件后缀名迷惑,浏览器是根据Content-Type响应头(可控)来处理响应的);

具体思路步骤如下:

1. 区分入口,解析路径

resolveId hook里,通过路径查询参数(有dir2json的)区分入口,并解析出绝对路径,简要逻辑如下:

// ...
resolveId: {
  order: "post",
  async handler(source: string, importer: string | undefined) {
    if (source.includes("?dir2json")) {
      // resolve
      let absolutePath;
  absolutePath = path.join(path.dirname(importer || ""), source);

  return absolutePath;
}
return null; // other

},
},
// …

2. 根据路径解析出json数据,返回虚拟模块字符串

load hook里,根据目录路径解析得到json数据,返回一个导出json数据的虚拟模块,简要代码如下:

// ...
async load(id: string) {
  if (id.includes("?dir2json")) {
    const dirPath = id.split("?").shift()!;
// 递归遍历目录文件,解析得到json数据
const res = {};
let importStr = "";
await traverseDir(dirPath, (filePath, rootDirPath) => {
  if (
    isSupportExt(/** ... */)
  ) {
    // 组装import 语句
    // ...

    // 组装json
    // ...
  }
});

// return code string
const finalDataCode = toStr(res); // toStr把json对象变成js code字符串
let code = `${importStr} 

export default ${finalDataCode}`;

return code;

}

return null; // other
},
// …

最终返回如下:

typescript支持

要提供ts类型提示,就要生成对应的全局类型声明,因为最终的json对象可以在上一步计算得出,那简单点 typeof jsonObject 就能得到对应的ts接口类型,这是其一方法;这里我用的是根据json对象字符串直接生成类型接口字符串,结果如下(右边dir2json.d.ts文件自动生成):

简要实现如下:

async load(id: string) {
    // ...
// 配置了options.dts
if (!!dts) {
  const moduleTag = id.split(path.sep).pop()!;
  // 收集要生成的类型信息,模块名
  dtsContext[moduleTag] = {
    moduleTag,
    dataCode,
  };

  // 生成 dts 文件内容字符串
  let str = dtsFileHeader;
  const dtsContent = Object.values(dtsContext);
  dtsContent.forEach((item) => {
    // ...  
    const dataInterface = item.dataCode.replaceAll(reg, "string;");
    str += `

declare module "*{item.moduleTag}" { const json: {dataInterface};
export default json;
}
`;
});

  // ...
  // 更新 dts 文件
  writeFile(dtsFilePath, str);
}

}

踩坑

虚拟模块格式

一开始我是打算直接组装返回类似以下格式的虚拟模块:

export default {
  h5: {
    home1: {
      home1: "/src/assets/home/h5/home1/home1.mp4",
    },
  },
  pc: {
    home1: {
      home1: "/src/assets/home/pc/home1/home1.mp4",
    },
  },
};

开发环境可以访问资源没问题,但构建的结果还是上面的路径,这就有问题了,因为vite构建时会追踪 import 语句中的路径,把对应的静态资源拷贝到输出目录并生成hash文件名,但上面的路径写在json数据里了,所以对应的文件不会被vite构建识别处理。

所以需要生成import语句,返回类似下面的虚拟模块:

import xxx from "/src/assets/home/h5/home1/home1.mp4"
import bbb from "/src/assets/home/pc/home1/home1.mp4"

export default {
h5: {
home1: {
home1: xxx,
},
},
pc: {
home1: {
home1: bbb,
},
},
};

组装虚拟模块字符串

这里有两个问题要处理

  1. 导入变量名得唯一:这里我用文件路径名处理下作为变量名,比如_src_assets_home_h5_home1_home1_mp4
// ...
// 导入变量名
const importVarName = absolutePath
    .replaceAll(path.sep, "_")
    .replace(".", "_");
importStr += `import ${importVarName} from "${absolutePath}";`;
// ...
  1. 组装 export default 语句时,json数据被stringify处理后,会带上引号,例如上面home1: xxx会变成"home1": "xxx",这样其他文件拿到的json数据里的路径信息就是变量名'xxx'而不是实际的路径。这里我在组装json数据时额外添加一些标记,到最后再识别出标记,连随引号去除即可:
async load(id: string) {
    // ...
// 增加 replaceTag, 最后code字符串去除变量的“ ”字符
res[getFileNameNotExt(name)] = `${replaceTag}${importVarName}${replaceTag}`;
                                 ^^^^^^^^^^^^                 ^^^^^^^^^^^^
// ...

// 组装成json格式返回
const dataCode = `${JSON.stringify(res, null, "  ")}`;
const finalDataCode = dataCode
  .replaceAll(`"${replaceTag}`, "")
               ^^^^^^^^^^^^^^^ 
  .replaceAll(`${replaceTag}"`, "");
               ^^^^^^^^^^^^^^^ 
let code = `${importStr} 

export default ${finalDataCode}`;

return code;

}

dts 文件生成

dts文件生成/更新时机是用户保存修改后,新增的?dir2json导入,会经过load hook然后被识别处理。

但正常情况下,一开始只有 import xxxJson from 'xxx?dir2json' 语句,xxxJson还没使用的情况下,保存修改时, xxxJson 会被tree-shaking机制去掉,这就导致浏览器实际接收的的文件内容里没有 import xxxJson from 'xxx?dir2json' 语句,也就不会触发 xxx?dir2json 模块的类型声明生成。这里想要用户在保存后触发 xxx?dir2json 模块的类型声明生成,就得让 xxxJson 被使用,防止被tree-shaking掉。

于是我在transform hook里识别出 import xxxJson from 'xxx?dir2json' 这样的导入语句,并增加sideEffectCode(使用xxxJson变量),这就避免了被tree-shaking掉,如下:

//...
    transform: {
      order: "pre",
      handler(code) {
        // Add sideEffectCode in files using dir2json-import to avoid tree-shaking in development mode
        // For dts files generated in development mode
        if (mode == "development" && !!dts && code.includes("?dir2json")) {
          // 正则匹配出`import xxxJson from 'xxx?dir2json'` 这样的导入语句
          const dir2jsonImportList = [
            ...code.matchAll(/import\s(.*?)\sfrom.*?dir2json.*?;/g), 
          ];
      const importNameList = dir2jsonImportList.map((item) => item[1]);
      // 生成并增加sideEffectCode,并加在最后一个import 语句后面
      const sideEffectCode = `\nwindow.dir2jsonSideEffect = [${importNameList.join(
        ","
      )}];`;
      const last = dir2jsonImportList[dir2jsonImportList.length - 1];
      code = code.replace(last[0], `${last[0]}${sideEffectCode}`);
    }
    return { code };
  },
},

//…

赏个star吧⭐️

全部代码可以查看这里,另外本插件也已发布npm包,并开源到github(vite-plugin-dir2json),跪求各位帅哥美女 star🙏

参考


这是一个从 https://juejin.cn/post/7369143633419288585 下的原始话题分离的讨论话题