Skip to content

Instantly share code, notes, and snippets.

@inkss
Last active March 8, 2025 13:31
Show Gist options
  • Save inkss/6f76ec73bb34052dcd2976063b1884aa to your computer and use it in GitHub Desktop.
Save inkss/6f76ec73bb34052dcd2976063b1884aa to your computer and use it in GitHub Desktop.
Hexo静态图片优化
class LazyLoader {
constructor(selector) {
this.lazyPictureObserver = null;
this.observedElements = new Set();
this.selector = selector;
this.initObserver();
this.observeElements();
}
// 初始化观察器
initObserver() {
this.lazyPictureObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting && (!volantis?.scroll || !volantis?.scroll?.isScrolling)) {
this.loadImage(entry.target);
this.lazyPictureObserver.unobserve(entry.target);
this.observedElements.delete(entry.target);
}
});
});
}
// 开始观察元素
observeElement(element) {
if (!this.observedElements.has(element)) {
this.lazyPictureObserver.observe(element);
this.observedElements.add(element);
}
}
// 观察所有符合选择器的元素
observeElements() {
document.querySelectorAll(this.selector).forEach(element => {
this.observeElement(element);
});
}
removeLazy(lazyImage) {
const pictureElement = lazyImage.closest('picture');
if (pictureElement && pictureElement.classList.contains('lazy')) {
pictureElement.classList.remove("lazy")
}
}
// 加载图片
loadImage(lazyImage) {
if (decodeURIComponent(lazyImage.src) === decodeURIComponent(lazyImage.dataset.src)
&& lazyImage.complete) {
this.removeLazy(lazyImage);
} else {
let sources = lazyImage.parentElement.getElementsByTagName('source');
for (let source of sources) {
source.srcset = source.dataset.srcset;
}
if (!lazyImage.classList.contains('not-animation')) {
lazyImage.classList.add('content-in')
}
lazyImage.src = lazyImage.dataset.src;
lazyImage.onload = () => {
this.removeLazy(lazyImage)
};
}
}
// 卸载所有观察器
unobserveAll() {
if (this.lazyPictureObserver) {
this.lazyPictureObserver.disconnect();
this.observedElements.clear();
}
}
// 重新初始化观察器并观察新元素
reinitObserver() {
this.unobserveAll();
this.initObserver();
this.observeElements();
}
}
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import { exec, execSync } from 'child_process';
// 从命令行参数获取输入文件夹
const inputFolder = path.resolve(process.cwd(), process.argv[2] || './source/img');
// 定义想转换的图片格式
const includeFormats = ['.png', '.jpg', '.jpeg', '.gif'];
// 定义计数器
let imageCount = 0;
// 开始计时
console.time('Image Conversion and Compression Time');
/**
* 检查 ffmpeg 是否安装并初始化变量
*/
const ffmpegInstalled = (() => {
try {
execSync('ffmpeg -version', { stdio: 'ignore' });
return true;
} catch (err) {
return false;
}
})();
/**
* 取相对路径
* @param {*} absolutePath 绝对路径
* @returns 相对路径
*/
const getRelativePath = (absolutePath) => {
let relativePath = path.relative(__dirname, absolutePath);
return relativePath.split(path.sep).join('/'); // 转换为使用正斜杠
};
/**
* 获取绝对路径并转换为系统格式
* @param {*} relativePath 相对路径
* @returns 绝对路径
*/
const getAbsolutePath = (relativePath) => {
let absolutePath = path.resolve(__dirname, relativePath);
return absolutePath.split('/').join(path.sep); // 转换为系统路径分隔符
};
/**
* 删除丢失原图和未更新的转换图
* @param {*} folder 图片文件夹
*/
const removeOrphanedConvertedImages = (folder) => {
// 如果 folder 传递的不是 source 下的内容,无需继续处理
if (!folder.includes('source')) {
return;
}
const files = fs.readdirSync(folder).sort();
const deleteList = new Set();
files.forEach(file => {
const inputFilePath = path.normalize(path.join(folder, file));
const stats = fs.statSync(inputFilePath);
if (stats.isDirectory()) {
removeOrphanedConvertedImages(inputFilePath);
return;
}
if (stats.isFile()) {
const ext = path.extname(inputFilePath).toLowerCase();
if (ext !== '.webp' && ext !== '.avif') { return; }
const baseName = path.basename(inputFilePath, path.extname(inputFilePath));
const dirName = path.dirname(inputFilePath);
const webpPath = path.normalize(path.join(dirName, baseName + '.webp'));
const avifPath = path.normalize(path.join(dirName, baseName + '.avif'));
// // 删除所有转换图
// deleteList.add(webpPath);
// deleteList.add(avifPath);
// return;
// 寻找同名文件,找不到意味着丢失原图
const originalFile = files.find(f => {
const originalExt = path.extname(f).toLowerCase();
return f.startsWith(baseName) && includeFormats.includes(originalExt);
});
if (!originalFile) {
deleteList.add(webpPath);
deleteList.add(avifPath);
console.log(`Marked for deletion: ${webpPath}, ${avifPath}`);
return;
}
const originalFilePath = path.normalize(path.join(dirName, originalFile));
const originalStats = fs.statSync(originalFilePath);
// 确保转换后的图片修改时间要更大
const shouldDeleteDueToModificationTime = (outputFilePath) => {
const outputStats = fs.statSync(path.normalize(outputFilePath));
return new Date(outputStats.mtime).getTime() < new Date(originalStats.mtime).getTime();
};
if (shouldDeleteDueToModificationTime(webpPath) || shouldDeleteDueToModificationTime(avifPath)) {
deleteList.add(webpPath);
deleteList.add(avifPath);
console.log(`Marked for deletion: ${webpPath}, ${avifPath}`);
}
}
});
// 删除待删除列表中的文件
deleteList.forEach(file => {
fs.unlinkSync(path.normalize(file));
console.log(`Deleted orphaned converted image: ${file}`);
});
};
/**
* 压缩图片
* @param {*} relativeInputFilePath 原图文件
* @returns Promise
*/
const compressImage = (relativeInputFilePath) => {
return new Promise(async (resolve, reject) => {
const inputFilePath = getAbsolutePath(relativeInputFilePath);
const ext = path.extname(inputFilePath).toLowerCase();
const supportedFormats = ['.png', '.jpg', '.jpeg', '.tiff', '.bmp', '.webp', '.avif'];
// 跳过非支持格式的压缩
if (!supportedFormats.includes(ext)) {
console.log(`Skipping compression for ${inputFilePath}, unsupported format`);
resolve();
return;
}
try {
let buffer;
if (ext === '.png') {
buffer = await sharp(inputFilePath).png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
} else if (ext === '.jpg' || ext === '.jpeg') {
buffer = await sharp(inputFilePath).jpeg({ quality: 80 }).toBuffer();
} else if (ext === '.webp') {
buffer = await sharp(inputFilePath).webp({ quality: 80, lossless: true }).toBuffer();
} else if (ext === '.avif') {
buffer = await sharp(inputFilePath).avif({ quality: 50 }).toBuffer();
} else if (ext === '.tiff') {
buffer = await sharp(inputFilePath).tiff({ quality: 80 }).toBuffer();
} else {
buffer = await sharp(inputFilePath).toBuffer();
}
const originalSize = fs.statSync(inputFilePath).size;
const compressedSize = buffer.length;
if (compressedSize < originalSize) {
fs.writeFileSync(inputFilePath, buffer);
console.log(`Compressed ${inputFilePath}, original size: ${originalSize} bytes, compressed size: ${compressedSize} bytes`);
} else {
console.log(`Skipping compression for ${inputFilePath}, compressed size: ${compressedSize} bytes is not smaller than original size: ${originalSize} bytes`);
}
resolve();
} catch (err) {
console.error(`Error compressing ${inputFilePath}:`, err);
reject(err);
}
});
};
/**
* 转换图片
* @param {*} relativeInputFilePath 原图文件
* @returns Promise
*/
const convertImage = (relativeInputFilePath) => {
return new Promise((resolve, reject) => {
const inputFilePath = getAbsolutePath(relativeInputFilePath);
const ext = path.extname(inputFilePath).toLowerCase();
const outputWebPPath = inputFilePath.replace(/\.[^/.]+$/, '.webp');
const outputAvifPath = inputFilePath.replace(/\.[^/.]+$/, '.avif');
const fileName = path.basename(inputFilePath);
// 检查输入文件是否已经是 WebP 或 AVIF 格式
if (ext === '.webp' || ext === '.avif') {
console.log(`Skipping conversion for ${inputFilePath}, already in target format`);
resolve();
return;
}
const convertPromises = [];
// 检查输出文件是否已经存在:webp
if (!fs.existsSync(outputWebPPath)) {
if (ext === '.gif' && ffmpegInstalled) {
// 将 GIF 动图转换为 WebP 动图
const webpCommand = `ffmpeg -i ${inputFilePath} -c:v libwebp -lossless 0 -q:v 80 -loop 0 -an -vsync 0 ${outputWebPPath}`;
const webpPromise = new Promise((resolve, reject) => {
exec(webpCommand, (error, stdout, stderr) => {
if (error) {
console.error(`Error converting ${inputFilePath} to WebP:`, error);
reject(error);
return;
}
console.log(`Converted ${inputFilePath} to ${outputWebPPath}`);
imageCount++;
resolve();
});
});
convertPromises.push(webpPromise);
} else {
// 将其他格式图片转换为 WebP 格式
const webpPromise = sharp(inputFilePath)
.webp({ quality: 80 })
.toFile(outputWebPPath)
.then(() => {
console.log(`Converted ${inputFilePath} to ${outputWebPPath}`);
imageCount++;
})
.catch(err => {
console.error(`Error converting ${inputFilePath} to WebP:`, err);
});
convertPromises.push(webpPromise);
}
}
// 检查输出文件是否已经存在:avif
if (!fs.existsSync(outputAvifPath)) {
if (ext === '.gif' && ffmpegInstalled) {
// 将 GIF 动图转换为 AVIF 动图
const avifCommand = `ffmpeg -i ${inputFilePath} -c:v libsvtav1 -qp 40 ${outputAvifPath}`;
const avifPromise = new Promise((resolve, reject) => {
exec(avifCommand, (error, stdout, stderr) => {
if (error) {
console.error(`Error converting ${inputFilePath} to AVIF:`, error);
reject(error);
return;
}
console.log(`Converted ${inputFilePath} to ${outputAvifPath}`);
imageCount++;
resolve();
});
});
convertPromises.push(avifPromise);
} else {
// 将其他格式图片转换为 AVIF 格式
const avifPromise = sharp(inputFilePath)
.avif({ quality: 50 })
.toFile(outputAvifPath)
.then(() => {
console.log(`Converted ${inputFilePath} to ${outputAvifPath}`);
imageCount++;
})
.catch(err => {
console.error(`Error converting ${inputFilePath} to AVIF:`, err);
});
convertPromises.push(avifPromise);
}
}
Promise.all(convertPromises)
.then(() => resolve())
.catch(err => reject(err));
});
};
/**
* 递归处理图片
* @param {*} folder 图片文件夹
*/
const processFolder = async (folder) => {
const files = fs.readdirSync(folder);
const filePromises = [];
// 处理文件夹中的文件和目录
for (const file of files) {
const inputFilePath = path.join(folder, file);
const stats = fs.statSync(inputFilePath);
if (stats.isDirectory()) {
filePromises.push(processFolder(inputFilePath));
} else if (stats.isFile() && includeFormats.includes(path.extname(inputFilePath).toLowerCase())) {
// 检查同名的 WebP 和 AVIF 文件是否存在
const baseName = path.basename(inputFilePath, path.extname(inputFilePath));
const dirName = path.dirname(inputFilePath);
const webpPath = path.join(dirName, `${baseName}.webp`);
const avifPath = path.join(dirName, `${baseName}.avif`);
if (!fs.existsSync(webpPath) || !fs.existsSync(avifPath)) {
// 压缩并转换图片
const compressionPromise = compressImage(inputFilePath)
.catch(err => console.error(`Error compressing ${inputFilePath}:`, err))
.finally(() => convertImage(inputFilePath)
.catch(err => console.error(`Error converting ${inputFilePath}:`, err)));
filePromises.push(compressionPromise);
} else {
console.log(`Skipping ${inputFilePath}, already converted to WebP and AVIF`);
}
}
}
// 等待所有操作完成
await Promise.all(filePromises);
};
// 删除丢失原图的转换图片
removeOrphanedConvertedImages(inputFolder);
// 处理文件夹
processFolder(inputFolder);
// 在所有转换和压缩完成后输出结果日志
process.on('exit', () => {
console.timeEnd('Image Conversion and Compression Time');
console.log(`Total images converted and compressed: ${imageCount}`);
});
import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
// 从命令行参数获取输入文件夹
const inputFolder = path.resolve(process.cwd(), process.argv[2] || './public');
// 判断用条件
const targetDomain = 'https://static.inkss.cn/img/';
// 占位图
const placeholder = "https://static.inkss.cn/img/default/transparent-placeholder-1x1.svg";
/**
* 检查文件存在和比较大小
* @param {*} originalFilePath 原图路径
* @param {*} targetExt 目标格式
* @returns true: 目标文件小于原图
* false: 目标文件不存在 or 大于原图
*/
const checkFileExistsAndSize = (originalFilePath, targetExt) => {
const dirName = path.dirname(originalFilePath);
const baseName = path.basename(originalFilePath, path.extname(originalFilePath));
const targetFilePath = path.join(dirName, `${baseName}${targetExt}`);
if (!fs.existsSync(targetFilePath)) {
return false; // 目标文件不存在
}
const originalStats = fs.statSync(originalFilePath);
const targetStats = fs.statSync(targetFilePath);
return targetStats.size < originalStats.size; // 比较文件大小
};
/**
* picture 标签处理替换
* @param {*} filePath html 文件
*/
const processHTMLFile = (filePath) => {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const dom = new JSDOM(fileContent);
const document = dom.window.document;
const imgTags = [...document.querySelectorAll('img')];
imgTags.forEach(img => {
const src = decodeURIComponent(img.getAttribute('src'));
if (src && src.startsWith(targetDomain)) {
const baseUrl = src.split('?')[0]; // 获取不带查询参数的 URL 部分
const relativePath = decodeURIComponent(baseUrl.replace(targetDomain, './public/img/'));
// 使用 checkFileExistsAndSize 函数判断文件是否存在且大小符合条件
const shouldAddWebP = checkFileExistsAndSize(relativePath, '.webp');
const shouldAddAvif = checkFileExistsAndSize(relativePath, '.avif');
const picture = document.createElement('picture');
if (shouldAddWebP || shouldAddAvif) {
if (shouldAddAvif) {
const avifSrc = baseUrl.replace(/\.[^/.]+$/, '.avif');
const sourceAvif = document.createElement('source');
sourceAvif.setAttribute('data-srcset', `${avifSrc}${src.includes('?') ? src.substring(src.indexOf('?')) : ''}`);
sourceAvif.setAttribute('type', 'image/avif');
sourceAvif.setAttribute('loading', 'lazy');
picture.appendChild(sourceAvif);
}
if (shouldAddWebP) {
const webpSrc = baseUrl.replace(/\.[^/.]+$/, '.webp');
const sourceWebp = document.createElement('source');
sourceWebp.setAttribute('data-srcset', `${webpSrc}${src.includes('?') ? src.substring(src.indexOf('?')) : ''}`);
sourceWebp.setAttribute('type', 'image/webp');
sourceWebp.setAttribute('loading', 'lazy');
picture.appendChild(sourceWebp);
}
}
img.removeAttribute("no-lazy");
img.setAttribute('loading', 'lazy');
img.setAttribute('data-src', src);
img.setAttribute('src', placeholder);
picture.classList.add("lazy")
// 将原 <img> 标签插入 <picture> 内
picture.appendChild(img.cloneNode(true));
// 创建 noscript 元素
const noScript = document.createElement('noscript');
const noScriptImg = document.createElement('img');
noScriptImg.setAttribute('src', src);
noScriptImg.setAttribute('alt', img.getAttribute('alt'));
noScript.appendChild(noScriptImg);
picture.appendChild(noScript);
// 用 <picture> 标签替换原 <img> 标签
img.replaceWith(picture);
}
});
fs.writeFileSync(filePath, dom.serialize(), 'utf-8');
console.log(`Processed: ${filePath}`);
};
/**
* 递归处理网页
* @param {*} folder 网页文件夹
*/
const processFolder = (folder) => {
fs.readdirSync(folder).forEach(file => {
const filePath = path.join(folder, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
processFolder(filePath);
} else if (stats.isFile() && path.extname(filePath).toLowerCase() === '.html') {
processHTMLFile(filePath);
}
});
};
// 处理文件夹
processFolder(inputFolder);
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { JSDOM } from 'jsdom';
// 编译出的文件目录
const outputFolder = path.resolve(process.cwd(), process.argv[3] || './public/');
// 获取时间戳
const timestamp = Date.now();
// 获取 git 状态(当前更改、暂存的更改)
const getGitStatus = () => {
const output = execSync('git status -z', { encoding: 'utf-8' });
return output.split('\0').map(file => file.trim()).filter(file => file);
};
// 获取最近七天内的提交记录
const getRecentCommits = () => {
const output = execSync('git log --since="7 days ago" --name-only --pretty=format: -z', { encoding: 'utf-8' });
return output.split('\0').map(file => file.trim()).filter(file => file);
};
// 获取变动的文件列表
const changedFiles = [...new Set([...getGitStatus(), ...getRecentCommits()])]
.filter(file => file.includes('/img/article/'))
.map(file => file.replace(/^source/, ''))
.map(file => decodeURIComponent(file.replace(/\\/g, '/')));
// 处理 HTML 文件中的字符串替换
const processHTMLFile = (filePath) => {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const dom = new JSDOM(fileContent);
const document = dom.window.document;
const imgTags = [...document.querySelectorAll('img')];
imgTags.forEach(img => {
const src = img.getAttribute('src');
if (src) {
const baseUrl = src.split('?')[0];
const relativePath = decodeURIComponent(baseUrl.replace('https://static.inkss.cn/', '/'));
if (changedFiles.includes(relativePath)) {
const newUrl = src.includes('?')
? `${src}&V=${timestamp}`
: `${baseUrl}?V=${timestamp}`;
img.setAttribute('src', newUrl);
}
}
});
fs.writeFileSync(filePath, dom.serialize(), 'utf-8');
console.log(`Processed: ${filePath}`);
};
/**
* 递归处理网页
* @param {*} folder 网页文件夹
*/
const processFolder = (folder) => {
fs.readdirSync(folder).forEach(file => {
const filePath = path.join(folder, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
processFolder(filePath);
} else if (stats.isFile() && path.extname(filePath).toLowerCase() === '.html') {
processHTMLFile(filePath);
}
});
};
// 处理文件夹
processFolder(outputFolder);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment