服务端区块
本页内容
sitelo 构建的是静态 HTML。服务端区块负责填补那些必须保持新鲜的部分 —— 时钟、评论、库存,任何依赖请求的东西。这个范例先构建一个带时间区块的页面,再运行一个小型 Node 服务器,同时提供 dist/ 和 /_sitelo/islands。
这个项目的副本在 sitelo 仓库的 examples/islands/ 下。
你会得到什么
- 一个带区块占位符和回退 HTML 的静态首页
src/islands/下一个仅服务端的区块模块,绝不会进入浏览器- 一个客户端加载器,把来自
/_sitelo/islands/<name>的片段换进页面 - 一份贴近生产形态的
server.js—— 静态文件 +createIslandsNodeHandler
项目结构
my-site/
sitelo.config.js
server.js # Node 宿主:静态 dist + 区块
netlify.toml # Netlify 重写 → 函数
vercel.json # Vercel 重写 → api 路由
package.json
netlify/functions/
islands.mjs # Netlify 区块处理器
api/islands/
[...path].js # Vercel 区块处理器
src/
index.ht.js # 带区块占位符的页面
js/
islands.js # 客户端加载器(打包进 dist/)
islands/
time.js # 仅服务端的片段模块
css/
styles.cssexport default {
site: 'https://example.com',
}1. 区块模块
一个普通的 .js 文件(不是 .ht.js)。它接收 { name, props, request },并返回一个 HTML 字符串。这个模块用到了请求时间和 user-agent,好让你看出它确实是按请求渲染的。
export default function time({ props, request }) {
const label = typeof props?.label === 'string' ? props.label : '服务器时间'
const now = new Date().toISOString()
const ua = request?.headers?.get?.('user-agent') ?? 'unknown'
return `
<p><strong>${label}:</strong> <time datetime="${now}">${now}</time></p>
<p class="muted">按请求渲染,用户代理为 <code>${escapeHtml(ua.slice(0, 48))}</code></p>
`
}
function escapeHtml(value) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}2. 把区块放进页面
island() 会把 props 嵌进占位元素。构建产物发布的是回退内容;端点响应之后,加载器会替换它。
import { island } from 'sitelo/islands'
export default () => `
<html lang="zh">
<head>
<title>服务端区块演示</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<h1>静态页面,动态区块</h1>
<p>这段 HTML 只构建了一次。下面的方框在请求时才被填充。</p>
${island(
'time',
{ label: '此刻' },
'<p>正在加载服务器时间…</p>',
)}
<script type="module" src="/js/islands.js"></script>
</body>
</html>
`import { html, head, title, link, body, h1, p, script } from 'javascript-to-html'
import { island } from 'sitelo/islands'
export default () =>
html({ lang: 'zh' },
head(
title('服务端区块演示'),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
h1('静态页面,动态区块'),
p('这段 HTML 只构建了一次。下面的方框在请求时才被填充。'),
island(
'time',
{ label: '此刻' },
'<p>正在加载服务器时间…</p>',
),
script({ type: 'module', src: '/islands.js' }),
),
)import { island } from 'sitelo/islands'
export default function Home() {
return (
<html lang="zh">
<head>
<title>服务端区块演示</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<h1>静态页面,动态区块</h1>
<p>这段 HTML 只构建了一次。下面的方框在请求时才被填充。</p>
{island(
'time',
{ label: '此刻' },
'<p>正在加载服务器时间…</p>',
)}
<script type="module" src="/js/islands.js" />
</body>
</html>
)
}3. 客户端加载器
import { mountIslands } from 'sitelo/islands/client'
mountIslands()body {
font-family: system-ui, sans-serif;
max-width: 36rem;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.5;
}
[data-sitelo-island] {
margin: 1.5rem 0;
padding: 1rem 1.25rem;
border: 1px solid #ccc;
}
[data-sitelo-island-state='loading'] {
opacity: 0.7;
}
.muted {
color: #666;
font-size: 0.9rem;
}4. Node 宿主
执行 sitelo build 之后,这个进程会提供 dist/,并用 sitelo/islands/server 的 createIslandsNodeHandler 渲染区块。区块模块留在 dist/ 之外 —— 宿主从 src/ 导入它们。
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createIslandsFromDirectory, createIslandsNodeHandler } from 'sitelo/islands/server'
const root = path.dirname(fileURLToPath(import.meta.url))
const dist = path.join(root, 'dist')
const port = Number(process.env.PORT) || 3000
const handleIslands = createIslandsNodeHandler({
islands: createIslandsFromDirectory(path.join(root, 'src/islands')),
})
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.xml': 'application/xml',
'.json': 'application/json',
}
function sendFile(res, filePath) {
const ext = path.extname(filePath)
res.statusCode = 200
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
fs.createReadStream(filePath).pipe(res)
}
function resolveStatic(urlPath) {
const clean = decodeURIComponent(urlPath.split('?')[0])
const relative = clean === '/' ? 'index.html' : clean.replace(/^\/+/, '')
const candidate = path.normalize(path.join(dist, relative))
if (!candidate.startsWith(dist + path.sep) && candidate !== dist) {
return null
}
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate
}
const asIndex = path.join(candidate, 'index.html')
if (fs.existsSync(asIndex) && fs.statSync(asIndex).isFile()) {
return asIndex
}
return null
}
const server = http.createServer(async (req, res) => {
await handleIslands(req, res, () => {
const file = resolveStatic(req.url ?? '/')
if (file) {
sendFile(res, file)
return
}
const notFound = path.join(dist, '404.html')
res.statusCode = 404
if (fs.existsSync(notFound)) {
sendFile(res, notFound)
} else {
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end('未找到')
}
})
})
server.listen(port, () => {
console.log(`正在监听 http://localhost:${port}`)
})5. 构建并运行
npm install
sitelo build
node server.js打开 http://localhost:3000。你应该会先短暂看到回退内容,然后是服务器时间。刷新一下 —— 时间戳会变。在 sitelo(dev)和 sitelo preview 里你不需要 server.js:CLI 已经在提供 /_sitelo/islands。
部署
本示例在 Node 服务器旁边还附带了几份宿主模板:
Node——npm run build && npm start(在平台上设置PORT)。使用createIslandsFromDirectory。Netlify——netlify.toml把/_sitelo/islands/*重写到netlify/functions/islands.mjs。Vercel——vercel.json重写到api/islands/[...path].js。- 纯静态托管(GitHub Pages、朴素的 S3)—— 在你加上函数之前,占位符会一直保留回退内容。
要在别处使用 serverless 或边缘环境,请用 createIslandsHandler(Web Request → Response)—— 参见服务端区块文档。如果该函数不同源,请把 mountIslands({ endpoint }) 指向它的 URL。
说明
只有静态托管时
GitHub Pages、朴素的 S3 等托管没有服务器进程。没有区块端点时,回退 HTML 就会一直留着 —— 页面照样能用,只是少了那个动态片段。
让 props 保持精简
props 会随 HTML 属性和请求查询串一起传输。不要把机密或大块数据放进去 —— 那些应当在服务端、在区块模块内部去获取。