2021 大前端技術回顧及未來展望

總結2021 年度趨勢

1、 TypeScript 穩健增長

回顧2021,官方的 Roadmap 闡明了TypeScript 的目標是繼續完善其類型系統、實現強大的工具提高生產力、提高使用體驗、提高社區參與程度、改進基礎設施和工程化系統。提出目標後,這一年TypeScript 團隊還是非常給力的發了4 個版本,目前最新版本4.5,其中許多新特性確實使用起來更香了,比如:

  • 更好的元組類型支持,允許任意位置的剩餘類型以及可選類型。
  • 更好的模板字符串字面量類型支持。
  • 更智能的條件分支域的類型推斷。
  • 索引類型支持Symbol 和模板字符串模式。
  • Awaited 類型和Promise 類型改進。
  • 等等。

除了特性,它還完善了許多使用體驗,比如:

  • 性能優化如更快的類型生成、增量編譯和Sourcemap 生成。
  • 更智能的IDE 補全。
  • 非Javascript 源文件定位。
  • 等等。

另外, TypeScript 新官網在8 月上線了,全新的文檔查閱起來也更加方便。

目前TypeScript 已經是IMWeb 團隊的標配。無論是Web 前端、Node.js 項目還是公共模塊,從腳手架模板就默認支持TypeScript,其中公共模塊體係不僅僅使用TypeScript 編寫代碼和類型檢查,同時利用ESLint 實現TS 語言標準AST 的特定校驗來實現公共模塊規範,還結合TypeDoc 生成使用文檔等等。

展望

TypeScript 在未來將提供更多激動人心的特性,例如:

  • 扁平化聲明文件(Flattening declarations),只輸出一份總的d.ts 文件,而不是一個模塊一個d.ts 文件。
  • 環境裝飾器(Ambient decorators),用來聲明一些環境信息,例如API 是否是deprecated。不影響輸出的運行時代碼,只在d.ts 聲明文件中體現。
  • 條件編譯(Conditional compilation),有點類似C++ 中的#if 宏定義,可以在編譯前預處理代碼並保留符合條件的代碼分支。
  • 函數表達式以及箭頭函數的裝飾器(Decorators for function expressions/arrow functions),目前TypeScript 中裝飾器只能用於class 中,未來將可能支持類外的函數表達式以及箭頭函數使用裝飾器。
  • 等等。

正如其Roadmap 所說,TypeScript 正在朝正確的方向前進,提高生產力還有很多的類型特性、性能優化、體驗優化、配套工具可以做,正努力成為JS 語言的標準類型系統。隨著TypeScript 的日益發展和完善,未來,TypeScript 是否能得到瀏覽器和Node.js 原生支持呢?我們一起期待吧。

2、React 一馬當先且持續創新

React 18 在2021 年下半年完成了Alpha、Beta 和Release Candidate 版本的發布,將於2022 年初發布正式版本。

當React 18 發佈時,它將包含開箱即用的改進(如Automatic batching),全新的API(如startTransition)以及內置支持了React.lazy 的全新SSR 架構。

這些功能之所以能夠實現,要歸功於在React 18 中新加入的可選的“並發渲染(concurrent rendering)” 機制,它為React 解鎖了非常多新的可能性,來幫助你提高你應用程序的實際與感知性能。

React 18 採用循序漸進的策略,由於React 18 中的並發性是可選功能,所以並不會立刻對組件行為帶來任何明顯的破壞性變化。你幾乎不需要對應用程序中的代碼進行任何改動就可以直接升級到React 18,且可以根據自己的節奏和需要來嘗試新特性。

總的來說,React 18 帶來了以下3 個方面的更新:

➢ Automatic batching

➢ SSR for Suspense

➢ New APIs for app and library developers

● Automatic batching

React 18 通過默認執行更多batching (批處理) 來增加開箱即用的性能改進,無需在應用程序或庫代碼中手動批處理更新。

batching 是指,React 可以將回調函數中多個setState 事件合併為一次渲染。

React 17 只在事件回調中batching,React 18 則會對任何來源的setState 做盡可能多的batching, 即使在promise、timeout 或者event 回調中調用多次setState,也都會合併為一次渲染。

將ReactDOM.render 替換為ReactDOM.createRoot 調用方式,即可開啟這些新特性。

● SSR for Suspense

完整名稱是:Streaming SSR with selective hydration。

即像水流一樣,打造一個從服務端到客戶端持續不斷的渲染管線,而不是renderToString 那樣一次性渲染機制。selective hydration 表示選擇性水合,水合指的是後端內容打到前端后,JS 需要將事件綁定其上,才能響應用戶交互或者DOM 更新行為,而在React 18 之前,這個操作必須是整體性的,而水合過程可能比較慢,會引起全局的卡頓,所以選擇性水合可以按需優先進行水合。

● New APIs for app and library developers

Concurrent APIs:

Concurrent Rendering 相關的變動是React 18 的主要變動之一,簡而言之,這個能力會讓React 應用保持更好的響應性。這是一種可中斷渲染的設計架構。什麼時候中斷渲染呢?當一個更高優先級渲染到來時,通過放棄當前的渲染,立即執行更高優先級的渲染,換來視覺上更快的響應速度。

  • useTransition:允許組件在切換到下一個界面之前等待內容加載,從而避免不必要的加載狀態。
  • startTransition:被startTransition 回調包裹的setState 觸發的渲染被標記為不緊急的渲染,這些渲染可能被其他緊急渲染所搶占。
  • useDeferredValue:返回一個延遲響應的值,例如一個選擇輸入框過濾列表的場景,我們可以針對列表使用useDeferredValue 傳入選擇器對應的值。

新的startTransition 與useDeferredValue API,本質上都是允許你將UI 的一部分標記為較低的更新優先級。

其他APIs:

  • useSyncExternalStore:useSyncExternalStore 將替代useMutableSource 用於訂閱外部源,解決Concurrent Rendering 可能導致的數據不一致的問題,也是庫作者可能需要,一般開發者不太能用到。
  • useId:useId 用於在客戶端與服務端之間產生唯一ID ,避免SSR hydrate 時元素不匹配。
  • useInsertionEffect:用於插入全局DOM 節點。

React 18 將在明年與新的React Native 架構(可用React 18 特性)一起發布。

3、Svelte 前端框架戰局中的黑馬

前端領域風起雲湧,框架層出不窮,前端三大馬車React、Vue、Angular 始終穩居前三甲。同時我們也注意到在眾多前端框架中,由Rich Harris (Ractive, Rollup 和Bubble 的作者) 開發的Svelte 有望成為一批黑馬,在前端框架中脫穎而出。

在《 Stack Overflow 於2021 年準備的最新調查》中,71.47% 的受訪者將Svelte 選為最受歡迎的框架,領先於React.js 的69.28% 和Vue 的64.41%。而在 JS 現狀2020 調查 中,Svelte 在用戶滿意度89%、興趣度66% 均取得了第一的成績表現。Svelte 從一誕生,就用來對標React/Vue 等框架,我們也看到了關於 Svelte 與React 的爭論,看到了19 年尤大回复的《如何看待Svelte 這個前端框架》以及21 年 vue-Svelte-size-analysis 評測,足見Svelte 的發展態勢。

 

前端戰局中的黑馬

我們調查發現,開發者喜愛Svelte,主要源於以下幾點:

1、更高的開發效率。Svelte 有著極其簡潔的語法,交互式教程讓其有較低的學習曲線和上手成本,熟悉vue 語法的基本上很快能夠上手。

2、更小的體積。Svelte 的核心思想在於通過靜態編譯減少框架運行時的代碼量,這在小型應用中,優勢相當明顯,React 的壓縮版本大小為42.2KB,Svelte 的壓縮版本大小為1.6KB。但是在中大型應用中,這個優勢會被慢慢縮小,甚至成為劣勢。

3、更高的性能。Svelte 沒有採用現在普遍使用的Virtual Dom,而是另闢蹊徑採用Template 語法,讓編譯器在編譯階段就記錄了哪些數據需要更新。這讓Svelte 性能不僅勝過React,還勝過Angular 和Vue。

4、更優的Web Components 分發。Svelte 直接編譯成JS,生成瀏覽器能夠識別的Web Components 組件,這讓基於Svelte 開發的組件能夠用於其它框架,譬如React/Vue/Angular 等。

時光飛逝,Svelte 的發展速度可能也超乎我們的想像。被詬病不支持TypeScript 的前端框架沒有未來的Svelte 在2021 年也支持了TypeScript,UI 庫Svelte Material UI 也在逐步迭代中,開發者社區也加入了越來越多的小伙伴,豐富了Svelte 在單元測試、Web Components、SSR 等方面的實踐。

回顧2021 年,Svelte 最重要的莫過於下面兩件事:

1、2021 年11 月20 日舉辦了秋季峰會。峰會Rich Harris 給我們講述了Svelte 的歷史,並宣布他將入職Vercel,之後全職維護Svelte。峰會上也邀請到了社區眾多的開發者,分享Svelte 的一些實踐,讓我們看到Svelte 更多的可能性。

2、SvelteKit 正式發布beta 版。SvelteKit 是基於Svelte 開發的web 應用框架,類似於基於Vue.js 開發的Nuxt.js 框架。它繼承了服務端渲染SSR,路由,支持TypeScript,支持less/sass,支持Vite 打包等特性。既能高效開發,又高性能。儘管目前SvelteKit 目前還有些bug 仍需要解決,部分缺失的功能亟待完善。但仍不妨礙項目敢在生產環境去使用它。

靜待花開的攪局者

雖然我們看到Svelte 深受開發者的喜歡,但是到目前為止,仍然很難看到有大型應用在使用Svelte,其性能優勢、體積優勢等並沒有在大型應用中得到驗證。由於React/Vue/Angular 先入為主,尤其是在大公司,已經有非常完備成體系的配套方案,成熟的體系基本上很難去改動,後起之秀也很難有如React 等框架活躍的社區,Svelte 要走的路還是很長。但是我們觀察到,包括阿里、字節、騰訊等大公司也都在新業務中嘗試使用Svelte 開發,在中小型應用、h5 應用、Web Components 等方面確實有它的優勢所在,也值得嘗試。儘管Svelte 有很多優勢,但想以一己之力挑戰React/Vue/Angular 的江湖地位,目前來看還是需要靜待標杆大型應用,靜待各大大公司推出基於Svelte 開發的UI 庫,或許Svelte 大放異彩的時機就會到來。

4、桌面端- 前端開發的下一個戰場

持續擴大桌面應用領域影響

自2014 年Github 推出Electron 開源框架開始,前端跳出Web 客戶端局限,開發桌面應用的能力成為了可能,近年來,依托Electron、React Native、Flutter 等應用框架,前端跨端開發桌面應用的概念持續升溫。儘管這些方案和傳統的QT、Xaramrin 等技術棧相比,性能未必最優,但它意味著一些極具性價比的可選方案出現,大大降低了開發桌面應用的門檻。

2021 年,前端Electron、React Native Desktop 等應用框架的更新迭代都趨於穩定,雖然沒有了一些突破性的亮點功能出現,但各個框架都針對性能、應用場景等痛點問題在持續進行深入的優化,而近年概念火熱的Flutter 也將它的桌面版在21 年納入了Beta 階段,異軍突起的Tauri 以其優異的性能和包大小受到了關注,潛力不容小覷。總體而言,在桌面應用開發領域,前端技術的影響力在與日俱增,前端可以參與的內容比重也在不斷增加。

Electron

Electron 是GitHub 開發的一個開源框架。它通過使用Node.js(作為後端)和Chromium 的渲染引擎(作為前端)完成跨平台的桌面GUI 應用程序的開發。已有大量知名桌面應用採用Electron 進行開發,如slack、VSCode 等。Electron 的所需開發能力與前端開發能力技術棧有著較大的重合,因此對於前端開發同學來說,使用Electron 進行桌面開發的上手門檻較低,同時Electron 作為一個深耕迭代8 年的項目,應用生態鏈豐富,進一步減少了上手成本。

使用Electron 進行桌面應用開發,對於前端自身能力提升也有賦能,一方面擴展了技術廣度,可以將前端的業務能力範疇由單一的Web 端頁面擴展到PC 應用開發,一些目前Electron 暫時不支持的能力,還可通過C++ 編寫Node 組件來擴展支持;另一方面很多前端側的限制被打破,比如一些傳統的Web 安全限制,系統底層接口的調用,能夠做到開發能力賦能。

當然,Electron 也並不是全無缺陷的,一些常受詬病的缺點有:

  • 打包體積過大,由於捆綁了Chromium 內核等大量依賴,導致Electron 的打包體積普遍在100M+,這一點我們可以使用asar 壓縮、動態鏈接庫等方式進行優化。
  • 內存佔用高,同樣的由於捆綁了Chromium 內核,Electron 的內存佔用普遍也較高。
  • UI 層視覺渲染效率低,這一點也可以通過優化手段,如多進程處理任務、甚至利用視覺假象來提升用戶體驗。

雖然Electron 有著一些已知的問題,但完善的生態鏈、與前端技術的高度重合,目前仍然是快速開發桌面應用的推薦方案,對於性能問題我們也較容易通過一些常見的優化手段來進行解決,達到80 分的程度。2021 年,Electron 依然保持著8 週一個major 版本的穩定更新頻率,推出了V12 到V15 的多個大版本,更新的內容主要集中在API 的刪改、系統特性的適配、Chromium 內核等依賴的版本更新等細節方面。

React Native Desktop

React Native 是Facebook 技術團隊於2015 年4 月在早先的React 前端框架基礎上開源的一套移動跨平台開發框架。對於桌面應用的構建,目前RN 團隊暫時沒有推出官方的桌面端版本,主要依托社區項目進行持續發展的能力建設。在這之中,微軟開發的React Native For Windows + macOS 技術方案是經驗積累最多,也是開發迭代最為穩定的方案,自15 年底項目發布以來,已經經過了6 年的穩定迭代。2021 年RN 團隊推出了0.64-0.66 三個重要版本,而微軟在React Native For Windows 的迭代中,也時刻保證對RN 主版本的更新,同時也支持了大量Windows 相關的特性。如果你構建的桌面應用主要目標用戶在Windows 平台,那麼使用React Native For Windows 不失為一個好的選擇。

值得一提的是,2021 年RN 技術團隊除了在推出的重要版本中提供對新的Android 12 與iOS 15 系統的支持外,也著重提到了與微軟團隊在桌面應用構建技術上的共建,RN 團隊表示,將通過引入Facebook 的Messenger 團隊共建,來為桌面應用提供一些「獨有的」技術能力,以此提升React Native 桌面版的用戶體驗,對此,我們也將拭目以待。

Flutter Desktop

Flutter 是由谷歌推出的移動UI 混合開發框架,它實現了一整套自底而上的基礎庫,用戶可以在iOS 和Android 構建高質量的原生用戶界面。

目前Flutter 為了支持在桌面側的開發能力,採用的是把代碼轉成Web 的跨端渲染方案。但Flutter to Web 性能還存在著大量提升的空間,雖然這一年內業內有不少優化方案,但想要性能有明顯提升,多少都會通過魔改Flutter 源碼的方式來實現,這些優化手段在長期的Flutter 版本迭代過程中,會有較大的優化成本。即使這樣,優化過後的Flutter to Web 性能,和傳統的Web 項目相比,也略有不足。所以在不考慮兼容性的前提下,採用to Web 方案的開發盡量使用Canvaskit Render 模式,該模式是基於Skia 的WebAssembly 方案,會有更好的渲染性能,但加載性能方面還需持續優化。

可能是為了徹底解決桌面端的性能問題,2021 年中,Flutter Desktop 側推出了Windows Native 方案,但它目前僅支持64 位系統,這使得它無法支持Win7 等較低32 位系統的Windows 版本,會大大增加了開發者的兼容成本。不過2022 年2 月,Flutter Desktop 正式推出了穩定版,適配了許多常用插件以包含對Windows 的支持,包括camera,file_picker 和shared_preferences。更重要的是,社區已經添加了各種其他package 對Windows 的支持,涵蓋了從Windows 任務欄集成到串行端口訪問的全部內容。同時許多Microsoft 的團隊也積極配合,為正式版的發布做出了很大貢獻。2022 年,Flutter Desktop 值得嘗試一下。

Tauri

最近搭上Rust 的東風的Tauri 受到非常多的關注,對標Electron,主要有以下4 點優勢:

  • 包體積大小更小
  • 運行時內存佔用更小
  • 安全擺在第一位
  • 真正的開源

但是理性思考,對於前端開發來說,有三個致命的缺點:

  • Tauri 使用系統webview,會有兼容性問題,這也是Electron 重點解決的問題
  • 拋棄了nodejs,生態圈目前來說還是很難比得上Electron 的
  • 底層開發要用Rust,有一定的上手成本

當然Tauri 現在還不是非常成熟,但是隨著Rust 的生態起來,瀏覽器兼容性漸小之後,勝負猶未可知。

5、Rust – 是時候掌握一門新語言了

Rust 是JS 基礎設施的未來

隨著前端生態工具的逐漸完善,大家除了探索前端的新領域之外,同時還在思考如何提高工具的性能,眾所周知,JavaScript 的性能一直是被大家所詬病的點,但是前端的基礎設施卻是十分要求性能的,比如構建等,所以大家開始考慮是否能夠用別的語言來編寫前端工具,於是Rust 吸引了大家的眼球,Rust 語言自誕生以來,就以它的安全性、性能、現代化的語法吸引了大批的開發者,在過去六年的stackoverflow 最受喜愛的編程和語言中連續獲得榜首的位置,並且已經有眾多領域都出現了Rust 重寫的項目,Linux 項目也表示正在使用Rust 重寫一部分功能,可以說Rust 進入前端領域也是一種必然的趨勢。Lee Robinson 在2021 年寫的一篇文章《Rust Is The Future of JavaScript Infrastucture》(《Rust 是JS 基礎設施的未來》)列舉了眾多Rust 編寫的前端工具項目,並表示Rust 將會持續加大影響Javascript 的生態圈,這篇文章也是被眾多公眾號轉了個遍,引發大家的熱烈討論。

Rust 工具融入前端生態

在前端構建領域,2021 年出現了一個十分突出的項目—— swc,它是由Rust 編寫的構建工具,可以用來編譯、壓縮、打包,目前它已經被一些知名項目使用,比如Next.js、Parcel、Deno 等,Next.js 12 直接使用了swc 替代babel,並在他們的官網博客表示說使用了swc 之後,熱更新速度提升到了原來的三倍,構建速度提升到了5 倍,由此可見,Rust 性能的強大。

除了構建方面,在前端的其他領域也是有著Rust 的身影,比如Deno 的運行時引擎也是用的Rust 編寫的V8 引擎;前端的下一代工俱全家桶Rome 宣布使用Rust 重寫;Node.js 可以通過napi-rs 來調用Rust 模塊,實現高性能擴展;使用Rust 編寫的dprint 規范代碼器,要比Prettier 快30 倍;Rust 也可以編譯成WASM,並且出現了像yew、percy 這樣的WASM 前端框架。

可以預見的是,Rust 工具將會更加深度地融入前端生態,說不定會引發前端生態的又一次更新換代。

前端人是時候學習一門新語言

相信有不少人看到過這樣一個推特截圖,Redux 作者Dan Abramov 在某個提問“未來三年最值得學習的語言是什麼” 下回答了“Rust”,這或許是對前端人員的一個啟發,我們也是時候學習一門新語言來讓前端生態圈再次煥發活力了,可是不少人會被Rust 陡峭的學習路線給勸退,但其實Rust 在不少地方是跟前端開發有著相似的地方的,要想入門的話也並不是那麼陡峭。

圖片

比如,在工具鏈上,Rust 的rustup 就相當於nvm,可以切換運行工具cargo(Rust 版的npm)的版本,但它也比nvm 強大,在安裝rustup 的同時,還會安裝clippy(Rust 版的eslint)、rustfmt(Rust 版的prettier),用Rust 配套工具新建的項目就已經帶有代碼格式化、分析配套的工具。

再來看看cargo 與npm 的相似之處,兩個工具在很多命令上都有著相似的地方,並且npm 一些需要自己在項目配置的命令在cargo 這是不需要配置的,甚至cargo 是自帶了monorepo 的管理,可以直接配置多package 的項目,與其說cargo 跟npm 對應,倒不如說cargo 更像是npm 與yarn 的結合,這也是Rust 團隊借鑒參考現代化語言工具鏈的成果。

圖片

在語法上Rust 也是極具現代化語言的特點,借鑒了函數式編程、結構化語言的特點,並且在它們的基礎上也創造了許多更為先進的語法。在函數式編程的地方,也有著不少JavaScript 的身影,比如JS 的箭頭函數對應了Rust 的閉合函數;Rust 的數組同樣也有著map、reduce、filter 等方法;Rust 的函數也可以賦值給一個變量。

如果在以前說前端可以去學習的第二語言是C++,那麼現在或許就是Rust 了,它有著比C++ 更現代化的依賴管理、語法、工具鏈,讓你不至於在一開始就被勸退,還能讓你在前端領域更具競爭力。

6、低代碼將持續成為熱點話題

距我們在2020 技術趨勢中談及“低代碼” 又過去了一年,從2020 年19 億到2021 年28.5 億的市場規模,無疑表明該領域依舊火熱,依舊在快速發展中。如果說2020 年讓我們收穫了對低代碼領域持續升溫的預期,那麼2021 年則讓我們看到了更多關於低代碼領域未來發展的趨勢。

一方面,我們看到騰訊微搭、阿里宜搭等企業級低代碼平台在行業內開始發力,公司內也有無極等專注管理台搭建的平台逐步成熟。大量平台型產品仍在差異化高速發展,仍是主流的發展思路。在IMWeb 團隊內,從19 年開始的運營低碼平台Vision,到20 年的管理台低碼框架Hulk,我們一直在通過垂直類低碼平台加速業務研發。2021 年,我們進一步在服務端場景進行了嘗試,打磨出了專注接口搭建的HulkData 平台。

HulkData 通過Web 可視化組件搭建流水線,基於數據庫或已有API,配合少量代碼生成全新的API 接口。HulkData 借鑒BPMN 2.0 協議使用圖形來表達業務流程,支持多業務,多數據資源,低代碼、插件機制、流程編排、請求和響應參數修改。Serverless 日漸成熟,Serverless 的無運維特性對HulkData 而言是一個非常良好的契機,在HulkData 上創建的接口會以SCF 的方式部署到騰訊雲,不需要再關注服務器運維。使用HulkData 服務端接口編排可快速實現業務邏輯,敏捷接付業務應用,比傳統開發模式交付速度提升80%。目前內部三大業務接入使用共400+ 接口在正常運行。

另一方面,值得思考的是,在高速發展的差異化、場景化的平台產品之間,是否存在某些共性?畢竟不管針對什麼場景,從零建設一個低碼平台的成本絕不低,此類的資源浪費在大廠裡尤為突出。

20 年底IMWeb 團隊內啟動的Gems 低代碼引擎項目,其實就是對這個問題的探索。低代碼引擎的核心目標,是提供一套基礎標準、設施,幫助上層平台更有效地建設。而其思路的關鍵,在於引擎模型及能力的完備性、以及針對不同場景下的可擴展性。Gems 作為低代碼引擎,在21 年裡不斷完善自身的基礎能力與設計,提供了全面板插件化、核心編輯對象API 等能力。除了平穩支撐團隊內的運營與管理台低碼平台,也逐步邁出到團隊之外,幫助到公司內多個團隊在自身業務場景低碼平台的高效建設。有關Gems 的更多內容可以關注我們團隊在 QCon 的相關分享。

同時,我們也看到在今年底的GMTC 大會上,阿里已經對外宣傳了集團的低代碼引擎,從分享內容看已經支撐了60 多個低代碼平台的建設;而騰訊內部的低代碼Oteam 也在21 年開始組織起來,主要的目標也是底層核心的共建。從整個行業看,低代碼引擎已經開始嶄露頭角,且可預見到趨勢還將上升。只是這個細分賽道更多可能只是大廠參與,因為其需要大量的場景支撐驗證,而這是小廠或獨立開發者不具備的。

