零构建前端开发Skill zero-build-frontend

零构建前端开发是一种前端技术,通过CDN直接加载React、Tailwind CSS等库,无需本地构建工具,适用于快速开发静态网站、地图应用、数据驱动应用和浏览器扩展。关键词:零构建、前端开发、CDN、React、Tailwind CSS、JavaScript、静态网站、浏览器扩展、Google Sheets、Leaflet。

前端开发 0 次安装 0 次浏览 更新于 3/15/2026

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: '&copy; OpenStreetMap, &copy; 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">