为了早下班,我开发了个vite插件,然而...
作者:思路为王
背景
最近有个需求需要做响应式页面,这不多媒体查询、flex布局啥的一套撸,问题不大,但是,这些页面涉及很多视频的展示,而设计同学只提供PC尺寸下的视频文件,为了在不同尺寸的设备上加载不同尺寸的视频(移动端减少加载资源大小),我都是复制一份资源,然后手动用 ffmpeg 修改视频的分辨率,这弄一两次还好,但当数量多时,而设计资源经常更新时,就很烦了。。于是凭着重复工作自动化的精神,我在想能不能写个工具自动化这些步骤呢?
——————————
插播一条机会,有需要的戳了解☞技术大厂,综合薪酬15-35K,前后端测试捞人。
思路及实现
- 想要自动化的事情:对一个视频/图片/其他资源...,修改它的分辨率/格式/其他处理...,生成新的文件
- 需要的信息:
- 基于哪个资源文件
- 想要做什么处理
刚好之前研究了一下vite(研究vite心得),通过它的插件机制,我们能轻松自定义资源的解析-加载-转换逻辑!
vite 插件运行在node端,nodejs能干嘛你就能干嘛,放飞你的想象力吧!
设计
经过一段时间的实践与思考,我敲定了插件的工作流程,如下:
- 在文件后缀名前面增加 query,格式如下:
import xx from "xxx@fit:fitFuncKey(a=xx&b=xx).xx"
^^^^^----------^^^^^^^^^^^
固定标识 | 自定义参数
fitFunc标识
fit 有转换的意思
- 然后在插件里根据固定标识识别出入口,然后解析出
- 目标文件路径;
- fitFunc(转换函数)标识及调用参数;
- 运行转换函数转换目标文件生成新的文件;
- 转换函数及参数支持自定义,通过插件options配置;
实现
区分入口,处理路径
在 resolveId 钩子里,根据固定标识识别出入口,解析出绝对路径,简要如下:
//...
const mediaFitTag = '@fit:';
// ...
resolveId(source: string, importer: string) {
if (source.includes(mediaFitTag)) {
let absolutePath;
if (source.includes(root)) {
// 如果路径已经是绝对路径了,直接复制
absolutePath = source;
} else {
absolutePath = path.join(path.dirname(importer || ""), source);
}
return absolutePath;
}
return null;
}
//...
解析转换信息,转换文件
在 load 钩子进行解析相关信息,执行转换处理,把转换结果写入新文件,最后返回 js 虚拟模块,简要如下:
//...
async load(id: string) {
if (id.includes(mediaFitTag)) {
// 1. 解码参数、目标文件地址、结果文件地址
// 1.1 提取参数
const fitFuncInfoArr = decodeParamStr(id);
// 1.2 目标文件地址、结果文件地址(这里取简单做法)
let inputFilePath = id.replace(/@fit:.*\./g, ".");
// 那些需要转换格式的 fitfunc 会改变 outputFilePath
let outputFilePath = id;
// 2. 匹配处理函数、运行转换函数,生成结果文件
// 暂不支持串联调用 fitfunc,暂时只考虑支持一个fitfunc函数调用
for (let index = 0; index < fitFuncInfoArr.length; index++) {
const { fitFuncName, params } = fitFuncInfoArr[index];
// 碰到转换格式的,需要更新输出文件格式
if (params.f) {
const originFormat = inputFilePath.split(".").pop()!;
outputFilePath = outputFilePath.replace(originFormat, params.f);
}
// 验证是否已存在 outputFilePath ,有则跳过,没有继续
if (!existsSync(outputFilePath)) {
const fitFunc = fitKit[fitFuncName];
await Promise.resolve(
fitFunc({
inputFilePath,
outputFilePath,
ctx: fitFuncContext,
params: params,
})
);
}
}
// 3. 返回文件路径导出语句
if (mode == "development") {
let code = `export default "${outputFilePath.replace(root, "")}";`;
return code;
} else {
// 如果文件是构建时生成的,需要将生成的文件添加到构建产物中
const referenceId = this.emitFile({
type: "asset",
name: path.basename(outputFilePath),
needsCodeReference: true,
source: readFileSync(outputFilePath),
});
let code = `export default import.meta.ROLLUP_FILE_URL_${referenceId};`;
return code;
}
}
return null;
},
//...
本插件针对视频、图片资源内置了 3 个常用的转换函数,详情请看这里:
builtInFitKit = {
scale: videoScaleFit, // 基于ffmpeg, 调整视频分辨率,用法: @fit:scale(w=xx&h=xx)
rs: imageResizeFit, // 基于sharp, 调整图片尺寸,用法: @fit:rs(w=xx&h=xx&f=cover...)
imgtf: imgTransformFit, // 基于sharp, 转换图片格式、质量等等,用法:@fit:imgtf(f=png&q=80)
};
支持自定义转换
插件支持 options.fitKit 配置,可轻松自定义转换逻辑,如下:
// vite.config.ts
import mediaFit from "unplugin-mediafit/vite";
export default defineConfig({
plugins: [
mediaFit({
/* options */
}),
],
});
// options简要
interface IOptions {
/**
* 自定义fitFunc集合,key为使用时的缩写
*/
fitKit?: { [key: string /**使用标识 */]: FitFunc };
/**
* 如需要使用ffmpeg, 请配置ffmpeg命令行工具路径,如果ffmpeg命令全局可用,传'ffmpeg'即可
*/
ffmpegPath?: string;
}
type FitFunc = (param: IFitFuncParam) => void;
// ...
例如,内置的转换视频分辨率的 FitFunc实现如下:
// fitFunc 一般包含以下逻辑
// 1. 读取 inputFilePath 文件
// 2. 处理
// 3. 将处理结果写入 outputFilepath 中
/**
* 调整视频分辨率,用法 scale(w=xx&h=xx)
* @param data IFitFuncParam
*/
const videoScaleFit: FitFunc = async (data: IFitFuncParam) => {
const { inputFilePath, outputFilePath, ctx, params } = data;
try {
const { w = -1, h = -1 } = params;
const argsStr = `-i ${inputFilePath} -vf scale=${w}:${h} ${outputFilePath}`;
await ctx.ffmpeg.run(argsStr);
} catch (error) {
// 删除文件
rm(outputFilePath);
}
};
最终 V1.0.0 版本
安装
# using npm
npm install -D unplugin-mediafit
# using pnpm
pnpm install -D unplugin-mediafit
# using yarn
yarn add --dev unplugin-mediafit
配置
暂时只支持 vite
// vite.config.ts
import mediaFit from "unplugin-mediafit/vite";
export default defineConfig({
plugins: [
mediaFit({
/* options */
}),
],
});
使用
踩坑
使用设计
一开始想要把query写在文件后缀名后面的,像这样:
import xx from "xxx.png@fit:fitFuncKey(a=xx&b=xx)"
^^^^^----------^^^^^^^^^^^
固定标识 | 自定义参数
fitFunc标识
但这样有个问题,typescript找不到模块声明,会报错
如果要解决的话,需要对每一个这种模块自动增加模块声明,会增加一点工作量。最终决定把query放到文件后缀前,这样可以不用考虑typescript的问题!
内置支持 ffmpeg 调用能力
一开始调研有3种方案:
- 引入
ffmpeg.wasm
,0.12 之前的版本支持 nodejs(用户免安装) - 插件里塞一个 ffmpeg 可执行文件,我插件里用 child_process 进行调用(用户免安装)
- 让用户自己安装ffmpeg,配置ffmpeg cli工具路径,我插件里用 child_process 进行调用(用户需要先安装ffmpeg)
我是比较倾向于‘用户免安装’方案的,毕竟让用户开箱即用在我看来是非常重要的交互。但实践过程中我发现以下问题:
ffmpeg.wasm
0.12 版本在node.js端使用时要求 node 版本 16.x,其他版本报错(不推荐)- 一个官方构建的 ffmpeg 可执行文件大小有80M左右,太大了。。。
后面我了解到可以自定义编译ffmpeg源码,只保留需要的最小功能!于是经过漫长的AI辅助与实验,在Mac M1电脑上用以下编译配置编译出了只保留修改 mp4 格式视频的分辨率等功能的最小版本产物,大小只有2.2M!
- m1 mac 上 ffmpeg 编译配置
./configure \
--disable-everything \
--enable-small \
--enable-gpl \
--enable-nonfree \
--enable-libx264 \
--enable-encoder=libx264 \
--enable-decoder=h264 \
--enable-parser=h264 \
--enable-protocol=file \
--enable-filter=scale \
--enable-demuxer=mov,mp4 \
--enable-muxer=mp4 \
--enable-static
- ffmpeg 编译并安装到当前 install 目录下
make clean && make -j$(sysctl -n hw.ncpu) && make install DESTDIR=$(pwd)/install
但自定义编译ffmpeg还有以下问题:
- 还需要针对Linux、window平台各编译一个可执行文件,插件里根据平台分别调用;
- 自定义编译只满足特定的功能,如果用户需要其他功能,自定义的可执行文件就废了😮💨;
经过一番思考与取舍,考虑到安装ffmpeg极其简单,最终采取第三种方案,当用户需要ffmpeg能力时,由用户自己安装ffmpeg,然后配置 ffmpeg 可执行文件的路径。
最后
本插件已发布npm包,并开源到 github unplugin-mediafit,如果觉得本插件对你有帮助或文章对你有启发,点个赞吧,非常感谢!
- 点赞
- 收藏
- 关注作者
评论(0)