總觀下來,差異化的平台產品仍將是我們接觸低代碼領域的主要途徑;而低代碼引擎的出現,將為整個行業帶來更多的可能。

7、D2C 前端智能化未來可期

“前端智能化” 是近些年業界在前端+ AI 方向上的新的探索。何謂智能化?就是將智能化算法結合前端工程化實踐,讓機器進行輔助開發。

D2C:歷史與現狀

截止目前,前端智能化領域最大規模落地的產品形態就是各種Design to Code (下文簡稱D2C) 工具:輸入UI 設計稿,通過一系列算法,輸出可用的代碼。

2017 年一篇論文 pix2Code,提出了圖像生成代碼的想法。

2018 年,微軟開源了 Sketch2Code 項目,進一步驗證了該方向的可行性。

緊接著2019 年,阿里淘系上線 imgcook,並在接下來的幾年裡支撐了雙十一、618 等大量業務。這標誌著D2C 技術逐漸成熟,大規模業務落地勢在必行。

時間來到2021,國內外各大公司都在此領域展開了相應的探索和實踐:

騰訊IMWeb 團隊啟動了Project Auton,已經在內部上線試水,預計今年6 月對外提供服務;阿里的imgcook 依舊在持續進行快速迭代;字節內部基於低代碼平台,孵化出了“ALYX” 項目,也在內部展開了實踐;58 團隊開源了 Picasso ; 轉轉上線了” 神筆馬良” 平台…

另外,D2C 領域也湧現出一批創業公司。如國內的 CodeFun  、藍湖,國外的 Framer  、Anima 等。

值得一提的是CodeFun,在易用性、還原度方面有相對較好的表現,上線後獲得了不錯的口碑。

但在整個前端開源社區,目前D2C 領域還沒有一個足夠有影響力的開源項目。因此各家也基本都處於“閉門造車” 的狀態。

硬幣的兩面:缺陷、場景與機會

相對於早期基於純視覺算法的方案,目前大規模落地的D2C 產品基本都是以設計稿源文件(Sketch、Figma、XD 等) 作為原始輸入。

由於純視覺算法很難從二維圖像上提取UI 的層級等信息,而設計稿文件則可以通過解析內部DSL 獲取更詳細的結構化UI 描述,更方便進行後續的處理與代碼生成。

傳統的pro-code 開發模式下,通常都是“PRD + 設計稿” 作為輸入,產出業務代碼。但D2C 系統把設計稿作為唯一輸入,設計稿只是單純的UI 描述,導致很多信息無法從設計中推斷出。如動畫、交互、邏輯甚至是響應式等都無法單獨依靠D2C 實現。

由於這些缺陷,D2C 的場景大多也只是作為面向開發的輔助工具。距離真正的完全智能化(無需人工干預即可產出邏輯完備且生產環境可用的代碼)還為時尚早。

雖然存在上述諸多缺陷,但在UI 開發這一領域,D2C 大有可為。

D2C 的產物(組件/ 頁面代碼或描述UI 的DSL) 通常有如下幾種消費路徑:

  1. 產出代碼,作為基礎UI 組件,由開發者進行二次開發
  2. 產出代碼,作為基礎物料供給,結合low-code/no-code 平台進行二次編輯和編排
  3. 產出DSL,結合定制化的render 進行直接渲染

尤其是第二種消費路徑,借助近些年大熱的low-code 平台,對D2C 產出的UI 物料進行數據綁定、邏輯編排、樣式編輯、交互編排等人工干預和二次編輯,可以補全D2C 的能力短板,並且建立出一套快速、高效、可沉澱、可複用的代碼生產SOP。

另外, D2C 以其高效的供給效率,可以突破low-code/no-code 的物料生產瓶頸,為前端的研發範式從pro-code 走向low-code 的變革加上了助推劑。

借助D2C + low-code/no-code,再結合近年來大熱的SaaS、FaaS、BaaS 等技術產品形態,可預見地在不遠的未來,真的可以實現不需要工程師就可以零代碼快速上線一個數據、交互、邏輯完備的產品。這極大地降低了很多創新型業務的初期成本,甚至可能助推下一波互聯網創業浪潮,讓我們拭目以待。

不過目前為止,還沒有出現哪一個平台能把上述幾種產品形態(D2C + low-code/no-code + SaaS/FaaS/BaaS) 完美地整合起來形成閉環,同時保持優秀的用戶體驗。未來幾年,這個領域或許會催生出一些明星創業公司。

展望未來:深耕、整合、研發範式變革

展望2022 年,可以預見前端業界智能化及D2C 還將進行持續地發展,整體為如下兩大趨勢:

  1. 縱向上:持續深耕,優化流程、算法和體驗,讓“智能化” 真正的越來越“智能”
  2. 橫向上:建立標準和流程,打通整合上下游能力,串聯low-code、no-code、FaaS、BaaS、SaaS、設計體系、算法體系、研發體系、數據體係等… 真正形成工業化的快速生成體系,解放生產力。

從長遠來看,一旦上述體系建立起來,必將驅動業界開始下一次的研發模式變革。從目前的pro-code 為主的研發模式,變革為pro-code、low-code、no-code 三種模式相輔相成、互相供給和賦能的模式。同時由於標準化體系的建立,物料和產物都可以更容易實現通用和復用。這對於研發效能的提示無疑是巨大的!

這一些都充滿想像,即使智能化的路程中充滿質疑與險阻,但未來是值得期待的。新的一年還將繼續深耕和發展,2022 未來可期……

8、DevOps,研發效能仍是重點

研發效能是目前互聯網企業和傳統軟件企業都高度關注的領域,互聯網大廠希望通過“研發效能” 實現持續的研發能力提升以應對日趨複雜的產品開發;腰部廠商則希望通過“研發效能” 實現彎道超車,充分發揮後來者居上的優勢;更多中小企業看到國內互聯網大廠不約而同地在這個領域重點投入,紛紛也是摩拳擦掌準備在效能領域發力。

和敏捷的概念類似,到底什麼是研發效能很難精確定義。其實很多複雜概念也不是定義出來的,而是逐步演化出來的,是先有現象再找到合適的表述。其實,效率和效能也從來都不是軟件工程的專有名詞,縱觀人類發展史,就是生產力和生產效率不斷提升的發展篇章,到了數字化時代,軟件研發效能的重要性被凸顯了出來。如果要用一句話來總結研發效能的話,我們會用“更高效、更高質量、更可靠、可持續地交付更優的業務價值” 來總結。

圖片

我們能做的不是提升研發效能的絕對值,而是盡可能減緩研發效能惡化的程度,使其下降的不至於太快,努力保持現狀就是成功。

圖片

IMWeb 團隊在DevOps 方面,2021 年有較大的進展。一方面,我們與騰訊雲Coding 在開發、測試、部署、運維等多個領域進行了共建,團隊自研的效能平台Thanos 與Coding 團隊深度打造應用工作流方案,代理聯調平台TDE 與Coding 團隊打通測試環境Nohost 網關,接口聯調契約平台Tolstoy 與Coding 共建API 託管、Mock 和測試的能力。在研效大背景下,我們通過騰訊雲Coding 實現了效能平台的大統一,整體研發效能提升30% 以上。

9、微前端,不可輕視的一環

2016 年ThoughtWorks 提出了微前端思想:將龐大的項目拆分成各個小型靈活項目,這些小項目互不干擾,可以獨立開發、獨立運行以及獨立部署,由此拉開微前端帷幕。在2019 年阿里在single-spa 基礎上開發了qiankun 微前端框架後,微前端的熱度一直在增加。在微前端的發展過程中,開發者們也慢慢摸索出當下微前端的應用場景:

圖片

時間來到2021 年,微前端的框架已經非常多了,其中名聲比較響亮的有老牌的single-spa,Github Star 數最高的微前端框架qiankun,以及新興微前端框架京東的MicroApp。

圖片

single-spa 自2020 年發布了v5.0 後,在去年上半年主要工作還是圍繞v5.0 一些Bug 的修復,而在下半年7 月份發布了v6.0 的beta 版本。雖然v6.0 也有一些Breaking Changes,但是對於這些Changes,大多數用戶是不需要更新自己代碼的。其中比較重要的是在瀏覽器方面,v6.0 將是最後一個支持IE11 的版本,且在以後的版本v7.0 + 將不再支持IE11,single-spa 團隊將會把更多精力從瀏覽器兼容轉到維護整個single-spa 生態上。v6.0 還加入兩個新特性:

  • 支持異步取消頁面導航
  • 暴露patchHistoryApi,開發者可以使用single-spa 封裝後的pushState/replaceState/popstate/hashchange。

不僅老牌框架在發力,號稱“可能是你見過最完善的微前端解決方案” qiankun 也在不斷更新。qiankun 主要還是解決不同應用場景的一些問題,以及修復沙箱中一些JavaScript 的兼容問題,比如沙箱中的defineProperty 問題,以及沙箱性能問題等。雖然qiankun 在去年看起來沒太多更新,但是它也給出了令人激動的V3.0 RoadMap,裡面說到了非常多更新,主要更新有:獨立應用加載模塊以及獨立沙箱模塊。

不過,qiankun 依然沒有解決侵入性強的問題,並不能像類似iframe 一樣很方便地嵌入頁面。

下半年一個好消息是,京東也推出了自己微前端的解決方案MicroApp。它並沒有採用single-spa 和qiankun 的組件化思路,而是藉鑑了WebComponent 的思想,通過CustomElement 結合自定義的ShadowDom,將微前端封裝成一個類WebComponent 組件,從而實現微前端的組件化渲染。它有以下特性:

  • 類WebComponent + HTML Entry
  • 生命週期
  • 資源地址補全
  • JS 沙箱、樣式隔離、元素隔離
  • 數據通信
  • 預加載
  • 插件系統

MicroApp 在使用性和侵入性都做得非常完美,這個框架的發展和未來是非常值得期待的。

總的來說,微前端的基礎來自於 “所有大型系統都逃不過熵增定律”,它能解決的問題也是解構一些巨石應用,所以微前端更多時候是“悲觀主義工程師” 在工程上的妥協。

Create Login Signup UI Screens in Vue with Bootstrap 4

In this tutorial, we are going to create responsive Login & Signup UI screens using the Bootstrap 4 CSS framework in the Vue.js application.

We will create a Login screen, Sign up screen, and Forgot Password screen from scratch.

Bootstrap is a free and open-source CSS based UI framework, and It is used for rapid front-end development. It offers plenty of UI components that are 100% responsive and can work on any device size smoothly. It contains CSS- and JavaScript-based design templates for typography, forms, buttons, navigation, and other interface components.

Vue.js is a robust progressive open-source JavaScript framework, and It is used use by web developers for creating excellent user interfaces and single-page applications. It makes app development quite simple and straightforward. The primary factor of Vue is that it is lightweight, flexible, modular, and highly performant.

Let’s start creating Login and Registration user-interface templates for Vue.js.

 

Vue.js Login & Signup UI Example

You need to have following tools and frameworks ready to get started with this tutorial:

  • Vue CLI
  • Vue
  • Bootstrap 4
  • Code Editor

Generate Vue App with Vue CLI

The Vue CLI offers the standard tooling option for swift development in Vue, run the command to install Vue CLI.

npm install -g @vue/cli

# or 

yarn global add @vue/cli

Make sure which vue-cli version has been installed in your development system:

vue --version

Generate a new Vue.js project by running the command from Vue CLI.

vue create vue-animated-ui

Answer Vue CLI questions with following choices.

# ? Please pick a preset: Manually select features
# ? Check the features needed for your project: Babel, Router, CSS Pre-processors, Linter
# ? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
# ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
# ? Pick a linter / formatter config: Basic
# ? Pick additional lint features: Lint on save
# ? Where do you prefer placing config for Babel, ESLint, etc.? In package.json
# ? Save this as a preset for future projects? (y/N) No

