跳转到内容

Observable Framework — 编译期跑数据,浏览器只看结果

是什么

Observable Framework 是 Observable 公司 2024 年 2 月开源的静态数据应用生成器。日常类比:传统 Jupyter notebook 是”现点现做”——前端要画图,就得有一个 Python kernel 一直在后厨候着;Framework 是”中央厨房预制菜”——build 时把 SQL/Python/R 跑一遍,结果封装成静态 CSV/Parquet/JSON,浏览器拿到只负责渲染,没人在后端。

最小骨架:

docs/
index.md ← 一篇 Markdown 即一页
data/sales.sql ← SQL 数据加载器,build 时执行
data/forecast.py ← Python 加载器,build 时跑预测

index.md 里这样写:

```sql
SELECT region, sum(amount) FROM sales GROUP BY region
```
```js
const data = FileAttachment("data/forecast.json").json()
Plot.barY(data, {x: "region", y: "amount"}).plot()
```

npm run build 把 SQL 跑一次落 Parquet,Python 跑一次落 JSON,DuckDB-Wasm 在浏览器读 Parquet,Plot 渲染图——整个站零后端,可丢任何 CDN。

为什么重要

不理解 Framework,下面这些事都没法解释:

  • 为什么数据团队做内部 dashboard 越来越爱”build 一次出静态站”——SaaS 比如 Hex/Mode 要月费且数据离开公司,Framework 全本地
  • 为什么 duckdb + WebAssembly 让”浏览器里跑 OLAP”成立——以前要 Postgres + 后端 API,现在 Parquet 文件 + DuckDB-Wasm 就够
  • 为什么 Observable 把 reactive notebook 做了快 8 年又另起炉灶做 Framework——notebook 的”实时 kernel”模式不适合发布给 100 个同事看
  • 为什么 LLM 生成数据应用越来越倾向 “Markdown + 加载器” 这套——文件即页面,结构透明,比框架内 DSL 好补全

核心要点

