前言
前不久刚刚将公司项目中的静态图片资源放到阿里云oss服务器上,同时删除了项目中的图片资源,成功为项目瘦身。
这不,今天就来了一个私有化部署的需求,需要将现有的项目单独部署到客户那边的服务器上,而且客户还只使用内网,这也就导致使用阿里云访问的图片资源全部访问不通,还得拿到本地来。
得,谁让咱们天生就是找事的好手呢,那整吧。
方案对比
既然来活了,那咱们首先得先确定下这个事怎么做?有以下几个方案:
方案一: 发挥中华民族的优良传统,勤劳,即手动将全部的静态资源引用处替换为本地引用,想想手就疼
方案二 : 将偷懒运用到极致,将静态资源全部放到public/assets
目录下(Vite项目中public目录下的文件会被直接复制到打包目录下),同时修改资源引用的统一前缀为 /assets
,即可引用到该静态资源。目测几分钟就能完成
方案三: 写个脚本,自动完成 1 操作,瞬间手就不疼了,但是脑壳开始疼了
对比下这三个方案的优缺点,选出最优解
方案一:
优点:简单
缺点:手疼且低效
方案二:
优点:省时、省力
缺点:需要考虑打包后的引用路径,同时因为文件都是直接复制到包中的,并没有经过hash处理,浏览器会缓存该文件,后续如果文件修改,不能第一时间反应再客户端。
方案三
优点:高效、一劳永逸、文件会经过Vite处理,生成带有hash值的新文件,没有缓存问题
缺点:这个脚本有点难写,涉及代码转换和项目文件扫描等知识,脑壳疼
最终,本着一劳永逸的想法,我选择了方案三。
过程
整体思路:
- 将全部静态资源引用汇总到统一文件中,方便管理及代码分析
- 使用代码转换工具将上面的文件内容转换为使用
import
导入的方式
静态资源汇总
所有的静态资源引用散布在项目的各个文件中,这不利于代码分析,也不利于代码转化,所以,第一步就是将散布在项目各个文件中的静态资源引用汇总到一个文件中,方便管理、代码分析、代码转化。
这一步是纯体力活,一次劳动,收益无穷。
最终静态资源汇总文件应该是这样的:
typescript
import { ASSETS_PREFIX } from './constants';
const contactUs = `${ASSETS_PREFIX}/login/contact_us.png`;
const userAvatar = `${ASSETS_PREFIX}/login/default_avatar.png`;
const loginBg = `${ASSETS_PREFIX}/login/login_bg.jpg`;
export {
contactUs,
userAvatar,
loginBg,
}
- 一个静态资源对应一个变量,一个变量对应一个静态资源路径
- 静态资源路径必须使用模版字符串统一前缀,便于后续做替换
- 统一导出
代码转换
静态资源全部会送完毕后,接下来就是做代码分析及转换。
我们的目标其实就是将上面的代码转换到下面这种:
typescript
import contactUs from '@/assets/login/contact_us.png';
import userAvatar from '@/assets/login/default_avatar.png';
import loginBg from '@/assets/login/login_bg.jpg'
export {
contactUs,
userAvatar,
loginBg,
}
既然涉及代码转换,很自然的就能想到使用babel做转换。
先来简单说下babel做代码转换的过程:
- 使用
@babel/parser
将代码解析为抽象语法树(AST: 表示当前代码结构的js对象) - 找到标识为
const
的变量,拿出该变量,并将其后对应的变量内容拿出来,将模版字符串中的变量替换为@/assets
,得到新静态资源本地路径(@/assets/login/contact_us.png
) - 组合
import
的 AST 对象,并使用该对象替换原来的const
相关的AST - 使用
@babel/generator
将新的AST转换为代码输出到对应文件中
代码如下:
typescript
import { parse } from '@babel/parser';
import generate from '@babel/generator';
import fs from 'fs';
// 静态资源汇总文件
let imgInfoFilePath = 'src/scripts/assets.ts';
// 要替换为的静态资源路径前缀
let replaceToCode = '@/assets';
function babelTransformCode() {
logInfo(`开始转换 ${imgInfoFilePath} 文件`);
try {
const code = fs.readFileSync(imgInfoFilePath, 'utf-8');
// 解析AST
const ast = parse(code, { sourceType: 'module', plugins: ['typescript'] });
// 遍历const声明节点
ast.program.body.forEach(node => {
if (node.type === 'VariableDeclaration') {
// 构建导入声明
const importDecl = {
type: 'ImportDeclaration',
specifiers: [],
source: {
type: 'StringLiteral',
},
};
node.declarations.forEach(decl => {
// 存储变量名
const localName = decl.id.name;
// 组装import路径
const filePath = `${replaceToCode}${decl?.init?.quasis?.[1]?.value?.raw}`;
// 组装import结构
importDecl.specifiers.push({
type: 'ImportDefaultSpecifier',
local: {
type: 'Identifier',
name: localName,
},
});
// 修改初始化为相对路径
importDecl.source.value = filePath;
});
// 用importDecl替换原变量声明节点
Object.assign(node, importDecl);
}
});
// 最终代码
const result = generate.default(ast, {}, code);
// 备份原文件
fs.renameSync(imgInfoFilePath, `${imgInfoFilePath}.bak`);
// 代码输出
fs.writeFileSync(imgInfoFilePath, result.code);
} catch (error: any) {
logError(error);
}
}
这样,代码就转换完成了。
这样转换完后,ts文件中相关的静态资源引用就替换完成了,但是css文件中的静态资源引用还没有被转换。
因为css文件中的静态资源路径都是完整路径,不存在其中掺杂变量的情况,所以我们只需要找到所有的css文件,并将其中的路径前缀统一替换为@/assets
即可。
typescript
import { globSync } from 'glob';
import fs from 'fs';
let replaceStr = 'https://xxxxx.xxxx.xxxxx';
let replaceToCode = '@/assets';
function replaceHttpsCode() {
try {
// 扫描文件
const files = globSync('./src/**/*.{scss,css}', { ignore: 'node_modules/**' });
files.forEach((file: string) => {
// 读取文件内容
let content = fs.readFileSync(file, 'utf8');
// 替换匹配到的字符串
content = content.replace(replaceStr, replaceToCode);
// 写入文件
fs.writeFileSync(file, content);
});
logSuccess('转换完成');
} catch (error: any) {
logError(error);
}
}
- 使用
glob
扫描当前目录下的scss、css文件。 - 读取文件内容,并使用replace方法替换掉静态资源路径
- 写入文件,完成转换
至此,代码全部转换完成。
封装成工具包
因为多个项目都会涉及静态资源转换的问题,所以我将此脚本封装为npm包,并提炼了 transform build
命令,只需执行该命令,即可完成资源转换,以下是源码分享:
cli.ts
typescript
import { Command } from 'commander';
import { version } from '../package.json';
import buildAction from './transform';
const program = new Command();
program
.command('build')
.description('transform assets and code')
.option(
'--replaceStr <path>',
'[string] 需要全局替换的字符串,默认值: https://zkly-fe-resource.oss-cn-beijing.aliyuncs.com/safeis-web-manage',
)
.option('--imgInfoFilePath <path>', '[string] 统一的静态资源文件路径 默认值: src/scripts/assets.ts')
.option('--replaceToCode <path>', '[string] 替换为的代码 默认值: @/assets')
.option('--assetsDir <path>', '[string] 静态资源文件目录 默认值: src/assets')
.action(options => {
buildAction(options);
});
program.version(version);
program.parse();
transfrom.ts
typescript
import { parse } from '@babel/parser';
import generate from '@babel/generator';
import chalk from 'chalk';
import { globSync } from 'glob';
import fs from 'fs';
interface Options {
replaceStr?: string;
imgInfoFilePath?: string;
replaceToCode?: string;
assetsDir?: string;
}
let replaceStr = 'https://zkly-fe-resource.oss-cn-beijing.aliyuncs.com/safeis-web-manage';
let imgInfoFilePath = 'src/scripts/assets.ts';
let replaceToCode = '@/assets';
let assetsDir = './src/assets';
function checkAssetsDir() {
logInfo('检查 src/assets 目录是否存在');
if (!fs.existsSync(assetsDir)) {
logError('assets 目录不存在,请先联系相关人员下载对应项目的静态资源文件,并放置在 src/assets 目录下');
} else {
logSuccess('assets 目录存在');
}
}
function babelTransformCode() {
logInfo(`开始转换 ${imgInfoFilePath} 文件`);
try {
const code = fs.readFileSync(imgInfoFilePath, 'utf-8');
// 解析AST
const ast = parse(code, { sourceType: 'module', plugins: ['typescript'] });
// 遍历VariableDeclarator节点
ast.program.body.forEach(node => {
if (node.type === 'VariableDeclaration') {
// 构建导入声明
const importDecl = {
type: 'ImportDeclaration',
specifiers: [],
source: {
type: 'StringLiteral',
},
};
// @ts-ignore
node.declarations.forEach(decl => {
// @ts-ignore
const localName = decl.id.name;
// @ts-ignore
const filePath = `${replaceToCode}${decl?.init?.quasis?.[1]?.value?.raw}`;
// @ts-ignore
logInfo(`替换 ${replaceStr}${decl?.init?.quasis?.[1]?.value?.raw} 为 ${filePath}`);
// 构建导入规范
// @ts-ignore
importDecl.specifiers.push({
type: 'ImportDefaultSpecifier',
local: {
type: 'Identifier',
name: localName,
},
});
// 修改初始化为相对路径
// @ts-ignore
importDecl.source.value = filePath;
});
// 用importDecl替换原变量声明节点
Object.assign(node, importDecl);
}
});
// 最终代码
// @ts-ignore
const result = generate.default(ast, {}, code);
logInfo(`备份 ${imgInfoFilePath} 文件为 ${imgInfoFilePath}.bak`);
fs.renameSync(imgInfoFilePath, `${imgInfoFilePath}.bak`);
fs.writeFileSync(imgInfoFilePath, result.code);
logSuccess(`转换 ${imgInfoFilePath} 成功`);
} catch (error: any) {
logError(error);
}
}
function replaceHttpsCode() {
logInfo('开始转换 其余文件中引用https导入的静态资源');
try {
// 扫描文件
const files = globSync('./src/**/*.{vue,js,ts,scss,css}', { ignore: 'node_modules/**' });
files.forEach((file: string) => {
// 读取文件内容
let content = fs.readFileSync(file, 'utf8');
if (content.includes(replaceStr)) {
logInfo(`替换 ${file} 中的 ${replaceStr} 为 ${replaceToCode}`);
}
// 替换匹配到的字符串
content = content.replace(replaceStr, replaceToCode);
// 保存文件
fs.writeFileSync(file, content);
});
logSuccess('转换完成');
} catch (error: any) {
logError(error);
}
}
function logInfo(info: string) {
console.log(chalk.gray(`[INFO] - 🆕 ${info}`));
}
function logSuccess(info: string) {
console.log(chalk.green(`[SUCCESS] - ✅ ${info}`));
}
function logError(info: string) {
console.log(chalk.red(`[ERROR] - ❌ ${info}`));
}
export default function main(options: Options) {
replaceStr = options.replaceStr || replaceStr;
imgInfoFilePath = options.imgInfoFilePath || imgInfoFilePath;
replaceToCode = options.replaceToCode || replaceToCode;
assetsDir = options.assetsDir || assetsDir;
checkAssetsDir();
babelTransformCode();
replaceHttpsCode();
}