Head over to project folder.

cd vue-animated-ui

Start to see the latest created Vue app on the browser window.

npm run serve

Adding Bootstrap 4 in Vue.js

To use the Bootstrap UI components, we need to install the Bootstrap module in our Vue app.

npm install bootstrap

# or

yarn add bootstrap

Import Bootstrap path in the main.js file. It makes Bootstrap module available throughout our app.

import Vue from 'vue'
import App from './App.vue'
import router from './router'

import 'bootstrap/dist/css/bootstrap.min.css'

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

We also need to define the Font Awesome icon CDN path in the public/index.html. It allow us to add some useful icons in our Vue app.

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">

Adding Global CSS in Vue.js Project

Let’s look at the most asked question regarding Vue that how to add global styles via CSS in a Vue.js project.

Create a css folder inside the src/assets folder then create the main.css file in it.

Define the global CSS path inside the main.js file just below the Bootstrap path.

import Vue from 'vue'
import App from './App.vue'
import router from './router'

import 'bootstrap/dist/css/bootstrap.min.css'
import '@/assets/css/main.css'

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

Add the common style of our primary authentication components in assets/css/main.css file.

* {
  box-sizing: border-box;
}

body {
  background: #2554FF !important;
  min-height: 100vh;
  display: flex;
  font-weight: 400;
}

body,
html,
.App,
.vue-tempalte,
.vertical-center {
  width: 100%;
  height: 100%;
}

.navbar-light {
  background-color: #ffffff;
  box-shadow: 0px 14px 80px rgba(34, 35, 58, 0.2);
}

.vertical-center {
  display: flex;
  text-align: left;
  justify-content: center;
  flex-direction: column;    
}

.inner-block {
  width: 450px;
  margin: auto;
  background: #ffffff;
  box-shadow: 0px 14px 80px rgba(34, 35, 58, 0.2);
  padding: 40px 55px 45px 55px;
  border-radius: 15px;
  transition: all .3s;
}

.vertical-center .form-control:focus {
  border-color: #2554FF;
  box-shadow: none;
}

.vertical-center h3 {
  text-align: center;
  margin: 0;
  line-height: 1;
  padding-bottom: 20px;
}

label {
  font-weight: 500;
}

.forgot-password,
.forgot-password a {
  text-align: right;
  font-size: 13px;
  padding-top: 10px;
  color: #7a7a7a;
  margin: 0;
}

.forgot-password a {
  color: #2554FF;
}

.social-icons {
  text-align: center;
  font-family: "Open Sans";
  font-weight: 300;
  font-size: 1.5em;
  color: #222222;
}

.social-icons ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
.social-icons ul li {
  display: inline-block;
  zoom: 1;
  width: 65px;
  vertical-align: middle;
  border: 1px solid #e3e8f9;
  font-size: 15px;
  height: 40px;
  line-height: 40px;
  margin-right: 5px;
  background: #f4f6ff;
}

.social-icons ul li a {
  display: block;
  font-size: 1.4em;
  margin: 0 5px;
  text-decoration: none;
}
.social-icons ul li a i {
  -webkit-transition: all 0.2s ease-in;
  -moz-transition: all 0.2s ease-in;
  -o-transition: all 0.2s ease-in;
  -ms-transition: all 0.2s ease-in;
  transition: all 0.2s ease-in;
}

.social-icons ul li a:focus i,
.social-icons ul li a:active i {
  transition: none;
  color: #222222;
}

Create User Login UI in Vue

Designing and developing a login screen a bit time consuming task and requires some additional skills for a software developer. As we know, the login component allows you to access an application. It contains some strong web development fundamentals such as creating HTML forms, routing to navigate to some other screen, and two-way data-binding to extract the data from the login form.

We’re going to learn how to build a beautiful login form in the Vue.js application.

Create a Login.vue file in components folder and add the following code inside the components/Login.vue file.

<template>
    <div class="vue-tempalte">
        <form>
            <h3>Sign In</h3>

            <div class="form-group">
                <label>Email address</label>
                <input type="email" class="form-control form-control-lg" />
            </div>

            <div class="form-group">
                <label>Password</label>
                <input type="password" class="form-control form-control-lg" />
            </div>

            <button type="submit" class="btn btn-dark btn-lg btn-block">Sign In</button>

            <p class="forgot-password text-right mt-2 mb-4">
                <router-link to="/forgot-password">Forgot password ?</router-link>
            </p>

            <div class="social-icons">
                <ul>
                    <li><a href="#"><i class="fa fa-google"></i></a></li>
                    <li><a href="#"><i class="fa fa-facebook"></i></a></li>
                    <li><a href="#"><i class="fa fa-twitter"></i></a></li>
                </ul>
            </div>

        </form>
    </div>
</template>

<script>
    export default {
        data() {
            return {}
        }
    }
</script>

Create User Login UI in Vue

Build Sign up Template

Well, sign up in simple terms means to create a new account or register in an application. It could be signing up for any web portal or even for a newsletter. When you visit any new website, you need to create an account using the name, email, and password.

In this step, we are going to create an eye-catching registration UI screen in Vue using the Bootstrap 4 Form component.

Create components/Signup.vue and paste the given below code inside the file.

<template>
    <div class="vue-tempalte">
        <form>
            <h3>Sign Up</h3>

            <div class="form-group">
                <label>Full Name</label>
                <input type="text" class="form-control form-control-lg"/>
            </div>

            <div class="form-group">
                <label>Email address</label>
                <input type="email" class="form-control form-control-lg" />
            </div>

            <div class="form-group">
                <label>Password</label>
                <input type="password" class="form-control form-control-lg" />
            </div>

            <button type="submit" class="btn btn-dark btn-lg btn-block">Sign Up</button>

            <p class="forgot-password text-right">
                Already registered 
                <router-link :to="{name: 'login'}">sign in?</router-link>
            </p>
        </form>
    </div>
</template>

<script>
    export default {
        data() {
            return {}
        }
    }
</script>

Build Vue Sign up Template

Forgot Password Screen in Vue

“Forgot password” screen is used to recover any password that you forgot for any specific account for the application.

Create components/ForgotPassword.vue and paste the given below code inside the file.

<template>
    <div class="vue-tempalte">
        <form>
            <h3>Forgot Password</h3>

            <div class="form-group">
                <label>Email address</label>
                <input type="email" class="form-control form-control-lg" />
            </div>

            <button type="submit" class="btn btn-dark btn-lg btn-block">Reset password</button>

        </form>
    </div>
</template>

<script>
    export default {
        data() {
            return {}
        }
    }
</script>

Forgot password in Vue

Enable Vue Router

Next, we enable the router in the vue app, these routes will allow us to navigate from one page to another page. So, first install the vue router package in the app.

npm install vue-router

