Last active
March 8, 2025 13:31
-
-
Save inkss/6f76ec73bb34052dcd2976063b1884aa to your computer and use it in GitHub Desktop.
Hexo静态图片优化
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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