diff --git a/example/vite.config.ts b/example/vite.config.ts index 6dbcecc..b88da93 100644 --- a/example/vite.config.ts +++ b/example/vite.config.ts @@ -2,5 +2,5 @@ import { defineConfig } from 'vite' import imagePlaceholder from '../src/index' export default defineConfig(() => ({ - plugins: [imagePlaceholder({ inline: true })], + plugins: [imagePlaceholder()], })) diff --git a/src/bufferToFile.ts b/src/bufferToFile.ts new file mode 100644 index 0000000..6411c67 --- /dev/null +++ b/src/bufferToFile.ts @@ -0,0 +1,35 @@ +import { createHash } from 'node:crypto' +import fs, { promises as fsp } from 'node:fs' +import path from 'node:path' +import type { ImageType, OutputFile, OutputFilename } from './types' + +const RE_HTTP = /^https?:\/\// + +const isHTTP = (url: string): boolean => RE_HTTP.test(url) + +export async function bufferToFile( + buffer: Buffer, + type: ImageType, + outDir: string, + assetsDir: string, + filename?: OutputFilename, +) { + const hash = createHash('sha256') + hash.update(buffer) + const hex = hash.digest('hex').slice(0, 12) + + const _filename = path.join(assetsDir, `${hex}.${type}`) + const file: OutputFile = { basename: hex, assetsDir, ext: type } + + const pathname = filename ? filename(_filename, file) : _filename + + const _http = isHTTP(pathname) + const output = path.join(outDir, _http ? _filename : pathname) + + const dirname = path.dirname(output) + + fs.mkdirSync(dirname, { recursive: true }) + await fsp.writeFile(output, new Uint8Array(buffer)) + + return _http ? pathname : path.join('/', _filename) +} diff --git a/src/plugin.ts b/src/plugin.ts index be582e2..65d9154 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,8 @@ +import path from 'node:path' import MagicString from 'magic-string' import type { Plugin, ResolvedConfig } from 'vite' import { isCSSRequest } from 'vite' +import { bufferToFile } from './bufferToFile' import { contentCache } from './cache' import { DEFAULT_PREFIX } from './constants' import { generatePathRules } from './pathRules' @@ -30,6 +32,25 @@ const parseOptions = ( return options as Required } +const parseOutput = ( + output: Required['output'], + config: ResolvedConfig, +) => { + const { outDir, assetsDir } = config.build + let assets + let filename + const out = path.join(config.root, outDir) + if (output === true) { + assets = assetsDir + } else if (typeof output === 'string') { + assets = output.replace(/^\/+/, '') + } else { + assets = (output!.dir || assetsDir).replace(/^\/+/, '') + filename = output.filename + } + return { assetsDir: assets, outDir: out, filename } +} + const bufferToBase64 = (image: ImageCacheItem) => { const base64 = image.buffer.toString('base64') const content = `data:${getMimeType(image.type)};base64,${base64}` @@ -70,8 +91,14 @@ function placeholderImporterPlugin( const RE_VIRTUAL = /^\0virtual:\s*/ const moduleId = `virtual:${opts.prefix.slice(1)}` const resolveVirtualModuleId = `\0${moduleId}` + let config: ResolvedConfig + let isBuild: boolean return { name: 'vite-plugin-image-placeholder-importer', + configResolved(_config) { + config = _config + isBuild = config.command === 'build' + }, resolveId(id) { if (id.startsWith(moduleId)) { return `\0${id}` @@ -85,7 +112,22 @@ function placeholderImporterPlugin( } const image = await pathToImage(url, pathRules, opts) if (image) { - const content = bufferToBase64(image) + let content: string + if (isBuild && opts.output) { + const { outDir, assetsDir, filename } = parseOutput( + opts.output, + config, + ) + content = await bufferToFile( + image.buffer, + image.type, + outDir, + assetsDir, + filename, + ) + } else { + content = bufferToBase64(image) + } contentCache.set(url, content) return `export default '${content}'` } @@ -94,7 +136,7 @@ function placeholderImporterPlugin( } } -function placeholderInlinePlugin( +function placeholderTransformPlugin( options: ImagePlaceholderOptions = {}, ): Plugin { const opts = parseOptions(options) @@ -109,17 +151,19 @@ function placeholderInlinePlugin( let isBuild = false let config: ResolvedConfig return { - name: 'vite-plugin-image-placeholder-inline', - config(_, { command }) { - isBuild = command === 'build' - }, + name: 'vite-plugin-image-placeholder-transform', configResolved(_config) { config = _config + isBuild = config.command === 'build' }, async transform(code, id) { - if (isBuild && !opts.inline) { + // 构建时如果未配置 inline 和 output, 则不转换,直接跳过 + if (isBuild && !opts.inline && !opts.output) { return } + // 开发环境不对css转换,因为CSS可以直接通过 GET请求获取资源, + // 跳过html资源,在transformIndexHtml中转换,开发环境时也是通过 GET请求获取, + // 优化性能,跳过非js资源和 assets 资源 if ( (!isBuild && isCSSRequest(id)) || isHTMLRequest(id) || @@ -129,68 +173,80 @@ function placeholderInlinePlugin( ) { return } - const s = new MagicString(code) - let hasReplaced = false - let match - // eslint-disable-next-line no-cond-assign - while ((match = RE_PATTERN.exec(code))) { - const url = match[4] || match[3] || match[2] || match[1] - const dynamic = match[0].includes('(') ? ['("', '")'] : ['"', '"'] - const start = match.index - const end = start + match[0].length - if (contentCache.has(url)) { - hasReplaced = true - s.update( - start, - end, - `${dynamic[0]}${contentCache.get(url)}${dynamic[1]}`, - ) - } else { - const image = await pathToImage(url, pathRules, opts) - if (image) { - hasReplaced = true - const content = bufferToBase64(image) - contentCache.set(url, content) - s.update(start, end, `${dynamic[0]}${content}${dynamic[1]}`) - } - } - } - if (!hasReplaced) { - return null - } - return { - code: s.toString(), - } + const result = await transformPlaceholder( + code, + RE_PATTERN, + pathRules, + opts, + config, + ) + + return result ? { code: result } : null }, async transformIndexHtml(html) { if (!isBuild) return html - if (!opts.inline) return html - const s = new MagicString(html) - let match - // eslint-disable-next-line no-cond-assign - while ((match = RE_PATTERN.exec(html))) { - const url = match[4] || match[3] || match[2] || match[1] - const dynamic = match[0].includes('(') ? ['("', '")'] : ['"', '"'] - const start = match.index - const end = start + match[0].length - if (contentCache.has(url)) { - s.update( - start, - end, - `${dynamic[0]}${contentCache.get(url)}${dynamic[1]}`, + if (!opts.inline && !opts.output) return html + const result = await transformPlaceholder( + html, + RE_PATTERN, + pathRules, + opts, + config, + ) + + return result || html + }, + } +} + +async function transformPlaceholder( + code: string, + pattern: RegExp, + rules: string[], + opts: Required, + config: ResolvedConfig, +) { + const s = new MagicString(code) + let hasReplaced = false + let match + // eslint-disable-next-line no-cond-assign + while ((match = pattern.exec(code))) { + const url = match[4] || match[3] || match[2] || match[1] + const dynamic = match[0].startsWith('(') ? ['("', '")'] : ['"', '"'] + const start = match.index + const end = start + match[0].length + if (contentCache.has(url)) { + hasReplaced = true + s.update(start, end, `${dynamic[0]}${contentCache.get(url)}${dynamic[1]}`) + } else { + const image = await pathToImage(url, rules, opts) + if (image) { + hasReplaced = true + let content: string + if (opts.output) { + const { outDir, assetsDir, filename } = parseOutput( + opts.output, + config, + ) + content = await bufferToFile( + image.buffer, + image.type, + outDir, + assetsDir, + filename, ) } else { - const image = await pathToImage(url, pathRules, opts) - if (image) { - const content = bufferToBase64(image) - contentCache.set(url, content) - s.update(start, end, `${dynamic[0]}${content}${dynamic[1]}`) - } + content = bufferToBase64(image) } + contentCache.set(url, content) + s.update(start, end, `${dynamic[0]}${content}${dynamic[1]}`) } - return s.toString() - }, + } + } + if (!hasReplaced) { + return null } + return s.toString() } export function imagePlaceholderPlugin( @@ -199,6 +255,6 @@ export function imagePlaceholderPlugin( return [ placeholderServerPlugin(options), placeholderImporterPlugin(options), - placeholderInlinePlugin(options), + placeholderTransformPlugin(options), ] } diff --git a/src/types.ts b/src/types.ts index 419721f..ca6ea0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,32 @@ export interface ImagePlaceholderOptions { * @default false */ inline?: boolean + + /** + * 生产构建时,输出图片资源到构建目录中 + * + * 如果取值为 true,默认根据 vite output 配置,输出到 dist/assets + * + * @default true + */ + output?: + | true + | string + | { + dir?: string + /** + * 重写 filename,有时候图片资源需要发布到CDN,可以在这里修改文件名称 + */ + filename?: OutputFilename + } +} + +export type OutputFilename = (filename: string, file: OutputFile) => string + +export interface OutputFile { + basename: string + assetsDir: string + ext: string } export type ImageType =