从0到1:Lustre全栈Web应用开发指南
引言:为什么选择Lustre构建全栈应用?
你还在为前端框架选择而纠结吗?React的复杂状态管理、Vue的模板语法限制、Angular的陡峭学习曲线是否让你望而却步?本文将带你探索一个全新选择——Lustre,这个受Elm启发的Gleam框架,如何让全栈Web开发变得简单而高效。
Lustre将函数式编程的优雅与Web平台的强大结合,提供了一种声明式、类型安全的开发方式。通过本文,你将学习如何利用Lustre构建从简单交互组件到复杂全栈应用的全过程,掌握其独特的组件模型、状态管理和服务器渲染能力。
目录
- 环境搭建:从零开始配置Lustre开发环境
- 核心概念:理解Lustre的MUV架构
- 组件开发:构建可复用的UI单元
- 路由管理:实现单页应用导航
- 表单处理:受控组件与数据验证
- 服务器组件:打造全栈渲染体验
- 状态管理:处理异步操作与副作用
- 部署优化:从开发到生产的完整流程
- 实战案例:构建一个完整的博客应用
环境搭建:从零开始配置Lustre开发环境
安装依赖
# 安装Gleam编译器
curl https://gleam.run/install.sh | sh
# 创建新项目
gleam new my_lustre_app
cd my_lustre_app
# 添加Lustre依赖
gleam add lustre
gleam add --dev lustre_dev_tools
项目结构
my_lustre_app/
├── src/ # 源代码目录
│ └── app.gleam # 应用入口文件
├── test/ # 测试目录
├── gleam.toml # 项目配置
└── manifest.toml # 依赖清单
第一个Lustre应用
import gleam/int
import lustre
import lustre/element.{text}
import lustre/element/html.{div, button, p}
import lustre/event.{on_click}
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
fn init(_flags) { 0 }
type Msg { Incr | Decr }
fn update(model, msg) {
case msg {
Incr -> model + 1
Decr -> model - 1
}
}
fn view(model) {
div([], [
button([on_click(Incr)], [text(" + ")]),
p([], [text(int.to_string(model))]),
button([on_click(Decr)], [text(" - ")])
])
}
运行开发服务器:
gleam run -m lustre_dev_tools
核心概念:理解Lustre的MUV架构
Lustre采用了与Elm相似的架构,基于三个核心部分:Model(模型)、Update(更新)和View(视图),形成了简洁而强大的单向数据流。
数据流架构
MUV架构详解
| 组件 | 作用 | 类比React |
|---|---|---|
| Model | 保存应用状态的数据结构 | State/Context |
| Msg | 描述状态变化的消息类型 | Actions/Events |
| Update | 根据Msg更新Model的纯函数 | Reducer |
| View | 将Model转换为UI的纯函数 | Render函数 |
核心类型定义
// Model - 应用状态
type Model { Model(count: Int, todos: List(Todo)) }
// Msg - 状态变更消息
type Msg {
Increment
Decrement
AddTodo(String)
ToggleTodo(Int)
}
// Update - 状态更新函数
fn update(model: Model, msg: Msg) -> Model {
case msg {
Increment -> Model(..model, count: model.count + 1)
Decrement -> Model(..model, count: model.count - 1)
// 其他消息处理...
}
}
// View - UI渲染函数
fn view(model: Model) -> Element(Msg) {
html.div([], [
html.h1([], [text("Lustre App")]),
html.p([], [text("Count: " <> int.to_string(model.count))]),
// 其他UI元素...
])
}
组件开发:构建可复用的UI单元
Lustre的组件系统基于Web Components API,允许创建封装的、可复用的UI单元。
基础组件结构
import lustre
import lustre/attribute
import lustre/component
import lustre/element
import lustre/element/html
// 组件配置
fn counter_config() -> component.Config(Msg) {
component.new([
component.on_property_change("value", decode.int),
component.on_context_change("theme", decode.string),
])
}
// 组件状态
type Model { Model(count: Int) }
// 组件消息
type Msg { Increment | Decrement }
// 初始化函数
fn init(_) -> #(Model, effect.Effect(Msg)) {
#(Model(0), effect.none())
}
// 更新函数
fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
case msg {
Increment -> #(Model(model.count + 1), effect.none())
Decrement -> #(Model(model.count - 1), effect.none())
}
}
// 视图函数
fn view(model: Model) -> element.Element(Msg) {
html.div([], [
html.button([event.on_click(Decrement)], [text("-")]),
html.span([], [text(int.to_string(model.count))]),
html.button([event.on_click(Increment)], [text("+")]),
])
}
// 注册组件
pub fn register() {
lustre.register_component(
"my-counter",
counter_config(),
init,
update,
view,
)
}
组件属性与事件
// 使用组件
fn app_view() -> element.Element(Msg) {
html.div([], [
element(
"my-counter",
[
attribute.property("value", 5),
attribute.on("count-change", CountChanged),
],
[],
),
])
}
// 处理组件事件
type Msg { CountChanged(Int) }
fn update(model: Model, msg: Msg) -> Model {
case msg {
CountChanged(value) -> Model(..model, last_count: value)
}
}
插槽与内容分发
// 带插槽的组件视图
fn view(model: Model) -> element.Element(Msg) {
html.div([], [
component.named_slot("header", [], [html.h3([], [text("默认标题")])]),
html.div([], [text("计数器: " <> int.to_string(model.count))]),
component.default_slot([], [html.p([], [text("默认内容")])]),
])
}
// 使用带插槽的组件
fn app_view() -> element.Element(Msg) {
element(
"my-counter",
[],
[
html.div([component.slot("header")], [html.h2([], [text("自定义标题")])]),
html.p([], [text("这是自定义内容")]),
],
)
}
组件样式封装
fn view(model: Model) -> element.Element(Msg) {
html.fragment([
html.style([], [
text("""
:host { display: flex; gap: 1rem; }
button { padding: 0.5rem 1rem; }
.count { font-size: 1.2rem; }
""")
]),
html.button([event.on_click(Decrement)], [text("-")]),
html.span([attribute.class("count")], [text(int.to_string(model.count))]),
html.button([event.on_click(Increment)], [text("+")]),
])
}
路由管理:实现单页应用导航
使用modem库实现客户端路由,管理应用的不同页面。
路由配置
import gleam/uri
import modem
import lustre/effect
import lustre/element/html
// 路由定义
type Route {
Home
Posts
PostById(id: Int)
About
NotFound(uri: Uri)
}
// 解析路由
fn parse_route(uri: Uri) -> Route {
case uri.path_segments(uri.path) {
[] | [""] -> Home
["posts"] -> Posts
["post", post_id] ->
case int.parse(post_id) {
Ok(id) -> PostById(id)
Error(_) -> NotFound(uri)
}
["about"] -> About
_ -> NotFound(uri)
}
}
// 生成链接
fn href(route: Route) -> attribute.Attribute(msg) {
let path = case route {
Home -> "/"
Posts -> "/posts"
PostById(id) -> "/post/" <> int.to_string(id)
About -> "/about"
NotFound(_) -> "/404"
}
attribute.href(path)
}
路由初始化与处理
// 初始化路由
fn init() -> #(Model, effect.Effect(Msg)) {
let route = case modem.initial_uri() {
Ok(uri) -> parse_route(uri)
Error(_) -> Home
}
let model = Model(route: route, posts: dict.new())
let effect = effect.batch([
modem.init(fn(uri) { parse_route(uri) |> UrlChanged }),
fetch_posts(),
])
#(model, effect)
}
// 处理路由变化
type Msg {
UrlChanged(Route)
// 其他消息...
}
fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
case msg {
UrlChanged(route) ->
#(Model(..model, route: route), load_route_data(route))
// 其他消息处理...
}
}
// 根据路由加载数据
fn load_route_data(route: Route) -> effect.Effect(Msg) {
case route {
Posts -> fetch_posts()
PostById(id) -> fetch_post(id)
_ -> effect.none()
}
}
路由视图渲染
// 应用视图
fn view(model: Model) -> element.Element(Msg) {
html.div([], [
view_header(model.route),
view_content(model),
view_footer(),
])
}
// 头部导航
fn view_header(current_route: Route) -> element.Element(Msg) {
html.nav([], [
html.ul([], [
view_nav_item(Home, current_route, "首页"),
view_nav_item(Posts, current_route, "文章"),
view_nav_item(About, current_route, "关于"),
])
])
}
// 导航项
fn view_nav_item(
route: Route,
current_route: Route,
label: String
) -> element.Element(Msg) {
let is_active = current_route == route
html.li([], [
html.a(
[
href(route),
attribute.classes([
#("nav-link", True),
#("active", is_active),
]),
],
[html.text(label)]
)
])
}
// 内容区域
fn view_content(model: Model) -> element.Element(Msg) {
case model.route {
Home -> view_home()
Posts -> view_posts(model.posts)
PostById(id) -> view_post(model.posts, id)
About -> view_about()
NotFound(uri) -> view_not_found(uri)
}
}
表单处理:受控组件与数据验证
Lustre提供了强大的表单处理能力,支持受控组件和声明式验证。
受控表单组件
import formal/form
import lustre/element/html
import lustre/event
// 表单数据类型
type LoginData { LoginData(username: String, password: String) }
// 创建表单
fn new_login_form() -> form.Form(LoginData) {
form.new({
use username <- form.field(
"username",
form.parse_string |> form.check_not_empty,
)
let check_password = fn(password) {
case string.length(password) >= 8 {
True -> Ok(password)
False -> Error("密码长度不能少于8个字符")
}
}
use password <- form.field(
"password",
form.parse_string |> form.check(check_password),
)
form.success(LoginData(username:, password:))
})
}
// 表单视图
fn view_login(form: form.Form(LoginData)) -> element.Element(Msg) {
let handle_submit = fn(values) {
form |> form.add_values(values) |> form.run |> LoginSubmitted
}
html.form([event.on_submit(handle_submit)], [
html.div([], [
html.label([], [text("用户名")]),
html.input([
attribute.type_("text"),
attribute.name("username"),
]),
show_errors(form.field_errors(form, "username")),
]),
html.div([], [
html.label([], [text("密码")]),
html.input([
attribute.type_("password"),
attribute.name("password"),
]),
show_errors(form.field_errors(form, "password")),
]),
html.button([], [text("登录")]),
])
}
// 显示错误信息
fn show_errors(errors: List(String)) -> element.Element(Msg) {
case errors {
[] -> element.none()
_ -> html.ul([attribute.class("errors")], [
for (error) in errors {
html.li([attribute.class("error")], [text(error)])
}
])
}
}
表单状态管理
// 模型中的表单状态
type Model {
LoginForm(form.Form(LoginData))
LoggedIn(User)
Error(String)
}
// 初始化表单
fn init() -> Model {
LoginForm(new_login_form())
}
// 处理表单提交
type Msg {
LoginSubmitted(Result(LoginData, form.Form(LoginData)))
LoginSuccess(User)
LoginError(String)
}
fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
case msg {
LoginSubmitted(Ok(data)) ->
#(model, login(data.username, data.password))
LoginSubmitted(Error(form)) ->
#(LoginForm(form), effect.none())
LoginSuccess(user) ->
#(LoggedIn(user), effect.batch([
store_user_session(user),
effect.from(fn(dispatch) {
dispatch(UrlChanged(Home))
}),
]))
LoginError(message) ->
#(Error(message), effect.none())
}
}
// 登录API调用
fn login(username: String, password: String) -> effect.Effect(Msg) {
let url = "/api/login"
let body = json.object([
#("username", json.string(username)),
#("password", json.string(password)),
])
rsvp.post(
url,
[header("Content-Type", "application/json")],
json.to_string(body),
rsvp.expect_json(
decode.map(
user_decoder(),
LoginSuccess,
LoginError,
),
),
)
}
服务器组件:打造全栈渲染体验
Lustre的服务器组件允许在服务器上渲染组件,并通过WebSocket与客户端保持同步。
服务器组件结构
import lustre
import lustre/server_component
import mist
import gleam/http/request
import gleam/http/response
// 服务器组件入口
pub fn main() {
let assert Ok(_) =
fn(req: request.Request(mist.Connection)) -> response.Response(mist.ResponseData) {
case request.path_segments(req) {
[] -> serve_html()
["runtime.mjs"] -> serve_runtime()
["ws"] -> serve_component(req)
_ -> response.new(404)
}
}
|> mist.new
|> mist.port(3000)
|> mist.start
process.sleep_forever()
}
// 提供HTML页面
fn serve_html() -> response.Response(mist.ResponseData) {
let html = html.html([], [
html.head([], [
html.script([
attribute.type_("module"),
attribute.src("/runtime.mjs"),
], []),
]),
html.body([], [
server_component.element([server_component.route("/ws")], []),
]),
])
response.new(200)
|> response.set_header("Content-Type", "text/html")
|> response.set_body(mist.Bytes(html.to_bytes()))
}
// 提供客户端运行时
fn serve_runtime() -> response.Response(mist.ResponseData) {
mist.send_file(
"priv/static/lustre-server-component.mjs",
)
}
服务器组件通信
// WebSocket处理
fn serve_component(req: request.Request(mist.Connection)) -> response.Response(mist.ResponseData) {
mist.websocket(
request: req,
on_init: init_component,
handler: handle_message,
on_close: close_component,
)
}
// 组件初始化
fn init_component(_) -> #(State, effect.Effect(Msg)) {
let component = counter.component()
let assert Ok(runtime) = lustre.start_server_component(component, Nil)
#(State(runtime: runtime), effect.none())
}
// 消息处理
fn handle_message(
state: State,
msg: mist.WebsocketMessage,
conn: mist.WebsocketConnection,
) -> mist.Next(State) {
case msg {
mist.Text(data) ->
case json.parse(data, server_component.message_decoder()) {
Ok(msg) ->
lustre.send(state.runtime, msg)
mist.continue(state)
Error(_) -> mist.continue(state)
}
_ -> mist.continue(state)
}
}
状态管理:处理异步操作与副作用
Lustre通过Effect类型统一处理所有副作用,包括API调用、定时器和DOM操作。
副作用处理基础
import lustre/effect
import rsvp // HTTP客户端库
// 无副作用
fn no_effect() -> effect.Effect(Msg) {
effect.none()
}
// 基本副作用
fn log_message(message: String) -> effect.Effect(Msg) {
effect.from(fn(dispatch) {
io.println(message)
})
}
// 带回调的副作用
fn delayed_message(ms: Int, msg: Msg) -> effect.Effect(Msg) {
effect.from(fn(dispatch) {
let timer = set_timeout(fn() { dispatch(msg) }, ms)
effect.from(fn(_) { clear_timeout(timer) })
})
}
HTTP请求处理
// 获取文章列表
fn fetch_posts() -> effect.Effect(Msg) {
let url = "https://api.example.com/posts"
rsvp.get(
url,
rsvp.expect_json(
decode.list(post_decoder()),
fn(result) {
case result {
Ok(posts) -> PostsFetched(posts)
Error(e) -> FetchError("posts", e)
}
},
),
)
}
// 获取单篇文章
fn fetch_post(id: Int) -> effect.Effect(Msg) {
let url = "https://api.example.com/posts/" <> int.to_string(id)
rsvp.get(
url,
rsvp.expect_json(
post_decoder(),
fn(result) {
case result {
Ok(post) -> PostFetched(post)
Error(e) -> FetchError("post", e)
}
},
),
)
}
// 文章解码器
fn post_decoder() -> decode.Decoder(Post) {
use id <- decode.field("id", decode.int)
use title <- decode.field("title", decode.string)
use body <- decode.field("body", decode.string)
use author <- decode.field("author", user_decoder())
decode.success(Post(
id: id,
title: title,
body: body,
author: author,
))
}
复杂副作用组合
// 并行请求
fn load_dashboard_data() -> effect.Effect(Msg) {
effect.batch([
fetch_posts(),
fetch_comments(),
fetch_notifications(),
])
}
// 串行请求
fn create_and_load_post(data: PostData) -> effect.Effect(Msg) {
effect.from(fn(dispatch) {
let url = "https://api.example.com/posts"
rsvp.post(
url,
[header("Content-Type", "application/json")],
json.to_string(encode_post_data(data)),
rsvp.expect_json(
decode.int, // 假设API返回新创建的ID
fn(result) {
case result {
Ok(id) ->
dispatch(PostCreated(id))
// 创建成功后获取完整文章
fetch_post(id) |> effect.run(dispatch)
Error(e) ->
dispatch(PostCreateError(e))
}
},
),
)
|> effect.run(dispatch)
})
}
// 乐观更新
fn toggle_todo_optimistic(id: Int) -> effect.Effect(Msg) {
effect.batch([
// 立即更新UI
effect.from(fn(dispatch) {
dispatch(ToggleTodoLocally(id))
}),
// 后台同步到服务器
update_todo_on_server(id)
])
}
fn update_todo_on_server(id: Int) -> effect.Effect(Msg) {
let url = "https://api.example.com/todos/" <> int.to_string(id)
rsvp.patch(
url,
[header("Content-Type", "application/json")],
json.to_string(json.object([#("completed", json.bool(true))])),
rsvp.expect_json(
decode.unit,
fn(result) {
case result {
Ok(_) -> TodoUpdatedSuccess(id)
Error(e) -> TodoUpdateFailed(id, e)
}
},
),
)
}
部署优化:从开发到生产的完整流程
构建优化
# 编译生产版本
gleam build --target=javascript --optimize
# 打包静态资源
mkdir -p dist
cp -r priv/static/* dist/
cp examples/01-basics/01-hello-world/index.html dist/
# 使用esbuild压缩JS
esbuild \
--bundle build/dev/javascript/app.mjs \
--minify \
--outfile=dist/app.min.mjs \
--target=es6
部署选项
静态部署
<!-- dist/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Lustre App</title>
<script type="module" src="/app.min.mjs"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
服务器部署
// src/server.gleam
import gleam/http/request
import gleam/http/response
import gleam/bytes
import mist
pub fn main() {
let assert Ok(_) =
fn(req: request.Request(Nil)) -> response.Response(mist.ResponseData) {
case request.path(req) {
"/" -> mist.send_file("dist/index.html")
"/app.min.mjs" -> mist.send_file("dist/app.min.mjs")
_ -> response.new(404)
}
}
|> mist.new
|> mist.port(8080)
|> mist.start
process.sleep_forever()
}
容器化部署
# Dockerfile
FROM erlang:25-alpine
WORKDIR /app
COPY gleam.toml manifest.toml ./
COPY src ./src
COPY priv ./priv
RUN apk add --no-cache nodejs npm
RUN gleam deps download
RUN gleam build --target=javascript --optimize
EXPOSE 8080
CMD ["gleam", "run", "-m", "server"]
实战案例:构建一个完整的博客应用
应用架构
数据模型
// 文章模型
type Post {
Post(
id: Int,
title: String,
slug: String,
content: String,
author: User,
createdAt: DateTime,
updatedAt: DateTime,
tags: List(String),
)
}
// 用户模型
type User {
User(
id: Int,
username: String,
name: String,
avatar: Option(String),
)
}
// 评论模型
type Comment {
Comment(
id: Int,
postId: Int,
author: User,
content: String,
createdAt: DateTime,
)
}
核心功能实现
文章列表与分页
// 文章列表组件
fn view_posts(model: Model) -> element.Element(Msg) {
html.div([attribute.class("posts-container")], [
html.h1([], [text("文章列表")]),
html.div([attribute.class("posts-list")], [
for (post) in model.posts {
view_post_card(post)
}
]),
html.div([attribute.class("pagination")], [
html.button(
[
attribute.disabled(model.page == 1),
event.on_click(LoadPage(model.page - 1)),
],
[text("上一页")]
),
html.span([], [text("第 " <> int.to_string(model.page) <> " 页")]),
html.button(
[
attribute.disabled(model.page >= model.totalPages),
event.on_click(LoadPage(model.page + 1)),
],
[text("下一页")]
),
])
])
}
// 文章卡片
fn view_post_card(post: Post) -> element.Element(Msg) {
html.article([attribute.class("post-card")], [
html.h2([], [
html.a(
[href(PostById(post.id))],
[text(post.title)]
)
]),
html.div([attribute.class("post-meta")], [
html.span([], [text(post.author.name)]),
html.span([], [text(format_date(post.createdAt))]),
html.div([attribute.class("tags")], [
for (tag) in post.tags {
html.span([attribute.class("tag")], [text(tag)])
}
])
]),
html.p([attribute.class("post-excerpt")], [
text(first_paragraph(post.content))
]),
html.a(
[href(PostById(post.id)), attribute.class("read-more")],
[text("阅读全文 →")]
)
])
}
文章详情与评论
// 文章详情页
fn view_post_detail(model: Model) -> element.Element(Msg) {
case model.current_post {
None -> html.div([], [text("加载中...")])
Some(post) -> html.article([attribute.class("post-detail")], [
html.header([], [
html.h1([], [text(post.title)]),
html.div([attribute.class("post-meta")], [
html.span([], [text(post.author.name)]),
html.span([], [text(format_date(post.createdAt))]),
])
]),
html.div([attribute.class("post-content")], [
render_markdown(post.content)
]),
html.section([attribute.class("comments")], [
html.h2([], [text("评论 (" <> int.to_string(list.length(model.comments)) <> ")")]),
if (model.user.is_some()) {
comment_form(model.comment_form)
} else {
html.p([], [
html.text("请 "),
html.a([href(Login)], [text("登录")]),
html.text(" 后发表评论")
])
},
html.div([attribute.class("comments-list")], [
for (comment) in model.comments {
view_comment(comment)
}
])
])
])
}
}
后台管理
// 文章编辑表单
fn post_editor(model: PostEditorModel) -> element.Element(Msg) {
html.form([event.on_submit(SubmitPost)], [
html.div([attribute.class("form-group")], [
html.label([], [text("标题")]),
html.input([
attribute.type_("text"),
attribute.name("title"),
attribute.value(model.title),
event.on_input(UpdateTitle),
])
]),
html.div([attribute.class("form-group")], [
html.label([], [text("内容")]),
html.textarea([
attribute.name("content"),
attribute.value(model.content),
event.on_input(UpdateContent),
attribute.class("markdown-editor"),
])
]),
html.div([attribute.class("form-group")], [
html.label([], [text("标签 (用逗号分隔)")]),
html.input([
attribute.type_("text"),
attribute.value(string.join(model.tags, ", ")),
event.on_input(UpdateTags),
])
]),
html.div([attribute.class("form-actions")], [
html.button(
[attribute.type_("button"), event.on_click(CancelEdit)],
[text("取消")]
),
html.button(
[attribute.type_("submit")],
[text(if model.is_new { "创建" } else { "更新" })]
)
])
])
}
部署与扩展
该博客应用可以部署为:
- 纯静态应用,使用客户端路由和API调用
- 混合应用,结合服务器组件实现SSR
- 全栈应用,使用Lustre的服务器组件和后端集成
总结与展望
Lustre提供了一种优雅的方式来构建现代Web应用,结合了函数式编程的强大和Web平台的灵活性。通过本文介绍的核心概念、组件模型、路由管理、表单处理、服务器组件和状态管理技术,你已经具备了构建复杂全栈应用的基础知识。
未来Lustre将继续发展,可能会增加更多企业级特性,如更完善的状态管理工具、更强大的服务器渲染能力和更好的开发体验。无论你是前端开发者还是全栈工程师,Lustre都值得你加入技术栈,体验函数式Web开发的乐趣。
扩展学习资源
- 官方文档:深入了解Lustre的API和高级特性
- 示例项目:研究examples目录中的20+个示例应用
- 社区库:探索rsvp、modem等配套生态系统
- 源码阅读:理解Lustre的内部实现和设计理念
祝你在Lustre的全栈开发之旅愉快!
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



