name: zero-build-frontend description: 零构建前端开发使用 CDN 加载的 React、Tailwind CSS 和原生 JavaScript。适用于构建无需捆绑器的静态 Web 应用、创建 Leaflet 地图、集成 Google Sheets 作为数据库或开发浏览器扩展。涵盖了 rosen-frontend、NJCIC 地图和 PocketLink 项目的模式。
零构建前端开发
用于构建生产质量 Web 应用程序的模式,无需构建工具、捆绑器或复杂工具链。
通过 CDN 使用 React (esm.sh)
基本设置
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>零构建 React 应用</title>
<!-- 通过 CDN 使用 Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
display: ['Special Elite', 'monospace'],
body: ['Roboto Mono', 'monospace'],
},
colors: {
brand: {
primary: '#2dc8d2',
secondary: '#f34213',
dark: '#183642',
}
}
}
}
}
</script>
<!-- Google 字体 -->
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Roboto+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<!-- 自定义样式 -->
<link rel="stylesheet" href="index.css">
</head>
<body>
<div id="root"></div>
<!-- ES 模块导入 -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
"htm": "https://esm.sh/htm@3.1.1"
}
}
</script>
<script type="module" src="index.js"></script>
</body>
</html>
使用 htm 的 React(无 JSX,无构建)
// index.js
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import htm from 'htm';
// 将 htm 绑定到 React.createElement
const html = htm.bind(React.createElement);
// 组件使用 html`` 代替 JSX
function App() {
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
const response = await fetch('data/archive-data.json');
const data = await response.json();
setRecords(data.records);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
}
const filtered = records.filter(r =>
r.title.toLowerCase().includes(search.toLowerCase())
);
if (loading) {
return html`<div class="flex items-center justify-center h-screen">
<div class="animate-spin w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full"></div>
</div>`;
}
return html`
<div class="min-h-screen bg-gray-900 text-white">
<header class="p-4 border-b border-gray-700">
<h1 class="font-display text-2xl">档案探索器</h1>
<input
type="text"
placeholder="搜索记录..."
value=${search}
onInput=${(e) => setSearch(e.target.value)}
class="mt-2 w-full p-2 bg-gray-800 rounded border border-gray-600 focus:border-brand-primary outline-none"
/>
</header>
<main class="p-4">
<${RecordList} records=${filtered} />
</main>
</div>
`;
}
function RecordList({ records }) {
return html`
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
${records.map(record => html`
<${RecordCard} key=${record.id} record=${record} />
`)}
</div>
`;
}
function RecordCard({ record }) {
return html`
<article class="p-4 bg-gray-800 rounded-lg border border-gray-700 hover:border-brand-primary transition-colors">
<h2 class="font-display text-lg mb-2">${record.title}</h2>
<p class="text-sm text-gray-400 mb-2">${record.publication_date}</p>
<p class="text-sm line-clamp-3">${record.summary}</p>
<div class="mt-2 flex flex-wrap gap-1">
${record.tags?.map(tag => html`
<span key=${tag} class="px-2 py-1 text-xs bg-gray-700 rounded">${tag}</span>
`)}
</div>
</article>
`;
}
// 挂载应用
const root = createRoot(document.getElementById('root'));
root.render(html`<${App} />`);
使用 localStorage 进行数据缓存
// services/cacheService.js
const CACHE_TTL = 60 * 60 * 1000; // 1 小时
export function getCached(key) {
const cached = localStorage.getItem(key);
if (!cached) return null;
try {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > CACHE_TTL) {
localStorage.removeItem(key);
return null;
}
return data;
} catch {
localStorage.removeItem(key);
return null;
}
}
export function setCache(key, data) {
localStorage.setItem(key, JSON.stringify({
data,
timestamp: Date.now()
}));
}
export async function fetchWithCache(url, cacheKey) {
// 先检查缓存
const cached = getCached(cacheKey);
if (cached) return cached;
// 获取新数据
const response = await fetch(url);
const data = await response.json();
// 为下次缓存
setCache(cacheKey, data);
return data;
}
// 使用示例
const records = await fetchWithCache('data/archive-data.json', 'archive-records');
Leaflet.js 地图
基本地图设置
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<style>
#map { height: 85vh; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="js/app.js"></script>
</body>
</html>
带聚类的地图应用
// js/app.js
class MapApp {
constructor() {
this.map = null;
this.markers = null;
this.data = [];
this.filters = {
year: null,
county: null,
status: null
};
}
async init() {
this.setupMap();
await this.loadData();
this.renderMarkers();
this.setupFilters();
}
setupMap() {
// 初始化地图,中心为新泽西州
this.map = L.map('map', {
center: [40.0583, -74.4057],
zoom: 8,
scrollWheelZoom: false, // 禁用鼠标滚轮缩放
zoomControl: false // 我们将添加自定义控件
});
// 添加瓦片图层(CARTO Voyager)
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap, © CARTO',
maxZoom: 19
}).addTo(this.map);
// 添加自定义缩放控件(右上角)
L.control.zoom({ position: 'topright' }).addTo(this.map);
// 初始化标记聚类组
this.markers = L.markerClusterGroup({
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
maxClusterRadius: 50,
spiderLegPolylineOptions: { weight: 1.5, color: '#2dc8d2' }
});
this.map.addLayer(this.markers);
}
async loadData() {
const response = await fetch('data/grantees.json');
this.data = await response.json();
}
renderMarkers() {
this.markers.clearLayers();
const filtered = this.data.filter(item => {
if (this.filters.year && item.year !== this.filters.year) return false;
if (this.filters.county && item.county !== this.filters.county) return false;
if (this.filters.status && item.status !== this.filters.status) return false;
return true;
});
filtered.forEach(item => {
if (!item.lat || !item.lng) return;
const marker = L.marker([item.lat, item.lng], {
icon: this.createIcon(item.status)
});
marker.bindPopup(this.createPopup(item));
this.markers.addLayer(marker);
});
// 更新计数显示
document.getElementById('count').textContent = filtered.length;
}
createIcon(status) {
const colors = {
'Active': '#2dc8d2',
'Completed': '#666666',
'Pending': '#f34213'
};
return L.divIcon({
html: `<div style="background: ${colors[status] || '#2dc8d2'}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;"></div>`,
className: 'custom-marker',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
}
createPopup(item) {
return `
<div class="popup-content">
<h3 class="font-bold text-lg">${item.name}</h3>
<p class="text-sm text-gray-600">${item.county} County</p>
<p class="text-sm mt-2">${item.description || ''}</p>
<div class="mt-2">
<span class="px-2 py-1 text-xs rounded bg-gray-200">${item.status}</span>
<span class="px-2 py-1 text-xs rounded bg-gray-200">${item.year}</span>
</div>
${item.website ? `<a href="${item.website}" target="_blank" class="block mt-2 text-brand-primary">访问网站 →</a>` : ''}
</div>
`;
}
setupFilters() {
// 年份过滤器
const years = [...new Set(this.data.map(d => d.year))].sort();
const yearSelect = document.getElementById('year-filter');
years.forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = year;
yearSelect.appendChild(option);
});
yearSelect.addEventListener('change', (e) => {
this.filters.year = e.target.value || null;
this.renderMarkers();
});
// 类似设置县、状态过滤器...
}
}
// 在加载时初始化
document.addEventListener('DOMContentLoaded', () => {
const app = new MapApp();
app.init();
});
使用 Google Sheets 作为数据库
获取已发布的 CSV
// Google Sheets 发布为 CSV
const SHEET_URL = 'https://docs.google.com/spreadsheets/d/e/SPREADSHEET_ID/pub?gid=0&single=true&output=csv';
async function loadFromSheets() {
const response = await fetch(SHEET_URL);
const csv = await response.text();
// 使用 PapaParse(CDN)解析
const { data, errors } = Papa.parse(csv, {
header: true,
skipEmptyLines: true,
transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, '_')
});
if (errors.length > 0) {
console.warn('CSV 解析错误:', errors);
}
return data;
}
使用 localStorage 的实时状态
class DataManager {
constructor(sheetUrl, cacheKey) {
this.sheetUrl = sheetUrl;
this.cacheKey = cacheKey;
this.data = [];
this.localState = this.loadLocalState();
}
loadLocalState() {
const stored = localStorage.getItem(`${this.cacheKey}-state`);
return stored ? JSON.parse(stored) : {};
}
saveLocalState() {
localStorage.setItem(`${this.cacheKey}-state`, JSON.stringify(this.localState));
}
async refresh() {
const response = await fetch(this.sheetUrl);
const csv = await response.text();
this.data = Papa.parse(csv, { header: true, skipEmptyLines: true }).data;
// 与本地状态合并
this.data.forEach(row => {
const localData = this.localState[row.id];
if (localData) {
Object.assign(row, localData);
}
});
return this.data;
}
updateLocal(id, updates) {
this.localState[id] = { ...this.localState[id], ...updates };
this.saveLocalState();
// 同时更新内存中的数据
const item = this.data.find(d => d.id === id);
if (item) Object.assign(item, updates);
}
}
// 使用示例
const manager = new DataManager(SHEET_URL, 'volunteer-data');
await manager.refresh();
// 标记任务为完成(存储在本地)
manager.updateLocal('task-123', { completed: true, completed_at: new Date().toISOString() });
浏览器扩展(Manifest V3)
manifest.json
{
"manifest_version": 3,
"name": "PocketLink",
"version": "1.0.0",
"description": "从右键上下文菜单创建短链接",
"permissions": [
"contextMenus",
"storage",
"activeTab",
"scripting",
"notifications",
"offscreen"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_page": "options.html",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
服务工作者(background.js)
// background.js - 服务工作者
// 在安装时创建上下文菜单
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'create-shortlink',
title: '创建短链接',
contexts: ['page', 'link']
});
});
// 处理上下文菜单点击
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'create-shortlink') return;
const url = info.linkUrl || info.pageUrl;
try {
const shortUrl = await createShortlink(url);
await copyToClipboard(shortUrl);
showNotification('短链接已创建', shortUrl);
} catch (error) {
showNotification('错误', error.message);
}
});
async function createShortlink(longUrl) {
const { apiToken } = await chrome.storage.sync.get('apiToken');
if (!apiToken) throw new Error('API 令牌未配置');
const response = await fetch('https://api-ssl.bitly.com/v4/shorten', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ long_url: longUrl })
});
if (!response.ok) throw new Error('API 请求失败');
const data = await response.json();
return data.link;
}
// 剪贴板方法(三种备用策略)
// 方法 1: Offscreen API(首选)
async function copyToClipboard(text) {
try {
await copyViaOffscreen(text);
} catch {
try {
await copyViaContentScript(text);
} catch {
await copyViaPopup(text);
}
}
}
async function copyViaOffscreen(text) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CLIPBOARD'],
justification: '复制短链接到剪贴板'
});
await chrome.runtime.sendMessage({ type: 'copy', text });
await chrome.offscreen.closeDocument();
}
async function copyViaContentScript(text) {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (text) => navigator.clipboard.writeText(text),
args: [text]
});
}
function showNotification(title, message) {
chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon48.png',
title,
message
});
}
选项页面
<!-- options.html -->
<!DOCTYPE html>
<html>
<head>
<style>
/* 内联 CSS 以符合扩展要求(无远程代码) */
body {
font-family: system-ui, sans-serif;
padding: 20px;
max-width: 400px;
margin: 0 auto;
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
button {
margin-top: 1rem;
padding: 10px 20px;
background: #2dc8d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover { background: #25a8b0; }
.status { margin-top: 1rem; padding: 10px; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>PocketLink 设置</h1>
<label for="apiToken">Bit.ly API 令牌</label>
<input type="password" id="apiToken" placeholder="输入您的 API 令牌">
<button id="save">保存设置</button>
<div id="status" class="status" style="display: none;"></div>
<script src="options.js"></script>
</body>
</html>
// options.js
document.addEventListener('DOMContentLoaded', async () => {
const tokenInput = document.getElementById('apiToken');
const saveButton = document.getElementById('save');
const status = document.getElementById('status');
// 加载已保存的令牌
const { apiToken } = await chrome.storage.sync.get('apiToken');
if (apiToken) tokenInput.value = apiToken;
saveButton.addEventListener('click', async () => {
const token = tokenInput.value.trim();
if (!token) {
showStatus('请输入 API 令牌', 'error');
return;
}
// 通过测试请求验证令牌
try {
const response = await fetch('https://api-ssl.bitly.com/v4/user', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('无效令牌');
await chrome.storage.sync.set({ apiToken: token });
showStatus('设置保存成功!', 'success');
} catch {
showStatus('无效 API 令牌', 'error');
}
});
function showStatus(message, type) {
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
setTimeout(() => { status.style.display = 'none'; }, 3000);
}
});
部署的缓存清除
<!-- 静态文件的手动版本控制 -->
<link rel="stylesheet" href="styles.css?v=1.3.0">
<script src="app.js?v=1.3.0"></script>
<!-- 或使用构建时间戳 -->
<script>
const version = Date.now();
document.write(`<link rel="stylesheet" href="styles.css?v=${version}">`);
</script>
部署模式
静态托管(FTP/SFTP)
# WordPress wp-content 部署的目录结构
wp-content/
└── archive-explorer/
├── index.html
├── index.js
├── index.css
├── components/
│ ├── Sidebar.js
│ ├── RecordList.js
│ └── RecordCard.js
└── data/
└── archive-data.json
子目录部署的路径管理
// constants.js
// 从当前 URL 自动检测基础路径
const getBasePath = () => {
const path = window.location.pathname;
const lastSlash = path.lastIndexOf('/');
return path.substring(0, lastSlash + 1);
};
export const BASE_PATH = getBasePath();
export const DATA_URL = `${BASE_PATH}data/archive-data.json`;
// 使用示例
const response = await fetch(DATA_URL);
性能提示
- 懒加载大型 JSON:增量解析或分页
- 使用 CSS 包含:在重复元素上使用
contain: layout style - 防抖搜索输入:在输入停止后等待 300 毫秒
- 虚拟化长列表:仅渲染可见项
- 预连接到 CDNs:
<link rel="preconnect" href="https://esm.sh">