Add the following code inside the router/index.js file.

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

  const routes = [
  {
    path: '/',
    name: 'signup',
    component: () => import('../components/Signup.vue')
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('../components/Login.vue')
  },
  {
    path: '/forgot-password',
    name: 'forgot-password',
    component: () => import('../components/ForgotPassword.vue')
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

Implement Vue Navigation with Bootstrap 4

Finally, we will create the simple and beautiful navigation bar that will allow us jump from one template to another template in Vue app.

Include the given below code inside the App.vue file.

<template>
  <div class="vue-tempalte">
    <!-- Navigation -->
    <nav class="navbar shadow bg-white rounded justify-content-between flex-nowrap flex-row fixed-top">
      <div class="container">
        <a class="navbar-brand float-left" href="https://www.positronx.io" target="_blank">
           positronX.io
        </a>
        <ul class="nav navbar-nav flex-row float-right">
          <li class="nav-item">
            <router-link class="nav-link pr-3" to="/login">Sign in</router-link>
          </li>
          <li class="nav-item">
            <router-link class="btn btn-outline-primary" to="/">Sign up</router-link>
          </li>
        </ul>
      </div>
    </nav>

    <!-- Main -->
    <div class="App">
      <div class="vertical-center">
        <div class="inner-block">
          <router-view />
        </div>
      </div>
    </div>
  </div>
</template>

Summary

We just created beautiful UI screes for Vue authentication process, we explored how to create some beautiful Login, Signup and Forgot password templates with Bootstrap 4.

You can download the full code from this GitHub repository.

WangEditor添加首行缩进功能

官方也不增加这个,但对于经常写文章的用户来说,首行缩进是很实用的功能,改变一下样式就可以了text-indent: 2em;

修改一下初始化wangEditor的地方,注册一个菜单

//初始化编辑器
var E = window.wangEditor;
// 菜单 key ,各个菜单不能重复
const menuKey = ‘MyTextIndentMenuKey’

// 注册菜单
E.registerMenu(menuKey, MyTextIndentMenu)

var editor = new E(‘#’+id);

 

新增一个MyTextIndentMenu.js,代码如下:

 

const E = window.wangEditor
const { $, BtnMenu , DropListMenu, PanelMenu, DropList, Panel, Tooltip } = E

const lengthRegex = /^(\d+)(\w+)$/
const percentRegex = /^(\d+)%$/
const reg = /^(SECTION|P|H[0-9]*)$/

// 第一,菜单 class ,Button 菜单继承 BtnMenu class https://www.wangeditor.com/doc/
class MyTextIndentMenu extends BtnMenu{
constructor(editor) {
// data-title属性表示当鼠标悬停在该按钮上时提示该按钮的功能简述text-indent: 2em;
const $elem = E.$(
`<div class=”w-e-menu w-e-icon-indent-increase” data-title=”首行缩进”>
</div>`
)
super($elem, editor)
}
// 菜单点击事件
clickHandler() {
// 做任何你想做的事情text-indent: 2em;
// 可参考【常用 API】文档,来操作编辑器
this.command();
}
// 菜单是否被激活(如果不需要,这个函数可以空着)
// 1. 激活是什么?光标放在一段加粗、下划线的文本时,菜单栏里的 B 和 U 被激活,如下图
// 2. 什么时候执行这个函数?每次编辑器区域的选区变化(如鼠标操作、键盘操作等),都会触发各个菜单的 tryChangeActive 函数,重新计算菜单的激活状态
tryChangeActive() {
// 激活菜单
// 1. 菜单 DOM 节点会增加一个 .w-e-active 的 css class
// 2. this.this.isActive === true
const editor = this.editor
const $selectionElem = editor.selection.getSelectionStartElem()
const $selectionStartElem = $($selectionElem).getNodeTop(editor)

if ($selectionStartElem.length <= 0) return

if ($selectionStartElem.elems[0].style[‘textIndent’] !== ”) {
this.active()
} else {
this.unActive()
}
}
/**
* 执行命令
* @param value value
*/
command() {
const editor = this.editor
const $selectionElem = editor.selection.getSelectionContainerElem()

// 判断 当前选区为 textElem 时
if ($selectionElem && editor.$textElem.equal($selectionElem)) {
// 当 当前选区 等于 textElem 时
// 代表 当前选区 可能是一个选择了一个完整的段落或者多个段落
const $elems = editor.selection.getSelectionRangeTopNodes()
if ($elems.length > 0) {
$elems.forEach((item) => {
this.operateElement($(item), editor)
})
}
} else {
// 当 当前选区 不等于 textElem 时
// 代表 当前选区要么是一个段落,要么是段落中的一部分
if ($selectionElem && $selectionElem.length > 0) {
$selectionElem.forEach((item) => {
this.operateElement($(item), editor)
})
}
}

// 恢复选区
editor.selection.restoreSelection()
this.tryChangeActive()
}

operateElement($node, editor) {
const $elem = $node.getNodeTop(editor)
let type = ‘increase’;
if($node.elems[0].style[‘textIndent’] !== ”)
type = ‘decrease’;

if (reg.test($elem.getNodeName())) {
if (type === ‘increase’) this.increaseIndentStyle($elem, this.parseIndentation(editor))
else if (type === ‘decrease’) this.decreaseIndentStyle($elem, this.parseIndentation(editor))
}
}
increaseIndentStyle($node, options) {
const $elem = $node.elems[0]
if ($elem.style[‘textIndent’] === ”) {
$node.css(‘text-indent’, options.value + options.unit)
} else {
const oldPL = $elem.style[‘textIndent’]
const oldVal = oldPL.slice(0, oldPL.length – options.unit.length)
const newVal = Number(oldVal) + options.value
$node.css(‘text-indent’, `${newVal}${options.unit}`)
}
}
decreaseIndentStyle($node, options) {
const $elem = $node.elems[0]
if ($elem.style[‘textIndent’] !== ”) {
const oldPL = $elem.style[‘textIndent’]
const oldVal = oldPL.slice(0, oldPL.length – options.unit.length)
const newVal = Number(oldVal) – options.value
if (newVal > 0) {
$node.css(‘text-indent’, `${newVal}${options.unit}`)
} else {
$node.css(‘text-indent’, ”)
}
}
}

parseIndentation(editor) {
const { indentation } = editor.config

if (typeof indentation === ‘string’) {
if (lengthRegex.test(indentation)) {
const [value, unit] = indentation.trim().match(lengthRegex).slice(1, 3)
return {
value: Number(value),
unit,
}
} else if (percentRegex.test(indentation)) {
return {
value: Number(indentation.trim().match(percentRegex)?indentation.trim().match(percentRegex):[1]),
unit: ‘%’,
}
}
} else if (indentation.value !== void 0 && indentation.unit) {
return indentation
}

return {
value: 2,
unit: ’em’,
}
}

}

希望是最淺顯易懂的 RxJS 教學

前言

關注 RxJS 已經好一段時間了,最早知道這個東西是因為 redux-observable,是一個 redux 的 middleware,Netflix 利用它來解決複雜的非同步相關問題,那時候我連redux-saga都還沒搞懂,沒想到就又有新的東西出來了。

半年前花了一些時間,找了很多網路上的資料,試圖想要搞懂這整個東西。可是對我來說,很多教學的步調都太快了,不然就是講得太仔細,反而讓初學者無所適從。

這次有機會在公司的新專案裡面嘗試導入redux-observable,身為提倡要導入的人,勢必要對這東西有一定的瞭解。秉持著這個想法,上週認真花了點時間再次把相關資源都研究了一下,漸漸整理出一套「我覺得應該可以把 RxJS 講得更好懂」的方法,在這邊跟大家分享一下。

在開始之前,要先大力稱讚去年 iT 邦幫忙鐵人賽的 Web 組冠軍:30 天精通 RxJS,這系列文章寫得很完整,感受得出來作者下了很多功夫在這上面。看完這篇之後如果對更多應用有興趣的,可以去把這系列的文章讀完。

好,那就讓我們開始吧!

請你先忘掉 RxJS

沒錯,你沒看錯。

要學會 RxJS 的第一件事情就是:忘記它。

忘記有這個東西,完全忘記,先讓我講幾個其他東西,等我們需要講到 RxJS 的時候我會再提醒你的。

在我們談到主角之前,先來做一些有趣的事情吧!

程式基礎能力測試

先讓我們做一個簡單的練習題暖身,題目是這樣的:

有一個陣列,裡面有三種類型的資料:數字、a~z組成的字串、數字組成的字串,請你把每個數字以及數字組成的字串乘以二之後加總
範例輸入:[1, 5, 9, 3, ‘hi’, ‘tb’, 456, ’11’, ‘yoyoyo’]

你看完之後應該會說:「這有什麼難的?」,並且在一分鐘以內就寫出下面的程式碼:

const source = [1, 5, 9, 3, 'hi', 'tb', 456, '11', 'yoyoyo'];
let total = 0;

for (let i = 0; i < source.length; i++) {
  let num = parseInt(source[i], 10);
  if (!isNaN(num)) {
    total += num * 2;
  }
}

相信大家一定都是很直覺的就寫出上面的程式碼,但如果你是個 functional programming 的愛好者,你可能會改用另外一種思路來解決問題:

const source = [1, 5, 9, 3, 'hi', 'tb', 456, '11', 'yoyoyo'];

let total = source
  .map(x => parseInt(x, 10))
  .filter(x => !isNaN(x))
  .map(x => x * 2)
  .reduce((total, value) => total + value )

一開始的例子叫做Imperative(命令式),用陣列搭配一堆函式的例子叫做Declarative(聲明式)。如果你去查了一下定義,應該會看到這兩個的解釋:

Imperative 是命令機器去做事情(how),這樣不管你想要的是什麼(what),都會按照你的命令實現;Declarative 是告訴機器你想要的是什麼(what),讓機器想出如何去做(how)

好,你有看懂上面這些在說什麼嗎?

我是沒有啦。

所以讓我們再看一個例子,其實 Declarative 你已經常常在用了,只是你不知道而已,那就是 SQL:

SELECT * from dogs INNER JOIN owners WHERE dogs.owner_id = owners.id

這句話就是:我要所有狗的資料加上主人的資料。

我只有說「我要」而已,那要怎麼拿到這些資料?我不知道,我也不用知道,都讓 SQL 底層決定怎麼去操作就好。

如果我要自己做出這些資料,在 JavaScript 裡面我必須這樣寫(程式碼取自声明式编程和命令式编程的比较):

//dogs = [{name: 'Fido', owner_id: 1}, {...}, ... ]
//owners = [{id: 1, name: 'Bob'}, {...}, ...]

var dogsWithOwners = []
var dog, owner

for(var di=0; di < dogs.length; di++) {
  dog = dogs[di]
  for(var oi=0; oi < owners.length; oi++) {
    owner = owners[oi]
    if (owner && dog.owner_id == owner.id) {
      dogsWithOwners.push({
        dog: dog,
        owner: owner
      })
    }
  }
}

應該可以大致體驗出兩者的差別吧?後者你必須自己一步步去決定該怎麼做,而前者只是僅僅跟你說:「我想要怎樣的資料」而已。

接著我們再把目光放回到把數字乘以二相加的那個練習。對我來說,最大的不同點是後面那個用陣列搭配函式的例子,他的核心概念是:

把原始資料經過一連串的轉換,變成你想要的資訊

這點超級重要,因為在一開始的例子中,我們是自己一步步去 parse,去檢查去相加,得出數字的總和。而後面的那個例子,他是把原始的資料(陣列),經過一系列的轉換(map, filter, reduce),最後變成了我們想要的答案。

畫成圖的話,應該會長這樣(請原諒我偷懶把乘二的部分拿掉了,但意思不影響):

把原始資料經過一連串的轉換,最後變成你想要的答案,這點就是後者最大的不同。只要你有了這個基礎知識之後,再來看 RxJS 就不會覺得太奇怪了。

Reactive Programming

談到 RxJS 的時候,都會談到 Reactive 這個詞,那什麼是 Reactive 呢?可以從英文上的字義來看,這個單字的意思是:「反應、反應性的」,意思就是你要對一些事情做出反應。

所以 Reactive 其實就是在講說:「某些事情發生時,我能夠做出反應」。

讓我們來舉一個大家非常熟知的例子:

window.addEventListener('click', function(){
  console.log('click!');
})

我們加了一個 event listener 在 window 上面,所以我們可以監聽到這個事件,每當使用者點擊的時候就列印出 log。換句話說,這樣就是:「當 window 被點擊時,我可以做出反應」。

正式進入 RxJS

如果你去看 ReactiveX 的網頁,你會發現他有明確的定義 ReactiveX:

ReactiveX is a combination of the best ideas from
the Observer pattern, the Iterator pattern, and functional programming

第一個 Observer pattern 就像是 event listener 那樣,在某些事情發生時,我們可以對其作出反應;第二個 Iterator pattern 我們跳過不講,我認為暫時不影響理解;第三個就像是一開始的例子,我們可以把一個陣列經過多次轉換,轉換成我們想要的資料。

在 Reactive Programming 裡面,最重要的兩個東西叫做 Observable 跟 Observer,其實一開始讓我最困惑的點是因為我英文不好,不知道這兩個到底誰是觀察的誰是被觀察的。

先把它們翻成中文,Observable 就是「可被觀察的」,Observer 就是所謂的「觀察者」。

這是什麼意思呢?就如同上面的例子一樣,當(可被觀察的東西)有事情發生,(Observer,觀察者)就可以做出反應。

直接舉一個例子你就知道了:

Rx.Observable.fromEvent(window, 'click')
  .subscribe(e => {
    console.log('click~');
  })

上面這段程式碼跟我幫 window 加上 event listener 在做的事情完全一樣,只是這邊我們使用了 RxJS 提供的方法叫做fromEvent,來把一個 event 轉成 Observable(可被觀察的),並且在最後加上 subscribe。

這樣寫就代表說我訂閱了這個 Observable,只要有任何事情發生,就會執行我傳進去的 function。

所以到底什麼是 Observable?

Observable 就是一個可被觀察的對象,這個對象可以是任何東西(例如說上述例子就是 window 的 click 事件),當有新資料的時候(例如說新的點擊事件),你就可以接收到這個新資料的資訊並且做出反應。

比起 Observable 這個冷冰冰的說法,我更喜歡的一個說法是 stream,資料流。其實每一個 Observable 就是一個資料流,但什麼是資料流?你就想像成是會一直增加元素的陣列就好了,有新的事件發生就 push 進去。如果你喜歡更專業一點的說法,可以叫它:「時間序列上的一連串資料事件」(取自 Reactive Programming 簡介與教學(以 RxJS 為例)

或是我再舉一個例子,stream 的另外一個解釋就是所謂的「串流影片」,意思就是隨著你不斷播放,就會不斷下載新的片段進來。此時你腦中應該要有個畫面,就是像水流那樣,不斷有新的東西流進來,這個東西就叫做 stream。


(圖片取自 giphy

我理解資料流了,然後呢?

上面有說過,我們可以把任何一個東西轉成 Observable,讓它變成資料流,可是這不就跟 addEventListener 一樣嗎?有什麼特別的?

有,還真的比較特別。

希望你沒有忘記我們剛開始做的那個小練習,就是把一個陣列透過一系列轉換,變成我們要的資料的那個練習。我剛剛有說,你可以把 Observable 想成是「會一直增加元素的陣列」,這代表什麼呢?

代表我們也可以把 Observable 做一系列的轉換!我們也可以用那些用在陣列上的 function!

Rx.Observable.fromEvent(window, 'click')
  .map(e => e.target)
  .subscribe(value => {
    console.log('click: ', value)
  })

我們把 click 事件經過 map 轉換為點擊到的 element,所以當我們最後在 subscribe 的時候,收到的 value 就會是我們點擊的東西。

接著來看一個稍微進階一點的例子:

Rx.Observable.fromEvent(window, 'click')
  .map(e => 1)
  .scan((total, now) => total + now)
  .subscribe(value => {
    document.querySelector('#counter').innerText = value;
  })

首先我們先把每一個 click 事件都透過map轉換成 1(或者你也可以寫成.mapTo(1)),所以每按一次就送出一個數字 1。scan的話其實就是我們一開始對陣列用的reduce,你可以想成是換個名字而已。透過scan加總以後傳給 subscriber,顯示在頁面上面。

就這樣簡單幾行,就完成了一個計算點擊次數的 counter。

可以用一個簡單的 gif 圖來表示上面的範例:

可是 Observable 不只這樣而已,接下來我們要進入到它最厲害的地方了。

威力無窮的組合技

如果把兩個陣列合併,會變成什麼?例如說[1, 2, 3][4, 5, 6]

這要看你指的「合併」是什麼,如果是指串接,那就是[1, 2, 3, 4, 5, 6],如果是指相加,那就是[5, 7, 9]

那如果把兩個 Observable 合併會變成什麼?

Observable 跟陣列的差別就在於多了一個維度:時間。

Observable 是「時間序列上的一連串資料事件」,就像我前面講的一樣,可以看成是一個一直會有新資料進來的陣列。

我們先來看看一張很棒的圖,很清楚地解釋了兩個 Observable 合併會變成什麼:


(取自:http://rxmarbles.com/#merge)

上面是一個 Observable,每一個圓點代表一個資料,下面也是一樣,把這兩個合併之後就變成最下面那一條,看圖解應該還滿好懂的,就像是把兩個時間軸合併一樣。

讓我們來看一個可以展現合併強大之處的範例,我們有 +1 跟 -1 兩個按鈕以及文字顯示現在的數字是多少:

該怎麼達成這個功能呢?基本的想法就是我們先把每個 +1 的 click 事件都通過mapTo變成數字 1,取叫 Observable_plus1 好了。再做出一個 Observable_minus1 是把每個 -1 的 click 事件都通過mapTo變成數字 -1。

把這兩個 Observable 合併之後,再利用剛剛提到的scan加總,就是目前應該要顯示的數字了!

Rx.Observable.fromEvent(document.querySelector('input[name=plus]'), 'click')
  .mapTo(1)
  .merge(
    Rx.Observable.fromEvent(document.querySelector('input[name=minus]'), 'click')
      .mapTo(-1)
  )
  .scan((total, now) => total + now)
  .subscribe(value => {
    document.querySelector('#counter').innerText = value;
  })

如果你還是不懂的話,可以參考下面的精美範例,示範這兩個 Observable 是怎麼合在一起的(O代表點擊事件,+1-1則是mapTo之後的結果):

讓我們來比較一下如果不用 Observable 的話,程式碼會長怎樣:

var total = 0;
document.querySelector('input[name=plus]').addEventListener('click', () => {
  total++;
  document.querySelector('#counter').innerText = total;
})

document.querySelector('input[name=minus]').addEventListener('click', () => {
  total--;
  document.querySelector('#counter').innerText = total;
})

有沒有發覺兩者真的差別很大?就如同我之前所說的,是兩種完全不同的思考模式,所以 Reactive Programming 困難的地方不是在於理解,也不是在於語法(這兩者相信你目前都有些概念了),而是在於換一種全新的思考模式。

以上面的寫法來說,就是告訴電腦:「按下加的時候就把一個變數 +1,然後更改文字;按下減的時候就 -1 並且也更改文字」,就可以達成計數器的功能。

以 Reactive 的寫法,就是把按下加當成一個資料流,把按下減也當成一個資料流,再透過各種 function 把這兩個流轉換並且合併起來,讓最後的那個流就是我們想要的結果(計數器)。

你現在應該能體會到我一開始說的了:「把原始資料經過一連串的轉換,最後變成你想要的答案」,這點就是 Reactive Programming 最大的特色。

組合技中的組合技

我們來看一個更複雜一點的範例,是在 canvas 上面實現非常簡單的繪圖功能,就是滑鼠按下去之後可以畫畫,放開來就停止。

要實現這個功能很間單,canvas 提供lineTo(x, y)這個方法,只要在滑鼠移動時不斷呼叫這個方法,就可以不斷畫出圖形來。但有一點要注意的是當你在按下滑鼠時,應該先呼叫moveTo(x, y)把繪圖的點移到指定位置,為什麼呢?

假設我們第一次畫圖是在左上角,第二次按下滑鼠的位置是在右下角,如果沒有先用moveTo移動而是直接用lineTo的話,就會多一條線從左上角延伸到右下角。moveTolineTo的差別就是前者只是移動,後者會跟上次的點連接在一起畫成一條線。

var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
ctx.beginPath(); // 開始畫畫

function draw(e){
  ctx.lineTo(e.clientX,e.clientY); // 移到滑鼠在的位置
  ctx.stroke(); // 畫畫
}

// 按下去滑鼠才開始偵測 mousemove 事件
canvas.addEventListener('mousedown', function(e){
  ctx.moveTo(e.clientX, e.clientY); // 每次按下的時候必須要先把繪圖的點移到那邊,否則會受上次畫的位置影響
  canvas.addEventListener('mousemove', draw);
})

// 放開滑鼠就停止偵測 
canvas.addEventListener('mouseup', function(e){
  canvas.removeEventListener('mousemove', draw);
})

那如果在 RxJS 裡面,該怎麼實作這個功能呢?

首先憑直覺,應該就是先加上mousedown的事件對吧!至少有個開頭。

Rx.Observable.fromEvent(canvas, 'mousedown')
  .subscribe(e => {
    console.log('mousedown');
  })

可是滑鼠按下去之後應該要變成什麼?這個時候應該要開始監聽mousemove對吧,所以我們這樣寫,用mapTo把每一個mousedown的事件都轉換成mousemove的 Observable:

Rx.Observable.fromEvent(canvas, 'mousedown')
  .mapTo(
    Rx.Observable.fromEvent(canvas, 'mousemove')
  )
  .subscribe(value => {
    console.log('value: ', value);
  })

接著你看一下 console,你會發現每當我點擊的時候,console 就會印出FromEventObservable {_isScalar: false, sourceObj: canvas#canvas, eventName: "mousemove", selector: undefined, options: undefined}

仔細想一下你會發現也滿合理的,因為我用mapTo把每一個滑鼠按下去的事件轉成一個 mousemove 的 Observable,所以用 subscribe 訂閱之後拿到的東西就會是這個 Observable。如果畫成圖,大概長得像這樣:

好了,那怎麼辦呢?我想要的其實不是 Observable 本身,而是屬於這個 Observable 裡面的那些東西啊!現在這個情形就是 Observable 裡面又有 Observable,有兩層,可是我想要讓它變成一層就好,該怎麼辦呢?

在此提供一個讓 Observable 變簡單的訣竅:

只要有問題,先想想 Array 就對了!

我前面有提過,可以把 Observable 看成是加上時間維度的進階版陣列,因此只要是陣列有的方法,Observable 通常也都會有。

舉例來說,一個陣列可能長這樣:[1, [2, 2.5], 3, [4, 5]]一共有兩層,第二層也是一個陣列。

如果想讓它變一層的話怎麼辦呢?壓平!

有用過 lodash 或是其他類似的 library 的話,你應該有聽過_.flatten這個方法,可以把這種陣列壓平,變成:[1, 2, 2.5, 3, 4, 5]

用 flat 這個關鍵字去搜尋 Rx 文件的話,你會找到一個方法叫做 FlatMap,簡單來說就是先map之後再自動幫你壓平。

所以,我們可以把程式碼改成這樣:

Rx.Observable.fromEvent(canvas, 'mousedown')
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove'))            
  .subscribe(e => {
    console.log(e);
  })

當你點擊之後,會發現隨著滑鼠移動,console 會印出一大堆 log,就代表我們成功了。

畫成示意圖的話會變成這樣(為了方便說明,我把flatMap在圖片上變成mapflatten兩個步驟):

接下來呢?接下來我們要讓它可以在滑鼠鬆開的時候停止,該怎麼做呢?RxJS 有一個方法叫做takeUntil,意思就是拿到…發生為止,傳進去的參數必須是一個 Observable。

舉例來說,如果寫.takeUntil(window, 'click'),就表示如果任何window的點擊事件發生,這個 Observable 就會立刻終止,不會再送出任何資料。

應用在繪畫的例子上,我們只要把takeUntil後面傳的參數換成滑鼠鬆開就好!順便把subscribe跟畫畫的 function 也一起完成吧!

Rx.Observable.fromEvent(canvas, 'mousedown')
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove'))
  .takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup'))         
  .subscribe(e => {
    draw(e);
  })

改完之後馬上來實驗一下!滑鼠按下去之後順利開始畫圖,鬆開以後畫圖停止,完美!

咦,可是怎麼按下第二次就沒反應了?我們做出了一個「只能夠成功畫一次圖」的 Observable。

為什麼呢?我們可以先來看一下takeUntil的示意圖(取自:http://rxmarbles.com/#takeUntil)

以我們的情形來說,就是只要mouseup事件發生,「整個 Observable」就會停止,所以只有第一次能夠畫圖成功。但我們想要的其實不是這樣,我們想要的是只有mousemove停止而已,而不是整個都停止。

所以,我們應該把takeUntil放在mousemove的後面,也就是:

Rx.Observable.fromEvent(canvas, 'mousedown')
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove')
      .takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup'))  
  )
  .subscribe(e => {
    draw(e);
  })

這樣子裡面的那個mousemove的 Observable 就會在滑鼠鬆開時停止發送事件,而我們最外層的這個 Observable 監聽的是滑鼠按下,會一直監聽下去。

到這邊其實就差不多了,但還有一個小 bug 要修,就是我們沒有在mousedown的時候利用moveTo移動,造成我們一開始說的那個會把上次畫的跟這次畫的連在一起的問題。

那怎麼辦呢?我已經把mousedown事件轉成其他資料流了,我要怎麼在mousedown的時候做事?

有一個方法叫做do,就是為了這種情形而設立的,使用時機是:「你想做一點事,卻又不想影響資料流」,有點像是能夠針對不同階段 subscribe 的感覺,mousedown的時候 subscribe 一次,最後要畫圖的時候又 subscribe 一次。

Rx.Observable.fromEvent(canvas, 'mousedown')
  .do(e => {
    ctx.moveTo(e.clientX, e.clientY)
  })
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove')
      .takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup'))  
  )
  .subscribe(e => {
    draw(e);
  })

到這邊,我們就順利完成了畫圖的功能。

如果你想試試看你有沒有搞懂,可以實作看看拖拉移動物體的功能,原理跟這個很類似,都是偵測滑鼠的事件並且做出反應。

喝口水休息一下,下半場要開始了

上半場的目標在於讓你理解什麼是 Rx,並且掌握幾個基本概念:

  1. 一個資料流可以經過一系列轉換,變成另一個資料流
  2. 這些轉換基本上都跟陣列有的差不多,像是mapfilterflatten等等
  3. 你可以合併多個 Observable,也可以把二維的 Observable 壓平

下半場專注的點則是在於實戰應用,並且圍繞著 RxJS 最適合的場景之一:API。

前面我們有提到說可以把 DOM 物件的 event 變成資料流,但除了這個以外,Promise 其實也可以變成資料流。概念其實也很簡單啦,就是 Promise 被 resovle 的時候就發送一個資料,被 reject 的時候就終止。

讓我們來看一個簡單的小範例,每按一次按鈕就會發送一個 request

function sendRequest () {
  return fetch('https://jsonplaceholder.typicode.com/posts/1').then(res => res.json())
}

Rx.Observable.fromEvent(document.querySelector('input[name=send]'), 'click')
  .flatMap(e => Rx.Observable.fromPromise(sendRequest()))
  .subscribe(value => {
    console.log(value)
  })

這邊用flatMap的原因跟剛才的畫圖範例一樣,我們要在按下按鈕時,把原本的資料流轉換成新的資料流,如果只用map的話,會變成一個二維的 Observable,所以必須要用flatten把它壓平。

你可以試試看把flatMap改成map,你最後 subscribe 得到的值就會是一堆 Observable 而不是你想要的資料。

知道怎麼用 Rx 來處理 API 之後,就可以來做一個經典範例了:AutoComplete。

我在做這個範例的時候有極大部分參考30 天精通 RxJS(19): 實務範例 – 簡易 Auto Complete 實作Reactive Programming 簡介與教學(以 RxJS 為例)以及构建流式应用—RxJS详解,再次感謝這三篇文章。

為了要讓大家能夠體會 Reactive Programming 跟一般的有什麼不一樣,我們先用老方法做出這個 Auto Complete 的功能吧!

先來寫一下最底層的兩個函式,負責抓資料的以及 render 建議清單的,我們使用維基百科的 API 來當作範例:

function searchWikipedia (term) {
    return $.ajax({
        url: 'http://en.wikipedia.org/w/api.php',
        dataType: 'jsonp',
        data: {
            action: 'opensearch',
            format: 'json',
            search: term
        }
    }).promise();
}

function renderList (list) {
  $('.auto-complete__list').empty();
  $('.auto-complete__list').append(list.map(item => '<li>' + item + '</li>'))
}

這邊要注意的一個點是維基百科回傳的資料會是一個陣列,格式如下:

[你輸入的關鍵字, 關鍵字清單, 每個關鍵字的介紹, 每個關鍵字的連結]

// 範例:
[
  "dd",
  ["Dd", "DDR3 SDRAM", "DD tank"],
  ["", "Double data rate type three SDRAM (DDR3 SDRAM)", "DD or Duplex Drive tanks"],
  [https://en.wikipedia.org/wiki/Dd", "https://en.wikipedia.org/wiki/DDR3_SDRAM", "...略"]
]

在我們的簡單示範中,只需要取 index 為 1 的那個關鍵字清單就好了。而renderList這個 function 則是傳進一個陣列,就會把陣列內容轉成li顯示出來。

有了這兩個最基礎的 function 之後,就可以很輕易地完成 Auto Complete 的功能:

document.querySelector('.auto-complete input').addEventListener('input', (e) => {
  searchWikipedia(e.target.value).then((data) => {
    renderList(data[1])
  })
})

程式碼應該很好懂,就是每次按下輸入東西的時候去 call api,把回傳的資料餵給renderList去渲染。

最基本的功能完成了,我們要來做一點優化,因為這樣子的實作其實是有一些問題的。

第一個問題,現在只要每打一個字就會送出一個 request,可是這樣做其實有點浪費,因為使用者可能快速的輸入了:java想要找相關的資料,他根本不在乎jjajav這三個 request。

要怎麼做呢?我們就改寫成如果 250ms 裡面沒有再輸入新的東西才發送 request 就好,就可以避免這種多餘的浪費。

這種技巧稱作debounce,實作上也很簡單,就是利用setTimeoutclearTimeout

var timer = null;
document.querySelector('.auto-complete input').addEventListener('input', (e) => {
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(() => {
    searchWikipedia(e.target.value).then((data) => {
      renderList(data[1])
    })
  }, 250)
})

在 input 事件被觸發之後,我們不直接做事情,而是設置了一個 250ms 過後會觸發的 timer,如果 250ms 內 input 再次被觸發的話,我們就把上次的 timer 清掉,再重新設置一個。

如此一來,就可以保證使用者如果在短時間內不斷輸入文字的話,不會送出相對應的 request,而是會等到最後一個字打完之後的 250 ms 才發出 request。

解決了第一個問題之後,還有一個潛在的問題需要解決。

假設我現在輸入a,接著刪除然後再輸入b,所以第一個 request 會是a的結果,第二個 request 會是b的結果。我們假設 server 出了一點問題,所以第二個的 response 反而比第一個還先到達(可能b的搜尋結果有 cache 但是a沒有),這時候就會先顯示b的內容,等到第一個 response 回來時,再顯示a的內容。

可是這樣 UI 就有問題了,我明明輸入的是b,怎麼 auto complete 的推薦關鍵字是a開頭?

所以我們必須要做個檢查,檢查返回的資料跟我現在輸入的資料是不是一致,如果一致的話才 render:

var timer = null;
document.querySelector('.auto-complete input').addEventListener('input', (e) => {
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(() => {
    searchWikipedia(e.target.value).then((data) => {
      if (data[0] === document.querySelector('.auto-complete input').value) {
        renderList(data[1])
      }
    })
  }, 250)
})

到這裡應該就差不多了,該有的功能都有了。

接著,讓我們來挑戰用 RxJS 實作吧!

首先,先從簡單版的開始做,就是不包含 debounce 跟上面 API 順序問題的實作,監聽 input 事件轉換成 request,然後用flatMap壓平,其實就跟上面的流程差不多:

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .map(e => e.target.value)
  .flatMap(value => {
    return Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

這邊用了兩個map,一個是把e轉成e.target.value,一個是把傳回來的結果轉成res[1],因為我們只需要關鍵字列表,其他的東西其實都不用。

那要如何實作debounce的功能呢?

RxJS 已經幫你實作好了,所以你只要加上.debounceTime(250)就好了,就是這麼簡單。

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .debounceTime(250)
  .map(e => e.target.value)
  .flatMap(value => {
    return Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

還有最後一個問題要解決,那就是剛才提到的 request 的順序問題。

Observable 有一個不同的解法,我來解釋給大家聽聽。

其實除了flatMap以外,還有另外一種方式叫做switchMap,他們的差別在於要怎麼把 Observable 給壓平。前者我們之前介紹過了,就是會把每一個二維的 Observable 都壓平,並且「每一個都執行」。

switchMap的差別在於,他永遠只會處理最後一個 Observable。拿我們的例子來說,假設第一個 request 還沒回來的時候,第二個 request 就發出去了,那我們的 Observable 就只會處理第二個 request,而不管第一個。

第一個還是會發送,還是會接收到資料,只是接收到資料以後不會再把這個資料 emit 到 Observable 上面,意思就是根本沒人理這個資料了。

可以看一下簡陋的圖解,flatMap每一個 promise resolve 之後的資料都會被發送到我們的 Observable 上面:

switchMap只會處理最後一個:

所以我們只要把flatMap改成switchMap,就可以永遠只關注最後一個發送的 request,不用去管 request 傳回來的順序,因為前面的 request 都跟這個 Observable 無關了。

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .debounceTime(250)
  .map(e => e.target.value)
  .switchMap(value => {
    return Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

做到這邊,就跟剛剛實作的功能一模一樣了。

但其實還有地方可以改進,我們來做個小小的加強好了。現在的話當我輸入abc,會出現abc的相關關鍵字,接著我把abc全部刪掉,讓 input 變成空白,會發現 API 這時候回傳一個錯誤:The "search" parameter must be set.

因此,我們可以在 input 是空的時候,不發送 request,只回傳一個空陣列,而回傳空陣列這件事情可以用Rx.Observable.of([])來完成,這樣會創造一個會發送空陣列的 Observable:

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .debounceTime(250)
  .map(e => e.target.value)
  .switchMap(value => {
    return value.length < 1 ? Rx.Observable.of([]) : Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

還有一個點擊關鍵字清單之後把文字設定成關鍵字的功能,在這邊就不示範給大家看了,但其實就是再創造一個 Observable 去監聽點擊事件,點到的時候就設定文字並且把關鍵字清單給清掉。

我直接附上參考程式碼:

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete__list'), 'click')
  .filter(e => e.target.matches('li'))
  .map(e => e.target.innerHTML)
  .subscribe(value => {
    document.querySelector('.auto-complete input').value = value;
    renderList([])
  })

雖然我只介紹了最基本的操作,但 RxJS 的強大之處就在於除了這些,你甚至還有retry可以用,只要輕鬆加上這個,就能夠有自動重試的功能。

相關的應用場景還有很多,只要是跟 API 有關連的幾乎都可以用 RxJS 很優雅的解決。

React + Redux 的非同步解決方案:redux-observable

這是我們今天的最後一個主題了,也是我開場所提到的。

React + Redux 這一套非常常見的組合,一直都有一個問題存在,那就是沒有規範非同步行為(例如說 API)到底應該怎麼處理。而開源社群也有許多不同的解決方案,例如說 redux-thunk、redux-promise、redux-saga 等等。

我們前面講了這麼多東西,舉了這麼多範例,就是要證明給大家看 Reactive programming 很適合拿來解決複雜的非同步問題。因此,Netflix 就開源了這套redux-observable,用 RxJS 來處理非同步行為。

在瞭解 RxJS 之後,可以很輕鬆的理解redux-observable的原理。

在 redux 的應用裡面,所有的 action 都會通過 middleware,你可以在這邊對 action 做任何處理。或者我們也可以把 action 看做是一個 Observable,例如說:

// 範例而已
Rx.Observable.from(actionStreams)
  .subscribe(action => {
    console.log(action.type, action.payload)
  })

有了這個以後,我們就可以做一些很有趣的事情,例如說偵測到某個 action 的時候,我們就發送 request,並且把 response 放進另外一個 action 裡面送出去。

Rx.Observable.from(actionStreams)
  .filter(action => action.type === 'GET_USER_INFO')
  .switchMap(
    action => Rx.Observable.from(API.getUserInfo(action.payload.userId))
  )
  .subscribe(userInfo => {
    dispatch({
      type: 'SET_USER_INFO',
      payload: userInfo
    })
  })

上面就是一個簡單的例子,但其實redux-observable已經幫我們處理掉很多東西了,所以我們只要記得一個概念:

action in, action out

redux-observable 是一個 middleware,你可以在裡面加上很多epic,每一個epic就是一個 Observable,你可以監聽某一個指定的 action,做一些處理,再轉成另外一個 action。

直接看程式碼會比較好懂:

import Actions from './actions/user';
import ActionTypes from './actionTypes/user'

const getUserEpic = action$ =>
  action$.ofType(actionTypes.GET_USER)
    .switchMap(
      action => Rx.Observable.from(API.getUserInfo(action.payload.userId))
    ).map(userInfo => Actions.setUsers(userInfo))

大概就是像這樣,我們監聽一個 action type(GET_USER),一接收到的時候就發送 request,並且把結果轉為setUsers這個 action,這就是所謂的 action in, action out。

這樣的好處是什麼?好處是明確制定了一個規範,當你的 component 需要資料的時候,就送出一個 get 的 action,這個 action 經過 middleware 的時候會觸發 epic,epic 發 request 給 server 拿資料,轉成另外一個 set 的 action,經過 reducer 設定資料以後更新到 component 的 props。

可以看這張流程圖:

總之呢,epic就是一個 Observable,你只要確保你最後回傳的東西是一個 action 就好,那個 action 就會被送到 reducer 去。

礙於篇幅的關係,今天對於redux-observable只是概念性的帶過去而已,沒有時間好好示範,之後再來找個時間好好寫一下redux-observable的實戰應用。

結論

從一開始的陣列講到 Observable,講到畫圖的範例再講到經典的 Auto Complete,最後還講了redux-observable,這一路的過程中,希望大家有體會到 Observable 在處理非同步行為的強大之處以及簡潔。

這篇的目的是希望能讓大家理解 Observable 大概在做什麼,以及介紹一些簡單的應用場景,希望能提供一篇簡單易懂的中文入門文章,讓更多人能體會到 Observable 的威力。

喜歡這篇的話可以幫忙分享出去,發現哪邊有寫錯也歡迎留言指正,感謝。

參考資料:

30 天精通 RxJS (01):認識 RxJS
Reactive Programming 簡介與教學(以 RxJS 為例)
The introduction to Reactive Programming you’ve been missing
构建流式应用—RxJS详解
Epic Middleware in Redux
Combining multiple Http streams with RxJS Observables in Angular2

影片:
Netflix JavaScript Talks – RxJS + Redux + React = Amazing!
RxJS Quick Start with Practical Examples
RxJS Observables Crash Course
Netflix JavaScript Talks – RxJS Version 5
RxJS 5 Thinking Reactively | Ben Lesh

15 Interesting JavaScript and CSS Libraries for August 2017

https://tutorialzine.com//media/2017/08/interesting-libraries-august-2017.png

Our mission at Tutorialzine is to keep you up to date with the latest and coolest trends in web development. That’s why every month we release a handpicked collection of some of the best resources that we’ve stumbled upon and deemed worthy of your attention.


titanic.gif

Titanic

A set of beautiful SVG icons with very detailed on-hover animations. Unlike most other web icon fonts, this one is actually JavaScript-powered and requires the bodymovin library for exporting After Effects animations to SVG format.


rebass.jpg

Rebass

Rebass is a React UI kit for building responsive web apps. It is made up of over 60 styled-components which are customizable via styled-system-based properties. This keeps styles isolated and reduces the need to write custom CSS.


bootstrap-4.jpg

Bootstrap 4 (Beta)

Bootstrap 4 is now officially in Beta! The new version of the framework brings forth a lot of great changes, including a flexbox-based grid system, new and restyled components, faster ES6 JavaScript plugins, improved documentation, and much more.


hover-buttons.jpg

Hover Buttons

A cool set of HTML buttons with animated on-hover effects. The buttons come in all shapes and sizes and there are a lot of great animations to choose from. The library is made with SCSS so you can easily remove the buttons you don’t need or change the styles to your liking.


react-simple-maps.jpg

React Simple Maps

React components library for creating maps made out of SVG. There are components for adding all kinds of map details like text annotations, markers, and custom colors for each region. Since the maps are SVG based they can be zoomed in and out with great efficiency.


gpu.jpg

Gpu.js

Library for running browser JavaScript code in the GPU. It allows you to execute complex calculations much quicker by compiling specially written JS into shader language that can run on the GPU via WebGL. If WebGL isn’t available the functions fallback to regular JavaScript.


pell-.jpg

Pell

Pell is a super lightweight WYSIWYG text editor for the web. It weights only 1kB, has absolutely no dependencies, and is made up of less than 200 lines of ES6 code. It supports all the needed actions for formatting markdown text, including inserting images and links.


chromeless.png

Chromeless

Web automation framework based on the Headless Chrome platform. Its API and features are very similar to those of other popular tools like PhantomJS and NightmareJS, with the main difference that it runs all test in Chrome’s headless-mode. Work locally or on AWS Lambda.


fitty.png

Fitty

Fitty is a vanilla JavaScript library that changes the font size of text to make it fit into a fixed-width container. It works with all standard web fonts, scaling their size up or down so that they optimally take the available space without line breaks – perfect for titles and other headings.


notifme.jpg

Notif.me

A Node.js library for sending notifications. It works as an all-in-one solution for handling emails, SMS, and push notifications. Each service has multiple providers you can choose from (e.g. SMPT or Sendmail for email, Neximo or Twilio for SMS).


shoelace.png

Shoelace

Shoelace is a super lightweight CSS starter kit that aims to provide a tinier alternative to frameworks like Bootstrap. It doesn’t have too many styles and features, just a solid CSS reset with some helpful UI components. The library’s code is built with CSS variables, making it easy to customize without the need of a preprocessor.


tenserflow.jpg

TensorFire

Framework for running neural networks in the browser. TensorFire is GPU-accelerated via WebGL, which makes it possible to run bigger machine learning models without a problem. The project is still in its early stages but there are already some very promising demos made with it (Gesture Detection Rock Paper Scissors).


vali.jpg

Vali

Admin dashboard template built with Bootstrap, PugJS, Sass, and other modern technologies. Because the project is created with easy customization in mind, all the styles are organized into many independent SASS modules. The template offers many components and widgets, you can check them out in this demo.


botui.jpg

BotUI

A JavaScript framework for building conversational bot interfaces. It has a super simple API that lets you configure the flow of conversations by adding messages, questions, and even form inputs fields for the user to fill in.

If you want to learn more about interactive conversational UI, check out our article Developer’s Introduction To Chatbots.


nanoid.png

Nano ID

Tiny JavaScript library for generating unique IDs. It uses only URL-friendly symbols for the generated strings but there is an option to provide your own alphabet. On the project’s GitHub page you can find some interesting info about the way the library works and the algorithms it uses.

Node.js V0.12新特性之给子进程的同步API

尽管发明Node.js的初衷主要是为了编写Web服务器,但开发人员又发现了其他适用(和不适用!)Node的用途。令人觉得惊喜的是,这些用途中有一个是编写shell脚本。并且那确实有意义:Node的跨平台支持已经相当好了,既然前端和后端都用JavaScript写了,如果构建系统也用JavaScript写不是更好吗,对吧?

异步对shell脚本的坏处

在这一用途上值得称道的库是Grunt,它是构建在ShellJS之上的。然而ShellJS有一块硬骨头要啃:Node迫使它用异步I/O。尽管对于Web服务器来说异步I/O很棒,因为它必须随时做出响应,但对于需要逐步执行的shell脚本来说,异步I/O意义不大。

所以,ShellJS的作者们发现了一个“有趣的”解决办法,让它可以运行一个shell命令,然后等着命令完成。大致上是下面这样的代码:

var child_process = require(‘child_process’);
var fs = require(‘fs’);

function execSync(command) {
// 在子shell中运行命令
child_process.exec(command + ‘ 2>&1 1>output && echo done! > done’);

// 阻塞事件循环,知道命令执行完
while (!fs.existsSync(‘done’)) {
// 什么都不做
}

// 读取输出
var output = fs.readFileSync(‘output’);

// 删除临时文件。
fs.unlinkSync(‘output’);
fs.unlinkSync(‘done’);

return output;
}
换句话说,在shell执行你的命令时,ShellJS依然在运行,并持续不断地轮询着文件系统,检查是否能找到表明命令已经完成的那个文件。有点儿像驴子。

这种效率低下又丑陋不堪的解决办法让Node核心团队受刺激了,实现了一个真正的解决方案 – Node v0.12最终应该会支持同步运行子进程。实际上这个特性已经在路线图上放了很长时间了– 我记得是在2011年的JSConf.eu上(!) ,跟现在已经退休的Node维护者Felix Geisendoerfer坐在一起,勾勒出了一个实现execSync的办法。在过了两年多以后,这一特性现在终于出现在了master分支上。

恭喜,ShellJS的人们挑了一个很好的刺儿! 🙂

同步对shell脚本的好处

我们刚加上的API spawnSync跟它的异步小伙伴类似,它提供的底层API让你可以完全掌控子进程的设置。它还会返回所有我们能够收集的信息:退出码、终止信号、可能的启动错误,以及这个进程的全部输出。当然,在流中使用spawnSync没有任何意义-它是同步的,所以事件处理器不能在进程退出前运行-所以进程的所有输出会被缓冲到一个单例字符串或缓冲对象中。

并且就像众所周知的exec(运行shell命令)和execFile(用于运行一个可执行文件)方法一样,我们为常见的情况添加了execSync和execFileSync,它们比spawnSync更易用。如果你用了这些API,Node会假定你关心的只是进程写到stdout中的数据。如果进程或shell返回了非零的退出码,node会认为出现错误了,exec(Sync)会抛出。

比如获取项目git历史的代码就像下面这样简单:

var history = child_process.execSync(‘git log’, { encoding: ‘utf8’ });
process.stdout.write(history);
现在你可能在想“怎么要用这么长时间?”从表面上看,启动一个子进程并读取它的输出看起来简直是小菜一碟。也确实是这样-如果你只关心非常常见的情况。但是,我们不想做出来的解决方案只是一半。

当需要同时发送输入并读取一或多个输出流时,有两个选择:用线程-或者用事件循环。比如Python的实现,我们发现他们或者用事件循环(在Unix系的平台上)或者用线程(在Windows上)。并且它的实现可真不是一碟小菜。

2011年我们就意识到Node已经有一个非常棒的事件循环库了,即libuv。理论上已经具备了实现这一特性的所有条件。然而总是有或大或小的问题,让它并不能真正可靠地工作。

比如说,当子进程退出时,kernel会给node发送一个SIGCHLD信号通知它,但当有多个事件循环存在时,有很长一段时间libuv都不能正确处理信号。还有,删除事件循环并且不留下堆栈跟踪的能力也是最近才加上的。之前Node根本不管,它只是在某点退出,然后让OS打扫战场。如果我们需要一个临时的事件循环,并且在不需要它后仍然继续运行,这种策略就不太合适了。

慢慢的,随着时间的推移,所有这些问题都被解决了。所以如果你现在再设法看看过去那些缓冲区管理、参数解析、超时处理等诸如此类的东西,你会发现这个特性的核心只是一个事件循环,带子进程、计时器,还有一堆附着在它上面的管道。

如果你不关心它都是如何运作的,只需要看看文档,让node为控制子进程提供的丰富选项震你一下吧。现在谁愿意去把ShellJS修好?:)

作者简介

本文最初由Bert Belder发表在StrongLoop上。Bert Belder从2010年就开始做Node.js了,并且他还是libuv的主要编写者之一,Node.js就是在这个库上构建的。他除了是StrongLoop和Node核心的技术领导者,他正在做的特性还会让Node处于创新的最前沿,甚至是在1.0版出来之后。StrongLoop降低了在Node中开发APIs的难度,还添加了监测、集群化以及私有注册的支持等DevOps能力。

查看英文原文:What’s New in Node.js v0.12 – execSync: a Synchronous API for Child Processes 2014年3月12日

执行了javascript后Gif就不动了

这是一个困扰我几天的问题,就是我的网站上的gif图标突然不动了,今天终于知道问题所在,是javascript引起的,原因就是我用了href="javascript:void(0)"这种伪协议哈。 
将我原来的代码: 
Java代码  
<a href="javascript:void(0);" onClick="email_pic()">  

改成 
Java代码  
<a href="javascript:;" onClick="email_pic();return false;">  

就OK了

基于OO的可移动DIV窗口。

  项目需要一个可移动的DIV窗口,要求调用简单,可以重用。本人马上就考虑到使用OO了。代码如下:

 

 

<!DOCTYPE HTML PUBLIC “-//W3C//DTD HTML 4.0 Transitional//EN”>
<HTML>
 <HEAD>
  <TITLE> New Document </TITLE>
  <META NAME=”Generator” CONTENT=”EditPlus”>
  <META NAME=”Author” CONTENT=””>
  <META NAME=”Keywords” CONTENT=””>
  <META NAME=”Description” CONTENT=””>
 </HEAD>

 <BODY>
<SCRIPT LANGUAGE=”JavaScript”>
  <!–
var ie=document.all;
var nn6=document.getElementById&&!document.all;
var isdrag=false;
var y,x;
var oDragObj;
var qtD = document;
function $(id){
 return getObj(id);
}
function getObj(id){
 return qtD.getElementById(id);
}
function cleanDiv(obj){
(typeof(obj)==”string”?$(obj):obj).innerHTML=”;
}

function msgWindow(msgContext){

 this.width=400;
 this.height=300;
 this.title = ”;
 this.content = ”;
 this.context = (typeof(msgContext)==”string”?$(msgContext):msgContext);

 this.popup = function(){
  var left = (screen.width – this.width) / 2;
  var top = (screen.height – this.height) / 2 -20;
  this.context.innerHTML ='<div id=”msgwin” style=”position:absolute;left:’+left+’px;top:’+top+’px;width:’+this.width+’px; height:’+this.height+’px; border:#999999 1pt solid; background-color:#F7F7F7″>’+
      ‘<div id=”title_0″ style=”width:100%; height:30px; background-image:url(http://www.1363.cn/images/btn_close.gif” onclick=”javascript:cleanDiv(\’context\’)” style=”cursor:hand; margin-top:6px;”/></div></div>’+
      ‘<div id=”txt”  style=”width:95%; height:90%; text-align:left;overflow: hidden; padding-left:10px;line-height:24px; color:#333333; font-size:12px; “>’+this.content+'</div></div>’;
  document.onmousedown=initDrag;
  document.onmouseup=new Function(“isdrag=false”);
 }
}

function moveMouse(e) {
 if (isdrag) {
  oDragObj.style.top  =  (nn6 ? nTY + e.clientY – y : nTY + event.clientY – y)+”px”;
  oDragObj.style.left  =  (nn6 ? nTX + e.clientX – x : nTX + event.clientX – x)+”px”;
  return false;
 }
}

function initDrag(e) {
 var oDragHandle = nn6 ? e.target : event.srcElement;
 if(oDragHandle.tagName!=’DIV’ || oDragHandle.id==’txt’)return;
 var topElement = “HTML”;
 while (oDragHandle.tagName != topElement && oDragHandle.id != “msgwin”) {
  oDragHandle = nn6 ? oDragHandle.parentNode : oDragHandle.parentElement;
 }
 if (oDragHandle.id==”msgwin”) {
  isdrag = true;
  oDragObj = oDragHandle;
  nTY = parseInt(oDragObj.style.top+0);
  y = nn6 ? e.clientY : event.clientY;
  nTX = parseInt(oDragObj.style.left+0);
  x = nn6 ? e.clientX : event.clientX;
  document.onmousemove=moveMouse;
  return false;
 }
}


function testNotes(member_id,truename){
 msg = new msgWindow(‘context’);
 msg.title=”提示窗口”;
 msg.content =’  欢迎光临本站。’;
 msg.popup();
}
  //–>
  </SCRIPT> 
  <div id=’context’></div>

  <SCRIPT LANGUAGE=”JavaScript”>
  <!–
 
testNotes(1,’aa’);
  //–>
  </SCRIPT>
 </BODY>
</HTML>