Framework 的心智模型可以拆成 三段

  1. Markdown 是源docs/*.md 一文件即一页面,prose / 代码块 / 组件混写。fenced code block```sql / ```js / ```python 决定 build 时由谁执行
  2. 数据加载器是 build-time 脚本data/foo.sql / data/foo.py / data/foo.R / data/foo.ts / data/foo.sh 任意可执行文件,stdout 是产物。Framework 按 mtime 缓存,没改不重跑
  3. 运行时只是 vite 静态产物:build 完输出 dist/,是普通 HTML/CSS/JS,扔 Netlify / Vercel / GitHub Pages / S3 + CloudFront 都行

关键内置组件:

  • DuckDB-Wasm:浏览器端跑 OLAP,```sql 代码块直接查 build 期落下的 Parquet
  • Observable Plot + D3observable-plot 是默认可视化层,D3 在底下兜底
  • InputsInputs.range() / Inputs.search() / Inputs.table() 等响应式控件
  • Reactive runtime:从 Observable notebook 移植的 dataflow——某 cell 依赖 xx 变它自动重算,无需 useState

写法上的关键约定:

  • 加载器名决定路由:data/sales.sql build 出 _file/data/sales.csv,页面 FileAttachment("data/sales.csv")
  • 代码块顶部 echo / display 等指令控制是否显示源码
  • index.md 不写 frontmatter 也行——和 Astro/Starlight 不同,结构来自目录而非 collection

实践案例

案例 1:SQL 加载器 + 浏览器查询

data/orders.sql

INSTALL httpfs; LOAD httpfs;
SELECT * FROM read_parquet('s3://my-bucket/orders/*.parquet')
WHERE order_date >= '2026-01-01'

build 时这条 SQL 跑一次,结果落 dist/_file/data/orders.parquetdocs/sales.md 里:

```sql id=top_skus
SELECT sku, sum(qty) AS total
FROM orders
GROUP BY sku ORDER BY total DESC LIMIT 10
```
```js
Plot.barY(top_skus, {x: "sku", y: "total"}).plot()
```

第二条 SQL 在浏览器端由 DuckDB-Wasm 执行——查的是同一个静态 Parquet。一次 build 落数据,多次过滤聚合都在前端。

案例 2:Python 数据加载器

data/forecast.py

import sys, json, pandas as pd
from prophet import Prophet
df = pd.read_csv("data/raw/sales.csv")
m = Prophet().fit(df.rename(columns={"date": "ds", "amount": "y"}))
future = m.make_future_dataframe(periods=90)
fcst = m.predict(future)[["ds", "yhat", "yhat_lower", "yhat_upper"]]
json.dump(fcst.to_dict(orient="records"), sys.stdout, default=str)

stdout 即产物。Framework 按文件名 data/forecast.py 注册路由 data/forecast.json。页面 FileAttachment("data/forecast.json").json() 读。Python 训练只在 build 跑一次,访客拿到的就是 JSON,不需要服务器。

案例 3:Inputs 联动 reactive cell

```js
const region = view(Inputs.select(["", "", "", "西"], {label: "区域"}))
```
```js
const filtered = orders.filter(d => d.region === region)
Plot.lineY(filtered, {x: "date", y: "amount"}).plot()
```

view() 让 select 的当前值变成响应式变量 region。下面 cell 引用 region,用户切换下拉框时自动重算 filtered 并重画——dataflow 由 runtime 调度,不用写监听。

踩过的坑

  1. 数据加载器是 build-time 的:第一感觉是”加载器不就是 API”——不是。npm run build 时跑一次落静态文件,访客不会触发它跑。要”用户输入参数 → 后端返回数据”那是普通 web 应用,应该上 Next.js / FastAPI

  2. 大数据 build 慢:1G CSV 在 build 期跑 SQL 聚合,每次 npm run build 都重跑会要命。Framework 用 mtime 缓存——加载器没改不重跑;CI 上要把缓存目录 (docs/.observablehq/cache) 缓存下来

  3. DuckDB-Wasm 内存有限:浏览器端 wasm 受 tab 内存限制(通常 2-4G),原始数据应该在 build 期 ETL 成小 Parquet,别原样 1G 丢前端

  4. hot reload 仅 devnpm run dev 改 md / 加载器自动刷;npm run build 之后产物是凝固的,要换数据必须重 build。即”看板每天 6 点自动刷”得靠 CI 定时触发 build

  5. 没有运行时后端:用户提交表单写库 / 登录鉴权 / 实时通知都做不到——要做就得另接 API(Cloudflare Workers / 自建服务)。Framework 解决”看”的部分,不解决”写”

适用 vs 不适用场景

适用

  • 数据团队做内部 dashboard / 报告 / KPI 看板——build 一次发静态站,不要 SaaS 月费
  • 新闻可视化 / 对外发布的数据故事——SEO 友好、CDN 极快、可离线截图
  • 把 Jupyter 探索结果”产品化”给 50 个同事看——比导出 PDF 灵活,比起 kernel 服务便宜
  • LLM 生成数据应用的目标格式——Markdown + 加载器结构透明

不适用

  • 实时数据流 / 秒级刷新——build 一次的设计不擅长,应转 streaming dashboard
  • 用户级权限 / 写库交互——无后端,要么外接 API 要么换框架
  • 大数据原始查询(百 G 起)——DuckDB-Wasm 跑不动,应保持服务端 OLAP(clickhouse / trino
  • 完全动态站——Framework 是 SSG,不要把 Next.js 该做的事让它做

学到什么

  1. build-time vs run-time 的取舍:把数据加载从 run-time 推到 build-time,省掉了一整套后端基建——代价是数据”凝固”,刷新靠 CI。这种取舍在 SSG / astro / Next.js ISR 反复出现
  2. 多语言加载器统一在 stdout:SQL/Python/R/JS/Shell 都通过 “可执行文件 + stdout 即产物” 这个统一接口接入——polyglot 的低成本做法
  3. WebAssembly 把 OLAP 搬到浏览器:DuckDB-Wasm + Parquet 让前端能跑过去要后端的查询,“零后端数据应用”这个新形态因此成立
  4. Markdown 是数据工程的最小公倍数:prose 给人读、代码块给机器跑——一个文件同时是文档和程序,是 jupyter-notebook 之后的下一代答案

延伸阅读

关联

  • observable-plot —— Framework 的可视化默认组件,API 表现层
  • duckdb —— SQL 代码块在浏览器端的执行引擎
  • d3 —— Plot 之下的底层渲染,Mike Bostock 一脉相承
  • astro —— 同为内容驱动的 SSG,Framework 更聚焦数据
  • jupyter-notebook —— Framework 想替代的”实时 kernel”模式