shadow-cljs-user-guide

Table of Contents

1. 简介

shadow-cljs 提供了编译 ClojureScript 项目所需的一切, 重点在于简洁性和易用性. 提供的构建目标抽象了大部分手动配置, 只需构建配置基本要素. 每个目标都为各种环境提供了最佳默认值, 并在开发和发布构建中获得优化体验.

1.1. 概述

shadow-cljs 由两部分组成:

如果需要, 可以将 shadow-cljs Clojure 库集成到任何其他 Clojure/JVM 构建工具中 (例如 leiningen (https://leiningen.org/) 或 Clojure CLI (https://clojure.org/guides/deps_and_cli) 工具).

建议使用 npm 包, 因为它提供了更优化的, 针对 CLJS 开发量身定制的开发体验.

1.2. 基本工作流程

使用 shadow-cljs 时, 在 shadow-cljs.edn 配置文件中定义一个或多个构建(build). 每个构建都有一个 :target 属性, 它代表一个为目标环境 (例如浏览器, node.js 应用程序或 Chrome 扩展) 优化的配置预设.

每个构建既可以生成开发(dev)输出也可以生成发布(release)输出, 具体取决于用于触发编译的命令. 标准的构建命令是: compile, watchrelease.

1.2.1. 开发模式(dev)

可以一次性 compile 一个开发构建, 或者运行一个 watch 进程, 该进程将监视源文件并自动重新编译它们 (如果需要, 还可以实时重载代码).

所有开发构建都为开发者体验进行了优化, 具有快速的反馈周期和其他功能, 如 REPL, 可以直接与你正在运行的代码进行交互.

开发构建不应该公开发布, 因为它们可能会变得非常大, 并且可能仅在编译它们的机器上工作, 具体取决于 :target.

1.2.2. 发布模式(release)

创建一个 release 构建将剥离所有与开发相关的代码, 并最终通过 Closure Compiler 运行代码. 这是一个针对 JavaScript 的优化编译器, 它将显著减小代码的整体大小.

1.3. 重要概念

在使用 shadow-cljs 时, 应该熟悉几个重要的概念. 它们对于理解所有东西如何组合在一起以及该工具如何与你的代码一起工作至关重要.

1.3.1. The Classpath

shadow-cljs 使用 Java 虚拟机 (JVM) 及其 "classpath" 来处理文件. 这是一个由许多 classpath 条目组成的虚拟文件系统. 每个条目要么是

  • 一个本地文件系统目录, 由配置中的 :source-paths 条目管理.
  • 或者一个 .jar 文件, 代表 Clojure(Script) 或 JVM 库. 它们是包含许多文件的压缩存档 (基本上就是一个 .zip 文件). 这些是由你的 :dependencies 添加的.

在 Clojure(Script) 中, 所有东西都是有命名空间的, 每个名称都期望解析为一个文件. 如果你有一个 (ns demo.app) 命名空间, 编译器期望在 classpath 上找到一个 demo/app.cljs (或 .cljc). classpath 将按顺序搜索, 直到找到为止. 假设你配置了 :source-paths ["src/main" "src/test"], 编译器将首先查找 src/main/demo/app.cljs, 然后是 src/test/demo/app.cljs. 当在任何源路径上都找不到文件时, JVM 将开始在 classpath 上的 .jar 文件中查找. 当它在任何库的根目录下找到 demo/app.cljs 时, 该文件将被使用.

当一个文件名在 classpath 上多次存在时, 只使用第一个. JVM 和 Clojure(Script) 上的所有东西都是有命名空间的, 以避免此类冲突. 这与 npm 非常相似, 每个包都必须有一个唯一的名称.

因此, 建议在选择名称和正确命名所有东西方面非常严谨. 总是使用 (ns your-company.components.foo) 而不是 (ns components.foo) 可能看起来很重复, 但它会在以后为你省去很多麻烦.

这与 npm 不同, npm 包名本身从不用于包内部, 只使用相对路径.

1.3.2. 服务器模式

shadow-cljs 可以以 "服务器" 模式启动, 这对于像 watch 这样的长时间运行的任务是必需的. watch 会隐式地启动服务器实例 (如果它尚未运行). 服务器将提供构建将连接到的 Websocket 端点, 以及用于 nREPL, Socket REPL 和开发 HTTP 服务器的所有其他端点.

当使用 shadow-cljs CLI 时, 所有命令都将重用正在运行的服务器实例 JVM, 而不是启动一个新的 JVM. 这要快得多, 因为启动时间可能相当慢.

然而, 一旦服务器运行起来, 只需在 :dependencies 更改时重新启动它, 其他所有事情都可以通过 REPL 完成.

1.3.3. REPL

REPL 是所有 Clojure(Script) 开发的核心, 每个 CLI 命令也可以直接从 REPL 使用. 即使命令行看起来更熟悉, 也绝对值得去适应 REPL.

1.4. 关于本书

1.4.1. 进行中的工作

这是一项正在进行中的工作. 如果你发现错误, 请提交 PR 来修复它, 或者提交一个 issue, 详细说明问题.

1.4.2. 贡献

本书的源文件托管在 Github (https://github.com/shadow-cljs/shadow-cljs.github.io) 上.

1.4.3. 使用的约定

本书中有许多示例. 其中使用的大多数东西从上下文中应该很明显, 但为了防止误解, 了解作者的意图很重要.

当给出命令行示例时, 我们可能会包含 BASH 注释 (以 # 开头), 并且通常会包含标准用户 UNIX 提示符 $ 来表示命令与其输出的分隔.

# 一个注释. 这个命令列出文件:
$ ls -l
shadow-cljs.edn
project.clj
...

许多示例是关于编译器的配置文件. 这个文件包含一个 EDN 映射. 在我们已经讨论过必需选项的地方, 我们通常会为了清晰而省略它们. 在这种情况下, 我们通常会包含一个省略号来表示 "必需但不是我们当前关注点的内容":

例 1. 指定依赖项

{:dependencies [[lib "1.0"]]}

例 2. 添加源路径

{...
 :source-paths ["src"]
 ...}

这使我们能够简洁地包含足够的上下文, 以理解感兴趣的配置的嵌套结构:

例 3. 嵌套选项

{...
 :builds {:build-id {...
                     :output-dir "resources/public/js"}}}

代码示例可能也会类似地缩短.

2. 安装

2.1. 通过 npm 独立安装

你需要:

在你的项目目录中, 你需要一个 package.json. 如果你还没有, 可以通过运行 npm init -y 来创建一个. 如果你还没有项目目录, 可以考虑通过运行以下命令来创建它:

$ npx create-cljs-project my-project

这将创建所有必需的基本文件, 你可以跳过以下命令.

如果你已经有一个 package.json 并且只想添加 shadow-cljs, 请运行

NPM

$ npm install --save-dev shadow-cljs

Yarn

$ yarn add --dev shadow-cljs

为了方便, 你可以运行 npm install -g shadow-cljsyarn global add shadow-cljs. 这将让你稍后可以直接运行 shadow-cljs 命令. 你的项目中应该始终安装一个 shadow-cljs 版本, 全局安装是可选的.

2.2. 作为库使用

虽然建议通过 npm 运行独立版本, 但你也可以将 shadow-cljs 嵌入到任何其他 Clojure JVM 工具中 (例如 lein, boot, …).

工件可以在以下地址找到:

clojars [thheller/shadow-cljs "3.1.5"] (https://clojars.org/thheller/shadow-cljs)

npm v3.1.5 (https://github.com/thheller/shadow-cljs)

3. 用法

shadow-cljs 可以以多种不同的方式使用, 但总体工作流程保持不变.

在开发期间, 你可以选择一次性 compile 一个构建, 或者运行一个 watch 工作进程, 它会监视你的源文件的更改并自动重新编译它们. 启用后, watch 还会热重载你的代码并提供一个 REPL. 在开发过程中, 重点是开发者体验和快速的反馈周期. 开发代码不应该公开发布.

当需要正式发布时, 你会创建一个 release 构建, 它会创建一个适合生产的优化构建. 为此, 会使用 Closure Compiler (https://developers.google.com/closure/compiler/), 它会对你的代码应用一些非常严肃的 :advanced 优化, 以创建可用的最优化输出. 当使用大量与原生 JavaScript 的互操作时, 这可能需要一些调整才能正常工作, 但对于 ClojureScript (以及来自 Closure Library (https://developers.google.com/closure/library/) 的代码) 来说, 它可以完美无瑕地工作.

3.1. 命令行

如果全局安装了, 你可以直接使用 shadow-cljs 命令.

$ shadow-cljs help

如果你更喜欢只使用本地 npm 安装, 你可以通过 npxyarn 来调用它.

# npm
$ npx shadow-cljs help

# yarn
$ yarn shadow-cljs help

# 手动
$ ./node_modules/.bin/shadow-cljs help

本指南将假设有全局安装以保持示例简短, 但这不是必需的.

开发期间常用的 shadow-cljs 命令

# 一次性编译一个构建并退出
$ shadow-cljs compile app

# 编译并监视
$ shadow-cljs watch app

# 连接到构建的 REPL (在 watch 运行时可用)
$ shadow-cljs cljs-repl app

# 连接到独立的 node repl
$ shadow-cljs node-repl

运行为生产使用优化的发布构建.

$ shadow-cljs release app

有时你可能会因为 :advanced 编译而遇到一些发布问题. 这些命令可以帮助追踪原因.

$ shadow-cljs check app
$ shadow-cljs release app --debug

3.1.1. 服务器模式

shadow-cljs 命令启动可能相当慢. 为了改善这一点, shadow-cljs 可以在 "服务器模式" 下运行, 这意味着启动一个专用进程, 所有其他命令都可以使用该进程来更快地执行, 因为它们不必启动新的 JVM/Clojure 实例.

执行长时间运行任务的命令 (例如 watch) 会隐式启动一个服务器实例, 但通常建议运行一个专用的服务器进程.

你可以在一个专用的终端中在前台运行该进程. 使用 CTRL+C 来终止服务器.

$ shadow-cljs server

# 或者 (如果你想用 REPL 来控制服务器进程)
$ shadow-cljs clj-repl

你也可以通过通用的 start|stop|restart 函数在后台运行服务器.

$ shadow-cljs start
$ shadow-cljs stop
$ shadow-cljs restart

一旦有任何服务器在运行, 其他所有命令都会使用它并运行得更快.

3.2. 构建工具集成

shadow-cljs 可以与其他 Clojure 工具集成, 因为其主要分发形式只是一个通过 Clojars (https://clojars.org/thheller/shadow-cljs) 提供的 .jar 文件. 默认情况下, 你的 :dependencies 是通过 shadow-cljs.edn 管理的, 但你也可以使用其他构建工具来管理你的依赖关系.

注意

强烈建议使用独立的 shadow-cljs 版本. 该命令做了很多事情来优化用户体验 (例如更快的启动), 而其他工具则没有. 你还会为自己省去很多处理依赖冲突和其他相关错误的麻烦.

3.2.1. Leiningen

如果你想使用 Leiningen (https://leiningen.org/) 来管理你的依赖关系, 你可以通过在你的 shadow-cljs.edn 配置中添加一个 :lein 条目来实现. 有了这个设置, shadow-cljs 命令将使用 lein 来启动 JVM, 忽略 shadow-cljs.edn 中的任何 :source-paths:dependencies; 而是依赖 leinproject.clj 中设置它们.

{:lein true
 ;; :source-paths 和 :dependencies 现在在此文件中被忽略
 ;; 通过 project.clj 配置它们
 :builds { ... }}

使用一个专用的 lein profile

{:lein {:profile "+cljs"}
 :builds {...}}

示例 project.clj

(defproject my-awesome-project
  ...
  :profiles
  {:cljs
   {:source-paths ["src/cljs"]
    :dependencies [[thheller/shadow-cljs "..."]
                   [reagent "0.8.1"]]}})

当使用 project.clj 管理你的 :dependencies 时, 你必须在你的 :dependencies 中 (直接或在一个 profile 中) 手动包含 thheller/shadow-cljs (https://clojars.org/thheller/shadow-cljs) 工件.

当启动 shadow-cljs 或尝试编译构建时遇到奇怪的 Java Stackstraces, 你可能遇到了依赖冲突. shadow-cljs 与特定匹配的 org.clojure/clojurescriptclosure-compiler 版本一起使用非常重要. 你可以通过 lein deps :tree 检查, 所需版本列在 clojars (https://clojars.org/thheller/shadow-cljs) (在右侧) 上.

直接从 Leiningen 运行任务

如果你不想使用 shadow-cljs 命令本身, 你也可以直接通过 lein 执行 shadow-cljs 命令.

建议仍然使用 shadow-cljs 命令来运行命令, 因为这将充分利用正在运行的服务器模式实例. 当直接使用 lein 时, 这将比启动额外的 JVM 快得多.

只编译一次 :dev 模式, 无 REPL 或实时重载:

$ lein run -m shadow.cljs.devtools.cli compile build-id

创建一个 :release 模式的优化构建:

$ lein run -m shadow.cljs.devtools.cli release build-id

3.2.2. tools.deps / deps.edn

新的 deps.edn (https://clojure.org/guides/deps_and_cli) 也可以用来管理你的 :dependencies:source-paths, 而不是使用内置方法或 lein. 所有 shadow-cljs 命令都将通过新的 clojure 实用程序启动.

tools.deps 仍在频繁变化. 请确保你使用的是最新版本.

要使用此功能, 请在你的配置中设置 :deps true 属性. 也可以配置应该使用哪些 deps.edn 别名.

你必须手动将 thheller/shadow-cljs 工件添加到你的 deps.edn 中.

简单的 shadow-cljs.edn 示例

{:deps true
 :builds ...}

简单的 deps.edn 示例

{:paths [...]
 :deps {thheller/shadow-cljs {:mvn/version <latest>}}}

:cljs 别名的 shadow-cljs.edn 示例

{:deps {:aliases [:cljs]}
 :builds ...}

示例 deps.edn

{:paths [...]
 :deps {...}
 :aliases
 {:cljs
  {:extra-deps {thheller/shadow-cljs {:mvn/version <latest>}}}}}

这样你就设置好了, 可以像平常一样运行 shadow-cljs.

选项: 直接通过 clj 运行

或者, 如果你想跳过直接运行 shadow-cljs 命令行工具, 你也可以直接通过 clj 运行.

这将绕过 "服务器模式". 这意味着你运行的任何东西都将运行一个新的 JVM 实例, 并且可能会慢得多. 你将失去一些在此处概述的功能 (https://code.thheller.com/blog/shadow-cljs/2017/11/18/the-many-ways-to-use-shadow-cljs.html). 除此之外, 编译结果将是相同的.

{:paths [...]
 :deps {...}
 :aliases
 {:shadow-cljs
  {:extra-deps {thheller/shadow-cljs {:mvn/version <latest>}}
   :main-opts ["-m" "shadow.cljs.devtools.cli"]}}}
clj -M:shadow-cljs watch app

你也可以通过命令行使用 -M 指定额外的别名, 例如 shadow-cljs -M:foo:bar ....

3.2.3. Boot

作者们对 Boot 的经验很少, 所以这一章需要贡献. 我们了解到 Boot 允许你用函数构建你的工具链. 由于 shadow-cljs 是一个普通的 JVM 库, 你可以调用其中的函数来调用任务.

一些 boot 任务可以在这里找到: https://github.com/jgdavey/boot-shadow-cljs

3.3. 运行 Clojure 代码

你可以使用 shadow-cljs CLI 从命令行调用特定的 Clojure 函数. 当你想在某些任务之前/之后运行一些代码时, 这很有用. 假设你想将你的发布构建的输出 rsync 到一个远程服务器.

示例 Clojure 命名空间在 src/my/build.clj

(ns my.build
  (:require
    [shadow.cljs.devtools.api :as shadow]
    [clojure.java.shell :refer (sh)]))

(defn release []
  (shadow/release :my-build)
  (sh "rsync" "-arzt" "path/to/output-dir" "my@server.com:some/path"))

运行 release 函数

$ shadow-cljs clj-run my.build/release
# or
$ shadow-cljs run my.build/release

你可以通过命令行向被调用的函数传递参数.

通过普通的 Clojure fn 参数传递

...
(defn release [server]
  (shadow/release :my-build)
  (sh "rsync" "-arzt" "path/to/output-dir" server))

从命令行传递服务器

$ shadow-cljs clj-run my.build/release my@server.com:some/path

提示

如果你想用像 tools.cli (https://github.com/clojure/tools.cli) 这样的东西来解析参数, 那么通常的 (defn release [& args]) 结构也适用.

你在这里拥有 Clojure 的全部能力. 如果你愿意, 你可以在此基础上构建整个工具. 另外一个好处是, 你这样写的任何东西也都可以直接通过 Clojure REPL 获得.

当服务器运行时, 命名空间不会自动重新加载, 它只会加载一次. 建议使用 REPL 进行开发并像往常一样重新加载文件 (例如 (require 'my.build :reload)). 你也可以运行 shadow-cljs clj-eval "(require 'my.build :reload)" 来从命令行手动重新加载.

3.3.1. 通过 clj-run 调用 watch

默认情况下, 由 clj-run 调用的函数只能访问一个最小的 shadow-cljs 运行时, 这足以运行 compile, release 和任何其他 Clojure 功能. 当你的函数完成时, JVM 将终止.

如果你想为给定的构建启动一个 watch, 你需要声明你正在调用的函数需要一个完整的服务器. 这将导致进程保持活动状态, 直到你显式调用 (shadow.cljs.devtools.server/stop!) 或按 CTRL+C 终止进程.

(ns demo.run
  (:require [shadow.cljs.devtools.api :as shadow]))

;; 这会失败, 因为缺少一个完整的服务器实例
(defn foo
  [& args]
  (shadow/watch :my-build))

;; 这个元数据将确保服务器被启动, 所以 watch 可以工作
(defn foo
  {:shadow/requires-server true}
  [& args]
  (shadow/watch :my-build))

4. REPL

REPL 是一个在处理 Clojure(Script) 代码时非常有用的工具. shadow-cljs 提供了几个内置的变体, 让你可以快速开始, 同时也提供了集成到你的标准构建中的变体.

当你想要快速测试一些代码时, 内置的 REPL 应该足够了. 如果你需要更复杂的设置, 也能自己做一些事情, 最好使用一个实际的构建.

4.1. ClojureScript REPL

默认情况下, 你可以在 node-replbrowser-repl 之间选择. 它们的工作方式相似, 区别在于一个在受管理的 node.js 进程中运行, 而另一个会打开一个浏览器窗口, 用于评估实际的代码.

4.1.1. Node REPL

$ shadow-cljs node-repl

这将启动一个带有已连接的 node 进程的空白 CLJS REPL.

如果你退出 Node REPL, node 进程也会被杀死!

node-repl 让你无需任何额外配置即可开始. 它可以通过通常的方式访问你的所有代码, 即 (require '[your.core :as x]). 由于它没有连接到任何构建, 当你的文件更改时, 它不会自动重新构建代码, 也不提供热重载.

4.1.2. Browser REPL

$ shadow-cljs browser-repl

这将启动一个空白的 CLJS REPL, 并将打开一个关联的浏览器窗口, 代码将在其中执行. 除了在浏览器中运行外, 它具有与上述 node-repl 相同的所有功能.

如果你关闭浏览器窗口, REPL 将停止工作.

4.1.3. 特定于构建的 REPL

node-replbrowser-repl 在没有任何特定构建配置的情况下工作. 这意味着它们只会做你告诉它们做的事情, 而不会自己做任何事情.

如果你想构建一个特定的东西, 你应该使用提供的构建目标之一来配置一个构建. 它们中的大多数会自动注入 ClojureScript REPL 所需的代码. 它不应该需要任何额外的配置. 为了让构建的 CLJS REPL 工作, 你需要两件事:

  1. 为你的构建运行一个 watch.
  2. 连接 :target 的 JS 运行时. 这意味着如果你使用的是 :browser 目标, 你需要打开一个加载了生成的 JS 的浏览器. 对于 node.js 构建, 这意味着运行 node 进程.

一旦你两者都有了, 你就可以通过命令行或从 Clojure REPL 连接到 CLJS REPL.

CLI

$ shadow-cljs watch build-id
...
# 不同的终端
$ shadow-cljs cljs-repl build-id
shadow-cljs - connected to server
[3:1]~cljs.user=>

REPL

$ shadow-cljs clj-repl
...
[2:0]~shadow.user=> (shadow/watch :browser)
[:browser] Configuring build.
[:browser] Compiling ...
[:browser] Build completed. (341 files, 1 compiled, 0 warnings, 3,19s)
:watching
[2:0]~shadow.user=> (shadow/repl :browser)
[2:1]~cljs.user=>

提示

输入 :repl/quit 退出 REPL. 这只会退出 REPL, watch 将继续运行.

提示

你可以并行运行多个 watch "工作者", 并在任何给定时间连接/断开它们的 REPL.

没有连接的运行时错误.

[3:1]~cljs.user=> (js/alert "foo")
There is no connected JS runtime.

如果你看到这个, 你需要在浏览器中打开你的应用或启动 node 进程.

4.2. Clojure REPL

除了提供的 ClojureScript REPL 之外, 还提供了一个 Clojure REPL. 这可以用来控制 shadow-cljs 进程并运行所有其他构建命令. 你可以从一个 Clojure REPL 开始, 然后在任何时候升级到一个 CLJS REPL (并切换回来).

从 CLI 运行

$ shadow-cljs clj-repl
...
shadow-cljs - REPL - see (help), :repl/quit to exit
[1:0]~shadow.user=>

shadow.cljs.devtools.api 命名空间具有或多或少与 CLI 对应方 1:1 映射的函数. 它默认被别名为 shadow.

示例命令

;; shadow-cljs watch foo
(shadow.cljs.devtools.api/watch :foo)
;; 这与上面相同, 因为提供了 ns 别名
(shadow/watch :foo)

;; shadow-cljs watch foo --verbose
(shadow/watch :foo {:verbose true})

;; shadow-cljs compile foo
(shadow/compile :foo)
;; shadow-cljs release foo
(shadow/release :foo)

;; shadow-cljs browser-repl
(shadow/browser-repl)
;; shadow-cljs node-repl
(shadow/node-repl)
;; shadow-cljs cljs-repl foo
(shadow/repl :foo)

;; 一旦你在 CLJS REPL 中, 你可以使用
:repl/quit
;; 或者
:cljs/quit
;; 来返回到 CLJ.

4.2.1. 内嵌式 (Embedded)

也可以在任何其他 CLJ 进程中完全使用 shadow-cljs. 只要 thheller/shadow-cljs 工件被加载到 classpath 上, 你就可以开始了.

使用 lein repl 的示例

$ lein repl
nREPL server started on port 57098 on host 127.0.0.1 - nrepl://127.0.0.1:57098
REPL-y 0.4.3, nREPL 0.6.0
Clojure 1.10.0
...
user=> (require '[shadow.cljs.devtools.server :as server])
nil
user=> (server/start!)
...
:shadow.cljs.devtools.server/started
user=> (require '[shadow.cljs.devtools.api :as shadow])
nil
user=> (shadow/compile :foo)
...

你可以通过运行 (shadow.cljs.devtools.server/stop!) 来停止嵌入式服务器. 这也将停止所有正在运行的构建进程.

如果你想切换到 CLJS REPL, 这可能需要在你用来启动服务器的工具中进行额外的设置. 由于 lein 默认使用 nREPL, 它将需要配置额外的 nREPL :middleware. 当使用 clj 时, 你可以轻松开始, 因为它不使用 nREPL.

5. 配置

shadow-cljs 是通过你项目根目录下的 shadow-cljs.edn 文件来配置的. 你可以通过运行 shadow-cljs init 来创建一个默认的. 它应该包含一个带有全局配置的映射和一个用于所有构建的 :builds 条目.

{:source-paths [...]
 :dependencies [...]
 :builds {...}}

一个示例配置可能看起来像这样:

{:dependencies
 [[reagent "0.8.0-alpha2"]]

 :source-paths
 ["src"]

 :builds
 {:app {:target :browser
        :output-dir "public/js"
        :asset-path "/js"
        :modules {:main {:entries [my.app]}}}}}

这个示例的文件结构应该看起来像这样:

.
├── package.json
├── shadow-cljs.edn
└── src
    └── my
        └── app.cljs

5.1. 源路径

:source-paths 配置你的 JVM classpath. 编译器将使用这个配置来查找 Clojure(Script) 源文件 (例如 .cljs).

将所有东西放在一个源路径下是可以的, 但如果你想以某种方式 "分组" 源文件, 你也可以使用多个源路径. 如果你想将测试分开, 这很有用.

使用多个源路径

{:source-paths ["src/main" "src/test"]
 ...}

文件结构

.
├── package.json
├── shadow-cljs.edn
└── src
    ├── main
    │   └── my
    │       └── app.cljs
    └── test
        └── my
            └── app_test.cljs

不建议按扩展名分离源文件 (例如 src/clj, src/cljs, src/cljc). 出于某种原因, 这在 CLJS 项目模板中被广泛使用, 但它只会让事情变得更难用.

5.2. 依赖

5.2.1. Clojure(Script)

你的依赖关系是通过 shadow-cljs.edn 配置文件根部的 :dependencies 键来管理的. 它们的声明方式与其他 Clojure 工具 (如 leinboot) 使用的表示法相同.

每个依赖项都写成一个向量, 使用 [library-name "version-string"] 嵌套在一个外部向量中.

示例 :dependencies

{:source-paths ["src"]
 :dependencies [[reagent "0.9.1"]]
 :builds ...}

请注意, 源路径在整个配置中只指定了一次. 系统将使用命名空间依赖图来确定任何给定构建的最终输出中需要哪些代码.

5.2.2. JavaScript

shadow-cljs 与 npm (https://www.npmjs.com/) 生态系统完全集成, 用于管理 JavaScript 依赖.

你可以使用 npmyarn 来管理你的依赖, 请参考它们各自的文档.

npm https://docs.npmjs.com/ yarn https://yarnpkg.com/en/docs

两者都通过你项目目录中的 package.json 文件来管理你的依赖. 几乎所有通过 npm 提供的包都会解释如何安装它. 这些说明现在也适用于 shadow-cljs.

安装一个 JavaScript 包

# npm
$ npm install the-thing

# yarn
$ yarn add the-thing

不需要其他任何东西. 依赖项将被添加到 package.json 文件中, 这将被用来管理它们.

提示

如果你还没有 package.json, 可以从命令行运行 npm init.

缺少 JS 依赖?

你可能会遇到与缺少 JS 依赖相关的错误. 大多数 ClojureScript 库尚未声明它们使用的 npm 包, 因为它们仍然期望使用 CLJSJS. 我们希望直接使用 npm, 这意味着你必须手动安装 npm 包, 直到库正确声明 :npm-deps 本身.

The required JS dependency "react" is not available, it was required by ...

这意味着你应该 npm install react.

提示

对于 react, 你可能需要这 3 个包: npm install react react-dom create-react-class.

5.3. 用户配置

大多数配置将在项目本身通过 shadow-cljs.edn 完成, 但某些配置可能是用户相关的. 像 CIDER (https://docs.cider.mx) 这样的工具可能需要额外的 cider-nrepl 依赖, 当通过 shadow-cljs.edn 添加该依赖时, 对于使用 Cursive 的不同团队成员来说将是无用的.

一组受限制的配置选项可以添加到 ~/.shadow-cljs/config.edn 中, 然后将应用于在此用户机器上构建的所有项目.

可以通过通常的 :dependencies 键添加依赖项. 请注意, 此处添加的依赖项将应用于所有项目. 尽量减少它们, 只在此处放置与工具相关的依赖项. 与构建相关的所有内容都应保留在 shadow-cljs.edn 中, 否则对于其他用户来说, 事情可能无法编译. 当使用 deps.ednlein 时, 这些依赖项将自动添加.

示例 ~/.shadow-cljs/config.edn

{:dependencies
 [[cider/cider-nrepl "0.21.1"]]}
;; 这个版本可能已过时, 请检查可用的最新版本

当使用 deps.edn 解析依赖项时, 你有时可能想要激活额外的别名. 这可以通过 :deps-aliases 来完成.

;; 项目中的 shadow-cljs.edn
{:deps {:aliases [:cljs]}}

;; ~/.shadow-cljs/config.edn
{:deps-aliases [:cider]}

这将使得 shadow-cljs 命令在使用 deps.edn 的项目中使用 [:cider :cljs] 别名. 如果你在 ~/.clojure/deps.edn 中有额外的 :cider 别名, 这可能很有用.

默认情况下, shadow-cljs 服务器模式将启动一个你可能不需要的嵌入式 nREPL 服务器. 你可以通过在用户配置中设置 :nrepl false 来禁用它.

目前用户配置中唯一其他可接受的值是 :open-file-command. 其他选项目前没有任何效果.

5.4. 服务器选项

本节适用于配置 shadow-cljs 服务器实例的其他选项. 它们是可选的.

5.4.1. nREPL

shadow-cljs 服务器通过 TCP 提供一个 nREPL (https://nrepl.org) 服务器. 如果你查看启动消息, 你会看到 nREPL 的端口, 并且该端口也将存储在 target/shadow-cljs/nrepl.port 中:

$ shadow-cljs watch app
shadow-cljs - HTTP server available at http://localhost:8600
shadow-cljs - server version: <version> running at http://localhost:9630
shadow-cljs - nREPL server started on port 64967
shadow-cljs - watching build :app
[:app] Configuring build.
[:app] Compiling ...

你可以使用 shadow-cljs.edn 配置端口和额外的中间件:

{...
 :nrepl {:port 9000
         :middleware []} ; 可选的命名空间限定符号列表
 ...}

默认的全局配置文件 ~/.nrepl/nrepl.edn 或本地的 .nrepl.edn 也将在启动时加载, 并可用于配置 :middleware.

如果流行的中间件 cider-nrepl (https://github.com/clojure-emacs/cider-nrepl) 在 classpath 上找到 (例如, 它包含在 :dependencies 中), 它将被自动使用. 不需要额外的配置. 这可以通过设置 :nrepl {:cider false} 来禁用.

你可以通过在 :nrepl 选项中设置 :init-ns 来配置连接时你启动的命名空间. 它默认为 shadow.user.

{...
 :nrepl {:init-ns my.repl}
 ...}

nREPL 服务器可以通过设置 :nrepl false 来禁用.

nREPL 用法 当连接到 nREPL 服务器时, 连接总是以 Clojure REPL 开始. 切换到 CLJS REPL 的工作方式与非 nREPL 版本类似. 首先需要为给定的构建启动 watch, 然后我们需要选择此构建以将当前的 nREPL 会话切换到该构建. 选择构建后, 所有内容都将在 ClojureScript 而不是 Clojure 中求值.

(shadow/watch :the-build)
(shadow/repl :the-build)

提示

使用 :cljs/quit 返回到 Clojure.

嵌入式 nREPL 服务器 当你在提供自己的 nREPL 服务器的其他工具 (例如 lein) 中嵌入使用 shadow-cljs 时, 你需要配置 shadow-cljs 中间件. 否则你将无法在 CLJ 和 CLJS REPL 之间切换.

示例 Leiningen project.clj

(defproject my-amazing-project "1.0.0"
  ...
  :repl-options
  {:init-ns shadow.user ;; 或者任何你选择的
   :nrepl-middleware
   [shadow.cljs.devtools.server.nrepl/middleware]}
  ...)

提示

在使用 CLJS REPL 之前, 你仍然需要手动启动嵌入式服务器.

5.4.2. Socket REPL

Clojure Socket REPL 在服务器模式下自动启动, 并默认使用随机端口. 工具可以通过检查 .shadow-cljs/socket-repl.port 来找到它启动的端口, 该文件将包含端口号.

你也可以通过 shadow-cljs.edn 设置一个固定端口.

{...
 :socket-repl
 {:port 9000}
 ...}

Socket REPL 可以通过设置 :socket-repl false 来禁用.

5.4.3. SSL

shadow-cljs HTTP 服务器支持 SSL. 它需要一个提供匹配私钥和证书的 Java Keystore.

配置了 SSL 的 shadow-cljs.edn

{...
 :ssl {:keystore "ssl/keystore.jks"
       :password "shadow-cljs"}
 ...}

以上是默认值, 所以如果你想使用它们, 只需设置 :ssl {} 即可.

你可以使用 java keytool 命令创建一个 Keystore. 创建一个受信任的自签名证书也是可能的, 但有些复杂.

创建的 Certificates.p12 (macOS) 或 localhost.pfx (Linux, Windows) 文件可以通过 keytool 实用程序转换成所需的 keystore.jks.

$ keytool -importkeystore -destkeystore keystore.jks -srcstoretype PKCS12 -srckeystore localhost.pfx

你必须使用 SAN (主题备用名称) 为 "localhost" (或任何你想要使用的主机) 生成证书. SAN 是让 Chrome 信任证书而不显示警告所必需的. 导出时使用的密码必须与分配给 Keystore 的密码匹配.

5.4.4. 主要 HTTP(S) 服务器

shadow-cljs 服务器启动一个主要 HTTP 服务器. 它用于提供 UI 和用于热重载和 REPL 客户端的 websockets. 默认情况下, 它监听端口 9630. 如果该端口正在使用, 它将递增一并再次尝试, 直到找到一个开放的端口.

指示所用端口的启动消息

shadow-cljs - server running at http://0.0.0.0:9630

当配置了 :ssl 时, 服务器将通过 https:// 提供服务.

提示

当使用 :ssl 时, 服务器自动支持 HTTP/2.

如果你想自己设置端口, 可以通过 :http 配置来实现.

:http 配置的 shadow-cljs.edn

{...
 :http {:port 12345
        :host "my.machine.local"}
 ...}

:ssl 仅将服务器切换到 https://. 如果你想保留 http:// 版本, 你可以配置一个单独的 :ssl-port.

{...
 :http {:port 12345
        :ssl-port 23456
        :host "localhost"}
 ...}

5.4.5. 开发用 HTTP(S) 服务器

shadow-cljs可以通过 :dev-http 配置条目提供额外的基本HTTP服务器. 默认情况下, 这些服务器会从配置的路径提供所有静态文件, 当找不到资源时会回退到 index.html (这通常是开发使用浏览器推送状态的应用程序时想要的).

这些服务器在 shadow-cljs 以服务器模式运行时自动启动. 它们不特定于任何构建, 只要为每个构建使用唯一的 :output-dir, 就可以用来为多个构建提供文件.

这些只是提供静态文件的通用Web服务器. 任何实时重载或REPL逻辑都不需要它们. 任何Web服务器都可以, 这些只是为了方便而提供的.

通过 http://localhost:8000 提供 public 目录的基本示例

{...
 :dev-http {8000 "public"}
 :builds {...}}

:dev-http 期望一个从端口号到配置的映射. 该配置支持几种最常见场景的快捷方式.

从文件系统根目录提供目录

:dev-http {8000 "public"}

从 classpath 根目录提供

:dev-http {8000 "classpath:public"}

这将尝试通过 classpath 上的 public/index.html 找到对 /index.html 的请求. 这可能包括 .jar 文件中的文件.

从多个根目录提供

:dev-http {8000 ["a" "b" "classpath:c"]}

这将首先尝试查找 <project-root>/a/index.html, 然后是 <project-root>/b/index.html, 然后是 classpath 上的 c/index.html. 如果找不到, 将调用默认处理程序.

更长的配置版本期望一个映射, 支持的选项是:

  • :root (字符串) 从哪个路径提供请求. 以 classpath: 开头的路径将从 classpath 而不是文件系统提供. 所有文件系统路径都相对于项目根目录.
  • :roots (字符串向量) 如果你需要多个根路径, 请使用此选项而不是 :root.
  • :ssl-port 当配置了 :ssl 时, 使用此端口进行 ssl 连接, 并在常规端口上提供正常的 HTTP. 如果未设置 :ssl-port 但配置了 :ssl, 则默认端口将只提供 SSL 请求.
  • :host 可选. 监听的主机名. 默认为 localhost.
  • :handler 可选. 一个完全限定的符号. 一个 (defn handler [req] resp), 如果找不到给定请求的资源, 则使用该函数. 默认为 shadow.http.push-state/handle (此处理程序仅响应 Accept: text/html 标头的请求).

以下两个选项仅在使用默认的内置处理程序时适用, 通常不需要更改:

  • :push-state/headers (可选) 一个用于响应的 HTTP 标头映射. 默认为 text/html 标准标头.
  • :push-state/index (可选) 要提供的文件. 默认为 index.html.
{...
 :dev-http
 {8080 {:root "public"
        :handler my.app/handler}}}

反向代理支持

默认情况下, 开发服务器会尝试本地提供请求, 但有时你可能想使用外部 Web 服务器来提供请求 (例如 API 请求). 这可以通过 :proxy-url 进行配置.

{...
 :dev-http
 {8000
  {:root "public"
   :proxy-url "https://some.host"}}}

一个到 http://localhost:8000/api/foo 的请求将提供由 https://some.host/api/foo 返回的内容. 所有没有本地文件的请求都将由代理服务器提供.

配置连接处理的附加可选选项有:

  • :proxy-rewrite-host-header 布尔值, 默认为 true. 决定是使用原始的 Host 标头还是来自 :proxy-url 的标头. 使用上面的示例是 localhost vs some.host.
  • :proxy-reuse-x-forwarded 布尔值, 默认为 false. 配置代理是否应将自身添加到 X-Forwarded-For 列表或启动一个新列表.
  • :proxy-max-connection-retries 整数, 默认为 1.
  • :proxy-max-request-time ms 作为整数, 默认为 30000. 30秒请求超时.

5.5. JVM 配置

shadow-cljs.edn 负责启动 JVM 时, 你可以配置额外的命令行参数以直接传递给 JVM. 例如, 你可能想减少或增加 shadow-cljs 使用的 RAM 量.

这是通过在 shadow-cljs.edn 的根目录配置 :jvm-opts 来完成的, 它期望一个字符串向量.

示例限制 RAM 使用为 1GB

{:source-paths [...]
 :dependencies [...]
 :jvm-opts ["-Xmx1G"]
 :builds ...}

可以传递给 JVM 的参数因版本而异, 但你可以在这里 (https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html) 找到一个示例列表. 请注意, 分配太少或太多的 RAM 可能会降低性能. 默认值通常足够好.

当使用 deps.ednproject.clj 时, :jvm-opts 需要在那里配置.

6. 构建配置

shadow-cljs.edn 还需要一个 :builds 部分. 构建应该是一个由构建 ID 键控的映射:

带构建映射的配置文件.

{:dependencies [[some-library "1.2.1"] ...]
 :source-paths ["src"]
 :builds
 {:app {:target :browser
        ... browser-specific options ...}
  :tests {:target :karma
          ... karma-specific options ...}}}

每个构建描述了编译器将构建的工件. 构建目标是 shadow-cljs 的一个可扩展功能, 编译器已经自带了相当多的构建目标.

6.1. 构建目标

shadow-cljs 中的每个构建都必须定义一个 :target, 它定义了你希望代码在何处执行. 浏览器和 node.js 有默认的内置目标. 它们都共享具有 :dev:release 模式的基本概念. :dev 模式提供了所有常见的开发便利功能, 如快速编译, 实时代码重载和 REPL. :release 模式将生成用于生产的优化输出.

目标将在单独的章节中介绍.

这里有一些目标:

  • :browser 输出适合在 Web 浏览器中运行的代码.
  • :bootstrap 输出适合在引导的 cljs 环境中运行的代码.
  • :browser-test 扫描测试以确定所需文件, 并输出适合在浏览器中运行的测试.
  • :karma 扫描测试以确定所需文件, 并输出与 karma-runner 兼容的测试. 参见 Karma (http://karma-runner.github.io/2.0/index.html).
  • :node-library 输出适合用作 node 库的代码.
  • :node-script 输出适合用作 node 脚本的代码.
  • :npm-module 输出适合用作 NPM 模块的代码.

每个目标都在其自己的章节中有更详细的介绍, 因为其余的构建选项会因你选择的目标而异.

6.2. 开发选项

每个构建 :target 通常都提供一些开发支持. 它们被分组在每个 :build:devtools 键下.

6.2.1. REPL

当运行 watch 时, REPL 的代码会自动注入, 通常不需要额外的配置. 有一些额外的选项可用于控制 REPL 的行为:

  • :repl-init-ns 允许配置 REPL 将在哪个命名空间中启动. 默认为 cljs.user.
  • :repl-pprint 使 REPL 在打印求值结果时使用 cljs.pprint 而不是常规的 pr-str. 默认为 false.
{...
 :builds
  {:app {...
         :devtools {:repl-init-ns my.app
                    :repl-pprint true
                    ...}}}}

6.2.2. Preloads

作为开发者, 你大部分时间都花在开发模式上. 你可能熟悉像 figwheel, boot-reloaddevtools 这样的工具. 几乎可以肯定你希望在你的构建中使用这些工具中的一个或多个.

Preloads 用于强制某些命名空间在你生成的 Javascript 的前面. 这通常用于在应用程序实际加载和运行之前注入工具和检测. preloads 选项只是 shadow-cljs.edn:devtools / :preloads 部分中的一个命名空间列表, 或者在特定模块的 :preloads 键中:

{...
 :builds
  {:app {...
         :devtools {:preloads [fulcro.inspect.preload]
                    ...}}}}

例如, 只在开发期间的主模块中包含 preloads, 而不在 web worker 中:

{...
 :builds
  {:app {...
         :modules {:main {...
                          :preloads
                          [com.fulcrologic.fulcro.inspect.preload
                           com.fulcrologic.fulcro.inspect.dom-picker-preload]
                          :depends-on #{:shared}}
                   :shared {:entries []}
                   :web-worker {...
                                :depends-on #{:shared}
                                :web-worker true}}}}}

:preloads 仅应用于开发构建, 不会应用于发布构建.

从版本 2.0.130 开始, shadow-cljs 在 watchcompile 中自动将 cljs-devtools 添加到 preloads 中, 如果它们在 classpath 上. 你只需要确保 binaryage/devtools 在你的 dependencies 列表中. (注意, 不是 binaryage/cljs-devtools.) 如果你不想在特定目标中使用 cljs-devtools, 你可以通过在这些目标的 :devtools 部分添加 :console-support false 来抑制它.

6.2.3. 热代码重载

React 和 ClojureScript 生态系统结合起来使这种事情变得超级有用. shadow-cljs 系统包含了你需要做热代码重载的一切, 无需借助外部工具.

为了使用它, 你只需运行:

shadow-cljs watch build-id

传递依赖的热重载

默认情况下, 编译后的文件和显式需要这些文件的文件会被重载. 这种方法可能不足够, 例如在为 :react-native 目标开发时. 要同时重载所有传递依赖, 使用值为 :full:reload-strategy 选项, 如下所示:

这对于较大的应用可能会变慢, 仅在确实需要时使用.

{...
 :builds
  {:app
   {:target :react-native
    :init-fn some.app/init
    :output-dir "app"
    ...
    :devtools
    {:reload-strategy :full}}}}

6.2.4. 生命周期钩子

你可以配置编译器在热代码重载引入更新代码之前和之后立即运行函数. 这对于停止/启动那些否则会闭包旧代码的东西很有用.

这些可以通过构建配置中的 :devtools 部分或直接在你的代码中通过元数据标签进行配置.

元数据

你可以在普通的 CLJS defn var 上设置某些元数据, 以通知编译器这些函数应该在实时重载时的某个时间被调用.

通过元数据配置钩子

(ns my.app)

(defn ^:dev/before-load stop []
  (js/console.log "stop"))

(defn ^:dev/after-load start []
  (js/console.log "start"))

这将在加载任何新代码之前调用 my.app/stop, 在所有新代码加载后调用 my.app/start. 你可以像这样标记多个函数, 它们将按照其命名空间的依赖顺序被调用.

还有这些的异步变体, 以防你需要做一些异步工作, 这些工作应该在继续重载过程之前完成.

异步钩子示例

(ns my.app)

(defn ^:dev/before-load-async stop [done]
  (js/console.log "stop")
  (js/setTimeout
    (fn []
      (js/console.log "stop complete")
      (done))))

(defn ^:dev/after-load-async start [done]
  (js/console.log "start")
  (js/setTimeout
    (fn []
      (js/console.log "start complete")
      (done))))

这些函数将接收一个回调函数, 当它们的工作完成时必须调用它. 如果不调用回调函数, 重载过程将不会继续.

可以用元数据标记命名空间, 这样即使它们被重新编译也永远不会被重载.

一个不可重载的 ns

(ns ^:dev/once my.thing)

(js/console.warn "will only execute once")

命名空间也可以被标记为总是重载.

一个总是可重载的 ns

(ns ^:dev/always my.thing)

(js/console.warn "will execute on every code change")

配置

除了元数据, 你还可以通过 shadow-cljs.edn 配置生命周期钩子.

  • :before-load 一个函数的符号 (带命名空间), 在刷新已重新编译的文件之前运行. 此函数必须是同步的.
  • :before-load-async 一个函数的符号 (带命名空间) (fn [done]), 在刷新之前运行. 此函数可以进行异步处理, 但必须调用 (done) 来指示其完成.
  • :after-load 一个函数的符号 (带命名空间), 在热代码重载完成后运行.
  • :after-load-async 一个函数的符号 (带命名空间) (fn [done]), 在热代码重载完成后运行. 此函数可以进行异步处理, 但必须调用 (done) 来指示其完成.
  • :autoload 一个布尔值, 控制代码是否应该被热加载. 如果设置了任一回调, 则隐式设置为 true. 默认情况下为 :browser 目标启用, 设置为 false 可禁用.
  • :ignore-warnings 一个布尔值, 控制带警告的代码是否应该被重载. 默认为 false.

生命周期钩子示例.

{...
 :builds
  {:app {...
         :devtools {:before-load my.app/stop
                    :after-load my.app/start
                    ...}}}}

钩子不能在 cljs.user 命名空间中声明. 钩子仅在其包含的命名空间实际包含在构建中时才使用. 如果你使用额外的命名空间, 请确保通过 :preloads 将其包含进来.

提示

如果既没有设置 :after-load 也没有设置 :before-load, 编译器将只尝试热重载 :browser 目标中的代码. 如果你仍然想要热重载但不需要任何回调, 你可以设置 :autoload true.

6.3. 构建钩子

有时需要在编译流程的特定阶段执行一些自定义代码. :build-hooks 允许你声明应该调用哪些函数, 并且它们可以完全访问当时的构建状态. 这非常强大, 并开辟了许多可能的工具选项.

它们在 :build-hooks 键下按构建配置

示例 :build-hooks

{...
 :builds
  {:app {:target ...
         :build-hooks
         [(my.util/hook 1 2 3)]
         ...}}}

示例钩子代码

(ns my.util)

(defn hook
  {:shadow.build/stage :flush}
  [build-state & args]
  (prn [:hello-world args])
  build-state)

此示例将在构建完成 :flush 阶段 (即写入磁盘) 后调用 (my.util/hook build-state 1 2 3). 该示例将打印 [:hello-world (1 2 3)], 但请在实际的钩子中做一些更有用的事情.

钩子只是一个带有一些附加元数据的普通 Clojure 函数. {:shadow.build/stage :flush} 元数据通知编译器仅为 :flush 调用此钩子. 如果钩子应该在多个阶段后调用, 你可以改为配置 {:shadow.build/stages #{:configure :flush}}. 至少需要一个配置的阶段, 否则钩子将永远不会执行任何操作.

所有构建钩子都将在 :target 工作完成后被调用. 它们将接收 build-state (一个包含所有当前构建数据的 clojure 映射) 作为它们的第一个参数, 并且必须返回这个 build-state, 无论是修改过的还是未修改的. 当使用多个阶段时, 你可以向 build-state 添加额外的数据, 以便后续阶段可以看到. 强烈建议仅使用命名空间限定的键, 以确保不会意外地破坏整个构建.

build-state 有一些重要的条目, 可能对你的钩子有用:

  • :shadow.build/build-id - 当前构建的 id (例如 :app)
  • :shadow.build/mode - :dev:release
  • :shadow.build/stage - 当前阶段
  • :shadow.build/config - 构建配置. 你可以直接在构建配置中存储钩子的配置数据, 或将其作为参数传递给钩子本身

在运行的 watch 中, 所有钩子将为每个构建重复调用. 避免做太多工作, 因为它们会显著影响你的构建性能.

6.3.1. 编译阶段

:build-hooks 可以使用的可能阶段是:

  • :configure - 初始 :target 特定配置
  • :compile-prepare - 在任何编译完成之前调用
  • :compile-finish - 在所有编译完成后调用
  • :optimize-prepare - 在运行 Closure Compiler 优化阶段之前调用 (:release only)
  • :optimize-finish - 在 Closure 完成后调用 (:release only)
  • :flush - 在所有内容刷新到磁盘后调用

在运行的 watch 中, :configure 只被调用一次. 其他任何阶段都可能为每次重新编译再次 (按顺序) 调用. build-state 将被重用, 直到构建配置更改, 此时它将被丢弃并创建一个新的.

6.4. 编译器缓存

shadow-cljs 默认会缓存所有编译结果. 每当与单个源文件相关的任何内容发生变化 (例如, 更改了编译器设置, 更改了依赖关系等), 缓存就会失效. 这极大地改善了开发者体验, 因为增量编译将比从头开始快得多.

然而, 如果你使用大量带有副作用的宏 (读取文件, 在编译器状态之外存储东西等), 缓存的失效可能无法可靠地完成. 在这些情况下, 你可能需要完全禁用缓存.

已知包含有副作用宏的命名空间可以被阻止缓存. 它们本身不会被缓存, 需要它们的命名空间也不会被缓存. clara-rules (https://github.com/cerner/clara-rules) 库有副作用宏, 默认被阻止. 你可以通过 :cache-blockers 配置全局指定要阻止的命名空间. 它期望一个命名空间符号的集合.

clara.rules 缓存阻止示例 (这是默认完成的)

{...
 :cache-blockers #{clara.rules}
 :builds {...}}

此外, 你可以通过 :build-options :cache-level 条目更广泛地控制缓存量. 支持的选项是:

  • :all 默认值, 所有 CLJS 文件都被缓存
  • :jars 仅缓存来自库的文件, 即 .jar 文件中的源文件
  • :off 不缓存任何 CLJS 编译结果 (到目前为止是最慢的选项)

不带缓存编译

{...
 :builds
  {:app
   {:target :browser
    ...
    :build-options
    {:cache-level :off}}}}

缓存文件存储在每个构建的专用目录中, 因此缓存永远不会在构建之间共享. 一个 id 为 :app 的构建将在目录中拥有 :dev 缓存:

cljs/core.cljs 的缓存位置

target/shadow-cljs/builds/app/dev/ana/cljs/core.cljs.cache.transit.json

:cache-root 设置默认为 target/shadow-cljs, 并控制所有缓存文件的写入位置. 它只能全局配置, 不能按构建配置.

{:source-paths [...]
 :dependencies [...]
 :cache-root ".shadow-cljs"
 :builds ...}
;; 缓存然后会到
;; .shadow-cljs/builds/app/dev/ana/cljs/core.cljs.cache.transit.json

:cache-root 总是相对于项目目录解析. 你也可以指定绝对路径 (例如 /tmp/shadow-cljs).

6.5. Closure Defines

Closure 库和编译器允许你定义基本上是编译时常量的变量. 你可以使用这些来配置构建的某些功能. 由于 Closure 编译器在运行 :advanced 优化时将这些视为常量, 它们完全支持死代码消除过程, 并可用于删除不应包含在 release 构建中的代码的某些部分.

你可以在你的代码中定义它们

(ns your.app)

(goog-define VERBOSE false)

(when VERBOSE
  (println "Hello World"))

这将默认将 your.app/VERBOSE 变量定义为 false. 这将导致 println:advanced 编译中被移除. 你可以通过 :closure-defines 选项将其切换为 true, 这将启用 println. 这可以仅为开发完成, 或总是完成.

{...
 :builds
  {:app
   {:target :browser
    ...
    :modules {:app {:entries [your.app]}}

    ;; 仅在开发中启用
    :dev {:closure-defines {your.app/VERBOSE true}}
    ;; 总是启用
    :closure-defines {your.app/VERBOSE true}

    ;; 你也可以为发布启用它
    :release {:closure-defines {your.app/VERBOSE true}}
    }}}

提示

通常使用 "禁用" 变体作为默认值更安全, 因为这使得它们不太可能在不应该的时候被包含在 release 构建中. 忘记设置一个 :closure-defines 变量几乎总是导致使用的代码更少而不是更多.

来自 Closure 库的 Closure Defines

6.6. 编译器选项

CLJS 编译器支持几个选项来影响某些代码的生成方式. 在大多数情况下, shadow-cljs 会为每个 :target 选择一些好的默认值, 但你可能偶尔想改变它们中的一些.

这些都分组在你的构建配置中的 :compiler-options 键下.

{:dependencies [...]
 :builds
  {:app
   {:target :browser
    ...
    :compiler-options {:fn-invoke-direct true}}}}

大多数标准的 ClojureScript 编译器选项 (https://clojurescript.org/reference/compiler-options) 要么是默认启用的, 要么是不适用的. 所以实际上很少有选项有效果. 很多选项也特定于某些 :target 类型, 并不普遍适用 (例如 :compiler-options {:output-wrapper true} 仅与 :target :browser 相关).

目前支持的选项包括

  • :optimizations 支持 :advanced, :simple:whitespace, 默认为 :advanced. :none 是开发的默认值, 不能手动设置. 使用 :nonerelease 将无法工作.
  • :infer-externs :all, :auto, truefalse, 默认为 :auto
  • :static-fns (布尔值) 默认为 true
  • :fn-invoke-direct (布尔值) 默认为 false
  • :elide-asserts (布尔值) 开发中默认为 false, 发布构建中为 true
  • :pretty-print:pseudo-names 默认为 false. 你可以使用 shadow-cljs release app --debug 来临时启用两者, 而无需修改你的配置. 这在遇到发布构建问题时非常有用.
  • :source-map (布尔值) 开发期间默认为 true, 发布时为 false.
  • :source-map-include-sources-content (布尔值) 默认为 true, 并决定源映射是否应直接在 .map 文件中包含其源.
  • :source-map-detail-level :all:symbols (:symbols 会稍微减小整体大小, 但准确性也稍差)
  • :externs 路径向量, 默认为 []
  • :checked-arrays (布尔值), 默认为 false
  • :anon-fn-naming-policy
  • :rename-prefix:rename-prefix-namespace
  • :warnings 作为一个 {warning-type true|false} 的映射, 例如 :warnings {:undeclared-var false} 来关闭特定警告.

不支持或不适用的选项

完全没有效果的选项包括

  • :verbose 由运行 shadow-cljs compile app --verbose 控制, 而不是在构建配置中.
  • :foreign-libs:libs
  • :stable-names 始终启用, 不能禁用
  • :install-deps
  • :source-map-path, :source-asset-path:source-map-timestamp
  • :cache-analysis 始终启用, 不能禁用.
  • :recompile-dependents
  • :preamble
  • :hashbang (:node-script 目标支持此项, 其他不支持)
  • :compiler-stats 使用 --verbose 获取详细信息
  • :optimize-constants 始终为发布构建完成, 不能禁用
  • :parallel-build 始终启用
  • :aot-cache
  • :package-json-resolution 请改用 :js-options :resolve
  • :watch-fn
  • :process-shim

6.6.1. 将警告视为错误

有时希望在出现警告时构建失败, 而不是继续构建 (例如在 CI 环境中). 你可以使用 :warnings-as-errors 编译器选项来自定义处理方式.

将所有警告视为错误

{...
 :builds
  {:app
   {...
    :compiler-options {:warnings-as-errors true}}}}

仅抛出某些警告

{...
 :builds
  {:app
   {...
    :compiler-options {:warnings-as-errors #{:undeclared-var}}}}}

一组可能的警告类型关键字可以在这里 (https://github.com/clojure/clojurescript/blob/5ad96a8b3ae2e3616a19715ba9ba2471a36933a2/src/main/clojure/cljs/analyzer.cljc#L124-L163) 找到.

仅为某些命名空间抛出

{...
 :builds
  {:app
   {...
    :compiler-options {:warnings-as-errors {:ignore #{some.ns some.library.*}
                                            :warnings-types #{:undeclared-var}}}}}

:ignore 接受一组引用命名空间的符号. 可以使用直接匹配或 .* 通配符. :warning-types 的功能与上面相同, 不指定它意味着除了被忽略的命名空间外, 所有警告都会抛出.

6.7. 输出语言选项

默认情况下, 生成的 JS 输出将与 ES6 兼容, 所有 "较新" 的功能都将使用 polyfills 转译为兼容代码. 这目前是最安全的默认设置, 支持大多数活跃使用的浏览器 (包括 IE10+).

如果你只关心更现代的环境, 并希望保留原始代码而不进行替换 (例如 node, Chrome 扩展等), 你可以选择其他输出选项.

请注意, 这主要影响从 npm 或 classpath 上的 .js 文件导入的 JS 代码. CLJS 目前只会生成 ES5 输出, 不受设置更高选项的影响.

你可以通过 :compiler-options 中的 :output-feature-set 来配置此项. 旧的 :language-out 选项不应再使用, 因为 :output-feature-set 取代了它.

支持的选项有:

  • :bare-minimum
  • :es3
  • :es5
  • :es6 - class, const, let, …
  • :es7 - 指数运算符 **
  • :es8 - async/await, 生成器, 共享内存和原子操作
  • :es2018 - 异步迭代, Promise.finally, 几个 RegExp 功能, 对象字面量中的扩展语法
  • :es2019
  • :es2020 - AsyncIterator, BigInt, ?? 运算符, 动态导入
  • :es-next - Closure Compiler 当前支持的所有功能
  • :browser-2020 - :es2019 减去几个 RegExp 功能
  • :browser-2021 - :es2020 减去 RegExp Unicode 属性

示例

{...
 :builds
  {:script
   {:target :node-script
    :main foo.bar/main
    ...
    :compiler-options {:output-feature-set :es7}}}}

关于这些选项的文档有些稀疏, 主要记录在代码中这里 (https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/parsing/parser/FeatureSet.java).

6.8. 条件读取

注意

此功能仅在 shadow-cljs 中有效. 它被 ClojureScript 项目官方拒绝 (https://dev.clojure.org/jira/browse/CLJS-2396). 它仍然可以在 CLJS 中正常编译, 但只有官方分支有效 (例如 :cljs). 有朝一日它可能被支持 (https://groups.google.com/d/msg/clojure-dev/8YJJM8lJuQs/hR5_vUZPCQAJ), 但目前还没有.

shadow-cljs 允许你在 .cljc 文件中配置额外的读取器功能. 默认情况下, 你只能使用读取器条件来为 :clj, :cljs:cljr 生成单独的代码. 然而, 在许多 CLJS 构建中, 根据你的 :target 选择生成哪个代码也是可取的.

示例: 一些 npm 包只在针对 :browser 时有效, 但你可能有一个也想在 :node-script 构建中使用的 ns. 当尝试使用服务器端渲染 (SSR) 和你的 React 应用时, 这可能会经常发生. codemirror 就是这样一个包.

(ns my.awesome.component
  (:require
    ["react" :as react]
    ["codemirror" :as CodeMirror]))

;; 假设你在某个 React :ref 上创建了一个 CodeMirror 实例
(defn init-cm [dom-node]
  (let [cm (CodeMirror/fromTextArea dom-node #js {...})]
    ...))
...

这个命名空间对于 :node-script:browser 两种构建都可以正常编译, 但是当尝试运行 :node-script 时会失败, 因为 codemirror 包试图访问 DOM. 由于 react-dom/server 不使用 refs, init-cm 函数将永远不会被调用.

虽然你可以使用 :closure-defines 来有条件地编译掉 init-cm fn, 但你不能用它来去掉额外的 :require. 读取器条件可以让你轻松做到这一点.

(ns my.awesome.component
  (:require
    ["react" :as react]
    ;; 注意: 这里的顺序很重要. 只有第一个适用的
    ;; 分支被使用. 如果 :cljs 首先使用, 它仍然会
    ;; 被 :server 构建采用
    #?@(:node [[]]
        :cljs [["codemirror" :as CodeMirror]])))

#?(:node ;; node 平台覆盖
   (defn init-cm [dom-node]
     :no-op)
   :cljs ;; 默认实现
   (defn init-cm [dom-node]
     ... 实际实现 ...))
...

:reader-features 配置示例

{...
 :builds
 ;; app 构建正常配置, 无需调整
 {:app
  {:target :browser
   ...}
  ;; 对于服务器, 我们添加 :node 读取器功能
  ;; 然后它将代替默认的 :cljs 被使用
  :server
  {:target :node-script
   :compiler-options
   {:reader-features #{:node}}}}}

:server 构建将不再有 codemirror require, init-cm 函数也将被移除. 变成只有

(ns my.awesome.component
  (:require
    ["react" :as react]))

;; 如果它从未被实际调用, 这很可能会作为死代码被移除
(defn init-cm [dom-node] :no-op)
...

此功能仅在 .cljc 文件中可用, 在 .cljs 文件中将失败.

6.9. 从 CLI 覆盖

有时希望从命令行对构建配置进行小的调整, 这些值不能静态地添加到 shadow-cljs.edn 配置中, 或者可能根据你所在的环境而改变.

你可以通过 --config-merge {:some "data"} 命令行选项传递额外的配置数据, 这些数据将被合并到构建配置中. 从 CLI 添加的数据将覆盖 shadow-cljs.edn 文件中的数据.

示例 shadow-cljs.edn 配置

{...
 :builds
  {:app
   {:target :browser
    :output-dir "public/js"
    ...}}}

从 CLI 覆盖 :output-dir

$ shadow-cljs release app --config-merge '{:output-dir "somewhere/else"}'

从 CLI 覆盖 :closure-defines

$ shadow-cljs release app --config-merge '{:closure-defines {your.app/DEBUG true}}'

--config-merge 期望一个 EDN 映射, 并且可以多次使用, 它们将从左到右合并. 添加的数据对构建钩子也是可见的. 它也接受文件路径, 如 --config-merge a/path.edn--config-merge classpath:a/resource.edn.

如果你指定多个构建 ID, 数据将被合并到所有指定的构建中. shadow-cljs release frontend backend --config-merge '{:hello "world"}' 将应用于两者.

**0. 使用环境变量 可以在 shadow-cljs.edn 中使用环境变量来设置配置值, 但你应该考虑改用 --config-merge. 如果你真的必须使用环境变量, 你可以通过 #shadow/env "FOO" 读取器标签来这样做. 你也可以使用更短的 #env.

示例 shadow-cljs.edn 配置

{...
 :builds
  {:app
   {:target :browser
    :output-dir "public/js"
    :closure-defines {your.app/URL #shadow/env "APP_URL"}
    ...}}}

还有一些你可以使用 #shadow/env 的支持形式.

#shadow/env "APP_URL"
#shadow/env ["APP_URL"]
;; 带默认值, 如果环境变量未设置则使用
#shadow/env ["APP_URL" "default-value"]
#shadow/env ["APP_URL" :default "default-value"]
;; 将 PORT 环境变量转换为整数, 带默认值
#shadow/env ["PORT" :as :int :default 8080]

支持的 :as 转换是 :int, :bool, :keyword, :symbol. 提供的 :default 值不会被转换, 并且期望已经是正确的类型.

使用的是 shadow-cljs 进程启动时使用的环境变量. 如果使用服务器进程, 则将使用其环境变量, 而不是其他命令可能设置的变量. 这在开发期间主要相关, 但可能会令人困惑. --config-merge 没有这个限制.

6.10. 构建和目标的默认值

可以设置将用于所有构建或特定类型的所有目标的默认值.

配置合并顺序如下: :build-defaults:target-defaults → 实际构建配置 → 额外的配置覆盖.

示例 shadow-cljs.edn 配置

{...
 :build-defaults
  {:closure-defines
   {your.app/VERBOSE true}}

 :target-defaults
  {:browser
   {:js-options
    {:resolve {"react" {:target :global
                         :global "React"}}}}}

 :builds
  {:app
   {:target :browser
    ...}}}

在此示例中, :app 目标将继承 :build-defaults:browser:target-defaults.

合并顺序中较后的配置可以覆盖, 但不能删除先前的配置项. 一旦设置了默认值, 移除它的唯一方法就是覆盖它.

7. 针对浏览器

:browser 目标生成用于在浏览器环境中运行的输出. 在开发过程中, 它支持实时代码重载, REPL, CSS重载. release 输出将由 Closure Compiler 使用 :advanced 优化进行压缩.

一个基本的浏览器配置如下所示:

{:dependencies [...]
 :source-paths [...]
 :builds
  {:app {:target :browser
         :output-dir "public/assets/app/js"
         :asset-path "/assets/app/js"
         :modules {:main {:entries [my.app]}}}}}

7.1. 输出设置

浏览器目标输出大量文件, 需要一个目录来存放它们. 你需要用某种服务器来提供这些资产, 并且 Javascript 加载代码需要知道到这些资产的以服务器为中心的路径. 你需要指定的选项是:

  • :output-dir 用于所有编译器输出的目录.
  • :asset-path 从 Web 服务器的根目录到 :output-dir 中资源的相对路径.

你的入口点 javascript 文件和所有相关的 JS 文件将出现在 :output-dir 中.

警告

每个构建都需要自己的 :output-dir, 你不能将多个构建放在同一个目录中. 这个目录也应该由构建独占. 其中不应该有其他文件. 虽然 shadow-cljs 不会删除任何东西, 但最好还是让它保持独立. 编译在开发过程中会创建比主入口点 javascript 文件更多的文件: 源映射, 原始源文件和生成的源文件.

:asset-path 是一个前缀, 它被添加到生成的 javascript 内部的模块加载代码的路径中. 它允许你将 javascript 模块输出到你的 Web 服务器根目录的特定子目录中. 开发期间的动态加载 (热代码重载) 和生产期间 (代码分割) 都需要这个来正确定位文件.

将生成的文件定位在目录和资产路径中, 这样其他资产 (图像, css 等) 就可以轻松地在同一服务器上共存, 而不会发生意外冲突.

例如: 如果你的 Web 服务器在请求 URI /x 时会提供文件夹 public/x, 并且你的模块的 output-dirpublic/assets/app/js, 那么你的 asset-path 应该是 /assets/app/js. 你不被要求使用绝对资产路径, 但强烈建议这样做.

7.2. 模块

模块配置了编译后的源文件如何捆绑在一起, 以及最终的 .js 文件如何生成. 每个模块都声明了一个入口命名空间列表, 并从中构建依赖图. 当使用多个模块时, 代码被分割, 以便最大量的代码被移动到图的外部边缘. 目标是最小化浏览器最初需要加载的代码量, 并按需加载其余部分.

提示

一开始不要太担心 :modules. 从一个开始, 以后再分割它们.

配置的 :modules 部分总是一个由模块 ID 键控的映射. 模块 ID 也用于生成 Javascript 文件名. 模块 :main 将在 :output-dir 中生成 main.js.

模块中可用的选项有:

  • :entries 作为此模块输出代码的依赖图根节点的命名空间.
  • :init-fn 指向一个函数的完全限定符号, 该函数应在模块初始加载时调用.
  • :depends-on 此模块需要的其他模块的名称, 以便它拥有所需的一切.
  • :prepend 将被前置到 js 输出的字符串内容. 对注释, 版权声明等有用.
  • :append 将被附加到 js 输出的字符串内容. 对注释, 版权声明等有用.
  • :prepend-js 一个包含有效 javascript 的字符串, 将前置到模块输出, 并将通过 Closure 优化器运行.
  • :append-js 一个包含有效 javascript 的字符串, 将附加到模块输出, 并将通过 Closure 优化器运行.

以下示例显示了最小的模块配置:

示例 :browser 配置

{...
 :builds
  {:app {:target :browser
         :output-dir "public/js"
         ...
         :modules {:main {:entries [my.app]}}}}}

:init-fn 的示例 :browser 配置

{...
 :builds
  {:app {:target :browser
         :output-dir "public/js"
         ...
         :modules {:main {:init-fn my.app/init}}}}}

shadow-cljs 将跟踪 :entries 中代码入口点的根集合的依赖图, 以找到实际需要编译并包含在输出中的所有内容. 不需要命名空间将不被包括在内.

上面的配置将创建一个 public/js/main.js 文件. 在开发期间, 会有一个额外的 public/js/cljs-runtime 目录, 里面有很多文件. 这个目录在发布构建中是不需要的.

7.3. 代码分割

声明多个模块需要一点额外的静态配置, 以便编译器可以弄清楚模块之间是如何关联的, 以及你将如何稍后加载它们.

除了 :entries 之外, 你还需要声明哪个模块依赖于哪个 (通过 :depends-on). 你如何构建这完全取决于你的需求, 并且没有一种万能的解决方案.

假设你有一个传统的网站, 有实际不同的页面.

  • www.acme.com - 提供主页
  • www.acme.com/login - 提供登录表单
  • www.acme.com/protected - 只有在用户登录后才可用的受保护部分

一个可能的配置是有一个所有页面共享的公共模块. 然后每个页面一个.

具有多个 :modules 的示例配置

{...
 :output-dir "public/js"
 :modules
  {:shared
   {:entries [my.app.common]}
   :home
   {:entries [my.app.home]
    :depends-on #{:shared}}
   :login
   {:entries [my.app.login]
    :depends-on #{:shared}}
   :protected
   {:entries [my.app.protected]
    :depends-on #{:shared}}}}

提示

你可以将 :shared 模块的 :entries 留空, 让编译器找出其他模块之间共享哪些命名空间.

生成的文件结构

.
└── public
    └── js
        ├── shared.js
        ├── home.js
        ├── login.js
        └── protected.js

在你的主页 HTML 中, 你需要在每个页面上始终包含 shared.js, 其他的则根据用户所在的页面有条件地包含.

/login 页面的 HTML

<script src="/js/shared.js"></script>
<script src="/js/login.js"></script>

.js 文件必须以正确的顺序包含. manifest.edn 文件可以帮助解决这个问题.

7.3.1. 动态加载代码

你的网站越动态, 你的需求可能就越动态. 服务器可能不总是知道客户端最终可能需要什么. 因此, 客户端可以在需要时动态加载代码.

有几种动态加载代码的方法. shadow.lazy 是最方便和最简单的.

使用 shadow.lazy

如此处 (https://clojureverse.org/t/shadow-lazy-convenience-wrapper-for-shadow-loader-cljs-loader/3841) 所宣布的, shadow-cljs 提供了一种方便的方法来引用可能延迟加载的代码.

(ns demo.app
  (:require
    [shadow.lazy :as lazy]
    [shadow.cljs.modern :refer (js-await)]))

(def x-lazy (lazy/loadable demo.thing/x))

(defn on-event [e]
  (js-await [x (lazy/load x-lazy)]
    (x e)))

假设上面的 on-event 函数在你的应用中发生某事时被调用, 例如当用户点击一个按钮时. lazy/loadable 配置了那将是什么. lazy/load 将实际加载它. 这可能需要一个异步网络跳跃, 所以它将在此时异步进行. 在上面的 js-await 主体中, x 将是加载时 demo.thing/x 的值.

(ns demo.thing)
(defn x [e]
  "hello world")

在这种情况下, 它将是函数, 我们可以直接调用它.

你不需要担心指定此代码最终在哪个模块中. 编译器将在编译期间弄清楚. loadable 宏还允许更复杂的引用.

(def xy (lazy/loadable [demo.thing/x demo.other/y]))

(def xym (lazy/loadable {:x demo.thing/x
                         :y demo.other/y}))

如果你加载 xy, 结果将是一个包含两个东西的向量. 如果你加载 xym, 它将是一个映射. 你可以用这种方式包含跨越多个模块的 var. 加载器将确保所有模块在继续之前都已加载.

使用 shadow-cljs 的内置加载器支持

这是低级版本, 上面的版本是基于它构建的. 如果你想构建自己的异步加载抽象, 请使用它. 上面的版本使用起来更方便.

编译器支持生成使用 shadow.loader 实用程序命名空间所需的数据. 它公开了一个简单的接口, 让你可以按需加载模块.

你只需要在你的构建配置中添加 :module-loader true. 加载器将始终被注入到默认模块中 (所有其他模块都依赖的那个).

在运行时, 你可以使用 shadow.loader 命名空间来加载模块. 你也可以通过在你的页面中使用 <script> 标签来急切地加载一个模块.

{...
 :builds
  {:app
   {:target :browser
    ...
    :module-loader true
    :modules {:main {:entries [my.app]}
              :extra {:entries [my.app.extra]
                      :depends-on #{:main}}}}}}

如果你的主入口点有以下内容:

(ns my.app
  (:require [shadow.loader :as loader]))

(defn fn-to-call-on-load []
  (js/console.log "extra loaded"))

(defn fn-to-call-on-error []
  (js/console.log "extra load failed"))

那么以下表达式可用于加载代码:

加载一个模块

;; load 返回一个 goog.async.Deferred, 并且可以像 promise 一样使用
(-> (loader/load "extra")
  (.then fn-to-call-on-load fn-to-call-on-error))

加载多个模块

;; 必须是一个 JS 数组, 也返回 goog.async.Deferred
(loader/load-many #js ["foo" "bar"])

包含一个回调

(loader/with-module "extra" fn-to-call-on-load)

你可以使用 (loaded? "module-name") 检查模块是否已加载.

你可以在这篇关于代码分割 ClojureScript (https://code.thheller.com/blog/shadow-cljs/2019/03/03/code-splitting-clojurescript.html) 的博客文章中阅读更实际的示例. 这只是一个基本的概述.

加载器成本

加载器非常轻量. 它有一些你可能不使用的依赖项. 在实践中, 使用 :module-loader true 会为默认模块增加约 8KB 的 gzip 压缩大小. 这将根据你已经使用的 goog.netgoog.events 的多少, 以及你为发布构建使用的优化级别而有所不同.

使用标准的 ClojureScript API

生成的代码能够使用标准的 ClojureScript cljs.loader API. 有关说明, 请参阅 ClojureScript 网站上的文档 (https://clojurescript.org/news/2017-07-10-code-splitting).

使用标准 API 的优点是你的代码将与他人良好地协作. 这对库作者可能特别重要. 缺点是标准分发中的动态模块加载 API 目前比 shadow-cljs 中的支持要难用一些.

7.4. 输出包装器

仅限发布构建: 由 Closure Compiler :advanced 编译生成的代码将创建许多全局变量, 这有可能与你页面上运行的其他 JS 产生冲突. 为了隔离创建的变量, 代码可以被包装在一个匿名函数中, 以便变量仅在该作用域内应用.

release 构建 для :browser 只有一个 :modules 的情况下, 默认会被包装在 (function(){<the-code>}).call(this); 中. 所以不会创建全局变量.

当使用多个 :modules (即代码分割) 时, 默认情况下不启用此功能, 因为每个模块都必须能够访问它所依赖的模块创建的变量. Closure Compiler 支持一个额外的选项, 以便在使用多个 :modules 的情况下启用输出包装器, 名为 :rename-prefix-namespace. 这将导致编译器将构建中使用的所有 "全局" 变量的作用域限定在一个实际的全局变量中. 默认情况下, 当 :output-wrapper 设置为 true 时, 这被设置为 :rename-prefix-namespace "$APP".

{...
 :builds
  {:target :browser
   ...
   :compiler-options
   {:output-wrapper true
    :rename-prefix-namespace "MY_APP"}}}

这只会创建 MY_APP 全局变量. 由于每个 "全局" 变量现在都将以 MY_APP. 为前缀 (例如 MY_APP.a 而不是 a), 代码大小可能会大幅增加. 保持这个简短很重要. 浏览器压缩 (例如 gzip) 有助于减少额外代码的开销, 但根据构建中全局变量的数量, 这仍然可能产生明显的增加.

请注意, 创建的变量实际上并不直接有用. 它将包含许多混淆/压缩的属性. 所有导出的 (例如 ^:export) 变量仍将导出到全局作用域, 不受此设置的影响. 该设置仅用于限制创建的全局变量的数量, 没有其他作用. 不要直接使用它.

7.5. Web Workers

:modules 配置也可以用于生成要用作 Web Workers 的文件. 你可以通过设置 :web-worker true 将任何模块声明为 Web Worker. 生成的文件将包含一些额外的引导代码, 它将自动加载其依赖项. :modules 的工作方式也确保了只有 worker 使用的代码才会出现在 worker 的最终文件中. 每个 worker 都应该有一个专用的 CLJS 命名空间.

生成 web worker 脚本的示例

{...
 :builds
  {:app
   {:target :browser
    :output-dir "public/js"
    :asset-path "/js"
    ...
    :modules
    {:shared
     {:entries []}
     :main
     {:init-fn my.app/init
      :depends-on #{:shared}}
     :worker
     {:init-fn my.app.worker/init
      :depends-on #{:shared}
      :web-worker true}}}}}

上面的配置将生成 worker.js, 你可以用它来启动 Web Worker. 它将拥有 :shared 模块的所有代码 (但不是 :main). my.app.worker 命名空间中的代码将只在 worker 中执行. Worker 的生成在开发和发布模式下都会发生.

请注意, :shared 模块中空的 :entries [] 将使其收集 :main:worker 模块之间共享的所有代码.

示例 echo worker

(ns my.app.worker)

(defn init []
  (js/self.addEventListener "message"
    (fn [^js e]
      (js/postMessage (.. e -data)))))

示例使用 worker

(ns my.app)

(defn init []
  (let [worker (js/Worker. "/js/worker.js")]
    (.. worker (addEventListener "message" (fn [e] (js/console.log e))))
    (.. worker (postMessage "hello world"))))

由于我们现在有一个 :shared 模块, 你必须确保在你的 HTML 中正确加载它. 如果你只加载 main.js, 你会得到一个错误.

<script src="/js/shared.js"></script>
<script src="/js/main.js"></script>

7.6. 可缓存的输出

在 Web 环境中, 希望将 .js 文件缓存很长时间以避免额外的请求. 为每个发布的版本生成一个唯一的文件名是常见的做法. 这改变了用于访问它的 URL, 因此可以安全地永久缓存.

7.6.1. 发布版本

可以通过 :release-version 配置设置来为每个版本创建唯一的文件名. 通常, 你会通过 --config-merge 从命令行传入这个.

shadow-cljs release app --config-merge '{:release-version "v1"}'

示例 :modules 配置

{...
 :builds
  {:app
   {:target :browser
    ...
    :output-dir "public/js"
    :asset-path "/js"
    :modules {:main {:entries [my.app]}
              :extra {:entries [my.app.extra]
                      :depends-on #{:main}}}}}}

这将会在 public/js 中创建 main.v1.jsextra.v1.js 文件, 而不是通常的 main.jsextra.js.

你可以使用手动版本或像构建时的 git sha 这样的自动化东西. 只要确保一旦你向用户发布了某些东西, 就更新它, 因为通过缓存, 他们不会请求旧文件的新版本.

7.6.2. 带指纹哈希的文件名

你可以向你的构建配置中添加 :module-hash-names true, 以便为每个生成的输出模块文件自动创建一个 MD5 签名. 这意味着 :main 模块将生成一个 main.<md5hash>.js, 而不仅仅是默认的 main.js.

:module-hash-names true 将包含完整的 32 位 MD5 哈希, 如果你喜欢更短的版本, 你可以指定一个 1-32 之间的数字 (例如 :module-hash-names 8). 请注意, 缩短哈希可能会增加产生冲突的机会. 我建议使用完整的哈希.

示例 :module-hash-names 配置

{...
 :builds
  {:app
   {:target :browser
    ...
    :output-dir "public/js"
    :asset-path "/js"
    :module-hash-names true
    :modules {:main {:entries [my.app]}
              :extra {:entries [my.app.extra]
                      :depends-on #{:main}}}}}}

它现在将在 :output-dir 中生成 main.<hash>.js, 而不是 main.js.

由于文件名会随着每次发布而改变, 将它们包含在你的 HTML 中会变得有点复杂. 如果你想用 shadow-cljs 外部的程序来完成这个任务, 你可以在输出清单中找到关于文件名的程序化信息. 如果你想在构建过程中完成这个任务, 请查看构建钩子, 特别是这个钩子 (https://github.com/thheller/shadow-cljs/blob/9a36743674e7bac457c43761c2ef0cce0423862a/src/main/shadow/html.clj#L28-L60).

7.7. 输出清单

shadow-cljs 在配置的 :output-dir 中生成一个 manifest.edn 文件. 这个文件包含了模块配置的描述, 以及一个额外的 :output-name 属性, 它将原始模块名映射到实际的文件名 (在使用 :module-hash-names 功能时很重要).

使用哈希文件名时 manifest.edn 的示例输出.

[{:module-id :common,
  :name :common,
  :output-name "common.15D142F7841E2838B46283EA558634EE.js",
  :entries [...],
  :depends-on #{},
  :sources [...]}
 {:module-id :page-a,
  :name :page-a,
  :output-name "page-a.D8844E305644135CBD5CBCF7E359168A.js",
  :entries [...],
  :depends-on #{:common},
  :sources [...]}
 ...]

清单包含了所有按依赖顺序排序的 :modules. 你可以用它来将 :module-id 映射到实际生成的文件名.

开发构建也会生成这个文件, 你可以检查它是否有修改, 以便知道新构建何时完成. :module-hash-names 在开发期间不适用, 所以你会得到通常的文件名.

你可以通过 :build-options :manifest-name 条目来配置生成的清单文件的名称. 它默认为 manifest.edn. 如果你配置一个以 .json 结尾的文件名, 输出将是 JSON 而不是 EDN. 该文件将相对于配置的 :output-dir.

示例 manifest.json 配置

{...
 :builds
  {:app
   {:target :browser
    ...
    :build-options {:manifest-name "manifest.json"}
    :modules {:main {:entries [my.app]}
              :extra {:entries [my.app.extra]
                      :depends-on #{:main}}}}}}

7.8. 开发支持

:browser 配置的 :devtools 部分支持一些额外的选项, 用于配置可选的开发时 HTTP 服务器和 CSS 重载.

7.8.1. 平视显示器 (HUD)

:browser 目标现在使用 HUD 来在构建开始时显示加载指示器. 如果有警告和错误, 它也会显示.

你可以通过在 :devtools 部分设置 :hud false 来完全禁用它.

你也可以通过设置 :hud #{:errors :warnings} 来指定你关心的功能来切换某些功能. 这将显示错误/警告, 但没有进度指示器. 可用选项有 :errors, :warnings, :progress. 只有包含的选项会被启用, 其他所有选项都将被禁用.

打开文件

警告包含一个到源位置的链接, 可以点击它在你的编辑器中打开文件. 这需要一点配置.

你可以在你的 shadow-cljs.edn 项目配置中或全局地在你的主目录下的 ~/.shadow-cljs/config.edn 中配置它.

:open-file-command 配置

{:open-file-command
 ["idea" :pwd "--line" :line :file]}

:open-file-command 期望一个表示非常简单的 DSL 的向量. 字符串保持原样, 关键字被它们各自的值替换. 可以使用嵌套向量来组合多个参数, 使用 clojure.core/format 样式模式.

上面的例子会执行

$ idea /path/to/project-root --line 3 /path/to/project-root/src/main/demo/foo.cljs

emacsclient 示例

{:open-file-command
 ["emacsclient" "-n" ["+%s:%s" :line :column] :file]}
$ emacsclient -n +3:1 /path/to/project-root/src/main/demo/foo.cljs

可用的替换变量有:

  • :pwd 进程工作目录 (即项目根目录)
  • :file 绝对文件路径
  • :line 警告/错误的行号
  • :column 列号
  • :wsl-file 翻译后的 WSL 文件路径. 当通过 WSL Bash 运行 shadow-cljs 时很有用. 将 /mnt/c/Users/someone/code/project/src/main/demo/foo.cljs 路径翻译成 C:\Users...
  • :wsl-pwd 翻译后的 :pwd

7.8.2. CSS 重载

浏览器开发工具也可以为你重载 CSS. 这默认是启用的, 在大多数情况下, 当你使用内置的开发 HTTP 服务器时, 不需要额外的配置.

任何包含在页面中的样式表如果在文件系统上被修改, 都会被重载. 最好使用绝对路径, 但相对路径也应该可以工作.

示例 HTML 片段

<link rel="stylesheet" href="/css/main.css"/>

示例 Hiccup, 因为我们不是野蛮人

[:link {:rel "stylesheet" :href "/css/main.css"}]

使用内置的开发 HTTP 服务器

:dev-http {8000 "public"}

这将导致浏览器在 public/css/main.css 改变时重载 /css/main.css.

shadow-cljs 目前不提供直接编译 CSS 的支持, 但通常的工具可以工作, 并且应该分开运行. 只需确保输出生成到正确的位置.

当你不使用内置的 HTTP 服务器时, 你可以指定 :watch-dir, 它应该是一个指向用于提供你内容的文档根目录的路径.

示例 :watch-dir 配置

{...
  {:builds
   {:app {...
          :devtools {:watch-dir "public"}}}}}

当你的 HTTP 服务器从虚拟目录提供文件, 并且文件系统路径与 HTML 中使用的路径不完全匹配时, 你可以通过设置 :watch-path 来调整路径, 它将用作前缀.

示例 public/css/main.css/foo/css/main.css 下提供

{...
  {:builds
   {:app
    {...
     :devtools {:watch-dir "public"
                :watch-path "/foo"}}}}}

7.8.3. 代理支持

默认情况下, devtools 客户端将尝试通过配置的 HTTP 服务器 (通常是 localhost) 连接到 shadow-cljs 进程. 如果你使用反向代理来提供你的 HTML, 这可能是不可能的. 你可以设置 :devtools-url 来配置使用哪个 URL.

{...
 :builds
  {:app {...
         :devtools {:before-load my.app/stop
                    :after-load my.app/start
                    :devtools-url "https://some.host/shadow-cljs"
                    ...}}}}

shadow-cljs 然后将使用 :devtools-url 作为发出请求的基础. 它不是最终的 URL, 所以你必须确保所有以你配置的路径开始的请求 (例如 /shadow-cljs/*) 都被转发到 shadow-cljs 运行的主机.

到代理的传入请求

https://some.host/shadow-cljs/ws/foo/bar?asdf

必须转发到

http://localhost:9630/foo/bar?asdf

客户端将发出 WebSocket 请求以及正常的 XHR 请求来加载文件. 确保你的代理正确升级 WebSockets.

请求必须转发到主 HTTP 服务器, 而不是构建本身中配置的那个.

7.9. 使用外部 JS Bundler

有时你可能希望使用的 npm 包可能使用了 shadow-cljs 本身不支持的功能. 有些包甚至明确期望被 webpack 处理. 在这些情况下, 使用 webpack (或类似的工具) 可能比让 shadow-cljs 尝试捆绑这些包更简单, 并且可以解决可能出现的问题.

shadow-cljs 支持一个选项, 让它专注于编译 CLJS 代码, 但让其他东西处理 npm/JS requires. 你可以通过 :js-provider :external 来做到这一点. 我在这篇博客文章 (https://code.thheller.com/blog/shadow-cljs/2020/05/08/how-about-webpack-now.html#option-2-js-provider-external:) 中更多地讨论了这个主题.

这将限制某些动态交互. 添加新的 npm requires 将需要重新加载页面, 因为它们不能再由 shadow-cljs 热加载进来. 在 REPL 中 require npm 包也将受限于那些已经由外部 JS 文件提供的包. 这通常不是什么大问题, 但需要注意.

在你的构建配置中你添加:

{:builds
 {:app
  {:target :browser
   :output-dir "..."
   :modules {:main {...}}
   :js-options
   {:js-provider :external
    :external-index "target/index.js"}}}}

shadow-cljs 然后只会以常规 JS 工具可以理解的格式输出所有需要的 npm 包 requires. 你然后需要手动运行 webpack (或类似的工具), 并将该构建的输出与 shadow-cljs 的输出分开包含.

所以, 不再只在你的 HTML 中包含一个 script 标签, 你需要包含两个.

<script defer src="/js/libs.js"></script>
<script defer src="/js/main.js"></script>

这里的 libs.js 假设是 webpack 的输出.

请注意, webpack (或类似的工具) 有时会输出多个文件, 所以你具体需要包含哪个可能取决于你如何构建所有东西. 请查阅它们的文档以获取更多详细信息. 就 shadow-cljs 而言, 唯一重要的部分是外部输出在 shadow-cljs 输出之前加载.

7.9.1. JS Tree Shaking

webpack 这样的工具可以潜在地对 npm 依赖进行 tree-shake, 以使其构建输出更小. 为此, :external-index 文件需要生成 ESM 代码, 而不是当前默认的 CommonJS, 即 require().

{:builds
 {:app
  {:target :browser
   :output-dir "..."
   :modules {:main {...}}
   :js-options
   {:js-provider :external
    :external-index "target/index.js"
    :external-index-format :esm}}}}

这只会在 :external-index 文件中使用 import. 对于 release 构建, 此文件将列出所有引用的导入, 对于 watch/compile, 它仍然会引用所有内容.

8. 针对 JavaScript 模块

:target :esm 发出可以在任何 ESM 环境中使用的文件.

ESM, 是 ECMAscript Modules 或简称 JavaScript Modules (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) 的缩写, 是 JavaScript 文件的现代化标准. 大多数现代平台都原生支持它, 越来越多的 JS 生态系统正在向这个方向发展. 以这种方式生成的每个模块都可以指定 "exports", 其他导入它的文件可以引用这些 "exports".

ESM 很像 :target :browser, 它由 :modules 配置选项驱动. 每个模块都可以声明其 :exports 以供他人访问.

与其他模式一样, 主要的配置选项适用并且可以设置. 额外的特定于目标的选项是:

  • :output-dir (可选, 默认 "public/js"). 所有 :modules 写入的路径.
  • :runtime (可选, 默认 :browser) 控制哪些用于 REPL/热重载的开发扩展被注入. 目前只支持 :browser. 设置为任何其他值以禁用.
  • :modules 必需, 关键字到模块配置的映射.

8.1. 模块配置

每个模块都有自己的一组选项, 用于控制模块的构建方式. 指定多个模块意味着代码将在它们之间分割.

  • :init-fn 可选, 一个 defn 在模块加载时运行.
  • :entries 可选, 一个要在此模块中加载的命名空间符号的向量.
  • :exports 必需, 一个从符号到完全限定符号的映射.
  • :depends-on 在使用多个 :modules 时必需. 指定此模块依赖的其他模块集.

8.1.1. 模块导出

控制实际导出哪些代码是通过 :exports 完成的.

示例构建配置

{:source-paths ["src/main"]
 :dev-http {8000 "public"}
 :builds
 {:app
  {:target :esm
   :output-dir "public/js"
   :modules {:demo {:exports {hello demo.lib/hello}}}}}}

这将生成 public/js/demo.js 文件. 名称由 :output-dir:modules 映射中的键 :demo 决定.

示例 CLJS 代码

(ns demo.lib)

(defn hello []
  (js/console.log "hello world"))

(defn not-exported []
  (js/console.log "foo"))

它将可以直接在任何 ESM 环境中加载. 例如浏览器. 将其放入 public/index.html 中, 并通过 http://localhost:8000 加载它.

<script type="module">
  import { hello } from "/js/demo.js";
  hello();
</script>

使用 npx shadow-cljs watch app 你应该在加载页面时看到 hello world 记录到浏览器控制台.

注意, 这里只有 hello 是可访问的, 因为它是在 :exports 中声明的. (defn not-exported [] ...) 将是不可访问的, 并且很可能在 :advanced 发布构建中被完全移除.

模块默认导出

ES 模块有一个 "特殊" 的 default 导出, 你经常会在 JS 示例中看到它. 这可以通过像定义任何其他导出一样定义 default 导出​​来表示.

{:source-paths ["src/main"]
 :dev-http {8000 "public"}
 :builds
 {:app
  {:target :esm
   :output-dir "public/js"
   :modules {:demo {:exports {default demo.lib/hello}}}}}}

导入端也随之改变

<script type="module">
  import hello from "/js/demo.js";
  hello();
</script>

许多平台或系统对这个 default 导出赋予了特殊的含义, 但它在构建配置中的声明方式与任何其他导出一样.

模块 :init-fn

有时你可能不需要任何 :exports, 而是希望代码在模块加载时自动运行. 这可以通过 :init-fn 完成.

{:source-paths ["src/main"]
 :dev-http {8000 "public"}
 :builds
 {:app
  {:target :esm
   :output-dir "public/js"
   :modules {:demo {:init-fn demo.lib/hello}}}}}

以及 HTML

<script type="module" src="js/demo.js"></script>

它也可以与 :exports 结合使用, 以运行一个函数并仍然提供 :exports.

{:source-paths ["src/main"]
 :dev-http {8000 "public"}
 :builds
 {:app
  {:target :esm
   :output-dir "public/js"
   :modules
   {:demo
    {:init-fn demo.lib/hello
     :exports {hello demo.lib/hello}}}}}}

保持这个 HTML 将基本上在页面加载时记录两次.

<script type="module">
  import hello from "/js/demo.js";
  hello();
</script>

8.2. 模块分割

{:source-paths ["src/main"]
 :dev-http {8000 "public"}
 :builds
 {:app
  {:target :esm
   :output-dir "public/js"
   :modules
   {:base
    {:entries []}
    :hello
    {:exports {hello demo.lib/hello}
     :depends-on #{:base}}
    :other
    {:exports {foo demo.foo/foo}
     :depends-on #{:base}}
    }}}}

并添加

(ns demo.foo)

(defn foo []
  (js/console.log "foo"))

这里我们声明了 3 个模块, 一个 :base 模块和另外两个都依赖于 :base 模块的模块. :base 模块声明了一个空的 :entries [] 向量, 这是一个方便的说法, 它应该提取其他两个模块共享的所有命名空间 (例如本例中的 cljs.core).

你现在可以在 HTML 中独立加载每个 :module.

<script type="module">
  import hello from "/js/hello.js";
  hello();
</script>

浏览器将自动加载 /js/base.js, 但不会加载 /js/other.js, 因为上面的代码不需要它. 你可以使用 :modules 为你的网站的不同部分分割代码.

8.3. 动态模块导入

模块也可以在运行时通过提供的 shadow.esm/dynamic-import 辅助函数动态加载.

(ns my.app
  (:require
    [shadow.esm :refer (dynamic-import)]
    [shadow.cljs.modern :refer (js-await)]))

(defn foo []
  (js-await [mod (dynamic-import "https://cdn.pika.dev/preact@^10.0.0")]
    (js/console.log "loaded module" mod)))

这将在运行时动态加载一个外部 ESM 模块, 而无需它成为构建的一部分. 你当然也可以用这种方式动态加载你自己的 :modules.

8.4. 第三方工具集成

在默认的 :runtime :browser 设置中, 所有依赖项都由 shadow-cljs 捆绑和提供. 这样做是为了输出可以直接在浏览器中加载. 当将 :target :esm 输出导入到另一个构建工具环境 (例如 webpack) 时, 可能会导致依赖项重复.

相反, 你可以配置 shadow-cljs 不捆绑任何 JS 依赖, 而是将它们留给其他工具.

这是通过在你的构建配置中设置 :js-provider 来完成的.

{:source-paths ["src/main"]
 :dev-http {8000 "public"}
 :builds
 {:app
  {:target :esm
   :output-dir "public/js"
   :js-options {:js-provider :import}
   :modules {:demo {:exports {default demo.lib/hello}}}}}}

对于此构建, shadow-cljs 将只编译和捆绑 CLJS 代码, 但将所有其他 JS 代码留给其他工具稍后提供. 请注意, 如果你的构建中有 (:require ["react"]) 或任何其他 npm 依赖, 来自 shadow-cljs 的输出必须先由另一个工具处理, 然后才能在浏览器中加载. 只有在某个其他工具确实要提供所需的依赖项时才设置此项.

9. 针对 React Native

:target :react-native 生成的代码旨在集成到默认的 react-native 工具链中 (例如 metro). 像 expo 这样包装了这些工具的工具应该可以自动工作, 不需要额外的设置.

你将需要与其他目标相同的基本主配置 (如 :source-paths), 构建特定的配置非常少, 至少需要 2 个选项 (除了 :target 本身)

  • :init-fn (必需). 你的应用程序初始化函数的命名空间限定符号. 此函数将在启动时调用一次, 并且可能应该渲染一些东西.
  • :output-dir (必需). 用于写入输出文件的目录.

示例 :react-native 配置

{:source-paths [...]
 :dependencies [...]
 ...
 :builds
  {:app
   {:target :react-native
    :init-fn demo.app/init
    :output-dir "app"}}}

编译后, 这会产生一个 app/index.js 文件, 旨在用作 react-native 工具的入口点. 在开发期间, :output-dir 将包含更多文件, 但你只应直接引用生成的 app/index.js. 发布构建将只生成优化的 app/index.js, 不需要其他文件.

9.1. React Native

使用 react-native 有两种方式, "纯" react-native, 它允许你使用原生代码和库, 以及 "包装" 在 expo (https://expo.io/) 中的方式 (如下所述). 上述所有步骤都足以开始使用 shadow-cljs 和纯 react-native. 请参阅此示例仓库:

https://github.com/thheller/reagent-react-native

9.2. Expo

expo (https://expo.io/) 使 react-native 的工作变得相当容易. 有两个提供的示例设置.

两个示例都是使用 expo init ... 生成的, 唯一的调整是在配置中将正确的 entryPoint 添加到生成的 app.json 中.

{
  "expo": {
    "name": "hello-world",
    "slug": "reagent-expo",
    ...
    "entryPoint":"./app/index.js",
    ...
  }
}

expo 要求在启动时注册一个 React 组件, 这可以手动完成, 也可以使用 shadow.expo/render-root 函数, 该函数负责创建组件, 而是直接期望一个 React 元素实例开始渲染.

来自 Reagent 示例 (https://github.com/thheller/reagent-expo/blob/2c73ed0513a8f5050b250c0c7e53b9ae7543cee9/src/main/test/app.cljs#L34-L40)

(defn start
  {:dev/after-load true}
  []
  (expo/render-root (r/as-element [root])))

(defn init []
  (start))

init 在启动时被调用一次. 由于该示例不需要做任何特殊的设置, 它只是直接调用 start. start 将在 watch 运行时每次代码更改被重载后重复调用. reagent.core/as-element 函数可用于从 reagent hiccup 标记生成所需的 React 元素.

9.3. 热代码重载

React native 不仅需要重载编译后的文件和显式需要这些文件的文件, 还需要它们的传递依赖, 才能使更改生效. 为此, 请使用 :reload-strategy 选项, 如在传递依赖的热重载中所述.

*0. 针对 node.js 有内置支持生成旨在用作独立脚本的代码, 以及旨在用作库的代码. 有关配置文件中所需的基本设置, 请参阅通用配置部分.

**0.1. node.js 脚本 :target :node-script 产生单文件的独立输出, 可以使用 node.js 运行. 代码只是 ClojureScript, 入口点很容易定义:

(ns demo.script)

(defn main [& cli-args]
  (prn "hello world"))

***0.1.1. 构建选项 你将需要与其他目标相同的基本主配置 (如 :source-paths), 但你需要一些特定于 node 的构建目标选项:

  • :main (必需). 你的脚本入口点函数的命名空间限定符号.
  • :output-to (必需). 生成的脚本的路径和文件名.
  • :output-dir (可选). 开发模式下支持文件的路径. 默认为一个缓存目录.

示例 node 脚本构建

{:source-paths [...]
 ...
 :builds
  {:script
   {:target :node-script
    :main demo.script/main
    :output-to "out/demo-script/script.js"}}}

编译后, 这会产生一个独立的 out/demo-script/script.js 文件, 旨在通过 node script.js <command line args> 调用. 运行时, 它将调用 (demo.script/main <command line args>) 函数. 这只会产生在 :output-to 中指定的文件. 任何其他支持文件 (例如开发模式) 都被写入临时支持目录.

***0.1.2. 热代码重载 你经常会编写作为服务器或其他一些长时间运行的进程运行的脚本. 热代码重载在处理这些脚本时非常有用, 并且设置起来很简单:

  1. 添加启动/停止回调函数.
  2. 配置构建以使用这些钩子.

这是一个 node 中的 http 服务器示例:

带启动/停止钩子的 node 脚本示例, 用于热代码重载.

(ns demo.script
  (:require ["http" :as http]))

(defn request-handler [req res]
  (.end res "foo"))

; 一个可以挂载服务器的地方, 以便我们可以停止/启动它
(defonce server-ref
  (volatile! nil))

(defn main [& args]
  (js/console.log "starting server")
  (let [server (http/createServer #(request-handler %1 %2))]
    (.listen server 3000
             (fn [err]
               (if err
                 (js/console.error "server start failed")
                 (js/console.info "http server running"))))

    (vreset! server-ref server)))

(defn start
  "启动的钩子. 也用作热代码重载的钩子."
  []
  (js/console.warn "start called")
  (main))

(defn stop
  "热代码重载钩子, 用于关闭资源, 以便热代码重载可以工作"
  [done]
  (js/console.warn "stop called")
  (when-some [srv @server-ref]
    (.close srv
            (fn [err]
              (js/console.log "stop completed" err)
              (done)))))

(js/console.log "__filename" js/__filename)

相关的配置是 (shadow-cljs.edn):

为热代码重载添加钩子.

{...
 :builds
  { :script {... as before
            ; 添加重载钩子
            :devtools {:before-load-async demo.script/stop
                       :after-load demo.script/start}}}}

警告

许多库隐藏了状态或执行了阻止热代码重载正常工作的操作. 编译器对此无能为力, 因为它不知道这些库在做什么. 热代码重载只有在你可以干净地 "停止" 和 "重启" 使用的工件的情况下才能很好地工作.

**0.2. node.js 库 :target :node-library 发出的代码可以用作 (通过 require) 标准的 node 库, 并且对于将你的代码发布为编译后的 Javascript 工件以供重用非常有用.

与其他模式一样, 主要的配置选项适用并且必须设置. 特定于目标的选项是:

  • :target 使用 :node-library
  • :output-to (必需). 生成的库的路径和文件名.
  • :output-dir (可选). 开发模式下支持文件的路径. 默认为一个缓存目录.

热代码重载的故事与脚本目标类似, 但可能效果不佳, 因为它不能像控制所有加载的代码那样轻松.

控制实际导出哪些代码是通过以下选项之一完成的:

  • :exports - 一个从关键字到完全限定符号的映射
  • :exports-var - 一个完全限定的符号
  • :exports-fn - 一个完全限定的符号

***0.2.1. 单个静态 "default" 导出 :exports-var 将只返回在该 var 下声明的任何内容. 它可以指向一个 defn 或一个普通的 def.

使用 :exports-var 的构建配置

{...
 :builds {:lib {:output-to "lib.js"
                :exports-var demo.ns/f
                ...}}}

示例 CLJS

(ns demo.ns)

(defn f [...] ...)

;; 
(def f #js {:foo ...})

使用生成的代码

$ node
> var f = require('./lib.js');
f(); // 实际的 demo.ns/f 函数

它实际上是在生成 module.exports = demo.ns.f;.

***0.2.2. 多个静态命名导出 具有多个导出的构建配置

{...
 :builds {:lib {:exports {:g demo.ns/f
                          :h other.ns/thing
                         :ns/ok? another.ns/ok?}
                ...}}}

关键字被用作导出对象中条目的名称. 对此关键字名称不进行任何混淆 (但会删除命名空间). 所以, 上面的例子将 cljs f 映射到 g, 等等:

$ node
> var lib = require('./lib.js');
lib.g(); // 调用 demo-ns/f
lib["ok?"](); // 调用 another-ns/ok?

你可以通过使用 :exports-var 指向一个 def 来实现完全相同的事情

(def exports #js {:g f
                  ...})

***0.2.3. "动态" 导出 此外, 你可以指定 :exports-fn 作为一个完全限定的符号. 这应该指向一个没有参数的函数, 该函数应该返回一个 JS 对象 (或函数). 这个函数只会被调用一次, 因为 node 会缓存返回值.

(ns demo.ns
  (:require [demo.other :as other]))

(defn generate-exports []
  #js {:hello hello
       :foo other/foo})
{...
 :builds {:lib {:exports-fn demo.ns/generate-exports
                ...}}}

注意

exports 配置会自动跟踪导出的符号, 并将它们传递给优化阶段. 这意味着在 :exports 中列出的任何内容都不会被 Google Closure 优化重命名.

***0.2.4. 完整示例 下面的示例创建了一个 lib.js 文件, 旨在通过正常的 Node require 机制来使用.

(ns demo.lib)

(defn hello []
  (prn "hello")
  "hello")

构建配置将是:

{...
 :builds {:library {:target :node-library
                    :output-to "out/demo-library/lib.js"
                    :exports {:hello demo.lib/hello}}}}

运行时的使用如你所料:

$ cd out/demo-library
$ node
> var x = require('./lib');
undefined
> x.hello()
hello
'hello'

:node-script 一样, 这只会创建在 :output-to 中指定的文件. :exports 映射将 CLJS vars 映射到它们应该被导出的名称.

**0.3. 创建 npm 包

10. 在 JS 生态系统中嵌入 - :npm-module 目标

还有一个额外的目标, 旨在将 CLJS 集成到现有的 JS 项目中. 输出可以与现有的 JS 工具 (例如 webpack, browserify, babel, create-react-app, …) 无缝集成, 只需少量配置.

  • :output-dir (必需) 输出文件的写入路径.
  • :entries (必需) 一个应被编译的命名空间符号的向量.
  • :ns-regexp (可选) 一个匹配项目文件命名空间的正则表达式. 这只扫描文件, 不会扫描 jars.

示例 shadow-cljs.edn 配置

{...
 :builds
  {:code
   {:target :npm-module
    :output-dir "out"
    :entries [demo.foo]}}}

在你项目根目录下的一个 JS 文件中, 你可以 require("./out/demo.foo") 来加载 CLJS 命名空间并从 JS 访问它. JS requires 必须是 JS 文件位置到 CLJS 输出的相对路径.

如果你计划在 NPM 上分发代码, 那么你可能想使用 :node-library 目标, 因为它允许对导出和优化进行更精细的控制.

10.1. 处理优化

:node-library 目标不同, 模块目标不知道你想要调用你导出的符号的名称, 所以它只是按原样导出它们. 如果你使用高级编译, 那么所有东西都会得到一个压缩的混淆名称!

这很容易补救, 只需在你想要保留的任何符号上添加 :export 元数据:

(ns demo.foo)

(def ^:export foo 5.662)

(defn ^:export bar [] ...)

这是一个被 ClojureScript 理解的标准注解, 并防止 Google Closure 重命名工件. JS 代码在优化后仍然能够访问它们. 没有 ^:export 提示, closure 编译器很可能会删除或重命名它们.

var ns = require("shadow-cljs/demo.foo");

ns.foo;
ns.bar();

11. 测试

shadow-cljs 提供了一些实用工具目标, 使构建测试变得更容易一些.

所有测试目标都会生成一个测试运行器, 并自动添加所有匹配可配置的 :ns-regexp 的命名空间. 默认的测试运行器是为 cljs.test 构建的, 但如果你喜欢使用其他测试框架, 你可以创建自定义的运行器.

默认的 :ns-regexp"-test$" , 所以你的第一个测试可能看起来像这样:

文件: src/test/demo/app_test.cljs

(ns demo.app-test
  (:require [cljs.test :refer (deftest is)]))

(deftest a-failing-test
  (is (= 1 2)))

在 Clojure 世界中, 将测试文件保存在它们自己的源路径中是常见的, 所以上面的例子假设你在你的 shadow-cljs.edn 配置中配置了 :source-paths ["src/main" "src/test"]. 你通常的应用代码放在 src/main 中, 测试放在 src/test 中. 然而这是可选的, 将所有东西都放在 src 中, 只使用 :source-paths ["src"] 也是完全可以的.

11.1. 在 node.js 中测试

这个目标将创建一个测试运行器, 包括所有匹配给定正则表达式的测试命名空间.

相关的配置选项是:

  • :target :node-test
  • :output-to 将用于运行测试的最终输出文件.
  • :ns-regexp (可选) 一个匹配项目文件命名空间的正则表达式. 这只扫描文件, 不会扫描 jars. 默认为 "-test$" .
  • :autorun (布尔值, 可选) 在构建完成时通过 node 运行测试. 这主要是为了与 watch 结合使用. node 进程退出代码将不会返回, 因为那将不得不强行杀死正在运行的 JVM.
  • :main (限定的符号, 可选) 在启动时调用以运行测试的函数, 默认为 shadow.test.node/main, 它使用 cljs.test 运行测试.

匹配所有 *-spec 命名空间的测试配置

{...
 :builds
  {:test
   {:target :node-test
    :output-to "out/node-tests.js"
    :ns-regexp "-spec$"
    :autorun true}}}

:node-test 目标只生成测试文件. 你可以通过 node 运行它.

$ shadow-cljs compile test
# or
$ shadow-cljs release test

# run tests manually, :autorun will do this automatically
$ node out/node-tests.js

# compile & test combined
$ shadow-cljs compile test && node out/node-tests.js

node 进程退出代码在成功时将被设置为 0, 在任何失败时为 1. (当使用 :autorun 时, node 进程退出代码将不会返回.)

11.2. 在浏览器中测试

这个目标旨在收集包含测试的命名空间 (基于文件名模式匹配), 并触发一个测试运行器. 它包含一个内置的运行器, 将自动扫描 cljs.test 测试并运行它们.

相关的配置选项是:

  • :target :browser-test
  • :test-dir 一个用于输出文件的文件夹. 见下文.
  • :ns-regexp (可选) 一个匹配项目文件命名空间的正则表达式. 这只扫描文件, 不会扫描 jars. 默认为 "-test$".
  • :runner-ns (可选) 一个可以包含 start, stop 和 init 函数的命名空间. 默认为 shadow.test.browser.

支持常规的 :devtools 选项, 所以你通常会创建一个 http 服务器来提供文件. 总的来说, 你需要一个看起来像这样的配置:

{...
 ;; tests are served via http://localhost:8021
 :dev-http {8021 "out/test"}
 :builds
  {:test
   {:target :browser-test
    :test-dir "out/test"}}}

如果你选择提供一个自定义的 :runner-ns, 它可能看起来像这样:

(ns tests.client-test-main
  {:dev/always true}
  (:require [shadow.test :as st]
            [shadow.test.env :as env]
            [cljs-test-display.core :as ctd]
            [shadow.dom :as dom]))

(defn start []
  (-> (env/get-test-data)
      (env/reset-test-data!))

  (st/run-all-tests (ctd/init! "test-root")))

(defn stop [done]
  ; 测试可以是异步的. 你必须调用 done, 以便运行器知道你实际上已经完成了
  (done))

(defn ^:export init []
  (dom/append [:div#test-root])
  (start))

然后在构建配置中添加 :runner-ns tests.client-test-main.

它只有 init, start, stop 方法. init 将在启动时被调用一次, stop 将在任何代码被重载之前被调用, start 将在所有代码被重载后被调用.

提示

:runner-ns 是可选的, 如果不使用, 就把它去掉.

11.2.1. 在 :test-dir 中生成的输出

输出在你的 test-dir 文件夹中包括两个主要工件:

  • index.html - 当且仅当尚不存在 index.html 文件时. 默认情况下, 生成的文件加载测试并在 :runner-ns 中运行 init. 你可以编辑或添加一个自定义版本, 它不会被覆盖.
  • js/test.js - Javascript 测试. 测试将始终有这个名称. 模块的条目是自动生成的.

任何 web 服务器都可以, :dev-http 只是一个方便的选项.

11.3. 针对 Karma 进行持续集成测试

当你想在某种 CI 服务器上针对浏览器运行你的 CLJS 测试时, 你需要能够从命令行运行测试并获得一个状态码. Karma 是一个众所周知且受支持的测试运行器, 可以为你做到这一点, shadow-cljs 包括一个目标, 可以在你的测试周围添加适当的包装器, 以便它们能在其中工作.

11.3.1. 安装 Karma

有关完整说明, 请参阅他们的网站 (http://karma-runner.github.io). 你通常需要在你的 package.json 中有类似这样的东西:

{
  "name": "CITests",
  "version": "1.0.0",
  "description": "Testing",
  ...
  "devDependencies": {
    "karma": "^2.0.0",
    "karma-chrome-launcher": "^2.2.0",
    "karma-cljs-test": "^0.1.0",
    ...
  },
  "author": "",
  "license": "MIT"
}

所以, 你需要 Karma, 一个浏览器启动器, 和 cljs-test 集成.

11.3.2. 构建

构建选项是:

  • :target :karma
  • :output-to js 文件的路径/文件名.
  • :ns-regexp (可选) 一个匹配测试命名空间的正则表达式, 默认为 "-test$

所以你可能会有类似这样的东西:

{...
 :builds
  {:ci
   {:target :karma
    :output-to "target/ci.js"
    :ns-regexp "-spec$"}}}

你还需要一个 karma.conf.js:

module.exports = function (config) {
  config.set({
    browsers: ['ChromeHeadless'],
    // 输出文件所在的目录
    basePath: 'target',
    // 文件本身
    files: ['ci.js'],
    frameworks: ['cljs-test'],
    plugins: ['karma-cljs-test', 'karma-chrome-launcher'],
    colors: true,
    logLevel: config.LOG_INFO,
    client: {
      args: ["shadow.test.karma.init"],
      singleRun: true
    }
  })
};

然后你可以如下运行测试 (假设你已经安装了工具的全局可执行文件):

$ shadow-cljs compile ci
$ karma start --single-run
12 01 2018 01:19:24.222:INFO [karma]: Karma v2.0.0 server started at http://0.0.0.0:9876/
12 01 2018 01:19:24.224:INFO [launcher]: Launching browser ChromeHeadless with unlimited concurrency
12 01 2018 01:19:24.231:INFO [launcher]: Starting browser ChromeHeadless
12 01 2018 01:19:24.478:INFO [HeadlessChrome 0.0.0 (Mac OS X 10.12.6)]: Connected on socket
TcfrjxVKmx7xN6enAAAA with id 85554456
LOG: 'Testing boo.sample-spec'
HeadlessChrome 0.0.0 (Mac OS X 10.12.6): Executed 1 of 1 SUCCESS (0.007 secs / 0.002 secs)

12. JavaScript 集成

12.1. NPM

npm (https://www.npmjs.com/) 已成为 JavaScript 的事实上的标准包管理器. 几乎所有的 JS 库都可以在那里找到, shadow-cljs 提供了无缝的集成来访问这些包.

12.1.1. 使用 npm 包

大多数 npm 包也会包含一些关于如何使用实际代码的说明. "旧的" CommonJS 风格只有 require 调用, 它们可以直接翻译:

var react = require("react");
(ns my.app
  (:require ["react" :as react]))

调用 require 时使用的任何 "string" 参数都会原样转移到 :require. :as 别名由你决定. 一旦我们有了它, 我们就可以像使用任何其他 CLJS 命名空间一样使用代码了!

(react/createElement "div" nil "hello world")

shadow-cljs 中: 始终使用 ns 形式和你提供的任何 :as 别名. 你也可以使用 :refer:rename. 这与 :foreign-libs /CLJSJS 的做法不同, 在那里你将东西包含在命名空间中, 但随后在你的代码中使用全局的 js/Thing.

一些包只导出一个单一的函数, 你可以直接通过使用 (:require ["thing" :as thing]) 然后 (thing) 来调用.

最近一些包开始在它们的例子中使用 ES6 import 语句. 这些也几乎可以 1:1 翻译, 只是在默认导出方面有一个小小的区别.

以下示例可用于翻译:

此表仅适用于你正在使用的代码被打包为实际的 ES6+ 代码的情况. 如果代码被打包为 CommonJS, 那么 $default 可能不适用. 有关更多信息, 请参阅下面的部分.

这里的名称 defaultExportexport 是为了显示它们所代表的内容而选择的. 在 defaultExport 的情况下, 或任何其他 :as 别名, 你可以用任何你喜欢的名称替换它. 在 :refer 的情况下, 你必须使用库选择的名称或使用 :rename 来更改它. 重要的新事物是默认导出的引入以及它们在 require 方面的含义.

示例默认导出

import defaultExport from "module-name";
(:require ["module-name$default" :as defaultExport])

示例模块别名

import * as name from "module-name";
(:require ["module-name" :as name])

示例模块引用

import { export } from "module-name";
(:require ["module-name" :refer (export)])

示例模块多重引用

import { export1 , export2 } from "module-name";
(:require ["module-name" :refer (export1 export2)])

示例模块引用重命名

import { export as alias } from "module-name";
(:require ["module-name" :rename {export alias}])

示例模块引用和重命名

import { export1 , export2 as alias2 } from "module-name";
(:require ["module-name" :refer (export1) :rename {export2 alias2}])

示例模块引用和默认导出

import defaultExport, { export } from "module-name";
(:require
 ["module-name" :refer (export)]
 ["module-name$default" :as defaultExport])

示例模块别名和默认导出

import defaultExport, * as name from "module-name";
(:require
 ["module-name" :as name]
 ["module-name$default" :as defaultExport])

示例模块无用导入 (包括一个用于副作用的模块)

import from "module-name";
(:require ["module-name"])

请注意, 以前我们被困在使用捆绑代码, 其中包含了很多我们实际上不需要的代码. 现在我们的情况更好了: 一些库也被打包成允许你只包含你需要的部分, 从而在你的最终构建中产生更少的代码.

react-virtualized 是一个很好的例子:

// 你可以从 'react-virtualized' 中以命名导出的形式导入任何你想要的组件, 例如
import { Column, Table } from 'react-virtualized'

// 但如果你只使用几个 react-virtualized 组件,
// 并且你担心增加应用程序的捆绑包大小,
// 你可以直接只导入你需要的组件, 像这样:
import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'
import List from 'react-virtualized/dist/commonjs/List'

通过我们改进的支持, 我们可以轻松地将其翻译为:

(ns my-ns
  ;; 全部
  (:require ["react-virtualized" :refer (Column Table)])
  ;; 或逐个
  (:require ["react-virtualized/dist/commonjs/AutoSizer$default" :as virtual-auto-sizer]
            ["react-virtualized/dist/commonjs/List$default" :as virtual-list]))

如果一个 :require 似乎不能正常工作, 建议在 REPL 中查看它.

$ shadow-cljs browser-repl (or node-repl)
...
[1:1]~cljs.user=> (require '["react-tooltip" :as x])
nil
[1:1]~cljs.user=> x
#object[e]
[1:1]~cljs.user=> (goog/typeOf x)
"function"
[1:1]~cljs.user=> (js/console.dir x)
nil

由于打印任意 JS 对象并不总是很有用 (如上所示), 你可以使用 (js/console.dir x) 来在浏览器控制台中获得更有用的表示. goog/typeOf 也可能很有用.

12.1.2. 包提供者

shadow-cljs 支持几种不同的方式将 npm 包包含到你的构建中. 它们可以通过 :js-options :js-provider 设置进行配置. 每个 :target 通常都会设置适合你的构建的那个, 大多数情况下你不需要接触这个设置.

目前有 3 个支持的 JS 提供者:

  • :require 直接映射到 JS require("thing") 函数调用. 它是所有 node.js 目标的默认设置, 因为它可以在运行时本地解析 require. 包含的 JS 不会以任何方式处理.
  • :shadow 通过 node_modules 解析 JS, 并在构建中包含每个引用文件的缩小版本. 它是 :browser 目标的默认设置. node_modules 源不会经过 :advanced 编译.
  • :closure:shadow 类似, 但尝试通过 Closure Compiler CommonJS/ES6 重写工具处理所有包含的文件. 它们也将通过 :advanced 编译进行处理.
  • :external 只收集 JS requires 并发出一个索引文件 (通过 :external-index "foo/bar.js" 配置), 该文件旨在由任何其他 JS 构建工具处理, 并将实际提供 JS 依赖. 发出的索引文件包含一些胶水代码, 以便 CLJS 输出可以访问 JS 依赖. 外部索引文件的输出应在 CLJS 输出之前加载.

:shadow vs :closure

理想情况下, 我们希望使用 :closure 作为我们的主要 JS 提供者, 因为它将通过 :advanced 运行整个应用程序, 为我们提供最优化的输出. 然而在实践中, 通过 npm 可用的大量代码与 :advanced 编译所做的积极优化不兼容. 它们要么根本无法编译, 要么在运行时暴露出非常难以识别的细微错误.

:shadow 是一种权宜之计, 它只通过 :simple 处理代码, 并实现了更可靠的支持, 同时仍然获得了相当优化的代码. 输出与 webpack 等其他工具生成的输出相当 (或通常更好).

直到 Closure 中的支持变得更可靠之前, :shadow:browser 构建的推荐 JS 提供者.

:browser 构建中使用 :closure 的示例配置.

{...
 :builds
  {:app
   {:target :browser
    ...
    :js-options {:js-provider :closure}
    }}}

12.1.3. CommonJS vs ESM

如今许多 npm 包都提供了多种构建变体. shadow-cljs 默认会选择在 package.json 中的 mainbrowser 键下链接的变体. 这最常指的是 CommonJS 代码. 一些现代包还提供了一个 module 条目, 通常指的是 ECMAScript 代码 (意味着 "现代" JS). CommonJS 和 ESM 之间的互操作可能很棘手, 所以 shadow-cljs 默认使用 CommonJS, 但使用 ESM 可能是有益的.

这在很大程度上取决于你使用的包, 是否能正常工作. 你可以配置 shadow-cljs 通过 :entry-keys JS 选项来优先选择 module 条目. 它接受一个在 package.json 中找到的字符串键的向量, 将按顺序尝试. 默认是 ["browser" "main" "module"].

偏好 "module" 而不是 "browser" 和 "main" 的示例配置.

{...
 :builds
  {:app
   {:target :browser
    ...
    :js-options {:entry-keys ["module" "browser" "main"]} ;; 首先尝试 "module"
    }}}

请确保彻底测试并比较构建报告输出, 以检查切换时的大小差异. 结果可能会有很大的正面或负面变化.

12.1.4. 解析包

默认情况下, shadow-cljs 将遵循 npm 约定解析所有 (:require ["thing" :as x]) require. 这意味着它将查看 <project>/node_modules/thing/package.json 并从那里跟踪代码. 为了自定义此工作方式, shadow-cljs 公开了一个 :resolve 配置选项, 让你可以覆盖事物的解析方式.

使用 CDN

假设你已经通过 CDN 在你的页面中包含了 React. 你可以再次开始使用 js/React, 但我们停止这样做是有充分理由的. 相反, 你可以继续使用 (:require ["react" :as react]), 但配置 "react" 的解析方式!

这是一个此类构建的示例 shadow-cljs.edn 配置:

{...
 :builds
  {:app
   {:target :browser
    ...
    :js-options
    {:resolve {"react" {:target :global
                         :global "React"}}}}
   :server
   {:target :node-script
    ...}}}

:app 构建现在将使用全局的 React 实例, 而 :server 构建将继续使用 "react" npm 包! 无需修改代码即可使其工作.

重定向 "require"

有时你希望对根据你的构建实际使用哪个 npm 包有更多的控制. 你可以从你的构建配置中 "重定向" 某些 require, 而无需更改代码. 这通常很有用, 如果你无法访问使用这些包的源文件, 或者你只想为一个构建更改它.

{...
 :builds
  {:app
   {:target :browser
    ...
    :js-options
    {:resolve {"react" {:target :npm
                         :require "preact-compat"}}}}}}

你也可以使用一个文件来覆盖依赖关系, 路径是相对于项目根目录的.

{...
 :builds
  {:app
   {:target :browser
    ...
    :js-options
    {:resolve {"react" {:target :file
                         :file "src/main/override-react.js"}}}}}}

限制

:shadow-js:closure:resolve 有完全的控制, 上面提到的一切都可以 без каких-либо недостатков. 然而, :js-provider :require 更受限制. 只有初始的 require 可以被影响, 因为在那之后, 标准的 require 就在控制之中了. 这意味着无法影响一个包可能内部 require 的东西. 因此, 不建议将其与直接使用 require 的目标 (例如 :node-script) 一起使用.

将 "react" 重定向到 "preact"

{...
 :builds
  {:app
   {:target :node-script
    ...
    :js-options
    {:resolve {"react" {:target :npm
                         :require "preact-compat"}}}}}}

react-table 的示例用法

(ns my.app
  (:require
    ["react-table" :as rt]))

上面的代码在浏览器中工作得很好, 因为每个 "react" require 都将被替换, 包括 "react-table" 内部的 "react" require. 然而, 对于 :js-provider :require, 会发出一个 require("react-table"), node 将控制它的解析方式. 这意味着它将解析为标准的 "react", 而不是我们配置的 "preact".

12.1.5. 备用模块目录

默认情况下, shadow-cljs 在解析 JS 包时只会查看 <project-dir>/node_modules 目录. 这可以通过 :js-options 中的 :js-package-dirs 选项进行配置. 这可以全局应用, 也可以按构建应用.

shadow-cljs.edn 中的全局配置

{...
 :js-options {:js-package-dirs ["node_modules" "../node_modules"]}
 ...}

应用于单个构建的配置

{...
 :builds
  {:app
   {...
    :js-options {:js-package-dirs ["node_modules" "../node_modules"]}}}}

相对路径将相对于项目根目录解析. 路径将从左到右尝试, 第一个匹配的包将被使用.

12.2. 处理 .js 文件

危险: 此功能是一项实验! 它目前仅在 shadow-cljs 中受支持, 如果你尝试使用它, 其他 CLJS 工具会对你大喊大叫. 请自行承担风险. 该功能最初被 CLJS 核心拒绝, 但我认为它很有用, 不应该被驳回 (https://dev.clojure.org/jira/browse/CLJS-2061?focusedCommentId=46191&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-46191) 而没有进一步的讨论.

CLJS 有一个备用实现 (https://clojurescript.org/guides/javascript-modules), 但反过来 shadow-cljs 不支持. 我发现这个实现在某些方面有所欠缺, 所以我选择了不同的解决方案. 乐于讨论两种方法的优缺点.

我们介绍了如何使用 npm 包, 但你可能正在处理一个已经有很多纯 JavaScript 的代码库, 你还不想用 ClojureScript 重写所有内容. shadow-cljs 提供了 JavaScript 和 ClojureScript 之间 100% 的完全互操作性. 这意味着你的 JS 可以使用你的 CLJS, 你的 CLJS 也可以使用你的 JS.

为了让它可靠地工作, 你只需要遵循一些约定, 但很可能你已经在这样做了.

12.2.1. Requiring JS

我们已经介绍了如何通过它们的名称访问 npm 包, 但在 classpath 上我们通过完整路径或相对于当前命名空间的路径来访问 .js 文件.

从 classpath 加载 JS

(ns demo.app
  (:require
    ["/some-library/components/foo" :as foo]
    ["./bar" :as bar :refer (myComponent)]))

提示

对于字符串 require, 扩展名 .js 将被自动添加, 但如果你愿意, 也可以指定扩展名. 请注意, 目前只支持 .js.

/some-library/components/foo 这样的绝对 require 意味着编译器将在 classpath 上寻找 some-library/components/foo.js; 与 node 不同, node 会尝试从本地文件系统加载文件. 同样的 classpath 规则适用, 所以文件可能在你的 :source-paths 中, 或者在某个第三方 .jar 库中.

相对 require 通过首先查看当前命名空间, 然后从中解析相对路径来解析. 在上面的例子中, 我们在 demo/app.cljs 中, ./bar require 解析为 demo/bar.js, 所以它与 (:require ["/demo/bar"]) 相同.

文件不一定要物理上位于同一目录中. 文件的查找出现在 classpath 上. 这与 node 不同, node 期望相对 require 总是解析为物理文件.

带独立路径的示例文件结构

.
├── package.json
├── shadow-cljs.edn
└── src
    └── main
        └── demo
            └── app.cljs
    └── js
        └── demo
            └── bar.js

12.2.2. 语言支持

期望 classpath 只包含可以无需编译器预处理即可使用的 JavaScript. npm 有一个非常相似的约定.

Closure Compiler 用于处理在 classpath 上找到的所有 JavaScript, 使用其 ECMASCRIPT_NEXT 语言设置. 这个设置具体意味着什么没有很好的文档记录, 但它主要代表了下一代 JavaScript 代码, 甚至可能不被大多数浏览器支持. ES6 以及大多数 ES8 功能都得到了很好的支持. 与标准 CLJS 类似, 当需要时, 这将被编译为带有 polyfills 的 ES5.

由于 Closure Compiler 不断更新, 新功能会随着时间的推移而可用. 只是不要期望最新的前沿预览功能能立即使用. 最近的一些新增功能, 如 async/await, 已经可以很好地工作了.

JS 应该使用 ES 模块语法, 使用 importexport. JS 文件可以包含其他 JS 文件并直接引用 CLJS 代码. 它们也可以直接访问 npm 包, 但有一个警告.

// 常规 JS require
import Foo, { something } from "./other.js";

// npm require
import React from "react";

// require CLJS 或 Closure 库 JS
import cljs from "goog:cljs.core";

export function inc(num) {
  return cljs.inc(1);
}

由于 Closure Compiler 的严格检查, 使用 import * as X from "npm"; 语法在 require CLJS 或 npm 代码时是不可能的. 在 require 其他 JS 文件时可以使用.

12.2.3. JavaScript 方言

由于有许多流行的 JavaScript 方言 (JSX, CoffeeScript, 等) 不能被 Closure Compiler 直接解析, 我们需要先对它们进行预处理, 然后再将它们放到 classpath 上. babel (https://babeljs.io/) 在 JavaScript 世界中被广泛使用, 所以我们将以 babel 处理 .jsx 文件为例.

示例 shadow-cljs.edn 配置

{:source-paths
 ["src/main"
  "src/gen"]
 ...}

示例文件结构

.
├── package.json
├── shadow-cljs.edn
└── src
    └── main
        └── demo
            └── app.cljs
    └── js
        ├── .babelrc
        └── demo
            └── bar.jsx

注意 src/js 没有被添加到 :source-paths 中, 这意味着它不会在 classpath 上.

src/js/demo/bar.jsx

import React from "react";

function myComponent() {
  return <h1>JSX!</h1>;
}

export { myComponent };

我们运行 babel (https://babeljs.io/docs/usage/cli/) 来转换文件并将它们写入配置的 src/gen 目录. 你使用哪个目录由你决定. 我更喜欢 src/gen 用于生成的文件.

$ babel src/js --out-dir src/gen
# or during development
$ babel src/js --out-dir src/gen --watch

babel 本身是通过 src/js/.babelrc 配置的. 有关 JSX 的官方示例, 请参阅官方 JSX 示例 (https://babeljs.io/docs/plugins/transform-react-jsx/) 和有关配置文件 (https://babeljs.io/docs/config-files) 的更多信息.

JSX 最小 .babelrc

{
  "plugins": ["@babel/plugin-transform-react-jsx"]
}

一旦 babelsrc/gen/demo/bar.js 写入, 它就可以通过 ClojureScript 使用, 甚至可以像你的 ClojureScript 源一样被热加载.

shadow-cljs 目前不提供任何运行这些转换步骤的支持. 请直接使用标准工具 (例如 babel, coffeescript, 等), 直到它提供为止.

12.2.4. 从 JS 访问 CLJS

JS 源可以通过使用 goog: 前缀导入它们的命名空间来直接访问你的所有 ClojureScript (和 Closure 库), 编译器会重写该前缀以将命名空间公开为默认的 ES6 导出.

import cljs, { keyword } from "goog:cljs.core";

// 在 JS 中构造 {:foo "hello world"}
cljs.array_map(keyword("foo"), "hello world");

提示

goog: 前缀目前只适用于 ES6 文件. require("goog:cljs.core") 将不起作用.

12.3. 迁移 cljsjs.*

CLJSJS 是一个旨在打包 Javascript 库以便在 ClojureScript 中使用的项目.

由于 shadow-cljs 可以直接访问 npm 包, 我们不需要依赖重新打包的 CLJSJS (https://github.com/cljsjs/packages) 包.

然而, 许多 CLJS 库仍然在使用 CLJSJS 包, 并且它们会在 shadow-cljs 中中断, 因为它不再支持它们. 然而, 模拟那些 cljsjs 命名空间非常容易, 因为它们大多是从 npm 包构建的. 它只需要一个 shim 文件, 将 cljsjs.thing 映射回其原始的 npm 包, 并公开预期的全局变量.

对于 React, 这需要一个像 src/cljsjs/react.cljs 这样的文件:

(ns cljsjs.react
  (:require ["react" :as react]
            ["create-react-class" :as crc]))

(js/goog.object.set react "createClass" crc)
(js/goog.exportSymbol "React" react)

因为手动为每个人做这件事会很乏味, 我创建了 shadow-cljsjs (https://github.com/thheller/shadow-cljsjs) 库, 它提供了这个功能. 它不包括每个包, 但我会继续添加, 并且非常欢迎贡献.

注意

shadow-cljsjs 库只提供 shim 文件. 你仍然需要 npm install 实际的包.

12.3.1. 为什么不使用 CLJSJS?

CLJSJS 包基本上只是从 npm 中获取包, 将它们放入一个 .jar 中, 并通过 clojars (https://clojars.org) 重新发布. 作为附赠, 它们通常会捆绑 Externs. 编译器除此之外不会对这些文件做任何事情, 只是将它们前置到生成的输出中.

当我们无法直接访问 npm 时, 这非常有用, 但它也存在某些问题, 因为并非所有包都可以轻松地与其他包组合. 一个包可能依赖于 react, 但不是通过 npm 表达这一点, 而是 (https://github.com/cljsjs/packages/tree/master/material-ui) 捆绑它们自己的 react. 如果你不小心, 你最终可能会在你的构建中包含 2 个不同版本的 react, 这可能会导致非常令人困惑的错误, 或者至少会大大增加构建大小.

除此之外, 并非每个 npm 包都通过 CLJSJS 可用, 并且保持包版本同步需要手动工作, 这意味着包通常已经过时.

shadow-cljs 完全不支持 CLJSJS, 以避免你的代码中出现冲突. 一个库可能尝试使用 "旧的" cljsjs.react, 而另一个直接使用新的 (:require ["react"]). 这又会导致你的页面上有 2 个版本的 react.

所以我们唯一缺少的是捆绑的 Externs. 在许多情况下, 由于改进的 externs 推断, 这些是不需要的. 通常那些 Externs 是使用第三方工具生成的, 这意味着它们并不完全准确.

结论: 直接使用 npm. 使用 :infer-externs auto.

13. 生成生产代码 - 所有目标

开发模式总是为每个命名空间输出单独的文件, 以便它们可以被隔离地热加载. 当你准备将代码部署到真实服务器时, 你希望在其上运行 Closure Compiler 以生成每个模块的单个压缩结果.

默认情况下, 发布模式输出文件应该只是开发模式文件的直接替代品: 你在 HTML 中包含它们的方式没有区别. 你可以使用文件名哈希来改善浏览器目标的缓存特性.

生成压缩输出

$ shadow-cljs release build-id

13.1. 发布配置

通常你不需要添加任何额外的配置来为你的构建创建发布版本. 默认配置已经捕获了所有必要的内容, 只有在你想要覆盖默认值时才需要额外的配置.

每个 :target 已经为每个平台提供了优化的良好默认值, 所以你不用太担心.

13.1.1. 优化

你可以使用配置的 :compiler-options 部分选择优化级别:

你通常不需要设置 :optimizations, 因为 :target 已经将其设置为适当的级别.

:optimizations 仅在使用 release 命令时适用. 开发构建从未由 Closure Compiler 优化. 开发构建始终设置为 :none.

{...
 :build
  {:build-id
   {...
    :compiler-options {:optimizations :simple}}}}

有关可用优化级别的更多信息, 请参阅 Closure 编译器的文档 (https://developers.google.com/closure/compiler/docs/compilation_levels).

13.1.2. 发布特定 vs. 开发配置

如果你希望在运行发布构建时在构建中具有单独的配置值, 你可以通过在构建部分中包含 :dev 和/或 :release 部分来覆盖设置:

示例 shadow-cljs.edn 构建配置

{:source-paths ["src"]
 :dependencies []
 :builds
  {:app
   {:target :browser
    :output-dir "public/js"
    :asset-path "/js"
    :modules {:base {:entries [my.app.core]}}
    ;; 这里是一些开发特定的配置
    :dev {:compiler-options {:devcards true}}
    ;; 这里是一些生产配置
    :release {:compiler-options {:optimizations :simple}}}}}

13.2. Externs

由于我们希望构建由 Closure Compiler :advanced 编译完全优化, 我们需要处理 Externs (https://developers.google.com/closure/compiler/docs/api-tutorial3). Externs 代表在进行 :advanced 编译时不包含的代码片段. :advanced 通过进行全程序优化工作, 但有些代码我们就是无法包含, 所以 Externs 向编译器告知这些代码. 没有 Externs, 编译器可能会重命名或删除一些它不应该的代码.

通常所有的 JS 依赖都是外部的, 不会通过 :advanced 传递, 因此需要 Externs.

提示

Externs 仅对 :advanced 是必需的, 在 :simple 模式下是不需要的.

13.2.1. Externs 推断

为了帮助处理 Externs, shadow-cljs 编译器提供了增强的 externs 推断功能, 这是默认启用的. 编译器将在编译时仅对你的文件执行额外的检查. 它不会警告你库中可能存在的 externs 问题.

每当编译器无法弄清楚你是在处理 JS 还是 CLJS 代码时, 你都会收到警告. 如果你没有收到任何警告, 你应该没问题.

示例代码

(defn wrap-baz [x]
  (.baz x))

示例警告

------ WARNING #1 --------------------------------------------------------------
 File: ~/project/src/demo/thing.cljs:23:3
--------------------------------------------------------------------------------
 21 |
 22 | (defn wrap-baz [x]
 23 |   (.baz x))
---------^----------------------------------------------------------------------
 Cannot infer target type in expression (. x baz)
--------------------------------------------------------------------------------

:advanced 中, 编译器可能会将 .baz 重命名为更短的东西, 而 Externs 告诉编译器这是一个不应重命名的外部属性.

警告告诉你编译器无法识别 x 绑定中的 baz 属性. 如果你向正在执行原生互操作的对象添加类型提示, shadow-cljs 可以生成适当的 externs.

类型提示以帮助生成 externs

(defn wrap-baz [x]
  (.baz ^js x))

^js 类型提示将导致编译器生成正确的 externs, 警告将消失. 该属性现在可以安全地不被重命名. 你可以直接标记互操作形式, 或者标记变量首次绑定的位置.

多个互操作调用

(defn wrap-baz [x]
  (.foo ^js x)
  (.baz ^js x))

为每个互操作调用添加注解可能很乏味, 所以你可以注解变量绑定本身. 它将在该变量的整个作用域中使用. 两个调用的 Externs 仍将被生成. 所以, 你可以这样做:

直接注解 x

(defn wrap-baz [^js x]
  (.foo x)
  (.baz x))

不要用 ^js 注解所有东西. 有时你可能在 CLJS 或 ClosureJS 对象上进行互操作. 那些不需要 externs. 如果你确定你正在处理一个 CLJS 对象, 请改用 ^clj 提示. 错误地使用 ^js 并不是世界末日, 但当一个变量可以被重命名而没有时, 它可能会影响一些优化.

使用 js/ 调用全局对象不需要类型提示.

不需要提示, externs 会自动推断

(js/Some.Thing.coolFunction)

:require 绑定上的调用也会被自动推断.

:as:refer 绑定不需要提示

(ns my.app
  (:require ["react" :as react :refer (createElement)]))

(react/createElement "div" nil "hello world")
(createElement "div" nil "hello world")

13.2.2. 手动 Externs

一些库以单独的 .js 文件形式提供 Externs. 你可以通过 :externs 编译器选项将它们包含到你的构建中.

手动 Externs 配置

{...
 :builds
  {:app
   {:target :browser
    ...
    :compiler-options {:externs ["path/to/externs.js" ...]}
    }}}

提示

编译器首先会查找相对于项目根目录的文件. 如果没有找到文件, 它也会尝试从 classpath 加载它们.

13.2.3. 简化的 Externs

手动编写 Externs 可能很有挑战性, shadow-cljs 提供了一种更方便的方式来编写它们. 首先创建一个 externs/<your-build>.txt, 所以构建 :app 将是 externs/app.txt. 在该文件中, 每行应该是一个指定不应重命名的 JS 属性的单词. 全局变量应以 global: 为前缀:

示例 externs/app.txt

# this is a comment
foo
bar
global:SomeGlobalVariable

在此示例中, 编译器将停止重命名 something.foo()something.bar().

13.3. 构建报告

shadow-cljs 可以为你的 release 构建生成一个详细的报告, 其中包括包含的源文件的详细分类以及它们各自对总大小的贡献.

可以在这里 (https://code.thheller.com/demos/build-report/ui-report.html) 找到一个示例报告.

报告可以通过运行一个单独的命令或通过为你的构建配置一个构建钩子来生成.

命令示例

$ npx shadow-cljs run shadow.cljs.build-report <build-id> <path/to/output.html>
# example
$ npx shadow-cljs run shadow.cljs.build-report app report.html

上面的示例将在 :app 构建的项目目录中生成一个 report.html.

提示

生成的 HTML 文件是完全自包含的, 包括所有必需的数据/js/css. 不需要其他外部源.

构建钩子示例

{...
 :builds
  {:app
   {:target :browser
    :output-dir "public/js"
    :modules ...
    :build-hooks
    [(shadow.cljs.build-report/hook)]
    }}}

这将为每个 release 构建在配置的 public/js 输出目录中自动生成一个 report.html. 这可以通过提供一个额外的 :output-to 选项来配置写入位置. 然后该路径被视为相对于项目目录, 而不是 :output-dir.

:output-to 的构建钩子

{...
 :builds
  {:app
   {:target :browser
    :output-dir "public/js"
    :modules ...
    :build-hooks
    [(shadow.cljs.build-report/hook
       {:output-to "tmp/report.html"})]
    }}}

只有 release 构建在使用钩子时会生成报告, 它不影响 watchcompile.

构建报告是通过解析源映射生成的, 所以钩子会自动强制生成源映射. 文件不会直接从 .js 文件链接, 除非你通过 :compiler-options {:source-map true} 实际启用了它们.

专用的构建报告命令与你可能正在运行的监视器分开运行. 你不需要停止它们中的任何一个, 也不需要在构建报告之前停止 shadow-cljs 服务器.

14. 编辑器集成

14.1. Cursive

Cursive 目前不支持通过 shadow-cljs.edn 解析依赖项. 你可以运行 shadow-cljs pom 来生成一个 pom.xml, 并使用 IntelliJ 导入它.

$ shadow-cljs pom

然后在 Cursive 中选择 File → New → Project from Existing Sources, 然后选择生成的 pom.xml 在项目目录中.

你需要为此启用 "Build Tools" → "Maven" 插件. 默认情况下可能没有启用.

或者, 你可以创建一个虚拟的 project.clj 或使用完整的 Leiningen 集成.

(defproject your/project "0.0.0"
  :dependencies
  [[thheller/shadow-cljs "X.Y.Z"]]
  :source-paths
  ["src"])

你可以在 IntelliJ 提供的终端内运行 npx shadow-cljs server, 并使用 Clojure REPL → Remote Run Configuration 来连接到提供的 nREPL 服务器. 只需在 Cursive Clojure REPL → Remote 中选择 "Use port from nREPL file" 选项, 或者如果你愿意, 可以配置一个固定的 nREPL 端口.

请注意, Cursive REPL 在首次连接时总是以 CLJ REPL 开始. 你可以通过调用 (shadow/repl :your-build-id) 将其切换到 CLJS. 这将自动切换 Cursive 选项. 你可以输入 :cljs/quit 来返回到 CLJ REPL.

注意

你不能通过 Cursive 选择框从 CLJ→CLJS 切换. 请确保使用上面的调用来切换.

14.2. Emacs / CIDER

本节是为 CIDER 0.20.0 及以上版本编写的. 确保你的 Emacs 环境有此版本或更高版本的 cider 包. 有关完整的安装详细信息, 请参阅 CIDER 文档 (https://docs.cider.mx).

14.2.1. 启动 ClojureScript REPL

启动 nREPL 和一个 ClojureScript REPL.

M-x cider-jack-in-cljs

CIDER 会提示你输入 ClojureScript REPL 的类型:

Select ClojureScript REPL type:

输入 shadow.

Select shadow-cljs build:

输入你的构建目标的名称, 例如, app.

Emacs 现在应该打开一个到 shadow-cljs 服务器的新 nREPL 连接, 并引导进入一个 ClojureScript REPL 环境:

shadow.user> To quit, type: :cljs/quit
[:selected :app]
cljs.repl>

你现在应该能够求值 ClojureScript, 跳转到 var 的定义 (使用 cider-find-var) 等等.

例如, 在浏览器中显示一个警报:

cljs.repl> (js/alert "Jurassic Park!")

14.2.2. 使用 dir-local 简化启动

你可以通过在项目根目录创建一个 .dir-locals.el 文件来简化启动流程.

((nil . ((cider-default-cljs-repl . shadow)
         (cider-shadow-default-options . "<your-build-name-here>"))))

或者, 当监视多个构建时:

((nil . ((cider-default-cljs-repl . shadow)
         (cider-shadow-default-options . "<your-build-name-here>")
         (cider-shadow-watched-builds . ("<first-build>" "<other-build>")))))

阅读关于 Emacs 和 shadow-cljs, 使用 .dir-locals.el 文件的 Cider 文档 (https://docs.cider.mx/cider/1.1/cljs/shadow-cljs.html)

14.2.3. 使用 deps.edn 和自定义 repl 初始化.

如果你想通过 deps.edn (https://clojure.org/guides/deps_and_cli) 管理你的依赖, 你可以使用自定义的 cljs-repl 初始化形式. 创建一个 :dev 别名, 带有 "dev" 的额外源路径, 并添加以下命名空间

(ns user
  (:require [shadow.cljs.devtools.api :as shadow]
            [shadow.cljs.devtools.server :as server]))

(defn cljs-repl
  "连接到给定的 build-id. 默认为 ~:app~."
  ([]
   (cljs-repl :app))
  ([build-id]
   (server/start!)
   (shadow/watch build-id)
   (shadow/nrepl-select build-id)))

假设你的 build-id:app, 将以下内容添加到你的 .dir-locals.el

((nil . ((cider-clojure-cli-global-options . "-A:dev")
         (cider-preferred-build-tool . clojure-cli)
         (cider-default-cljs-repl . custom)
         (cider-custom-cljs-repl-init-form . "(do (user/cljs-repl))")
         (eval . (progn
                   (make-variable-buffer-local 'cider-jack-in-nrepl-middlewares)
                   (add-to-list 'cider-jack-in-nrepl-middlewares
                                "shadow.cljs.devtools.server.nrepl/middleware"))))))

cider-jack-in-cljs 应该就可以直接工作了.

14.3. Proto REPL (Atom)

Proto REPL 主要用于 Clojure 开发, 所以大多数功能不适用于 ClojureScript. 然而, 可以用它来进行简单的求值.

你需要设置几件事情才能让它工作.

  1. 在你的一个 :source-paths 中创建一个 user.clj.
(ns user)

(defn reset [])

该文件必须定义 user/reset fn, 因为 Proto REPL 在连接时会调用它. 如果找不到 user/reset, 它会调用 tools.namespace, 这会销毁正在运行的 shadow-cljs 服务器. 我们不希望那样. 你可以在这里做一些事情, 但对于 CLJS 我们不需要做任何事情.

  1. [proto-repl "0.3.1"] 添加到你的 ~/.shadow-cljs/config.ednshadow-cljs.edn:dependencies 中.
  2. 配置一个固定的 nREPL 端口
  3. 启动 shadow-cljs 服务器或 shadow-cljs watch 你的构建.
  4. 运行 Atom 命令 Proto Repl: Remote Nrepl Connection, 连接到 localhost 和你配置的端口
  5. 求值 (shadow.cljs.devtools.api/watch :your-build) (如果你在 4 中使用了服务器)
  6. 求值 (shadow.cljs.devtools.api/nrepl-select :your-build). REPL 连接现在处于 CLJS 模式, 意味着你求值的任何内容都将在 JS 中求值. 你可以求值 :repl/quit 来返回到 Clojure 模式. 如果你得到 [:no-worker :browser], 你需要先启动 watch.
  7. 在你求值 CLJS 之前, 你需要连接你的客户端 (例如, 当构建 :browser 应用时, 你的浏览器).
  8. 求值一些 JS, 例如 (js/alert "foo"). 如果你得到 There is no connected JS runtime, 则客户端未正确连接. 否则, 浏览器应该显示一个警报.

14.4. Chlorine (Atom)

Chlorine 连接 Atom 到一个 Socket REPL, 但也尝试刷新命名空间. 所以, 首先, 打开 Chlorine 包配置并检查配置 Should we use clojure.tools.namespace to refresh 是否设置为 simple, 否则它会销毁正在运行的 shadow-cljs 服务器.

一旦你检查了配置是正确的, 你就可以启动你的 shadow 应用 (用任何构建替换 app):

$ shadow-cljs watch app

现在, 你需要做的就是运行 atom 命令 Chlorine: Connect Clojure Socket Repl. 这将连接一个 REPL 来求值 Clojure 代码. 接下来你需要运行 Chlorine: Connect Embeded, 它也会连接 ClojureScript REPL.

现在, 你可以使用 Chlorine: Evaluate... 命令来求值任何 Clojure 或 ClojureScript REPL. 它会将 .clj 文件作为 Clojure 求值, 将 cljc 文件作为 ClojureScript 求值.

14.5. Calva (VS Code)

Calva 内置了对 shadow-cljs 的支持.

14.5.2. 启动 REPL

启动 REPL 的最简单方法是使用 Calva 的 Jack-in 命令, 然后选择 shadow-cljs 项目类型. 这将启动 shadow-cljs 监视器并注入必要的 cider-nrepl 依赖.

如果你想自己启动 REPL, 你可以:

  1. 使用 Calva 命令 Copy Jack-in Command to Clipboard
  2. 从终端启动 REPL (VS Code 的内置终端对此非常适用)
  3. 使用 Calva 命令 Connect to a Running REPL

14.5.3. 将 Calva 连接到构建

一旦 shadow 完成其初始编译, 启动应用 (在浏览器, node, 或任何地方, 取决于你的应用).

Calva 会提示你附加 REPL 连接到哪个构建. Calva 有一个命令 (和一个状态栏按钮) 用于切换附加的构建.

开始 hack 吧!

有关如何使用 Calva 的信息, 请参阅 calva.io (https://calva.io).

14.6. Fireplace.vim (Vim/Neovim)

Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978) 是一个 Vim/Neovim 插件, 它通过充当 nREPL (https://nrepl.org/) 客户端来提供 Clojure REPL 集成. 当与 Shadow-CLJS 结合使用时, 它也提供 ClojureScript REPL 集成.

本指南以官方 Shadow-CLJS 快速入门 (https://github.com/thheller/shadow-cljs#quick-start) 指南中创建的应用为例, 因此引用了应用的 shadow-cljs.edn 中的一些配置项. 也就是说, 这些配置项相当通用, 应该适用于其他稍作修改的应用.

14.6.1. 依赖

使用你喜欢的方法在 Vim/Neovim 中安装插件来安装 Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978).

作为一个 nREPL (https://nrepl.org/) 客户端, Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978) 依赖于 CIDER-nREPL (https://docs.cider.mx/cider-nrepl/) (它是一个提供通用的, 与编辑器无关的 REPL 操作的 nREPL 中间件), 因此你需要将此依赖项包含在 ~/.shadow-cljs/config.ednshadow-cljs.edn 中 (如下一个子部分所示). 一旦看到此依赖项, Shadow-CLJS 将注入所需的 CIDER-nREPL 中间件.

14.6.2. 准备应用

按照官方 Shadow-CLJS 快速入门 (https://github.com/thheller/shadow-cljs#quick-start) 指南创建示例应用, 并按如下方式修改其 shadow-cljs.edn:

;; shadow-cljs configuration
{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 ;; ADD - Fireplace.vim 所需的 CIDER-nREPL 中间件
 :dependencies
 [[cider/cider-nrepl "0.22.4"]]

 ;; ADD - Fireplace.vim 连接的 REPL 服务器的端口 (例如, 3333)
 :nrepl
 {:port 3333}

 ;; ADD - 为应用提供服务的开发时 HTTP 服务器的端口 (例如, 8080)
 :dev-http
 {8080 "public"}

 :builds
 {:frontend ; NOTE - 这是下面各处引用的构建 ID.
  {:target :browser
   :modules {:main {:init-fn acme.frontend.app/init}}}}}

完成后, 启动应用 (注意 shadow-cljs.edn 中指定的 Shadow-CLJS 构建 ID, frontend):

npx shadow-cljs watch frontend

在浏览器中打开应用 http://localhost:8080/. 如果没有这一步, 如果你尝试从 Vim/Neovim 连接到 REPL 服务器, 你会从 Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978) 得到以下错误消息:

No application has connected to the REPL server.
Make sure your JS environment has loaded your compiled ClojureScript code.

14.6.3. 将 Fireplace.vim 连接到 REPL 服务器

在 Vim/Neovim 中打开一个 ClojureScript 源文件, 并执行以下命令以将 Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978) 连接到 REPL 服务器 (注意 shadow-cljs.edn 中指定的 REPL 服务器端口, 3333):

:Connect 3333
=>
Connected to nrepl://localhost:3333/
Scope connection to: ~/code/clojurescript/acme-app (ENTER)

这将创建一个 Clojure (而不是 ClojureScript) REPL 会话. 执行以下命令以向会话添加 ClojureScript 支持 (注意 shadow-cljs.edn 中指定的 Shadow-CLJS 构建 ID, frontend):

:CljEval (shadow/repl :frontend)
=>
To quit, type: :cljs/quit
[:selected :frontend]
Press ENTER or type command to continue

你现在应该能够对 REPL 服务器执行 Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978) 命令了. 请参考 Fireplace.vim (https://www.vim.org/scripts/script.php?script_id=4978) 文档以获取可以执行的完整命令列表.

15. 故障排除

15.1. 启动错误

有时 shadow-cljs 无法正常启动. 错误通常非常令人困惑且难以识别. 最常见的原因是一些重要依赖项上的几个依赖项冲突. 当仅使用 shadow-cljs.edn 来管理你的 :dependencies 时, 它将提供一些额外的检查来防止这些类型的错误, 但当使用 deps.ednproject.clj 时, 这些保护措施无法完成, 所以这些错误在使用这些工具时更常发生.

通常需要注意的重要依赖项是

  • org.clojure/clojure
  • org.clojure/clojurescript
  • org.clojure/core.async
  • com.google.javascript/closure-compiler-unshaded

每个 shadow-cljs 版本都只用一个特定的版本组合进行测试, 建议为了最佳兼容性而坚持使用该版本集. 使用不同版本可能会工作, 但如果你遇到任何奇怪的问题, 首先考虑修复你的依赖版本.

你可以在 clojars 上找到每个版本所需的依赖项:

诊断这些问题的方法因工具而异, 所以请参考相应的部分以获取更多信息.

通常, 如果你想确保, 你可以直接声明匹配的依赖版本和你选择的 shadow-cljs 版本, 但这意味着每当你升级 shadow-cljs 时, 你也必须更新那些版本. 正确识别不需要的依赖可能需要更多工作, 但会使未来的升级更容易.

shadow-cljs 很可能总是使用上面列出的所有依赖项的最新版本, 所以如果你需要坚持使用旧的依赖项, 你可能需要坚持使用旧的 shadow-cljs 版本.

shadow-cljs 在它使用的 com.google.javascript/closure-compiler-unshaded 版本上通常会领先好几个版本, 所以如果你依赖于 org.clojure/clojurescript 通常提供的版本, 那可能会导致问题. 确保选择 thheller/shadow-cljs 版本而不是 org.clojure/clojurescript 首选的版本.

如果你想让你的生活更轻松, 只需使用 shadow-cljs.edn 来管理你的依赖. 你遇到这些问题的可能性要小得多, 或者至少会直接警告你.

如果你已确保你获得了所有正确的版本但事情仍然出错, 请在 Github 上开一个 Issue (https://github.com/thheller/shadow-cljs/issues), 并提供完整的问题描述, 包括你的完整依赖列表.

15.1.1. deps.edn / tools.deps

当使用 deps.edn 通过 shadow-cljs.edn 中的 :deps 键来管理你的依赖时, 建议直接使用 clj 工具进行进一步的诊断. 首先你需要检查你通过 shadow-cljs.edn 应用了哪些别名. 所以如果你设置了 :deps {:aliases [:dev :cljs]}, 你在运行进一步命令时需要指定这些别名.

首先, 你应该确保在 deps.edn 中直接声明的所有依赖项都具有预期的版本. 有时传递依赖会导致包含有问题的版本. 你可以通过以下方式列出所有依赖项:

列出所有活动的依赖项

$ clj -A:dev:cljs -Stree

这将列出所有依赖项. 追踪这个问题有点手动, 但你需要验证你是否获得了上面提到的依赖项的正确版本.

有关更多信息, 请参考官方的 tools.deps (https://clojure.org/reference/deps_and_cli) 文档.

15.1.2. project.clj / Leiningen

当使用 project.clj 来管理你的依赖时, 你需要在使用 lein 直接诊断问题时指定你从 shadow-cljs.edn 配置的 :lein profiles. 例如 :lein {:profile "+cljs"} 将要求每个命令都使用 lein with-profile +cljs.

deps 的示例列表

# no profile
$ lein deps :tree
# with profile
$ lein with-profile +cljs deps :tree

这通常会在顶部列出所有当前的冲突, 并在底部提供带有依赖树的建议. 这些建议并不总是完全准确, 所以不要被误导, 也不要向 thheller/shadow-cljs 工件添加排除项.

有关更多信息, 请参考 Leiningen (https://leiningen.org/) 文档.

15.2. REPL

让 CLJS REPL 工作有时可能很棘手, 而且很多事情都可能出错, 因为所有活动部件都可能相当复杂. 本指南希望解决人们遇到的最常见问题以及如何修复它们.

page-98-diagram.png

Figure 1: CLJS REPL 架构. CLJ 客户端(例如 shadow-cljs cljs-repl app)或 nREPL 客户端(例如 Cider, Cursive)与 shadow-cljs 服务器(例如 shadow-cljs watch app)通信, 服务器再与一个或多个 JS 运行时(例如 Chrome, Firefox)通信.

15.2.1. CLJS REPL 的结构

Clojure 中的 REPL 正如其名: 读取一个形式, 求值它, 打印结果, 循环再做一次.

然而, 在 ClojureScript 中, 事情要复杂一些, 因为编译发生在 JVM 上, 但结果是在 JavaScript 运行时中求值的. 为了 "模拟" 普通的 REPL 体验, 需要做几个步骤. 尽管 shadow-cljs 中的实现与常规 CLJS 有些不同, 但基本原则保持不变.

首先你需要一个 REPL 客户端. 这可以是 CLI (例如 shadow-cljs cljs-repl app) 或你的编辑器通过 nREPL 连接. 客户端将始终直接与 shadow-cljs 服务器对话, 并处理其余部分. 从客户端的角度来看, 它仍然像一个常规的 REPL, 但在后台发生了一些步骤.

  1. 读取: 一切都从从给定的 InputStream 读取一个 CLJS 形式开始. 这要么是直接从 stdin 的阻塞读取, 要么是在 nREPL 的情况下从字符串读取. 字符流被转换成实际的数据结构, "(+ 1 2)" (一个字符串) 变成 (+ 1 2) (一个列表).
  2. 编译: 该形式然后在 shadow-cljs JVM 端被编译并转换成一组指令.
  3. 传出: 这些指令被传输到一个连接的 JavaScript 运行时. 这可以是一个浏览器或一个 node 进程.
  4. 求值: 连接的运行时将接收到的指令并求值它们.
  5. 打印: 求值结果在 JS 运行时中作为字符串打印.
  6. 传回: 打印的结果被传输回 shadow-cljs JVM 端.
  7. 回复: JVM 端将接收到的结果转发给初始调用者, 结果被打印到适当的 OutputStream (或作为 nREPL 消息发送).
  8. 循环: 从 1) 开始重复.

15.2.2. JavaScript 运行时

shadow-cljs JVM 端的事情将需要一个正在运行的 watch 来处理给定的构建, 它将处理所有相关的 REPL 命令. 它使用一个专用的线程并管理在开发期间可能发生的所有给定事件 (例如 REPL 输入, 文件更改等).

然而, 编译后的 JS 代码也必须由一个 JS 运行时 (例如浏览器或 node 进程) 加载, 并且该 JS 运行时必须连接回正在运行的 shadow-cljs 进程. 大多数 :target 配置都会默认添加必要的代码, 并且应该自动连接. 连接是如何发生的取决于运行时, 但通常是使用 WebSocket 连接到正在运行的 shadow-cljs HTTP 服务器.

一旦连接上, REPL 就可以使用了. 请注意, 重新加载 JS 运行时 (例如手动刷新浏览器页面) 将清除运行时的所有 REPL 状态, 但一些编译器端的状态将保持不变, 直到 watch 也被重启.

一个 watch 进程可以连接多个 JS 运行时. shadow-cljs 默认选择第一个连接的 JS 运行时作为求值目标. 如果你在多个浏览器中打开一个给定的 :browser 构建, 只有第一个将用于求值代码. 或者你可能在 iOS 和 Android 上同时开发一个 :react-native 应用. 只有一个运行时可以求值, 如果那个断开连接, 下一个将根据它们连接的时间接管.

15.2.3. 缺少 JS 运行时

没有应用程序连接到 REPL 服务器. 请确保你的 JS 环境已加载你编译的 ClojureScript 代码.

这个错误消息仅仅意味着没有 JS 运行时 (例如浏览器) 连接到 shadow-cljs 服务器. 你的 REPL 客户端已成功连接到 shadow-cljs 服务器, 但如上所述, 我们仍然需要一个 JS 运行时来实际求值任何东西.

常规的 shadow-cljs 构建不管理任何它们自己的 JS 运行时, 所以你负责运行它们.

  • :target :browser 对于 :browser 构建, watch 进程会将给定的代码编译到配置的 :output-dir (默认为 public/js). 生成的 .js 必须在浏览器中加载. 一旦加载, 浏览器控制台应该显示一个 WebSocket 连接的消息. 如果你使用任何类型的自定义 HTTP 服务器或有过于激进的防火墙阻止连接, 你可能需要设置一些额外的配置 (例如通过 :devtools-url). 目标是能够连接到主 HTTP 服务器.
  • :target :node-script, :node-library 这些目标会生成一个旨在在 node 进程中运行的 .js 文件. 然而, 鉴于选项的多样性, 你需要自己运行它们. 例如, 一个 :node-script 你会通过 node the-script.js 运行, 启动时它会尝试连接到 shadow-cljs 服务器. 你应该在启动时看到一个 WebSocket 连接的消息. 输出被设计为只在编译它们的机器上运行, 不要将 watch 输出复制到其他机器.
  • :target :react-native 生成的 <:output-dir>/index.js 文件需要被添加到你的 react-native 应用中, 然后在实际的设备或模拟器上加载. 启动时, 它也会尝试连接到 shadow-cljs 服务器. 你可以通过 react-native log-android|log-ios 检查日志输出, 并且在应用运行时应该看到一个 WebSocket 连接的消息. 如果你在启动时看到一个与 websocket 相关的错误, 那么它可能未能连接到 shadow-cljs 进程. 当 IP 检测选择了不正确的 IP 时, 可能会发生这种情况. 你可以通过 shadow-cljs watch app --verbose 检查使用了哪个 IP, 并通过 shadow-cljs watch app --config-merge '{:local-ip "1.2.3.4"}' 来覆盖它.

16. 发布库

ClojureScript 库像 Clojure 一样发布到 maven 仓库. 最常见的是它们被发布到 Clojars (https://clojars.org/), 但所有其他标准的 maven 仓库也都可以工作.

shadow-cljs 本身没有直接的发布支持, 但由于 ClojureScript 库只是发布在 JAR (基本上只是一个 ZIP 压缩文件) 中的未编译源文件, 任何能够发布到 maven 的常用工具都可以工作 (例如 mvn, gradle, lein, 等). 发布不需要额外的编译或其他步骤. ClojureScript 编译器和因此 shadow-cljs 完全不参与.

16.1. Leiningen

发布库有多种选择, 我目前推荐 Leiningen (https://leiningen.org/). 设置非常直接, 不需要太多的配置.

这并不意味着你必须在库的开发过程中使用 Leiningen. 建议只用 Leiningen 来发布, 但通常情况下还是使用 shadow-cljs. 你只需要在发布时复制一次实际的 :dependencies 定义. 记住要排除与开发相关的依赖.

假设你已经在使用推荐的项目结构, 即所有主要源文件都位于 src/main 中, 你可以用一个非常简单的 project.clj 来发布.

(defproject your.cool/library "1.0.0"
  :description "Does cool stuff"
  :url "https://the.inter.net/wherever"
  ;; this is optional, add what you want or remove it
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies
  ;; always use "provided" for Clojure(Script)
  [[org.clojure/clojurescript "1.10.520" :scope "provided"]
   [some.other/library "1.0.0"]]
  :source-paths
  ["src/main"])

这将生成所需的 pom.xml 并将 src/main 中的所有源文件放入发布的 .jar 文件中. 你只需要运行 lein deploy clojars 来发布它. 第一次这样做时, 你首先需要设置正确的身份验证. 请参考官方 Leiningen (https://github.com/technomancy/leiningen/blob/stable/doc/DEPLOY.md) 和 Clojars (https://github.com/clojars/clojars-web/wiki/Tutorial) 文档, 了解如何设置.

16.1.1. 禁用 JAR 签名

Leiningen 默认在发布前通过 GPG 签署库, 这是一个很好的默认设置, 但鉴于设置起来可能很麻烦, 而且实际上没有多少人验证签名, 你可以通过在 project.clj 中添加一个简单的 :repositories 配置来禁用该步骤.

(defproject your.cool/library "1.0.0"
  ...
  :repositories
  {"clojars" {:url "https://clojars.org/repo"
              :sign-releases false}}
  ...)

16.1.2. 保持 JAR 清洁

如果你编写测试或使用其他与开发相关的代码, 请确保将它们保留在 src/devsrc/test 中, 以避免将它们与库一起发布.

另外, 避免向 resources/* 生成输出, 因为 Leiningen 和其他工具可能会将这些文件包含到 .jar 中, 这可能会给下游用户带来问题. 你的 .jar 应该只包含实际的源文件, 没有编译代码.

你可以并且应该通过运行 lein jar 并通过 jar -tvf target/library-1.0.0.jar 检查最终进入其中的文件来验证一切是否干净.

16.2. 声明 JS 依赖

请注意, 目前只有 shadow-cljs 有一个干净的与 npm 自动互操作的故事. 这可能对使用其他工具的库用户构成问题. 你可能需要考虑提供一个 CLJSJS 回退和/或发布关于 webpack 相关工作流程的额外文档.

你可以通过在你的项目中包含一个带有 :npm-depsdeps.cljs 来直接声明 npm 依赖 (例如 src/main/deps.cljs).

示例 src/main/deps.cljs

{:npm-deps {"the-thing" "1.0.0"}}

你也可以在这里提供额外的 :foreign-libs 定义. 它们不会影响 shadow-cljs, 但可能对其他工具有帮助.

有关更多信息, 请参阅 https://clojurescript.org/reference/packaging-foreign-deps.

17. 当事情不顺利时该怎么办?

由于 JS 世界仍在迅速发展, 并且不是每个人都使用相同的方式来编写和分发代码, 有些事情 shadow-cljs 无法自动解决. 这些通常可以通过自定义 :resolve 配置来解决, 但也可能存在 bug 或疏忽.

如果你无法用本章中的说明解决此类问题, 那么请尝试在 #shadow-cljs Slack 频道 (https://clojurians.slack.com/messages/C6N245JGG) 上提问.

18. Hacking

18.1. 修补库

shadow-cljs 编译器确保你源路径上的东西会先被编译, 覆盖来自 JARs 的文件. 这意味着你可以从一个库中复制一个源文件, 对其进行修补, 并将其包含在你自己的源目录中.

这是一种测试修复 (甚至是对 shadow-cljs 本身!) 的便捷方式, 而无需克隆该项目并了解其设置, 构建等.

版本 1.0 最后更新 2025-05-07 15:05:56 UTC

Author: 青岛红创翻译

Created: 2025-06-13 Fri 14:59