stepper_cache 是一个Flarum的缓存插件。我们将分几个部分来实现这个插件:
1. 创建插件的基本结构:
stepper_cache/
├── composer.json
├── extend.php
├── js/
│   ├── dist/
│   │   └── forum.js
│   ├── src/
│   │   ├── forum/
│   │   │   ├── index.js
│   │   │   └── serviceWorker.js
│   └── package.json
└── src/
    └── Listeners/
        └── AddAssets.php
2. 首先是 composer.json:
{
    "name": "stepper/cache",
    "description": "Browser-side caching plugin for Flarum using Service Worker",
    "type": "flarum-extension",
    "require": {
        "flarum/core": "^1.2.0"
    },
    "autoload": {
        "psr-4": {
            "Stepper\\Cache\\": "src/"
        }
    },
    "extra": {
        "flarum-extension": {
            "title": "Stepper Cache",
            "icon": {
                "name": "fas fa-bolt",
                "backgroundColor": "#238c59",
                "color": "#fff"
            }
        }
    }
}
3. extend.php 文件:
<?php
use Flarum\Extend;
use Stepper\Cache\Listeners\AddAssets;
return [
    (new Extend\Frontend('forum'))
        ->js(__DIR__.'/js/dist/forum.js')
        ->content(AddAssets::class),
];
4. src/Listeners/AddAssets.php:
<?php
namespace Stepper\Cache\Listeners;
use Flarum\Frontend\Document;
use Illuminate\Support\Str;
class AddAssets
{
    public function __invoke(Document [imath:0]document)
    {
        // 添加Service Worker注册脚本
        $document->head[] = '<script>
            if ("serviceWorker" in navigator) {
                window.addEventListener("load", function() {
                    navigator.serviceWorker.register("/sw.js")
                        .then(function(registration) {
                            console.log("ServiceWorker registered");
                        })
                        .catch(function(err) {
                            console.log("ServiceWorker registration failed: ", err);
                        });
                });
            }
        </script>';
    }
}
5. js/src/forum/serviceWorker.js:
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `stepper-static-[/imath:0]{CACHE_VERSION}`;
const RESPONSE_CACHE = `stepper-response-${CACHE_VERSION}`;
const CACHE_DURATION = 28 * 60 * 60 * 1000; // 28 hours in milliseconds
// 需要缓存的静态资源类型
const STATIC_EXTENSIONS = [
    '.css', '.js', '.woff2', '.woff', '.ttf', '.eot', '.png', '.jpg', 
    '.jpeg', '.gif', '.svg', '.ico'
];
// 判断是否为静态资源
function isStaticResource(url) {
    return STATIC_EXTENSIONS.some(ext => url.toLowerCase().endsWith(ext));
}
// 判断是否为写操作请求
function isWriteRequest(request) {
    return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method);
}
// 判断缓存是否过期
function isCacheExpired(cachedResponse) {
    const dateHeader = cachedResponse.headers.get('date');
    if (!dateHeader) return true;
    
    const cachedDate = new Date(dateHeader).getTime();
    return Date.now() - cachedDate > CACHE_DURATION;
}
// 异步刷新缓存
async function refreshCache(request, cacheName) {
    try {
        const response = await fetch(request.clone());
        const cache = await caches.open(cacheName);
        await cache.put(request, response);
    } catch (error) {
        console.error('Cache refresh failed:', error);
    }
}
// 获取页面中的所有静态资源URL
async function extractStaticUrls(response) {
    const text = await response.clone().text();
    const staticUrls = new Set();
    
    // 提取<link>标签中的资源
    const linkMatches = text.match(/<link[^>]*href=["']([^"']+)["'][^>]*>/g) || [];
    linkMatches.forEach(match => {
        const url = match.match(/href=["']([^"']+)["']/)?.[1];
        if (url && isStaticResource(url)) {
            staticUrls.add(new URL(url, location.origin).href);
        }
    });
    
    // 提取<script>标签中的资源
    const scriptMatches = text.match(/<script[^>]*src=["']([^"']+)["'][^>]*>/g) || [];
    scriptMatches.forEach(match => {
        const url = match.match(/src=["']([^"']+)["']/)?.[1];
        if (url && isStaticResource(url)) {
            staticUrls.add(new URL(url, location.origin).href);
        }
    });
    
    // 提取<img>标签中的资源
    const imgMatches = text.match(/<img[^>]*src=["']([^"']+)["'][^>]*>/g) || [];
    imgMatches.forEach(match => {
        const url = match.match(/src=["']([^"']+)["']/)?.[1];
        if (url && isStaticResource(url)) {
            staticUrls.add(new URL(url, location.origin).href);
        }
    });
    
    return Array.from(staticUrls);
}
// Service Worker安装事件
self.addEventListener('install', event => {
    event.waitUntil(
        Promise.all([
            caches.open(STATIC_CACHE),
            caches.open(RESPONSE_CACHE)
        ])
    );
});
// Service Worker激活事件
self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames
                    .filter(cacheName => 
                        cacheName.startsWith('stepper-') && 
                        !cacheName.endsWith(CACHE_VERSION)
                    )
                    .map(cacheName => caches.delete(cacheName))
            );
        })
    );
});
// 拦截请求
self.addEventListener('fetch', event => {
    const request = event.request;
    
    // 忽略写操作请求
    if (isWriteRequest(request)) {
        return;
    }
    
    event.respondWith(
        (async function() {
            const url = new URL(request.url);
            const cacheName = isStaticResource(url.pathname) ? 
                STATIC_CACHE : RESPONSE_CACHE;
            
            // 尝试从缓存中获取响应
            const cachedResponse = await caches.match(request);
            
            if (cachedResponse) {
                // 检查缓存是否过期
                if (isCacheExpired(cachedResponse)) {
                    // 异步刷新缓存
                    refreshCache(request, cacheName);
                }
                return cachedResponse;
            }
            
            // 如果缓存中没有,发起网络请求
            try {
                const response = await fetch(request);
                
                if (!response || response.status !== 200) {
                    return response;
                }
                
                // 缓存响应
                const clonedResponse = response.clone();
                const cache = await caches.open(cacheName);
                cache.put(request, clonedResponse);
                
                // 如果是HTML响应,提取并缓存静态资源
                if (response.headers.get('content-type')?.includes('text/html')) {
                    const staticUrls = await extractStaticUrls(response);
                    const staticCache = await caches.open(STATIC_CACHE);
                    
                    staticUrls.forEach(async url => {
                        try {
                            const staticResponse = await fetch(url);
                            if (staticResponse.ok) {
                                staticCache.put(url, staticResponse);
                            }
                        } catch (error) {
                            console.error('Failed to cache static resource:', url);
                        }
                    });
                }
                
                return response;
                
            } catch (error) {
                // 如果网络请求失败,返回缓存的响应(如果有的话)
                if (cachedResponse) {
                    return cachedResponse;
                }
                throw error;
            }
        })()
    );
});
6. js/src/forum/index.js:
app.initializers.add('stepper-cache', () => {
    console.log('Stepper Cache initialized');
});
使用说明:
- 将插件安装到Flarum后,它会自动注册Service Worker并开始缓存静态资源和响应。 
- 插件的主要功能: - 自动缓存所有静态资源(CSS、JS、字体、图片等)
- 缓存GET请求的响应
- 缓存时间为28小时
- 当缓存命中时,异步刷新缓存内容
- 自动分析HTML页面并缓存其中引用的所有静态资源
- 不缓存POST、PUT、DELETE等写操作请求
- 支持离线访问已缓存的内容
 
- 缓存策略: - 优先使用缓存内容
- 如果缓存过期,返回缓存内容的同时异步刷新
- 如果缓存不存在,从网络获取并缓存
- 网络请求失败时回退到缓存(如果有)
 
- 安装步骤: -    composer require stepper/cache
 
- 注意事项: - Service Worker只能在HTTPS环境下运行(localhost除外)
- 需要确保服务器正确设置了CORS和缓存相关的响应头
- 建议在生产环境中使用
 
这个插件会显著提升Flarum论坛的访问速度和用户体验,特别是对重复访问的用户来说。