简短故事:Clojure(Script) 与 Web
从 21 世纪初至今
- 学过 Vim 和 Emacs,但两个都用得很烂
- HTTP 和 Web
- 从 LAMP 到 SPA 以及更多的框架
- 大量的脚本编写和元编程(JavaScript, Ruby, Groovy, Scala 等)
- JavaScript 从玩具变成了承载核心业务的基石
LISP
- 括号多到数不清
- 既迷人又怪异
- 大神们写了各种书(《Practical Common Lisp》、《SICP》等)
- 听过很多关于它的疯狂故事(PG, HN, 《黑客与画家》等)
- 各种 LISP 实现都非常奇特
实用的 LISP
- 2009 年左右在一次 Java 会议上发现了 Clojure
- 因为 JVM 的存在,终于有了一个值得尝试的相关技术
- 第一次认真尝试:在 Spring 应用中结合 Java 接口与 Clojure 实现
- 情况变得更好:基于 JavaScript 的新 Clojure(Google Closure Library,Google Closure Compiler:生产级的激进压缩)
- 渐进式启蒙:(动词 数据)
Clojure(Script):清晰思考的工具
- REPL(交互式开发环境)
- 语言适应领域,而非领域适应语言
- 语法?什么语法?一切皆为数据结构
- 同构(Isomorphic)
- Clojure 是一门宿主语言(JVM, JavaScript, Dart 等)
- 去除冗余,直击本质
- 规范 (Specs) + 函数 + 数据 + 验证:(带规范的动词 数据)
Clojure(Script) 基础
以下每个代码块都经过处理,你可以直接将其粘贴到 ClojureScript REPL 中进行现场演示。
在 Lisp 语法中,左括号后的第一个元素即为函数:
(+ 1 2 3)
;; => 6
(str "Hello, " "ClojureScript")
;; => "Hello, ClojureScript"
数据字面量:值优先
Clojure(Script) 代码主要由简单的数据形态构成:
42 ;; 数字
"Ada" ;; 字符串
:admin ;; 关键字,常作为数据标签
[1 2 3] ;; 向量,有序值
{:name "Ada" :visits 1} ;; 映射(Map),键值对
#{:cljs :repl :data} ;; 集合(Set),唯一值
命名空间与 require
在 REPL 中,使用 require 加载命名空间并为其指定简短别名。
实际文件中通常在顶部使用 ns 表单,但 require 对于现场 REPL 演示更友好。
(require '[clojure.string :as str])
(str/upper-case "fast twitch")
;; => "FAST TWITCH"
(str/trim " hello ")
;; => "hello"
关键字也可以像微型查找函数一样使用:
(def user {:name "Ada" :visits 1})
(:name user)
;; => "Ada"
(assoc user :active? true)
;; => {:name "Ada", :visits 1, :active? true}
(update user :visits inc)
;; => {:name "Ada", :visits 2}
键与命名空间键
映射的键是用于查找值的标签。关键字是常用的映射键,因为它们短小、易读,且能作为查找函数使用。
(def response
{:status 200
:body "ok"})
(get response :status)
;; => 200
(:body response)
;; => "ok"
命名空间关键字在斜杠前添加了前缀。它们本质上仍然是关键字,但前缀有助于在大型数据映射中避免冲突。
(def person
{:person/name "Ada"
:account/name "ada-lovelace"
:account/id 42})
(:person/name person)
;; => "Ada"
(:account/name person)
;; => "ada-lovelace"
(select-keys person [:person/name :account/id])
;; => {:person/name "Ada", :account/id 42}
你可以检查关键字的命名空间部分和名称部分:
(namespace :account/id)
;; => "account"
(name :account/id)
;; => "id"
(keyword "account" "email")
;; => :account/email
函数
defn 用于给函数命名。名称后的向量是参数列表。
(defn greet [name]
(str "Hello, " name "!"))
(greet "Ada")
;; => "Hello, Ada!"
当函数较小且仅在局部使用时,匿名函数非常有用:
((fn [x] (* x 10)) 7)
;; => 70
(#(* % 10) 7)
;; => 70
多元函数
同一个函数名可以支持不同数量的参数。
(defn greeting
([] (greeting "friend"))
([name] (str "Hello, " name))
([title name] (str "Hello, " title " " name)))
(greeting)
;; => "Hello, friend"
(greeting "Ada")
;; => "Hello, Ada"
(greeting "Dr." "Hopper")
;; => "Hello, Dr. Hopper"
这使得简单的默认值可以紧邻完整版本定义。
map
map 对集合中的每一项运行一个函数。它返回一个惰性序列,因此如果你想在 REPL 中看到向量,请使用 vec。
(map inc [1 2 3])
;; => (2 3 4)
(vec (map inc [1 2 3]))
;; => [2 3 4]
(def todos
[{:title "Read the schema" :done? true}
{:title "Try the REPL" :done? false}])
(map :title todos)
;; => ("Read the schema" "Try the REPL")
->
线程优先宏 -> 将前一个结果作为第一个数据参数放入下一个表单中。它使从左到右的数据处理逻辑更易于阅读。
(-> {:name "Ada" :visits 1}
(assoc :active? true)
(update :visits inc))
;; => {:name "Ada", :visits 2, :active? true}
读作:从这个 map 开始,然后关联一个值,再更新一个值。
->>
线程最后宏 ->> 将前一个结果放在下一个表单的末尾。这在集合处理管道中很常见。
(def todos
[{:title "Read the schema" :done? true}
{:title "Try the REPL" :done? false}])
(->> todos
(filter :done?)
(map :title)
vec)
;; => ["Read the schema"]
读作:获取 todos,保留已完成项,提取它们的标题,生成一个向量。
使用 let 定义局部名称
let 为值赋予临时名称。这些名称仅在 let 块内部有效。
(let [title "Try the REPL"
done? false]
{:title title
:status (if done? "done" "open")})
;; => {:title "Try the REPL", :status "open"}
解构 (Destructuring)
解构无需手动调用 get 即可从映射和向量中提取名称。
(def user {:name "Ada" :visits 1})
(let [{:keys [name visits]} user]
(str name " visited " visits " time(s)"))
;; => "Ada visited 1 time(s)"
(let [ [first-item second-item & remaining] ["a" "b" "c" "d"]]
{:first first-item
:second second-item
:remaining remaining})
;; => {:first "a", :second "b", :remaining ("c" "d")}
这对于请求映射(request maps)特别方便:
(def request
{:request-method :get
:params {:id "42"}
:headers {"accept" "application/json"}})
(let [{:keys [request-method params]} request
{:keys [id]} params]
{:method request-method
:todo-id id})
;; => {:method :get, :todo-id "42"}
if, when, if-let 和 when-let
if 在两个值之间进行选择。在 Clojure(Script) 中,只有 false 和 nil 为假;0、"" 和空集合均为真。
(if (:done? {:title "Read" :done? true})
"Done"
"Open")
;; => "Done"
(if 0 "yes" "no")
;; => "yes"
when 用于“仅当测试为真时执行此操作”;否则返回 nil。
(when (seq "Ada")
"There is a name")
;; => "There is a name"
(when (seq "")
"This will not appear")
;; => nil
if-let 和 when-let 仅在值非假时绑定名称。这种模式常出现在路由匹配、Cookie 处理和浏览器渲染中。
(def request-with-user {:session {:user "Ada"}})
(if-let [user (get-in request-with-user [:session :user])]
(str "Signed in as " user)
"Please log in")
;; => "Signed in as Ada"
(when-let [title (get-in {:params {:todo {:title "Ship demo"}}}
[:params :todo :title])]
(str "Todo: " title))
;; => "Todo: Ship demo"
cond, case 和 condp
当有多个测试条件时使用 cond。它会按顺序检查。
(defn todo-status [{:keys [done? blocked?]}]
(cond
blocked? "blocked"
done? "done"
:else "open"))
(todo-status {:done? false :blocked? true})
;; => "blocked"
当一个值与已知常量进行比较时,使用 case。
(defn method-action [request-method]
(case request-method
:get "read"
:post "write"
:delete "remove"
"unknown"))
(method-action :post)
;; => "write"
condp 在此仓库中不太常用,但当每个分支使用相同的谓词时非常有用。
(defn score-band [score]
(condp <= score
90 :great
75 :solid
:needs-practice))
(score-band 82)
;; => :solid
cond->
cond-> 从一个值开始,仅当测试为真时才应用每一步。本项目使用它来构建请求映射、头部信息和中间件配置。
(defn response-map [{:keys [body location]}]
(cond-> {:status 200
:headers {}}
body
(assoc :body body)
location
(assoc-in [:headers "Location"] location)))
(response-map {:body "ok" :location "/todos"})
;; => {:status 200, :headers {"Location" "/todos"}, :body "ok"}
some->
some-> 类似于 ->,但一旦某一步返回 nil,它就会停止并返回 nil。这在数据可能缺失时非常有用。
(some-> {:pathname {:groups {:id "42"}}}
:pathname
:groups
:id)
;; => "42"
(some-> nil
:pathname
:groups
:id)
;; => nil
嵌套映射:get-in, assoc-in 和 update-in
请求映射和响应映射通常是嵌套的。这些辅助函数避免了手动层层挖掘。
(def response {:status 200 :headers {}})
(assoc-in response [:headers "Content-Type"] "text/plain")
;; => {:status 200, :headers {"Content-Type" "text/plain"}}
(get-in {:params {:todo {:title "Learn get-in"}}}
[:params :todo :title])
;; => "Learn get-in"
(update-in {:todos {1 {:done? false}}}
[:todos 1 :done?]
not)
;; => {:todos {1 {:done? true}}}
使用 atom, @, swap! 和 reset! 管理状态
大多数数据是不可变的。当应用需要更改状态时,本仓库使用 atom,例如用于待办事项、计数器和浏览器 UI 状态。
(def store
(atom {:clicks 0
:chips []}))
@store
;; => {:clicks 0, :chips []}
(swap! store update :clicks inc)
;; => {:clicks 1, :chips []}
(swap! store update :chips conj "REPL")
;; => {:clicks 1, :chips ["REPL"]}
(reset! store {:clicks 0 :chips []})
;; => {:clicks 0, :chips []}
在源文件中,本仓库常使用 defonce 来定义浏览器或服务器状态。defonce 的意思是“仅在不存在时定义”,这在 REPL 重载期间非常方便。在现场演示中,普通的 def 较少引起困惑,因为它每次都会重置示例。
reduce
reduce 在遍历集合的同时携带一个累加器。源码中使用它来构建映射、头部信息和配置。
(reduce
(fn [total n]
(+ total n))
0
[10 20 30])
;; => 60
(reduce
(fn [headers [k v]]
(assoc headers k v))
{}
[ [:content-type "text/plain"]
[:etag "v1"]])
;; => {:content-type "text/plain", :etag "v1"}
loop 和 recur
当你需要显式循环而无需可变计数器时,请使用 loop 和 recur。在许多情况下,map、filter 或 reduce 会更简单。
(loop [n 3
result []]
(if (zero? n)
result
(recur (dec n) (conj result n))))
;; => [3 2 1]
JavaScript 互操作
ClojureScript 托管在 JavaScript 之上,因此当我们需要使用平台功能时,可以直接调用 JavaScript。
(.log js/console "Hello from ClojureScript")
;; 在 JS 控制台打印
(.toISOString (js/Date.))
;; => 当前时间的 ISO 字符串
(.round js/Math 4.6)
;; => 5
在 API 边界处使用 clj->js 和 js->clj:
(def json-text
(.stringify js/JSON (clj->js {:ok true :items [1 2 3]}) nil 2))
json-text
;; => "{\n \"ok\": true,\n \"items\": [\n 1,\n 2,\n 3\n ]\n}"
(js->clj (.parse js/JSON json-text) :keywordize-keys true)
;; => {:ok true, :items [1 2 3]}
对于原生 JavaScript 对象,属性访问是显式的:
(def js-user #js {:name "Ada" :visits 2})
(.-name js-user)
;; => "Ada"
(aget js-user "visits")
;; => 2
JavaScript Promise
许多浏览器、Node、Deno 和 Bun API 都返回 Promise。本仓库常使用 -> 来使 Promise 链更具可读性。
(-> (js/Promise.resolve "hello")
(.then (fn [text]
(.toUpperCase text)))
(.then (fn [loud]
(.log js/console loud)))
(.catch (fn [error]
(.error js/console error))))
;; 打印 "HELLO"; 返回一个 Promise
try, catch 和 throw
当 JavaScript 或解析代码可能抛出异常时使用 try。在 ClojureScript 中,(catch :default error ...) 可以捕获普通的 JavaScript 错误。
(try
(.parse js/JSON "{bad json")
(catch :default error
(.-message error)))
;; => JSON 解析错误消息
(try
(throw (js/Error. "Boom"))
(catch :default error
(str "Caught: " (.-message error))))
;; => "Caught: Boom"
宏基础
宏在代码运行前对其进行转换。我们已经使用过宏了:->, ->>, when, cond-> 以及本仓库的 env-var / serve 辅助工具。
(macroexpand-1
'(-> {:status 200}
(assoc :body "ok")))
;; => (assoc {:status 200} :body "ok")
(macroexpand-1
'(when true
"runs when true"))
;; => (if true (do "runs when true"))
在本仓库中,自定义宏(如 env-var 和 serve)位于 src/fast_twitch/macros.cljc。对于新手演示,在编写自定义宏之前,先从核心宏的 macroexpand-1 开始。
一旦理解了概念,就可以正常评估条件线程:
(cond-> {:status 200}
true
(assoc :body "ok")
false
(assoc :debug true))
;; => {:status 200, :body "ok"}
实用库
对于库代码片段,请使用在类路径中具有依赖项的项目或示例。在本仓库中,examples/07-json-api-malli-openapi 已经包含了 Malli 和 Integrant。
Malli
Malli 让我们用数据来描述数据。模式(Schema)只是一个 Clojure(Script) 值,我们可以对其进行验证、解释、转换或重用于文档。
(require '[malli.core :as m])
(require '[malli.error :as me])
(def TodoCreate
[:map
{:closed true}
[:title [:string {:min 1}]]
[:done {:optional true} :boolean]])
(m/validate TodoCreate {:title "Learn Malli" :done false})
;; => true
(m/validate TodoCreate {:title "" :extra "not allowed"})
;; => false
(me/humanize
(m/explain TodoCreate {:title "" :extra "not allowed"}))
;; => {:title ["should be at least 1 character"],
;; :extra ["disallowed key"]}
当你需要一个可重用的验证函数时,使用 m/validator:
(require '[malli.core :as m])
(def TodoCreate
[:map
{:closed true}
[:title [:string {:min 1}]]
[:done {:optional true} :boolean]])
(def valid-todo? (m/validator TodoCreate))
(valid-todo? {:title "Ship the demo"})
;; => true
(valid-todo? {:done true})
;; => false
对初学者来说重要的概念是:与其将验证规则隐藏在许多条件判断中,不如将有效数据的形态放在一个简单的数据值中。
Integrant
Integrant 从配置映射启动应用。每个顶级键描述系统的一部分,ig/ref 表示某一部分依赖于另一部分。
(require '[integrant.core :as ig])
(defmethod ig/init-key ::settings [_ options]
options)
(defmethod ig/init-key ::greeter [_ {:keys [settings]}]
(fn [name]
(str (:prefix settings) ", " name "!")))
(def config
{::settings {:prefix "Hello"}
::greeter {:settings (ig/ref ::settings)}})
(def system (ig/init config))
((::greeter system) "Ada")
;; => "Hello, Ada!"
发生了什么:
::settings首先初始化。::greeter接收了已初始化的设置。system是一个包含已初始化组件的映射。
对于拥有资源的组件,使用 ig/halt-key! 添加清理逻辑:
(require '[integrant.core :as ig])
(defmethod ig/init-key ::store [_ initial-state]
(atom initial-state))
(defmethod ig/halt-key! ::store [_ store]
(reset! store {}))
(def store-system
(ig/init {::store {:todos []}}))
@(::store store-system)
;; => {:todos []}
(ig/halt! store-system)
;; => 忽略返回值;halt! 用于副作用
对初学者来说重要的概念是:将配置保持为数据,并将启动和关闭行为保持在附加到这些数据键的小方法中。
FastTwitch
- 基于主流 JavaScript 运行时的 Web 后端
- 捕捉构建 Web 后端真正需要的东西
- 经过多次尝试
- ExpressJs
- Fastify
- 现在仅使用
fetchAPI 和 Web 标准 - API 灵感来源于
ring

