使用 ClojureScript 開發瀏覽器插件的過程與收穫

本文首發於我的博客javascript


隨着 Firefox 57 的到來,以前維護的一個瀏覽器插件 gooreplacer 必須升級到 WebExtensions 才能繼續使用,看了下以前寫的 JS 代碼,毫無修改的衝動,怕改了這個地方,那個地方忽然就 broken 了。所以,此次選擇了 cljs,總體下來流程很順利,除了遷移以前的功能,又加了更多功能,但願能成爲最簡單易用的重定向插件 :-)css

閒話少說,下面的內容依次會介紹 cljs 的工做機制、開發環境,如何讓 cljs 適配瀏覽器插件規範,以及重寫 gooreplacer 時的一些經驗。
本文的讀者須要對 Clojure 語言、瀏覽器插件開發通常流程有基本瞭解,而且完成 ClojureScript 的 Quick Start。對於 Clojure,我目前在 sf 上有一套視頻課程,供參考。html

爲了方便你們使用 cljs 開發插件,我整理了一份模板,供你們參考。gooreplacer 完整代碼在這裏,技術棧爲 ClojureScript + Reagent + Antd + React-Bootstrap。前端

ClojureScript 工做機制

ClojureScript 是使用 Clojure 編寫,最終編譯生成 JS 代碼的一個編譯器,在編譯過程當中使用 Google Closure Compiler 來優化 JS 代碼、解決模塊化引用的問題。總體工做流程以下:java

cljs 編譯流程

Cljs 還提供 與原生 JS 的交互集成第三方類庫的支持,因此,只要能用 JS 的地方,都能用 cljs,node

開發環境準備

開發 cljs 的環境首選 lein + figwheel,figwheel 相比 lein-cljsbuild 提供了熱加載的功能,這一點對於開發 UI 很重要!react

對於通常的 cljs 應用,基本都是用一個 script 標籤去引用編譯後的 js 文件,而後這個 js 文件再去加載其餘依賴。好比:webpack

<html>
    <body>
        <script type="text/javascript" src="js/main.js"></script>
    </body>
</html>

js/main.js 是 project.clj 裏面指定的輸出文件,它會去加載其餘所需文件,其內容大體以下:git

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,會報以下錯誤程序員

inline script error

爲了去掉這些錯誤,手動加載 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 文件

動態加載的 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 在同一級目錄。採用這種方式會報以下的錯誤

content script 報錯

根據錯誤提示,能夠看出是 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.jschrome.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](https://webpack.js.org/
),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 開了就不關了,直到電腦重啓。

我機器上啓動的 repl 列表

IDE

Clojure 裏面採用 Emacs + Cider 的開發環境很是完美,可是到了 cljs 裏面,開發流程沒有那麼平滑,老是有些磕磕絆絆,也給 cider 提了個 issue,貌似一直沒人理,支持確實很差,不過有了 figwheel,在必定程度上能彌補這個缺陷。在 Emacs 裏面配置 repl 可參考:

Cider 默認會使用 rhino 做爲 repl 求值環境,這個在開發瀏覽器插件時功能頗有限,可是對於查看函數定義仍是能夠的。能夠根據須要換成 figwheel。

總結

ClojureScript 能夠算是 Clojure 語言的一個殺手級應用,React 使得後端程序員也能快速做出美觀實用的界面。ClojureScript + React,用起來不能再開心啦!

JS 社區裏面層出不窮的框架每次都讓躍躍欲試的我望而卻步,有了 cljs,算是把 Lisp 延伸到了更寬廣的「領土」。最近看到這麼一句話,與你們分享:

也許 Lisp 不是解決全部問題最合適的語言,可是它鼓勵你設計一種最合適的語言來解決這個難題。

出處忘記了,大致是這麼個意思。

參考

相關文章
相關標籤/搜索