本文首發於個人部落格
隨著 Firefox 57 的到來,之前維護的一個瀏覽器外掛 gooreplacer 必須升級到 WebExtensions 才能繼續使用,看了下之前寫的 JS 程式碼,毫無修改的衝動,怕改了這個地方,那個地方突然就 broken 了。因此,這次選擇了 cljs,整體下來流程很順利,除了遷移之前的功能,又加了更多功能,希望能成為最簡單易用的重定向外掛 :-)
閒話少說,下面的內容依次會介紹 cljs 的工作機制、開發環境,如何讓 cljs 適配瀏覽器外掛規範,以及重寫 gooreplacer 時的一些經驗。
本文的讀者需要對 Clojure 語言、瀏覽器外掛開發一般流程有基本瞭解,並且完成 ClojureScript 的 Quick Start。對於 Clojure,我目前在 sf 上有一套視訊課程,供參考。
為了方便大家使用 cljs 開發外掛,我整理了一份模板,供大家參考。gooreplacer 完整程式碼在這裡,技術棧為 ClojureScript + Reagent + Antd + React-Bootstrap。
ClojureScript 工作機制
ClojureScript 是使用 Clojure 編寫,最終編譯生成 JS 程式碼的一個編譯器,在編譯過程中使用 Google Closure Compiler 來優化 JS 程式碼、解決模組化引用的問題。整體工作流程如下:
Cljs 還提供 與原生 JS 的互動、整合第三方類庫的支援,所以,只要能用 JS 的地方,都能用 cljs,
開發環境準備
開發 cljs 的環境首選 lein + figwheel,figwheel 相比 lein-cljsbuild 提供了熱載入的功能,這一點對於開發 UI 很重要!
對於一般的 cljs 應用,基本都是用一個 script 標籤去引用編譯後的 js 檔案,然後這個 js 檔案再去載入其他依賴。比如:
<html>
<body>
<script type="text/javascript" src="js/main.js"></script>
</body>
</html>複製程式碼
js/main.js 是 project.clj 裡面指定的輸出檔案,它會去載入其他所需檔案,其內容大致如下:
var CLOSURE_UNCOMPILED_DEFINES = {};
var CLOSURE_NO_DEPS = true;
if(typeof goog == "undefined") document.write('<script src="js/out/goog/base.js"></script>');
document.write('<script src="js/out/goog/deps.js"></script>');
document.write('<script src="js/out/cljs_deps.js"></script>');
document.write('<script>if (typeof goog == "undefined") console.warn("ClojureScript could not load :main, did you forget to specify :asset-path?");</script>');
document.write('<script>goog.require("process.env");</script>');
document.write("<script>if (typeof goog != \"undefined\") { goog.require(\"figwheel.connect.build_dev\"); }</script>");
document.write('<script>goog.require("hello_world.core");</script>');複製程式碼
消除 inline script
對於一般的 Web 專案,只引用這一個 js 檔案就夠了,但是對於瀏覽器外掛來說,有一些問題,瀏覽器外掛出於安全因素考慮,是不讓執行 incline script,會報如下錯誤
為了去掉這些錯誤,手動載入 js/main.js 裡面動態引入的檔案,require 所需名稱空間即可,修改後的 html 如下:
<html>
<body>
<script src="js/out/goog/base.js"></script>
<script src="js/out/cljs_deps.js"></script>
<script src="js/init.js"></script>
</body>
</html>複製程式碼
其中 init.js 內容為:
// figwheel 用於熱載入,這裡的 build_dev 其實是 build_{build_id},預設是 dev
goog.require("figwheel.connect.build_dev");
// 載入為 main 的名稱空間
goog.require("hello_world.core");複製程式碼
這樣就可以正常在瀏覽器外掛環境中執行了。可以在 DevTools 中觀察到所有引用的 js 檔案
在左下角可以看到,總共有 92 個檔案。
對於 background page/option page/popup page 這三處都可採用這種措施,但是 content script 沒法指定 js 指令碼載入順序,可以想到的一種方式是:
"content_scripts": [{
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_end",
"js": ["content/js/out/goog/base.js", "content/js/out/cljs_deps.js", "content/init.js"]
}]複製程式碼
這裡的 content 的目錄與 manifest.json 在同一級目錄。採用這種方式會報如下的錯誤
根據錯誤提示,可以看出是 base.js 再去動態引用其他 js 檔案時,是以訪問網站為相對路徑開始的,因此也就找不到正確的 JS 檔案了。
解決方法是設定 cljsbuild 的 optimizations
為 :whitespace
,把所有檔案打包到一個檔案,然後引用這一個就可以了,這個方法不是很完美,採用 whitespace 一方面使編譯時間更長,在我機器上需要12s;另一方面是無法使用 figwheel,會報 A Figwheel build must have :compiler > :optimizations default to nil or set to :none 的錯誤,因此也就無法使用程式碼熱載入的功能。
gooreplacer 裡面只使用了 background page 與 option page,所以這個問題也就避免了。
區分 dev 與 release 模式
這裡的 dev 是指正常的開發流程,release 是指開發完成,準備打包上傳到應用商店的過程。
在 dev 過程中,推薦設定 cljsbuild 的 optimizations
為 none,以便得到最快的編譯速度;
在 release 過程中,可以將其設定為 advanced
,來壓縮、優化 js 檔案,以便最終的體積最小。
為了在兩種模式中複用使用的圖片、css 等資源,可採用了軟鏈的來實現,resources 目錄結構如下:
.
├── css
│ └── option.css
├── dev
│ ├── background
│ │ ├── index.html
│ │ └── init.js
│ ├── content
│ ├── manifest.json -> ../manifest.json
│ └── option
│ ├── css -> ../../css/
│ ├── images -> ../../images/
│ ├── index.html
│ └── init.js
├── images
│ ├── cljs.png
│ ├── cljs_16.png
│ ├── cljs_32.png
│ └── cljs_48.png
├── manifest.json
└── release
├── background
│ ├── index.html
│ └── js
│ └── main.js
├── content
│ └── js
│ └── main.js
├── manifest.json -> ../manifest.json
└── option
├── css -> ../../css/
├── images -> ../../images/
├── index.html
└── js
└── main.js複製程式碼
其次,為了方便開啟多個 figwheel 例項來分別編譯 background、option 裡面的 js,定義了多個 lein 的 profiles,來指定不同環境下的配置,具體可參考 模板的 project.clj 檔案。
externs
在 optimizations 為 advanced 時,cljs 會充分借用 Google Closure Compiler 來壓縮、混淆程式碼,會把變數名重新命名為 a b c 之類的簡寫,為了不使 chrome/firefox 外掛 API 裡面的函式混淆,需要載入它們對應的 externs 檔案,一般只需要這兩個 chrome_extensions.js、chrome.js。
測試環境
cljs 自帶的 test 功能比較搓,比較好用的是 doo,為了使用它,需要先提前安裝 phantom 來提供 headless 環境,寫好測試就可以執行了:
lein doo phantom {build-id} {watch-mode}複製程式碼
非常棒的一點是它也能支援熱載入,所以在開發過程中我一直開著它。
re-agent
re-agent 是對 React 的一個封裝,使之符合 cljs 開發習慣。毫無誇張的說,對於非專業前端程式設計師來說,要想使用 React,cljs 比 jsx 是個更好的選擇,Hiccup-like 的語法比 jsx 更緊湊,不用再去理睬 webpack,babel 等等層出不窮的 js 工具,更重要的一點是 immutable 在 cljs 中無處不在,re-agent 裡面有自己維護狀態的機制 atom,不在需要嚴格區分 React 裡面的 props 與 state。
瞭解 re-agent 的最好方式就是從它官網給出的示例開始,然後閱讀 re-frame wiki 裡面的 Creating Reagent Components,瞭解三種不同的 form 的區別,98% gooreplacer 都在使用 form-2。如果對原理感興趣,建議也把其他 wiki 看完。
re-agent 還有一點比較實用,提供了對 React 原生元件的轉化函式:adapt-react-class,使用非常簡單:
(def Button (reagent/adapt-react-class (aget js/ReactBootstrap "Button")))
[:div
[:h2 "A sample title"]
[Button "with a button"]]複製程式碼
這樣就不用擔心 React 的類庫不能在 cljs 中使用的問題了。
說到 re-agent,就不能不提到 om.next,這兩個在 cljs 社群裡面應該是最有名的 React wrapper,om.next 理念與使用難度均遠高於 re-agent,初學者一般不推薦直接用 om.next。感興趣的可以看看這兩者之間的比較:
坑
巨集
cljs 裡面載入巨集的機制有別於 Clojure,一般需要單獨把巨集定義在一個檔案裡面,然後在 cljs 裡面用(:require-macros [my.macros :as my])
這樣的方式去引用,而且巨集定義的檔名字尾必須是 clj 或 cljc,不能是 cljs,這一點坑了我好久。。。
由於巨集編譯與 cljs 程式設計在不同的時期,所以如果巨集寫錯了,就需要把 repl 殺掉重啟來把新的巨集 feed 給 cljs,這點也比較痛苦,因為 repl 的啟動速度實在是有些慢。這一點在 Clojure 裡面雖然也存在,但是 Clojure 裡面一般 repl 開了就不關了,直到電腦重啟。
IDE
Clojure 裡面採用 Emacs + Cider 的開發環境非常完美,但是到了 cljs 裡面,開發流程沒有那麼平滑,總是有些磕磕絆絆,也給 cider 提了個 issue,貌似一直沒人理,支援確實不好,不過有了 figwheel,在一定程度上能彌補這個缺陷。在 Emacs 裡面配置 repl 可參考:
Cider 預設會使用 rhino 作為 repl 求值環境,這個在開發瀏覽器外掛時功能很有限,但是對於檢視函式定義還是可以的。可以根據需要換成 figwheel。
總結
ClojureScript 可以算是 Clojure 語言的一個殺手級應用,React 使得後端程式設計師也能快速作出美觀實用的介面。ClojureScript + React,用起來不能再開心啦!
JS 社群裡面層出不窮的框架每次都讓躍躍欲試的我望而卻步,有了 cljs,算是把 Lisp 延伸到了更寬廣的“領土”。最近看到這麼一句話,與大家分享:
也許 Lisp 不是解決所有問題最合適的語言,但是它鼓勵你設計一種最合適的語言來解決這個難題。
出處忘記了,大體是這麼個意思。