手搓HTML資源合併工具:自動合併CSS/JS減少請求數
引言:網站性能優化的關鍵戰役
在現代Web開發中,網站性能直接影響用戶體驗、轉化率乃至搜索引擎排名。其中一個關鍵性能指標是頁面加載時間,而影響加載時間的重要因素就是瀏覽器需要發起的HTTP請求數量。每個CSS、JavaScript文件都需要單獨的HTTP請求,這些請求的建立、傳輸和解析都會消耗寶貴的時間。
傳統的優化方法包括手動合併CSS和JavaScript文件,但這種方法在大型項目中很快就變得難以維護。每當增加新功能或修改現有代碼時,開發者都需要重新考慮文件合併策略,這不僅耗時且容易出錯。
本文將深入探討如何從零開始構建一個自動化的HTML資源合併工具,該工具能夠智能地分析HTML文檔,自動合併CSS和JavaScript文件,並生成優化後的HTML代碼。我們將從理論基礎到實踐實現,一步步構建一個功能完整的資源合併工具。
第一章:理解資源合併的技術原理
1.1 HTTP請求開銷分析
要理解為什麼資源合併如此重要,我們首先需要分析HTTP請求的開銷:
DNS查詢時間:瀏覽器需要解析域名到IP地址
TCP握手時間:建立TCP連接需要三次握手
TLS協商時間(對於HTTPS):加密連接的建立
請求發送時間:請求頭部的傳輸
等待時間:服務器處理請求的時間
響應接收時間:響應數據的傳輸時間
對於小文件(如CSS和JS),這些開銷往往超過了文件本身的傳輸時間。合併多個小文件可以顯著減少這些重複的開銷。
1.2 瀏覽器並行加載限制
大多數瀏覽器對同一域名的並行請求數有限制(通常為6-8個)。當頁面包含大量資源時,超過限制的請求必須排隊等待,進一步延遲了頁面渲染。通過合併資源,我們可以確保關鍵資源更快下載。
1.3 緩存效率考量
合適的資源合併策略還能提高緩存效率。將頻繁變動的代码與穩定的库代码分開合併,可以確保用戶在下一次訪問時只需下載變動的部分。
第二章:工具設計與架構
2.1 整體架構設計
我們的資源合併工具將採用模塊化設計,包含以下核心組件:
HTML解析器:分析HTML文檔,識別CSS和JavaScript資源
資源收集器:獲取並存儲外部資源內容
合併策略引擎:決定如何合併資源的邏輯
資源處理器:處理CSS和JavaScript的具體合併操作
輸出生成器:生成優化後的HTML和合併後的資源文件
2.2 工具工作流程
text
輸入HTML
↓
HTML解析與資源識別
↓
資源下載與緩存
↓
應用合併策略
↓
CSS合併處理
↓
JavaScript合併處理
↓
生成合併後資源文件
↓
更新HTML引用
↓
輸出優化後HTML
2.3 合併策略考慮
我們需要考慮多種合併策略:
按類型合併:所有CSS合併為一個文件,所有JavaScript合併為一個文件
按媒體查詢合併:將相同媒體查詢的CSS合併
按加載時機合併:將同步和異步加載的JavaScript分開合併
按頁面區域合併:按頁面功能區域合併資源
第三章:環境搭建與基礎框架
3.1 項目初始化
首先創建項目目錄結構:
bash
mkdir html-resource-merger
cd html-resource-merger
npm init -y
3.2 安裝依賴包
我們需要以下核心依賴:
bash
npm install cheerio jsdom css-tree terser postcss axios
npm install -D @types/node typescript ts-node
3.3 TypeScript配置
創建 tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
第四章:HTML解析與資源識別
4.1 HTML解析器實現
我們將使用Cheerio庫來解析HTML,它提供了類似jQuery的API,便於操作DOM:
typescript
// src/parsers/html-parser.ts
import * as cheerio from 'cheerio';
import * as fs from 'fs';
import * as path from 'path';
import { Resource, ResourceType } from '../types/resource';
export class HTMLParser {
private $: cheerio.CheerioAPI;
private htmlContent: string;
private basePath: string;
constructor(htmlContent: string, basePath: string = '') {
this.htmlContent = htmlContent;
this.basePath = basePath;
this.$ = cheerio.load(htmlContent);
}
// 識別所有CSS資源
public extractCSSResources(): Resource[] {
const resources: Resource[] = [];
// 查找link標籤
this.$('link[rel="stylesheet"]').each((i, elem) => {
const href = this.$(elem).attr('href');
const media = this.$(elem).attr('media') || 'all';
if (href && !href.startsWith('http') && !href.startsWith('//')) {
const absolutePath = path.resolve(this.basePath, href);
resources.push({
type: ResourceType.CSS,
url: href,
absolutePath,
media,
element: elem,
inline: false
});
}
});
// 查找style標籤中的外部資源(如@import)
this.$('style').each((i, elem) => {
const content = this.$(elem).html() || '';
const importMatches = content.matchAll(/@import\\s+(url\\()?["']([^"']+)["'](\\))?/g);
for (const match of importMatches) {
if (match[2]) {
const absolutePath = path.resolve(this.basePath, match[2]);
resources.push({
type: ResourceType.CSS,
url: match[2],
absolutePath,
media: 'all',
element: elem,
inline: false,
import: true
});
}
}
});
return resources;
}
// 識別所有JavaScript資源
public extractJSResources(): Resource[] {
const resources: Resource[] = [];
this.$('script[src]').each((i, elem) => {
const src = this.$(elem).attr('src');
const async = this.$(elem).attr('async') !== undefined;
const defer = this.$(elem).attr('defer') !== undefined;
const module = this.$(elem).attr('type') === 'module';
if (src && !src.startsWith('http') && !src.startsWith('//')) {
const absolutePath = path.resolve(this.basePath, src);
resources.push({
type: ResourceType.JS,
url: src,
absolutePath,
async,
defer,
module,
element: elem,
inline: false
});
}
});
return resources;
}
// 獲取所有內聯資源
public extractInlineResources(): Resource[] {
const resources: Resource[] = [];
// 內聯CSS
this.$('style:not([src])').each((i, elem) => {
const content = this.$(elem).html() || '';
if (content.trim().length > 0) {
resources.push({
type: ResourceType.CSS,
content,
element: elem,
inline: true
});
}
});
// 內聯JavaScript
this.$('script:not([src])').each((i, elem) => {
const content = this.$(elem).html() || '';
if (content.trim().length > 0) {
resources.push({
type: ResourceType.JS,
content,
element: elem,
inline: true
});
}
});
return resources;
}
// 移除資源標籤
public removeResource(resource: Resource): void {
if (resource.element) {
this.$(resource.element).remove();
}
}
// 添加合併後的資源引用
public addMergedResource(
type: ResourceType,
url: string,
attributes: Record<string, string> = {}
): void {
if (type === ResourceType.CSS) {
const linkTag = `<link rel="stylesheet" href="${url}" ${Object.entries(attributes).map(([k, v]) => `${k}="${v}"`).join(' ')}>`;
this.$('head').append(linkTag);
} else if (type === ResourceType.JS) {
const scriptTag = `<script src="${url}" ${Object.entries(attributes).map(([k, v]) => `${k}="${v}"`).join(' ')}></script>`;
this.$('body').append(scriptTag);
}
}
// 獲取處理後的HTML
public getHTML(): string {
return this.$.html();
}
// 靜態方法:從文件加載HTML
public static fromFile(filePath: string): HTMLParser {
const content = fs.readFileSync(filePath, 'utf-8');
const basePath = path.dirname(filePath);
return new HTMLParser(content, basePath);
}
}
4.2 資源類型定義
創建類型定義文件:
typescript
// src/types/resource.ts
export enum ResourceType {
CSS = 'css',
JS = 'js'
}
export interface Resource {
type: ResourceType;
url?: string;
absolutePath?: string;
content?: string;
media?: string;
async?: boolean;
defer?: boolean;
module?: boolean;
element?: any;
inline: boolean;
import?: boolean;
position?: number;
}
export interface MergedResource {
type: ResourceType;
content: string;
urls: string[];
media?: string;
attributes?: Record<string, string>;
}
export interface MergeStrategy {
name: string;
shouldMerge: (resource1: Resource, resource2: Resource) => boolean;
groupKey: (resource: Resource) => string;
}
第五章:資源下載與緩存管理
5.1 資源下載器實現
typescript
// src/fetchers/resource-fetcher.ts
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { Resource } from '../types/resource';
export class ResourceFetcher {
private cache: Map<string, string> = new Map();
// 獲取資源內容
public async fetch(resource: Resource): Promise<string> {
if (resource.absolutePath) {
return this.fetchLocal(resource.absolutePath);
} else if (resource.url && (resource.url.startsWith('http') || resource.url.startsWith('//'))) {
return this.fetchRemote(resource.url);
} else if (resource.content) {
return resource.content;
}
throw new Error(`無法獲取資源: ${resource.url}`);
}
// 獲取本地資源
private fetchLocal(filePath: string): string {
const cacheKey = `local:${filePath}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
this.cache.set(cacheKey, content);
return content;
} catch (error) {
throw new Error(`讀取文件失敗: ${filePath}, 錯誤: ${error}`);
}
}
// 獲取遠程資源
private async fetchRemote(url: string): Promise<string> {
const cacheKey = `remote:${url}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
try {
const response = await axios.get(url, {
timeout: 10000,
headers: {
'User-Agent': 'HTMLResourceMerger/1.0'
}
});
const content = response.data;
this.cache.set(cacheKey, content);
return content;
} catch (error) {
throw new Error(`下載遠程資源失敗: ${url}, 錯誤: ${error}`);
}
}
// 批量獲取資源
public async fetchAll(resources: Resource[]): Promise<Map<Resource, string>> {
const results = new Map<Resource, string>();
const promises: Array<Promise<void>> = [];
for (const resource of resources) {
promises.push(
this.fetch(resource)
.then(content => results.set(resource, content))
.catch(error => {
console.error(`獲取資源失敗: ${resource.url || '內聯資源'}, 錯誤: ${error}`);
results.set(resource, '');
})
);
}
await Promise.all(promises);
return results;
}
// 清除緩存
public clearCache(): void {
this.cache.clear();
}
// 獲取緩存統計信息
public getCacheStats(): { size: number; hits: number } {
return {
size: this.cache.size,
hits: 0 // 實際項目中可以添加命中計數器
};
}
}
5.2 緩存策略實現
為了提高性能,我們實現一個更智能的緩存系統:
typescript
// src/fetchers/advanced-cache.ts
import * as crypto from 'crypto';
interface CacheEntry {
content: string;
timestamp: number;
hash: string;
size: number;
}
export class AdvancedCache {
private cache: Map<string, CacheEntry> = new Map();
private maxSize: number;
private ttl: number; // 生存時間(毫秒)
constructor(maxSize: number = 100 * 1024 * 1024, ttl: number = 3600000) {
this.maxSize = maxSize;
this.ttl = ttl;
}
// 計算內容的哈希值
private computeHash(content: string): string {
return crypto.createHash('md5').update(content).digest('hex');
}
// 設置緩存
public set(key: string, content: string): void {
const hash = this.computeHash(content);
const size = Buffer.byteLength(content, 'utf8');
// 檢查緩存大小,如果超過限制則清除舊條目
if (this.getTotalSize() + size > this.maxSize) {
this.evictOldEntries();
}
this.cache.set(key, {
content,
timestamp: Date.now(),
hash,
size
});
}
// 獲取緩存
public get(key: string): string | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// 檢查是否過期
if (Date.now() – entry.timestamp > this.ttl) {
this.cache.delete(key);
return null;
}
return entry.content;
}
// 檢查內容是否已緩存(通過哈希)
public hasContent(content: string): { key: string | null; cached: boolean } {
const hash = this.computeHash(content);
for (const [key, entry] of this.cache.entries()) {
if (entry.hash === hash) {
return { key, cached: true };
}
}
return { key: null, cached: false };
}
// 清除過期條目
private evictOldEntries(): void {
const now = Date.now();
const toDelete: string[] = [];
// 首先清除過期條目
for (const [key, entry] of this.cache.entries()) {
if (now – entry.timestamp > this.ttl) {
toDelete.push(key);
}
}
toDelete.forEach(key => this.cache.delete(key));
// 如果仍然超過大小限制,清除最舊的條目
if (this.getTotalSize() > this.maxSize) {
const sortedEntries = Array.from(this.cache.entries())
.sort((a, b) => a[1].timestamp – b[1].timestamp);
while (this.getTotalSize() > this.maxSize * 0.8 && sortedEntries.length > 0) {
const [oldestKey] = sortedEntries.shift()!;
this.cache.delete(oldestKey);
}
}
}
// 獲取總緩存大小
private getTotalSize(): number {
let total = 0;
for (const entry of this.cache.values()) {
total += entry.size;
}
return total;
}
// 獲取緩存統計信息
public getStats(): {
size: number;
entries: number;
hitRate: number;
} {
return {
size: this.getTotalSize(),
entries: this.cache.size,
hitRate: 0 // 實際項目中可以跟蹤命中率
};
}
// 清除所有緩存
public clear(): void {
this.cache.clear();
}
}
第六章:CSS合併與處理
6.1 CSS解析器與合併器
typescript
// src/processors/css-processor.ts
import * as postcss from 'postcss';
import * as path from 'path';
import * as fs from 'fs';
import { Resource, MergedResource } from '../types/resource';
export class CSSProcessor {
private basePath: string;
constructor(basePath: string = '') {
this.basePath = basePath;
}
// 合併多個CSS資源
public async mergeResources(
resources: Resource[],
contents: Map<Resource, string>
): Promise<MergedResource> {
let mergedContent = '';
const urls: string[] = [];
for (const resource of resources) {
const content = contents.get(resource);
if (content) {
// 處理CSS中的相對路徑
const processedContent = await this.processCSSUrls(
content,
resource.absolutePath ? path.dirname(resource.absolutePath) : this.basePath
);
// 添加來源註釋
mergedContent += `\\n/* Source: ${resource.url || 'inline'} */\\n`;
mergedContent += processedContent + '\\n';
if (resource.url) {
urls.push(resource.url);
}
}
}
// 壓縮CSS
const compressedContent = await this.minifyCSS(mergedContent);
return {
type: 'css',
content: compressedContent,
urls,
media: this.getCommonMediaQuery(resources)
};
}
// 處理CSS中的相對URL
private async processCSSUrls(
cssContent: string,
baseDir: string
): Promise<string> {
const plugin = postcss.plugin('css-url-processor', () => {
return (root) => {
root.walkDecls((decl) => {
// 處理background-image, background, src等屬性
if (decl.prop.match(/background(-image)?|src|content/i)) {
decl.value = this.processUrlValue(decl.value, baseDir);
}
});
root.walkAtRules('import', (rule) => {
// 處理@import規則
const matches = rule.params.match(/["']([^"']+)["']/);
if (matches) {
const importPath = matches[1];
if (!importPath.startsWith('http') && !importPath.startsWith('//')) {
const absolutePath = path.resolve(baseDir, importPath);
if (fs.existsSync(absolutePath)) {
// 這裡可以遞歸處理@import,但為了簡單起見,我們保留原樣
// 實際項目中應該遞歸處理
}
}
}
});
};
});
const result = await postcss([plugin()]).process(cssContent, {
from: undefined
});
return result.css;
}
// 處理URL值中的相對路徑
private processUrlValue(value: string, baseDir: string): string {
// 匹配url()中的路徑
return value.replace(/url\\(["']?([^"')]+)["']?\\)/gi, (match, urlPath) => {
// 跳過data URL和絕對路徑
if (urlPath.startsWith('data:') ||
urlPath.startsWith('http://') ||
urlPath.startsWith('https://') ||
urlPath.startsWith('//') ||
urlPath.startsWith('/')) {
return match;
}
// 轉換相對路徑為相對於合併後CSS文件的路徑
// 注意:這是一個簡化版本,實際項目中需要更複雜的路徑計算
const absolutePath = path.resolve(baseDir, urlPath);
const relativeToOutput = path.relative(this.basePath, absolutePath);
return `url("${relativeToOutput}")`;
});
}
// 壓縮CSS
private async minifyCSS(cssContent: string): Promise<string> {
const plugins = [
require('cssnano')({
preset: 'default'
})
];
const result = await postcss(plugins).process(cssContent, {
from: undefined
});
return result.css;
}
// 提取共同的媒體查詢
private getCommonMediaQuery(resources: Resource[]): string | undefined {
const mediaQueries = resources.map(r => r.media).filter(Boolean);
if (mediaQueries.length === 0) {
return undefined;
}
// 如果所有資源有相同的媒體查詢,返回該查詢
const firstMedia = mediaQueries[0];
if (mediaQueries.every(mq => mq === firstMedia)) {
return firstMedia;
}
// 否則返回'all',並在合併時將媒體查詢保留在規則內部
return 'all';
}
// 分析CSS資源的依賴關係
public analyzeDependencies(cssContent: string): string[] {
const imports: string[] = [];
const importRegex = /@import\\s+(url\\()?["']([^"']+)["'](\\))?/g;
let match;
while ((match = importRegex.exec(cssContent)) !== null) {
imports.push(match[2]);
}
return imports;
}
}
6.2 CSS重寫規則處理
當合併CSS時,我們需要特別注意選擇器優先級和規則順序:
typescript
// src/processors/css-rewriter.ts
import * as postcss from 'postcss';
export interface CSSRule {
selector: string;
declarations: Array<{ property: string; value: string }>;
media?: string;
specificity?: number;
}
export class CSSRewriter {
// 計算選擇器優先級
private calculateSpecificity(selector: string): number {
// 簡化的優先級計算
// 實際項目中應使用更準確的算法
let score = 0;
// ID選擇器
const idMatches = selector.match(/#/g);
if (idMatches) score += idMatches.length * 10000;
// 類選擇器、屬性選擇器、偽類
const classMatches = selector.match(/\\.|\\[|\\:/g);
if (classMatches) score += classMatches.length * 100;
// 元素選擇器、偽元素
const elementMatches = selector.match(/[a-zA-Z]+(?!-)/g);
if (elementMatches) score += elementMatches.length;
return score;
}
// 重寫CSS以保持正確的層疊順序
public async rewriteCSSForMerge(
cssContents: Array<{ content: string; originalOrder: number }>
): Promise<string> {
const allRules: CSSRule[] = [];
// 解析所有CSS文件中的規則
for (const { content, originalOrder } of cssContents) {
const root = postcss.parse(content);
root.walkRules((rule) => {
const parent = rule.parent;
const media = parent && parent.type === 'atrule' ? parent.params : undefined;
const declarations = rule.nodes
.filter(node => node.type === 'decl')
.map(decl => ({
property: decl.prop,
value: decl.value
}));
if (declarations.length > 0) {
allRules.push({
selector: rule.selector,
declarations,
media,
specificity: this.calculateSpecificity(rule.selector)
});
}
});
}
// 按媒體查詢、原始順序和優先級排序
allRules.sort((a, b) => {
// 首先按媒體查詢分組
const mediaA = a.media || '';
const mediaB = b.media || '';
if (mediaA !== mediaB) {
return mediaA.localeCompare(mediaB);
}
// 然後按優先級(降序)
if (a.specificity !== b.specificity) {
return (b.specificity || 0) – (a.specificity || 0);
}
return 0;
});
// 生成合併後的CSS
let result = '';
let currentMedia = '';
for (const rule of allRules) {
// 開始新的媒體查詢塊
if (rule.media !== currentMedia) {
if (currentMedia) {
result += '}\\n\\n';
}
if (rule.media) {
result += `@media ${rule.media} {\\n`;
}
currentMedia = rule.media || '';
}
// 添加規則
const indent = rule.media ? ' ' : '';
result += `${indent}${rule.selector} {\\n`;
for (const decl of rule.declarations) {
result += `${indent} ${decl.property}: ${decl.value};\\n`;
}
result += `${indent}}\\n`;
}
// 關閉最後的媒體查詢塊
if (currentMedia) {
result += '}\\n';
}
return result;
}
// 移除重複的CSS規則
public removeDuplicateRules(cssContent: string): string {
const root = postcss.parse(cssContent);
const ruleMap = new Map<string, { rule: any; parent: any }>();
root.walkRules((rule) => {
const key = `${rule.selector}|${rule.parent?.type === 'atrule' ? rule.parent.params : ''}`;
if (ruleMap.has(key)) {
// 合併聲明
const existing = ruleMap.get(key)!;
const existingDecls = new Set(
existing.rule.nodes
.filter((n: any) => n.type === 'decl')
.map((d: any) => `${d.prop}:${d.value}`)
);
// 添加不重複的聲明
rule.nodes.forEach((node: any) => {
if (node.type === 'decl') {
const declKey = `${node.prop}:${node.value}`;
if (!existingDecls.has(declKey)) {
existing.rule.append(node.clone());
}
}
});
// 移除當前規則
rule.remove();
} else {
ruleMap.set(key, { rule, parent: rule.parent });
}
});
return root.toString();
}
}
第七章:JavaScript合併與處理
7.1 JavaScript處理器實現
typescript
// src/processors/js-processor.ts
import * as terser from 'terser';
import { Resource, MergedResource } from '../types/resource';
export class JSProcessor {
// 合併多個JavaScript資源
public async mergeResources(
resources: Resource[],
contents: Map<Resource, string>,
options: {
minify: boolean;
sourceMap: boolean;
} = { minify: true, sourceMap: false }
): Promise<MergedResource> {
let mergedContent = '';
const urls: string[] = [];
// 按原始順序合併
const sortedResources = […resources].sort((a, b) => {
return (a.position || 0) – (b.position || 0);
});
for (const resource of sortedResources) {
const content = contents.get(resource);
if (content) {
// 添加來源註釋
mergedContent += `\\n// Source: ${resource.url || 'inline'}\\n`;
// 處理模塊代碼
if (resource.module) {
mergedContent += this.wrapModule(content, resource.url || '');
} else {
// 添加IIFE避免全局污染
mergedContent += this.wrapInIIFE(content);
}
mergedContent += '\\n';
if (resource.url) {
urls.push(resource.url);
}
}
}
// 可選的壓縮
let finalContent = mergedContent;
if (options.minify) {
finalContent = await this.minifyJS(mergedContent, options.sourceMap);
}
return {
type: 'js',
content: finalContent,
urls,
attributes: this.getCommonAttributes(resources)
};
}
// 將代碼包裹在IIFE中避免衝突
private wrapInIIFE(code: string): string {
// 檢查是否已經是IIFE
if (code.trim().startsWith('(function')) {
return code;
}
return `(function() {
${code}
})();`;
}
// 處理ES模塊
private wrapModule(code: string, moduleUrl: string): string {
// 簡單的模塊包裹,實際項目中需要更完整的模塊系統
return `// Module: ${moduleUrl}
(() => {
const module = { exports: {} };
const exports = module.exports;
${code}
return module.exports;
})();`;
}
// 壓縮JavaScript
private async minifyJS(
code: string,
generateSourceMap: boolean
): Promise<string> {
try {
const result = await terser.minify(code, {
compress: {
drop_console: false,
drop_debugger: true,
ecma: 2020,
passes: 2
},
mangle: {
toplevel: true,
reserved: ['$', 'jQuery', 'require', 'exports', 'module']
},
format: {
comments: false,
beautify: false
},
sourceMap: generateSourceMap
});
if (result.error) {
console.warn('JavaScript壓縮失敗:', result.error);
return code;
}
return result.code || code;
} catch (error) {
console.warn('JavaScript壓縮異常:', error);
return code;
}
}
// 提取共同的屬性
private getCommonAttributes(resources: Resource[]): Record<string, string> {
const attributes: Record<string, string> = {};
// 檢查是否所有資源都是async
const allAsync = resources.every(r => r.async);
const allDefer = resources.every(r => r.defer);
const allModule = resources.every(r => r.module);
if (allAsync) attributes.async = '';
if (allDefer) attributes.defer = '';
if (allModule) attributes.type = 'module';
return attributes;
}
// 分析JavaScript依賴關係
public analyzeDependencies(code: string): {
requires: string[];
imports: string[];
exports: string[];
} {
const requires: string[] = [];
const imports: string[] = [];
const exports: string[] = [];
// 簡單的正則匹配,實際項目中應使用AST分析
const requireRegex = /require\\(["']([^"']+)["']\\)/g;
const importRegex = /from\\s+["']([^"']+)["']|import\\s+["']([^"']+)["']/g;
const exportRegex = /export\\s+(?:const|let|var|function|class|default)\\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
let match;
while ((match = requireRegex.exec(code)) !== null) {
requires.push(match[1]);
}
while ((match = importRegex.exec(code)) !== null) {
const importPath = match[1] || match[2];
if (importPath) imports.push(importPath);
}
while ((match = exportRegex.exec(code)) !== null) {
exports.push(match[1]);
}
return { requires, imports, exports };
}
}
7.2 JavaScript代碼分析與依賴解析
為了更準確地處理JavaScript合併,我們需要實現AST分析:
typescript
// src/analyzers/js-analyzer.ts
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
export interface JSAnalysisResult {
dependencies: string[];
exports: {
named: string[];
default: boolean;
};
globalVariables: Set<string>;
sideEffects: boolean;
size: number;
}
export class JSAnalyzer {
// 分析JavaScript代碼
public analyze(code: string, filename: string = ''): JSAnalysisResult {
try {
const ast = parser.parse(code, {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'asyncGenerators',
'classProperties',
'dynamicImport',
'decorators-legacy'
]
});
const dependencies = new Set<string>();
const namedExports = new Set<string>();
let hasDefaultExport = false;
const globalVariables = new Set<string>();
let hasSideEffects = false;
traverse(ast, {
// 分析導入
ImportDeclaration(path) {
dependencies.add(path.node.source.value);
},
// 分析CommonJS require
CallExpression(path) {
if (t.isIdentifier(path.node.callee, { name: 'require' }) &&
path.node.arguments.length === 1 &&
t.isStringLiteral(path.node.arguments[0])) {
dependencies.add(path.node.arguments[0].value);
}
},
// 分析動態導入
Import(path) {
const parent = path.parent;
if (t.isCallExpression(parent) &&
parent.arguments.length === 1 &&
t.isStringLiteral(parent.arguments[0])) {
dependencies.add(parent.arguments[0].value);
}
},
// 分析導出
ExportNamedDeclaration(path) {
if (path.node.declaration) {
if (t.isVariableDeclaration(path.node.declaration)) {
path.node.declaration.declarations.forEach(decl => {
if (t.isIdentifier(decl.id)) {
namedExports.add(decl.id.name);
}
});
} else if (t.isFunctionDeclaration(path.node.declaration) &&
path.node.declaration.id) {
namedExports.add(path.node.declaration.id.name);
} else if (t.isClassDeclaration(path.node.declaration) &&
path.node.declaration.id) {
namedExports.add(path.node.declaration.id.name);
}
}
if (path.node.specifiers) {
path.node.specifiers.forEach(spec => {
if (t.isExportSpecifier(spec) && t.isIdentifier(spec.exported)) {
namedExports.add(spec.exported.name);
}
});
}
},
ExportDefaultDeclaration() {
hasDefaultExport = true;
},
// 檢測副作用
CallExpression(path) {
// 檢測可能產生副作用的調用
if (t.isMemberExpression(path.node.callee)) {
const obj = path.node.callee.object;
if (t.isIdentifier(obj) &&
(obj.name === 'console' || obj.name === 'document' || obj.name === 'window')) {
hasSideEffects = true;
}
}
},
// 分析全局變量
AssignmentExpression(path) {
if (t.isMemberExpression(path.node.left)) {
const obj = path.node.left.object;
if (t.isIdentifier(obj) && obj.name === 'window') {
if (t.isIdentifier(path.node.left.property)) {
globalVariables.add(path.node.left.property.name);
}
}
} else if (t.isIdentifier(path.node.left)) {
// 簡單的全局變量檢測(不考慮作用域)
globalVariables.add(path.node.left.name);
}
}
});
return {
dependencies: Array.from(dependencies),
exports: {
named: Array.from(namedExports),
default: hasDefaultExport
},
globalVariables,
sideEffects: hasSideEffects,
size: Buffer.byteLength(code, 'utf8')
};
} catch (error) {
console.warn(`分析JavaScript文件失敗 ${filename}:`, error);
return {
dependencies: [],
exports: { named: [], default: false },
globalVariables: new Set(),
sideEffects: true, // 無法分析時假定有副作用
size: Buffer.byteLength(code, 'utf8')
};
}
}
// 檢測代碼衝突
public detectConflicts(
analysis1: JSAnalysisResult,
analysis2: JSAnalysisResult
): string[] {
const conflicts: string[] = [];
// 檢測全局變量衝突
const globalVars1 = analysis1.globalVariables;
const globalVars2 = analysis2.globalVariables;
for (const varName of globalVars1) {
if (globalVars2.has(varName)) {
conflicts.push(`全局變量衝突: ${varName}`);
}
}
// 檢測導出名稱衝突
const exports1 = analysis1.exports.named;
const exports2 = analysis2.exports.named;
for (const exportName of exports1) {
if (exports2.includes(exportName)) {
conflicts.push(`導出名稱衝突: ${exportName}`);
}
}
return conflicts;
}
// 建議合併順序基於依賴關係
public suggestMergeOrder(
files: Array<{ filename: string; analysis: JSAnalysisResult }>
): string[] {
// 簡單的拓撲排序(不考慮循環依賴)
const graph: Record<string, string[]> = {};
const inDegree: Record<string, number> = {};
// 初始化圖
files.forEach(file => {
graph[file.filename] = [];
inDegree[file.filename] = 0;
});
// 構建依賴圖
files.forEach(file1 => {
file1.analysis.dependencies.forEach(dep => {
files.forEach(file2 => {
if (file2.filename === dep ||
file2.filename.includes(dep) ||
dep.includes(file2.filename)) {
graph[file2.filename].push(file1.filename);
inDegree[file1.filename]++;
}
});
});
});
// 拓撲排序
const queue = files.filter(f => inDegree[f.filename] === 0).map(f => f.filename);
const result: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
result.push(current);
graph[current].forEach(neighbor => {
inDegree[neighbor]–;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
// 如果有剩餘節點,說明存在循環依賴,按原始順序返回
if (result.length !== files.length) {
return files.map(f => f.filename);
}
return result;
}
}
第八章:合併策略引擎
8.1 策略模式實現
typescript
// src/strategies/merge-strategy.ts
import { Resource, MergeStrategy } from '../types/resource';
// 基礎策略:按類型合併
export class BasicMergeStrategy implements MergeStrategy {
name = 'basic';
shouldMerge(resource1: Resource, resource2: Resource): boolean {
// 相同類型且都是外部資源
return resource1.type === resource2.type &&
!resource1.inline &&
!resource2.inline;
}
groupKey(resource: Resource): string {
return resource.type;
}
}
// 媒體查詢策略:按CSS媒體查詢分組合併
export class MediaQueryMergeStrategy implements MergeStrategy {
name = 'media-query';
shouldMerge(resource1: Resource, resource2: Resource): boolean {
if (resource1.type !== 'css' || resource2.type !== 'css') {
return false;
}
const media1 = resource1.media || 'all';
const media2 = resource2.media || 'all';
return media1 === media2;
}
groupKey(resource: Resource): string {
return `css-${resource.media || 'all'}`;
}
}
// 加載策略:按加載特性分組合併
export class LoadingMergeStrategy implements MergeStrategy {
name = 'loading';
shouldMerge(resource1: Resource, resource2: Resource): boolean {
if (resource1.type !== resource2.type) {
return false;
}
if (resource1.type === 'css') {
return true; // CSS總是合併
}
// JavaScript按加載特性分組
const async1 = resource1.async || false;
const async2 = resource2.async || false;
const defer1 = resource1.defer || false;
const defer2 = resource2.defer || false;
const module1 = resource1.module || false;
const module2 = resource2.module || false;
return async1 === async2 &&
defer1 === defer2 &&
module1 === module2;
}
groupKey(resource: Resource): string {
if (resource.type === 'css') {
return 'css';
}
const parts = ['js'];
if (resource.async) parts.push('async');
if (resource.defer) parts.push('defer');
if (resource.module) parts.push('module');
return parts.join('-');
}
}
// 頁面區域策略:按預定義的頁面區域分組合併
export class PageSectionMergeStrategy implements MergeStrategy {
private sectionPatterns: Map<RegExp, string>;
constructor(patterns: Array<{ pattern: RegExp; section: string }> = []) {
this.sectionPatterns = new Map();
patterns.forEach(p => {
this.sectionPatterns.set(p.pattern, p.section);
});
}
name = 'page-section';
shouldMerge(resource1: Resource, resource2: Resource): boolean {
const section1 = this.getSection(resource1);
const section2 = this.getSection(resource2);
return resource1.type === resource2.type &&
section1 === section2;
}
groupKey(resource: Resource): string {
const section = this.getSection(resource);
return `${resource.type}-${section}`;
}
private getSection(resource: Resource): string {
if (!resource.url) return 'unknown';
for (const [pattern, section] of this.sectionPatterns.entries()) {
if (pattern.test(resource.url)) {
return section;
}
}
// 默認按目錄結構判斷
const url = resource.url;
if (url.includes('/vendor/') || url.includes('/lib/')) {
return 'vendor';
} else if (url.includes('/components/')) {
return 'components';
} else if (url.includes('/utils/')) {
return 'utils';
} else {
return 'app';
}
}
}
// 複合策略:組合多個策略
export class CompositeMergeStrategy implements MergeStrategy {
private strategies: MergeStrategy[];
constructor(strategies: MergeStrategy[] = []) {
this.strategies = strategies;
}
name = 'composite';
shouldMerge(resource1: Resource, resource2: Resource): boolean {
// 所有策略都同意合併時才合併
return this.strategies.every(strategy =>
strategy.shouldMerge(resource1, resource2)
);
}
groupKey(resource: Resource): string {
// 組合所有策略的groupKey
return this.strategies.map(s => s.groupKey(resource)).join('|');
}
addStrategy(strategy: MergeStrategy): void {
this.strategies.push(strategy);
}
}
8.2 策略管理器
typescript
// src/strategies/strategy-manager.ts
import { Resource, MergeStrategy } from '../types/resource';
import {
BasicMergeStrategy,
MediaQueryMergeStrategy,
LoadingMergeStrategy,
PageSectionMergeStrategy,
CompositeMergeStrategy
} from './merge-strategy';
export type StrategyName = 'basic' | 'media-query' | 'loading' | 'page-section' | 'composite';
export class StrategyManager {
private strategies: Map<StrategyName, MergeStrategy>;
constructor() {
this.strategies = new Map();
this.registerDefaultStrategies();
}
private registerDefaultStrategies(): void {
this.strategies.set('basic', new BasicMergeStrategy());
this.strategies.set('media-query', new MediaQueryMergeStrategy());
this.strategies.set('loading', new LoadingMergeStrategy());
this.strategies.set('page-section', new PageSectionMergeStrategy());
// 默認的複合策略:加載策略 + 頁面區域策略
const composite = new CompositeMergeStrategy([
new LoadingMergeStrategy(),
new PageSectionMergeStrategy()
]);
this.strategies.set('composite', composite);
}
// 獲取策略
public getStrategy(name: StrategyName): MergeStrategy {
const strategy = this.strategies.get(name);
if (!strategy) {
throw new Error(`策略不存在: ${name}`);
}
return strategy;
}
// 註冊自定義策略
public registerStrategy(name: string, strategy: MergeStrategy): void {
this.strategies.set(name as StrategyName, strategy);
}
// 根據資源自動選擇最佳策略
public autoSelectStrategy(resources: Resource[]): MergeStrategy {
const cssResources = resources.filter(r => r.type === 'css');
const jsResources = resources.filter(r => r.type === 'js');
// 分析資源特性
const hasMultipleMediaQueries = new Set(
cssResources.map(r => r.media || 'all')
).size > 1;
const hasMixedLoadingAttributes =
new Set(jsResources.map(r =>
`${r.async ? 'a' : ''}${r.defer ? 'd' : ''}${r.module ? 'm' : ''}`
)).size > 1;
// 根據分析結果選擇策略
if (hasMultipleMediaQueries) {
return this.getStrategy('media-query');
} else if (hasMixedLoadingAttributes) {
return this.getStrategy('loading');
} else {
return this.getStrategy('composite');
}
}
// 分組資源
public groupResources(
resources: Resource[],
strategy: MergeStrategy
): Map<string, Resource[]> {
const groups = new Map<string, Resource[]>();
// 為每個資源分配位置
resources.forEach((resource, index) => {
resource.position = index;
});
// 分組
resources.forEach(resource => {
const key = strategy.groupKey(resource);
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(resource);
});
// 在每個組內按原始順序排序
groups.forEach(resourceList => {
resourceList.sort((a, b) => (a.position || 0) – (b.position || 0));
});
return groups;
}
// 評估合併效果
public evaluateMerge(
originalResources: Resource[],
mergedGroups: Map<string, Resource[]>
): {
requestReduction: number;
estimatedSizeChange: number;
groupCount: number;
} {
const originalRequestCount = originalResources.filter(r => !r.inline).length;
const mergedRequestCount = mergedGroups.size;
const requestReduction = originalRequestCount – mergedRequestCount;
// 注意:這裡需要實際計算大小變化
const estimatedSizeChange = 0;
return {
requestReduction,
estimatedSizeChange,
groupCount: mergedGroups.size
};
}
}
第九章:核心合併引擎
9.1 合併引擎主類
typescript
// src/core/merger-engine.ts
import { HTMLParser } from '../parsers/html-parser';
import { ResourceFetcher } from '../fetchers/resource-fetcher';
import { CSSProcessor } from '../processors/css-processor';
import { JSProcessor } from '../processors/js-processor';
import { StrategyManager, StrategyName } from '../strategies/strategy-manager';
import { Resource, MergedResource } from '../types/resource';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
export interface MergeOptions {
strategy?: StrategyName | 'auto';
minifyCSS?: boolean;
minifyJS?: boolean;
generateSourceMap?: boolean;
inlineThreshold?: number; // 小於此大小的資源將被內聯
outputDir?: string;
versioning?: boolean; // 是否添加版本號
cacheBusting?: boolean; // 是否添加緩存破壞參數
}
export interface MergeResult {
html: string;
mergedResources: Array<{
type: string;
filename: string;
size: number;
originalResources: string[];
}>;
stats: {
originalRequests: number;
finalRequests: number;
reductionPercentage: number;
totalSize: number;
estimatedLoadTime: number;
};
}
export class MergerEngine {
private htmlParser: HTMLParser;
private resourceFetcher: ResourceFetcher;
private cssProcessor: CSSProcessor;
private jsProcessor: JSProcessor;
private strategyManager: StrategyManager;
private options: Required<MergeOptions>;
constructor(
htmlContent: string,
basePath: string = '',
options: MergeOptions = {}
) {
this.htmlParser = new HTMLParser(htmlContent, basePath);
this.resourceFetcher = new ResourceFetcher();
this.cssProcessor = new CSSProcessor(basePath);
this.jsProcessor = new JSProcessor();
this.strategyManager = new StrategyManager();
this.options = {
strategy: 'auto',
minifyCSS: true,
minifyJS: true,
generateSourceMap: false,
inlineThreshold: 2048, // 2KB
outputDir: path.join(basePath, 'dist'),
versioning: true,
cacheBusting: true,
…options
};
// 確保輸出目錄存在
if (!fs.existsSync(this.options.outputDir)) {
fs.mkdirSync(this.options.outputDir, { recursive: true });
}
}
// 執行合併
public async merge(): Promise<MergeResult> {
console.log('開始資源合併處理…');
// 1. 解析HTML並提取資源
console.log('解析HTML並提取資源…');
const cssResources = this.htmlParser.extractCSSResources();
const jsResources = this.htmlParser.extractJSResources();
const inlineResources = this.htmlParser.extractInlineResources();
const allExternalResources = […cssResources, …jsResources];
console.log(`找到 ${allExternalResources.length} 個外部資源`);
// 2. 選擇合併策略
console.log('選擇合併策略…');
const strategy = this.options.strategy === 'auto'
? this.strategyManager.autoSelectStrategy(allExternalResources)
: this.strategyManager.getStrategy(this.options.strategy);
console.log(`使用策略: ${strategy.name}`);
// 3. 分組資源
console.log('分組資源…');
const resourceGroups = this.strategyManager.groupResources(
allExternalResources,
strategy
);
console.log(`分為 ${resourceGroups.size} 個組`);
// 4. 獲取資源內容
console.log('獲取資源內容…');
const resourceContents = await this.resourceFetcher.fetchAll(
allExternalResources
);
// 5. 處理每組資源
console.log('處理每組資源…');
const mergedResources: MergedResource[] = [];
for (const [groupKey, resources] of resourceGroups.entries()) {
console.log(`處理組: ${groupKey}, 包含 ${resources.length} 個資源`);
const type = resources[0].type;
if (type === 'css') {
const merged = await this.cssProcessor.mergeResources(
resources,
resourceContents
);
mergedResources.push(merged);
} else if (type === 'js') {
const merged = await this.jsProcessor.mergeResources(
resources,
resourceContents,
{
minify: this.options.minifyJS,
sourceMap: this.options.generateSourceMap
}
);
mergedResources.push(merged);
}
}
// 6. 生成合併後的文件
console.log('生成合併後的文件…');
const generatedFiles = await this.generateMergedFiles(mergedResources);
// 7. 更新HTML
console.log('更新HTML…');
this.updateHTMLWithMergedResources(resources =>
generatedFiles.find(f => f.originalUrls.some(url => resources.some(r => r.url === url)))
);
// 8. 處理內聯資源
console.log('處理內聯資源…');
this.processInlineResources(inlineResources);
// 9. 獲取最終HTML
const finalHTML = this.htmlParser.getHTML();
// 10. 計算統計信息
const stats = this.calculateStats(
allExternalResources,
generatedFiles,
finalHTML
);
console.log('資源合併完成!');
console.log(`請求數從 ${stats.originalRequests} 減少到 ${stats.finalRequests}`);
console.log(`減少了 ${stats.reductionPercentage}%`);
return {
html: finalHTML,
mergedResources: generatedFiles.map(f => ({
type: f.type,
filename: f.filename,
size: f.size,
originalResources: f.originalUrls
})),
stats
};
}
// 生成合併後的文件
private async generateMergedFiles(
mergedResources: MergedResource[]
): Promise<Array<{
type: string;
filename: string;
content: string;
size: number;
originalUrls: string[];
}>> {
const generatedFiles = [];
for (const mergedResource of mergedResources) {
// 決定是否內聯
const shouldInline = mergedResource.content.length < this.options.inlineThreshold;
if (shouldInline) {
// 內聯處理
this.inlineMergedResource(mergedResource);
} else {
// 生成文件
const filename = this.generateFilename(mergedResource);
const filePath = path.join(this.options.outputDir, filename);
// 寫入文件
fs.writeFileSync(filePath, mergedResource.content, 'utf-8');
generatedFiles.push({
type: mergedResource.type,
filename,
content: mergedResource.content,
size: Buffer.byteLength(mergedResource.content, 'utf8'),
originalUrls: mergedResource.urls
});
}
}
return generatedFiles;
}
// 生成文件名
private generateFilename(mergedResource: MergedResource): string {
const hash = crypto.createHash('md5')
.update(mergedResource.content)
.digest('hex')
.substring(0, 8);
const type = mergedResource.type;
const timestamp = this.options.versioning ?
`-${Date.now().toString(36)}` : '';
let basename = `merged-${type}${timestamp}-${hash}`;
if (type === 'css' && mergedResource.media && mergedResource.media !== 'all') {
const mediaSlug = mergedResource.media
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-+/g, '-')
.toLowerCase();
basename = `merged-${type}-${mediaSlug}${timestamp}-${hash}`;
}
return `${basename}.${type}`;
}
// 內聯合併的資源
private inlineMergedResource(mergedResource: MergedResource): void {
if (mergedResource.type === 'css') {
// 創建style標籤
const styleTag = `<style${mergedResource.media && mergedResource.media !== 'all' ? ` media="${mergedResource.media}"` : ''}>\\n${mergedResource.content}\\n</style>`;
this.htmlParser.addInlineResource('style', styleTag);
} else if (mergedResource.type === 'js') {
// 創建script標籤
const scriptTag = `<script>\\n${mergedResource.content}\\n</script>`;
this.htmlParser.addInlineResource('script', scriptTag);
}
// 移除原始資源引用
// 注意:這裡需要追蹤哪些原始資源被內聯了
}
// 更新HTML引用
private updateHTMLWithMergedResources(
findFile: (resources: Resource[]) => any
): void {
// 這裡需要實現邏輯來替換原始資源引用為合併後的資源引用
// 由於代碼較長,此處省略具體實現細節
// 基本思路是:
// 1. 移除所有原始資源標籤
// 2. 添加合併後的資源標籤
}
// 處理內聯資源
private processInlineResources(inlineResources: Resource[]): void {
// 可以選擇壓縮內聯資源
inlineResources.forEach(resource => {
if (resource.type === 'css' && this.options.minifyCSS) {
// 壓縮內聯CSS
} else if (resource.type === 'js' && this.options.minifyJS) {
// 壓縮內聯JS
}
});
}
// 計算統計信息
private calculateStats(
originalResources: Resource[],
generatedFiles: Array<{ size: number }>,
finalHTML: string
): MergeResult['stats'] {
const originalRequests = originalResources.length;
const finalRequests = generatedFiles.length;
const reductionPercentage = originalRequests > 0 ?
Math.round((1 – finalRequests / originalRequests) * 100) : 0;
const totalSize = generatedFiles.reduce((sum, file) => sum + file.size, 0);
// 估算加載時間(簡化模型)
const estimatedLoadTime = this.estimateLoadTime(
originalRequests,
finalRequests,
totalSize
);
return {
originalRequests,
finalRequests,
reductionPercentage,
totalSize,
estimatedLoadTime
};
}
// 估算加載時間
private estimateLoadTime(
originalRequests: number,
finalRequests: number,
totalSize: number
): number {
// 簡化模型:
// – 每個請求的固定開銷:100ms
// – 帶寬:1MB/s
const requestOverhead = 100; // ms
const bandwidth = 1024 * 1024; // bytes per second
const originalTime = originalRequests * requestOverhead + totalSize / bandwidth * 1000;
const finalTime = finalRequests * requestOverhead + totalSize / bandwidth * 1000;
return originalTime – finalTime; // 節省的時間
}
// 靜態方法:從文件合併
public static async mergeFile(
htmlFilePath: string,
options: MergeOptions = {}
): Promise<MergeResult> {
const basePath = path.dirname(htmlFilePath);
const htmlContent = fs.readFileSync(htmlFilePath, 'utf-8');
const merger = new MergerEngine(htmlContent, basePath, options);
return merger.merge();
}
// 保存結果到文件
public saveResult(result: MergeResult, outputPath?: string): void {
const htmlOutputPath = outputPath ||
path.join(this.options.outputDir, 'index-optimized.html');
// 保存HTML
fs.writeFileSync(htmlOutputPath, result.html, 'utf-8');
// 保存報告
const reportPath = path.join(this.options.outputDir, 'merge-report.json');
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), 'utf-8');
console.log(`優化後的HTML已保存到: ${htmlOutputPath}`);
console.log(`合併報告已保存到: ${reportPath}`);
}
}
9.2 命令行界面
為了方便使用,我們創建一個命令行工具:
typescript
// src/cli/cli.ts
#!/usr/bin/env node
import * as commander from 'commander';
import * as fs from 'fs';
import * as path from 'path';
import { MergerEngine } from '../core/merger-engine';
import { StrategyName } from '../strategies/strategy-manager';
const program = new commander.Command();
program
.name('html-resource-merger')
.description('自動合併HTML中的CSS和JavaScript資源,減少HTTP請求數')
.version('1.0.0');
program
.argument('<html-file>', '要處理的HTML文件路徑')
.option('-o, –output <dir>', '輸出目錄', 'dist')
.option('-s, –strategy <strategy>', '合併策略: basic, media-query, loading, page-section, composite, auto', 'auto')
.option('–no-minify-css', '禁用CSS壓縮')
.option('–no-minify-js', '禁用JavaScript壓縮')
.option('–inline-threshold <kb>', '內聯閾值(KB)', '2')
.option('–no-versioning', '禁用版本號')
.option('–no-cache-busting', '禁用緩存破壞')
.option('–source-map', '生成source map')
.option('–verbose', '詳細輸出')
.action(async (htmlFile, options) => {
try {
console.log(`處理文件: ${htmlFile}`);
// 檢查文件是否存在
if (!fs.existsSync(htmlFile)) {
console.error(`錯誤: 文件不存在 ${htmlFile}`);
process.exit(1);
}
// 處理選項
const mergeOptions = {
strategy: options.strategy as StrategyName | 'auto',
minifyCSS: options.minifyCss,
minifyJS: options.minifyJs,
generateSourceMap: options.sourceMap,
inlineThreshold: parseInt(options.inlineThreshold) * 1024,
outputDir: path.isAbsolute(options.output) ?
options.output : path.join(path.dirname(htmlFile), options.output),
versioning: options.versioning,
cacheBusting: options.cacheBusting
};
if (options.verbose) {
console.log('合併選項:', mergeOptions);
}
// 執行合併
const result = await MergerEngine.mergeFile(htmlFile, mergeOptions);
// 保存結果
const merger = new MergerEngine('', path.dirname(htmlFile), mergeOptions);
merger.saveResult(result);
// 顯示統計信息
console.log('\\n=== 合併統計 ===');
console.log(`原始請求數: ${result.stats.originalRequests}`);
console.log(`最終請求數: ${result.stats.finalRequests}`);
console.log(`請求減少量: ${result.stats.reductionPercentage}%`);
console.log(`總文件大小: ${(result.stats.totalSize / 1024).toFixed(2)} KB`);
console.log(`估計加載時間節省: ${result.stats.estimatedLoadTime.toFixed(0)} ms`);
console.log('\\n合併完成!');
} catch (error) {
console.error('處理過程中發生錯誤:', error);
process.exit(1);
}
});
// 添加監視模式命令
program
.command('watch')
.description('監視HTML文件變化並自動重新合併')
.argument('<html-file>', '要監視的HTML文件路徑')
.option('-o, –output <dir>', '輸出目錄', 'dist')
.option('-s, –strategy <strategy>', '合併策略', 'auto')
.action(async (htmlFile, options) => {
console.log(`開始監視: ${htmlFile}`);
// 使用chokidar或其他文件監視庫
// 這裡簡化實現
const chokidar = require('chokidar');
const watcher = chokidar.watch(htmlFile, {
persistent: true,
ignoreInitial: true
});
watcher.on('change', async (path: string) => {
console.log(`\\n檢測到變化: ${path}`);
console.log('重新合併資源…');
try {
const mergeOptions = {
strategy: options.strategy as StrategyName | 'auto',
outputDir: path.isAbsolute(options.output) ?
options.output : path.join(path.dirname(htmlFile), options.output),
};
const result = await MergerEngine.mergeFile(htmlFile, mergeOptions);
const merger = new MergerEngine('', path.dirname(htmlFile), mergeOptions);
merger.saveResult(result);
console.log('重新合併完成!');
} catch (error) {
console.error('重新合併失敗:', error);
}
});
console.log('監視中… 按Ctrl+C退出');
});
// 添加分析命令
program
.command('analyze')
.description('分析HTML資源但不進行合併')
.argument('<html-file>', '要分析的HTML文件路徑')
.action(async (htmlFile) => {
console.log(`分析文件: ${htmlFile}`);
try {
const basePath = path.dirname(htmlFile);
const htmlContent = fs.readFileSync(htmlFile, 'utf-8');
const parser = new (require('../parsers/html-parser').HTMLParser)(htmlContent, basePath);
const cssResources = parser.extractCSSResources();
const jsResources = parser.extractJSResources();
const inlineResources = parser.extractInlineResources();
console.log('\\n=== 資源分析報告 ===');
console.log(`CSS文件: ${cssResources.length}`);
console.log(`JavaScript文件: ${jsResources.length}`);
console.log(`內聯資源: ${inlineResources.length}`);
console.log(`總外部請求: ${cssResources.length + jsResources.length}`);
if (cssResources.length > 0) {
console.log('\\nCSS資源:');
cssResources.forEach((resource, i) => {
console.log(` ${i + 1}. ${resource.url} ${resource.media ? `(${resource.media})` : ''}`);
});
}
if (jsResources.length > 0) {
console.log('\\nJavaScript資源:');
jsResources.forEach((resource, i) => {
const attrs = [];
if (resource.async) attrs.push('async');
if (resource.defer) attrs.push('defer');
if (resource.module) attrs.push('module');
console.log(` ${i + 1}. ${resource.url} ${attrs.length > 0 ? `(${attrs.join(', ')})` : ''}`);
});
}
// 建議優化策略
console.log('\\n=== 優化建議 ===');
if (cssResources.length + jsResources.length > 10) {
console.log('❌ 資源過多: 考慮合併CSS和JavaScript文件');
} else if (cssResources.length + jsResources.length > 6) {
console.log('⚠️ 資源較多: 考慮部分合併');
} else {
console.log('✅ 資源數量合理');
}
// 檢查是否使用了HTTP/2
console.log('\\n提示: 如果使用HTTP/2,合併小文件可能不是最優策略');
console.log(' HTTP/2支持多路複用,可以更有效地處理多個小文件');
} catch (error) {
console.error('分析失敗:', error);
process.exit(1);
}
});
program.parse();
第十章:高級功能與優化
10.1 HTTP/2感知合併
HTTP/2改變了資源加載的最佳實踐,因此我們的工具應該能夠感知HTTP/2:
typescript
// src/strategies/http2-strategy.ts
import { Resource, MergeStrategy } from '../types/resource';
export class HTTP2AwareStrategy implements MergeStrategy {
name = 'http2-aware';
// HTTP/2下,小文件合併可能不是最佳選擇
shouldMerge(resource1: Resource, resource2: Resource): boolean {
// 獲取文件大小(需要異步獲取)
// 如果文件很小,在HTTP/2下不需要合併
const size1 = this.estimateSize(resource1);
const size2 = this.estimateSize(resource2);
// 如果文件都很小(< 10KB),在HTTP/2下不合併
if (size1 < 10240 && size2 < 10240) {
return false;
}
// 否則使用基本合併邏輯
return resource1.type === resource2.type;
}
groupKey(resource: Resource): string {
const size = this.estimateSize(resource);
if (size < 10240) {
return `${resource.type}-small`;
} else if (size < 102400) {
return `${resource.type}-medium`;
} else {
return `${resource.type}-large`;
}
}
private estimateSize(resource: Resource): number {
// 簡單的估計,實際項目中需要更準確的實現
if (resource.url) {
if (resource.url.includes('jquery') || resource.url.includes('bootstrap')) {
return 102400; // 大約100KB
} else if (resource.url.includes('utils') || resource.url.includes('helpers')) {
return 10240; // 大約10KB
} else {
return 30720; // 大約30KB
}
}
return 0;
}
}
10.2 差異化緩存策略
typescript
// src/cache/differential-caching.ts
import * as fs from 'fs';
import * as crypto from 'crypto';
export interface CachingStrategy {
shouldCache(resource: Resource): boolean;
getCacheKey(resource: Resource): string;
getCacheDuration(resource: Resource): number;
}
export class DifferentialCaching implements CachingStrategy {
// 根據資源類型決定緩存策略
shouldCache(resource: Resource): boolean {
// 不緩存內聯資源
if (resource.inline) return false;
// 總是緩存第三方庫
if (this.isThirdParty(resource)) return true;
// 根據文件內容決定
return true;
}
getCacheKey(resource: Resource): string {
if (resource.absolutePath && fs.existsSync(resource.absolutePath)) {
const content = fs.readFileSync(resource.absolutePath, 'utf-8');
const hash = crypto.createHash('md5').update(content).digest('hex');
return `${resource.type}-${hash.substring(0, 8)}`;
}
// 使用URL作為備用鍵
return `${resource.type}-${crypto.createHash('md5').update(resource.url || '').digest('hex').substring(0, 8)}`;
}
getCacheDuration(resource: Resource): number {
// 第三方資源緩存更長時間
if (this.isThirdParty(resource)) {
return 30 * 24 * 60 * 60 * 1000; // 30天
}
// 應用代碼緩存較短時間
return 24 * 60 * 60 * 1000; // 1天
}
private isThirdParty(resource: Resource): boolean {
if (!resource.url) return false;
const thirdPartyPatterns = [
/cdn\\./,
/cloudflare\\./,
/googleapis\\./,
/bootstrapcdn\\./,
/unpkg\\./,
/jquery/,
/vue/,
/react/,
/angular/,
/lodash/
];
return thirdPartyPatterns.some(pattern => pattern.test(resource.url!));
}
}
10.3 資源加載時機優化
typescript
// src/optimizers/loading-optimizer.ts
import { HTMLParser } from '../parsers/html-parser';
import { Resource } from '../types/resource';
export class LoadingOptimizer {
// 重新排列資源以優化關鍵渲染路徑
public optimizeCriticalPath(parser: HTMLParser): void {
// 提取所有CSS
const cssResources = parser.extractCSSResources();
const jsResources = parser.extractJSResources();
// 關鍵CSS(首屏可見內容所需)應該內聯
const criticalCSS = this.extractCriticalCSS(cssResources);
if (criticalCSS) {
this.inlineCriticalCSS(parser, criticalCSS);
}
// 非關鍵CSS應該異步加載
const nonCriticalCSS = cssResources.filter(r => !r.url?.includes('critical'));
this.loadCSSAsync(parser, nonCriticalCSS);
// JavaScript應該按需加載
this.optimizeJSLoading(parser, jsResources);
}
private extractCriticalCSS(cssResources: Resource[]): string | null {
// 簡化實現:假設第一個CSS文件包含關鍵樣式
// 實際項目中應使用關鍵CSS提取工具如Penthouse
return null;
}
private inlineCriticalCSS(parser: HTMLParser, css: string): void {
// 在head中添加內聯style標籤
const styleTag = `<style>${css}</style>`;
// 實現添加到head的邏輯
}
private loadCSSAsync(parser: HTMLParser, cssResources: Resource[]): void {
// 使用preload或async屬性加載CSS
cssResources.forEach(resource => {
if (resource.element) {
// 修改link標籤添加media="print" onload策略
// 實際實現需要操作DOM
}
});
}
private optimizeJSLoading(parser: HTMLParser, jsResources: Resource[]): void {
// 將非關鍵JavaScript標記為async或defer
jsResources.forEach(resource => {
if (!this.isCriticalJS(resource)) {
// 添加async或defer屬性
// 實際實現需要操作DOM
}
});
}
private isCriticalJS(resource: Resource): boolean {
// 判斷JavaScript是否關鍵(需要立即執行)
const criticalPatterns = [
/polyfill/,
/modernizr/,
/critical/,
/init\\.js$/,
/main\\.js$/
];
if (!resource.url) return true; // 內聯腳本假設為關鍵
return criticalPatterns.some(pattern => pattern.test(resource.url!));
}
}
第十一章:測試與驗證
11.1 單元測試
typescript
// test/unit/html-parser.test.ts
import { HTMLParser } from '../../src/parsers/html-parser';
import { describe, it, expect } from '@jest/globals';
describe('HTMLParser', () => {
const testHTML = `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="styles/print.css" media="print">
<style>
@import url("imported.css");
body { margin: 0; }
</style>
</head>
<body>
<script src="js/jquery.js"></script>
<script src="js/app.js" async></script>
<script>
console.log('inline script');
</script>
</body>
</html>
`;
it('應該正確提取CSS資源', () => {
const parser = new HTMLParser(testHTML, '/test');
const cssResources = parser.extractCSSResources();
expect(cssResources).toHaveLength(3); // 2個link + 1個@import
expect(cssResources[0].url).toBe('styles/main.css');
expect(cssResources[1].media).toBe('print');
expect(cssResources[2].import).toBe(true);
});
it('應該正確提取JS資源', () => {
const parser = new HTMLParser(testHTML, '/test');
const jsResources = parser.extractJSResources();
expect(jsResources).toHaveLength(2);
expect(jsResources[0].url).toBe('js/jquery.js');
expect(jsResources[1].async).toBe(true);
});
it('應該正確提取內聯資源', () => {
const parser = new HTMLParser(testHTML, '/test');
const inlineResources = parser.extractInlineResources();
expect(inlineResources).toHaveLength(2); // 1個style + 1個script
expect(inlineResources[0].type).toBe('css');
expect(inlineResources[1].type).toBe('js');
});
it('應該正確移除資源標籤', () => {
const parser = new HTMLParser(testHTML, '/test');
const cssResources = parser.extractCSSResources();
expect(cssResources).toHaveLength(3);
parser.removeResource(cssResources[0]);
const remainingResources = parser.extractCSSResources();
expect(remainingResources).toHaveLength(2);
});
});
11.2 集成測試
typescript
// test/integration/merger-engine.test.ts
import { MergerEngine } from '../../src/core/merger-engine';
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
describe('MergerEngine 集成測試', () => {
const testDir = path.join(__dirname, 'test-fixtures');
const htmlFile = path.join(testDir, 'index.html');
beforeAll(() => {
// 創建測試目錄和文件
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
// 創建測試HTML文件
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>測試頁面</title>
<link rel="stylesheet" href="css/style1.css">
<link rel="stylesheet" href="css/style2.css">
</head>
<body>
<h1>測試</h1>
<script src="js/script1.js"></script>
<script src="js/script2.js"></script>
</body>
</html>
`;
fs.writeFileSync(htmlFile, htmlContent, 'utf-8');
// 創建CSS文件
const cssDir = path.join(testDir, 'css');
if (!fs.existsSync(cssDir)) {
fs.mkdirSync(cssDir, { recursive: true });
}
fs.writeFileSync(
path.join(cssDir, 'style1.css'),
'body { color: red; }',
'utf-8'
);
fs.writeFileSync(
path.join(cssDir, 'style2.css'),
'h1 { color: blue; }',
'utf-8'
);
// 創建JS文件
const jsDir = path.join(testDir, 'js');
if (!fs.existsSync(jsDir)) {
fs.mkdirSync(jsDir, { recursive: true });
}
fs.writeFileSync(
path.join(jsDir, 'script1.js'),
'console.log("script1");',
'utf-8'
);
fs.writeFileSync(
path.join(jsDir, 'script2.js'),
'console.log("script2");',
'utf-8'
);
});
afterAll(() => {
// 清理測試文件
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
});
it('應該正確合併資源', async () => {
const result = await MergerEngine.mergeFile(htmlFile, {
outputDir: path.join(testDir, 'dist'),
minifyCSS: false,
minifyJS: false
});
expect(result.stats.originalRequests).toBe(4);
expect(result.stats.finalRequests).toBe(2); // 1個CSS + 1個JS
expect(result.stats.reductionPercentage).toBe(50);
// 檢查輸出文件是否存在
const distDir = path.join(testDir, 'dist');
expect(fs.existsSync(distDir)).toBe(true);
// 檢查合併後的HTML
expect(result.html).toContain('merged-css');
expect(result.html).toContain('merged-js');
// 檢查合併後的資源文件
const files = fs.readdirSync(distDir);
const cssFiles = files.filter(f => f.endsWith('.css'));
const jsFiles = files.filter(f => f.endsWith('.js'));
expect(cssFiles.length).toBe(1);
expect(jsFiles.length).toBe(1);
}, 10000); // 設置較長超時時間
});
11.3 性能基準測試
typescript
// test/benchmark/performance.test.ts
import { MergerEngine } from '../../src/core/merger-engine';
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, expect } from '@jest/globals';
describe('性能基準測試', () => {
const createTestHTML = (resourceCount: number): string => {
let html = '<!DOCTYPE html><html><head><title>測試</title>';
// 添加CSS
for (let i = 0; i < Math.floor(resourceCount / 2); i++) {
html += `<link rel="stylesheet" href="css/style${i}.css">\\n`;
}
html += '</head><body><h1>測試</h1>';
// 添加JS
for (let i = 0; i < Math.floor(resourceCount / 2); i++) {
html += `<script src="js/script${i}.js"></script>\\n`;
}
html += '</body></html>';
return html;
};
const createTestResources = (dir: string, count: number): void => {
// 創建CSS文件
const cssDir = path.join(dir, 'css');
if (!fs.existsSync(cssDir)) {
fs.mkdirSync(cssDir, { recursive: true });
}
for (let i = 0; i < Math.floor(count / 2); i++) {
fs.writeFileSync(
path.join(cssDir, `style${i}.css`),
`.element-${i} { color: #${i.toString(16).padStart(6, '0')}; }`,
'utf-8'
);
}
// 創建JS文件
const jsDir = path.join(dir, 'js');
if (!fs.existsSync(jsDir)) {
fs.mkdirSync(jsDir, { recursive: true });
}
for (let i = 0; i < Math.floor(count / 2); i++) {
fs.writeFileSync(
path.join(jsDir, `script${i}.js`),
`console.log("script${i}");`,
'utf-8'
);
}
};
it('應該高效處理大量資源', async () => {
const testDir = path.join(__dirname, 'benchmark-test');
const htmlFile = path.join(testDir, 'index.html');
// 準備測試環境
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
const resourceCount = 50;
const htmlContent = createTestHTML(resourceCount);
fs.writeFileSync(htmlFile, htmlContent, 'utf-8');
createTestResources(testDir, resourceCount);
// 運行性能測試
const startTime = Date.now();
const result = await MergerEngine.mergeFile(htmlFile, {
outputDir: path.join(testDir, 'dist'),
minifyCSS: true,
minifyJS: true
});
const endTime = Date.now();
const duration = endTime – startTime;
console.log(`處理 ${resourceCount} 個資源用時: ${duration}ms`);
console.log(`請求數從 ${result.stats.originalRequests} 減少到 ${result.stats.finalRequests}`);
// 性能斷言:處理50個資源應該在10秒內完成
expect(duration).toBeLessThan(10000);
// 清理
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
}, 30000); // 30秒超時
});
第十二章:部署與使用
12.1 構建與打包
創建構建腳本 build.ts:
typescript
// scripts/build.ts
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
async function build() {
console.log('開始構建…');
try {
// 構建CLI工具
await esbuild.build({
entryPoints: ['src/cli/cli.ts'],
bundle: true,
platform: 'node',
target: 'node14',
outfile: 'dist/cli.js',
external: [
'commander',
'chokidar',
'axios',
'cheerio',
'css-tree',
'terser',
'postcss',
'cssnano',
'@babel/parser',
'@babel/traverse',
'@babel/types'
],
sourcemap: true,
minify: true
});
// 添加shebang到CLI文件
const cliContent = fs.readFileSync('dist/cli.js', 'utf-8');
fs.writeFileSync('dist/cli.js', '#!/usr/bin/env node\\n' + cliContent);
// 設置執行權限
fs.chmodSync('dist/cli.js', '755');
// 構建庫版本
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node14',
outfile: 'dist/index.cjs',
external: [
'commander',
'chokidar',
'axios',
'cheerio',
'css-tree',
'terser',
'postcss',
'cssnano',
'@babel/parser',
'@babel/traverse',
'@babel/types'
],
sourcemap: true
});
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'neutral',
format: 'esm',
target: 'es2020',
outfile: 'dist/index.mjs',
external: [
'commander',
'chokidar',
'axios',
'cheerio',
'css-tree',
'terser',
'postcss',
'cssnano',
'@babel/parser',
'@babel/traverse',
'@babel/types'
],
sourcemap: true
});
// 複製類型定義
fs.cpSync('src/types', 'dist/types', { recursive: true });
// 創建package.json用於分發
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
const distPackageJson = {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
main: './index.cjs',
module: './index.mjs',
types: './types/index.d.ts',
bin: {
'html-resource-merger': './cli.js'
},
dependencies: packageJson.dependencies,
keywords: packageJson.keywords,
author: packageJson.author,
license: packageJson.license,
repository: packageJson.repository,
engines: {
node: '>=14.0.0'
}
};
fs.writeFileSync(
'dist/package.json',
JSON.stringify(distPackageJson, null, 2),
'utf-8'
);
console.log('構建完成!');
} catch (error) {
console.error('構建失敗:', error);
process.exit(1);
}
}
build();
12.2 使用示例
創建示例目錄和文件:
html
<!– examples/sample-site/index.html –>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>示例網站</title>
<!– CSS資源 –>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components/button.css">
<link rel="stylesheet" href="css/components/modal.css">
<link rel="stylesheet" href="css/print.css" media="print">
<!– JavaScript資源 –>
<script src="js/vendor/jquery.min.js"></script>
<script src="js/utils/helpers.js"></script>
<script src="js/components/dialog.js" defer></script>
<script src="js/main.js" defer></script>
</head>
<body>
<header>
<h1>資源合併示例</h1>
<button class="btn-primary">點擊我</button>
</header>
<main>
<p>這是一個展示資源合併工具的示例網站。</p>
</main>
<footer>
<p>© 2023 資源合併工具示例</p>
</footer>
</body>
</html>
12.3 使用文檔
創建詳細的使用文檔:
markdown
# HTML資源合併工具使用文檔
## 安裝
### 全局安裝
```bash
npm install -g html-resource-merger
項目內安裝
bash
npm install –save-dev html-resource-merger
基本使用
命令行工具
bash
# 基本用法
html-resource-merger index.html
# 指定輸出目錄
html-resource-merger index.html -o dist
# 使用特定合併策略
html-resource-merger index.html –strategy=media-query
# 禁用壓縮
html-resource-merger index.html –no-minify-css –no-minify-js
# 監視模式
html-resource-merger watch index.html
Node.js API
javascript
const { MergerEngine } = require('html-resource-merger');
async function optimizeHTML() {
const result = await MergerEngine.mergeFile('index.html', {
strategy: 'composite',
minifyCSS: true,
minifyJS: true,
outputDir: 'dist'
});
console.log(`減少了 ${result.stats.reductionPercentage}% 的請求`);
return result.html;
}
合併策略
工具支持多種合併策略:
1. basic (基本策略)
-
所有CSS合併為一個文件
-
所有JavaScript合併為一個文件
2. media-query (媒體查詢策略)
-
按CSS媒體查詢分組合併
-
不同媒體查詢的CSS分開合併
3. loading (加載策略)
-
按加載特性分組合併
-
async、defer、module分開處理
4. page-section (頁面區域策略)
-
按頁面功能區域分組合併
-
如vendor、components、utils等
5. composite (複合策略,默認)
-
結合loading和page-section策略
-
智能分組,平衡緩存和並行加載
6. auto (自動策略)
-
自動分析資源特性
-
選擇最合適的合併策略
配置選項
| strategy | string | 'auto' | 合併策略 |
| minifyCSS | boolean | true | 是否壓縮CSS |
| minifyJS | boolean | true | 是否壓縮JavaScript |
| inlineThreshold | number | 2048 | 內聯閾值(字節) |
| outputDir | string | 'dist' | 輸出目錄 |
| versioning | boolean | true | 是否添加版本號 |
| cacheBusting | boolean | true | 是否添加緩存破壞參數 |
| sourceMap | boolean | false | 是否生成source map |
高級功能
1. 關鍵CSS提取
工具可以識別並內聯關鍵CSS,優化首屏渲染。
2. HTTP/2優化
在HTTP/2環境下,工具會調整合併策略,避免過度合併小文件。
3. 差異化緩存
根據資源類型設置不同的緩存策略,提高緩存效率。
4. 資源加載優化
自動添加async/defer屬性,優化資源加載時機。
集成到構建流程
Webpack集成
javascript
// webpack.config.js
const { MergerEngine } = require('html-resource-merger');
module.exports = {
// …其他配置
plugins: [
{
apply: (compiler) => {
compiler.hooks.afterEmit.tapPromise('HtmlResourceMerger', async () => {
await MergerEngine.mergeFile('dist/index.html', {
outputDir: 'dist',
minifyCSS: true,
minifyJS: true
});
});
}
}
]
};
Gulp集成
javascript
// gulpfile.js
const { MergerEngine } = require('html-resource-merger');
const { src, dest } = require('gulp');
function mergeResources() {
return src('src/*.html')
.pipe(async (file) => {
const result = await MergerEngine.mergeFile(file.path, {
outputDir: 'dist'
});
file.contents = Buffer.from(result.html);
return file;
})
.pipe(dest('dist'));
}
exports.merge = mergeResources;
性能最佳實踐
設置合適的內聯閾值:小於2KB的資源建議內聯
使用版本號:確保瀏覽器緩存正確更新
監視模式開發:開發時使用監視模式自動重新合併
分析模式:定期使用分析模式檢查資源結構
HTTP/2考慮:如果使用HTTP/2,考慮調整合併策略
故障排除
常見問題
資源路徑錯誤
-
確保相對路徑正確
-
使用絕對路徑或basePath配置
合併後樣式錯亂
-
檢查CSS媒體查詢
-
使用media-query策略
JavaScript錯誤
-
檢查變量衝突
-
使用模塊包裹避免污染
性能問題
-
減少過度合併
-
考慮HTTP/2特性
貢獻指南
歡迎貢獻代碼!請參閱CONTRIBUTING.md文件。
許可證
MIT License
text
## 第十三章:結論與未來發展
### 13.1 總結
通過本文,我們從零開始構建了一個完整的HTML資源合併工具。該工具具有以下特點:
1. **智能解析**:準確識別HTML中的CSS和JavaScript資源
2. **多策略合併**:支持多種合併策略,適應不同場景
3. **資源處理**:完整的CSS和JavaScript處理管道
4. **性能優化**:內聯、壓縮、緩存優化等多種優化手段
5. **易用性**:提供命令行工具和Node.js API
6. **可擴展性**:模塊化設計,易於擴展新功能
### 13.2 性能收益
通過實際測試,該工具可以:
1. 減少50%-90%的HTTP請求數
2. 通過壓縮減少20%-60%的文件大小
3. 優化資源加載順序,改善關鍵渲染路徑
4. 提高瀏覽器緩存效率
### 13.3 局限與改進方向
當前工具的局限性:
1. **靜態分析限制**:無法動態分析JavaScript執行時加載的資源
2. **CSS優先級處理**:複雜的CSS層疊上下文處理可能不夠完善
3. **資源依賴分析**:對複雜的模塊依賴關係分析有限
未來改進方向:
1. **動態資源檢測**:集成Puppeteer等工具進行動態分析
2. **AI優化策略**:使用機器學習優化合併策略
3. **圖像優化集成**:集成圖像壓縮和WebP轉換
4. **CDN集成**:直接上傳優化後資源到CDN
5. **預渲染優化**:支持SPA應用的資源預加載
### 13.4 與現有工具的比較
| 特性 | 本工具 | Webpack | Parcel | Vite |
|——|——–|———|——–|——|
| 零配置 | ✓ | ✗ | ✓ | ✓ |
| HTML中心 | ✓ | ✗ | ✗ | ✓ |
| 多策略合併 | ✓ | ✗ | ✗ | ✗ |
| 靜態分析 | ✓ | ✓ | ✓ | ✓ |
| 動態分析 | ✗ | ✓ | ✓ | ✓ |
| 構建速度 | 快 | 慢 | 快 | 很快 |
| 適用場景 | 傳統網站 | 現代應用 | 通用 | 現代應用 |
### 13.5 最終建議
對於不同類型的項目,建議:
1. **傳統多頁應用**:使用本工具進行資源合併優化
2. **現代前端應用**:使用Webpack/Vite等現代構建工具
3. **靜態網站**:在構建流程中集成本工具
4. **遺留系統**:使用本工具進行漸進式優化
資源合併是Web性能優化的重要手段,但並非萬能靈藥。在HTTP/2逐漸普及的今天,我們需要更加智能地判斷何時合併、何時保持獨立。本工具提供的多策略支持和HTTP/2感知功能,正是為了適應這種變化。
通過自主開發這樣的工具,我們不僅獲得了性能優化的能力,更重要的是深入理解了Web性能優化的原理。這種理解將幫助我們在未後的項目中做出更好的技術決策。
—
**附錄A:完整package.json示例**
```json
{
"name": "html-resource-merger",
"version": "1.0.0",
"description": "自動合併HTML中的CSS和JavaScript資源,減少HTTP請求數",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/types/index.d.ts",
"bin": {
"html-resource-merger": "dist/cli.js"
},
"scripts": {
"build": "ts-node scripts/build.ts",
"dev": "ts-node src/cli/cli.ts",
"test": "jest",
"test:coverage": "jest –coverage",
"lint": "eslint src/**/*.ts",
"format": "prettier –write src/**/*.ts",
"prepublishOnly": "npm run build && npm test"
},
"keywords": [
"html",
"css",
"javascript",
"merge",
"optimization",
"performance",
"http-requests"
],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"axios": "^1.3.4",
"cheerio": "^1.0.0-rc.12",
"commander": "^10.0.0",
"css-tree": "^2.3.1",
"postcss": "^8.4.21",
"terser": "^5.16.1"
},
"devDependencies": {
"@babel/parser": "^7.21.3",
"@babel/traverse": "^7.21.3",
"@babel/types": "^7.21.3",
"@types/jest": "^29.4.0",
"@types/node": "^18.14.2",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"chokidar": "^3.5.3",
"cssnano": "^5.1.15",
"esbuild": "^0.17.8",
"eslint": "^8.35.0",
"jest": "^29.4.3",
"prettier": "^2.8.4",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
},
"engines": {
"node": ">=14.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/yourusername/html-resource-merger.git"
},
"bugs": {
"url": "https://github.com/yourusername/html-resource-merger/issues"
},
"homepage": "https://github.com/yourusername/html-resource-merger#readme"
}
附錄B:項目結構
text
html-resource-merger/
├── src/
│ ├── analyzers/ # 代碼分析器
│ │ ├── js-analyzer.ts
│ │ └── …
│ ├── cache/ # 緩存管理
│ │ ├── advanced-cache.ts
│ │ └── …
│ ├── cli/ # 命令行界面
│ │ └── cli.ts
│ ├── core/ # 核心引擎
│ │ └── merger-engine.ts
│ ├── fetchers/ # 資源獲取
│ │ └── resource-fetcher.ts
│ ├── optimizers/ # 優化器
│ │ └── loading-optimizer.ts
│ ├── parsers/ # 解析器
│ │ └── html-parser.ts
│ ├── processors/ # 資源處理器
│ │ ├── css-processor.ts
│ │ ├── css-rewriter.ts
│ │ ├── js-processor.ts
│ │ └── …
│ ├── strategies/ # 合併策略
│ │ ├── merge-strategy.ts
│ │ ├── strategy-manager.ts
│ │ └── …
│ ├── types/ # 類型定義
│ │ └── resource.ts
│ └── index.ts # 主入口
├── test/ # 測試文件
│ ├── unit/
│ ├── integration/
│ └── benchmark/
├── examples/ # 使用示例
├── scripts/ # 構建腳本
│ └── build.ts
├── dist/ # 構建輸出
├── package.json
├── tsconfig.json
├── jest.config.js
├── .eslintrc.js
├── .prettierrc
├── README.md
└── LICENSE
通過這個完整的實現,我們不僅創建了一個實用的工具,更重要的是深入理解了Web性能優化的核心原理。無論是對於個人項目還是企業級應用,這種從底層理解問題並構建解決方案的能力,都是極其寶貴的技術資產。


