作者:範懷宇(輕芒聯合創始人、CTO)
感謝掘金的邀請,能夠有機會在這裡跟大家分享一些輕芒做小程式的具體實踐,結合我們在做輕芒雜誌過程中的技術選擇,重點和大家聊一些輕芒雜誌互動實現的經驗。
開始之前,先自我介紹一下,我大概 2009 年開始入行,2011 年去了豌豆莢,2016 年底我和王俊煜、崔瑾一起出來做了輕芒。在過去十年的職業生涯中,我經歷過很多「端」,我們最早在 Windows 上提供豌豆莢的各種服務,然後還有塞班系統、Android、iOS 以及 Web,現在小程式也是非常重要的一個「端」。看整個客戶端的技術發展會發現,從「端」的技術來講是越來越薄的。原來做 Windows 開發,你可能面臨很多的光碟,非常厚的 MSDN 的文件,還有海量的 Windows32 API。而到小程式,已經越來越薄,需要了解的細節也更少。這是因為,平臺上的框架會變得越來越厚,我們開發者入手一個平臺會變得越來越簡單。今天「端」的發展趨勢是,使用者在哪裡,「端」就在哪裡,我們更多的是來適應「端」,而且無論在哪個「端」都需要提供最好的產品體驗。
輕芒在小程式釋出的第一天就上線了和小程式相關的產品,包括為使用者推薦高品質內容的輕芒雜誌,我們也把產品經驗開放給內容創作者使用,通過輕芒小程式+,他們無需程式碼就可以搭建自己的小程式。
回到今天的主題:互動。首先要搞清楚什麼是互動,在傳統客戶端的分層邏輯裡我們會常聽到 MVC,資料、控制、介面,這是一個可能用了二十幾年的分層模型。互動毫無疑問包含內容呈現,把我們需要給使用者的資料呈現出來。還有一部分很重要的,是你要和使用者互動,互動介面通常不是靜態頁面,使用者會在介面上產生一些行為,這些行為通過控制層會反饋到資料層,資料層進行一些變化和更迭,再通過控制層送回給介面層重新進行資料的渲染和呈現,這是整個 MVC 流轉的方式。除了內容的呈現和渲染,還有很重要的一點是要處理好所有和使用者的互動,這塊其實佔了前端很大的開發量,因此,會有的人可能前端開發無聊,有的人則會覺得非常有魅力。做互動其實沒有什麼銀彈。在軟體開發裡我們一直會需要一個「銀彈」,就是「當我知道那個祕籍之後,我就可以做得非常輕鬆,什麼事情都可以變得非常瀟灑」,其實並不存在。大量的實踐混雜在細節裡,包括我今天和大家分享的很多東西,聽上去沒有那麼神奇,甚至說有的一點都不工程,但這是真正的實踐。你真正要把一些東西做好,很多都是在細節裡面的。
首先,我先介紹一下小程式的平臺特點,因為所有的前端開發,都是在一個平臺上進行,你得對這個平臺非常熟悉,就像在一個舞臺上做演出,你需要知道舞臺在哪裡、光在哪裡、聲音在哪裡等等。你需要了解這個平臺的特性,才能在後面的實踐中更加自如。
小程式互動層的設計特點
跟我經歷過的其他平臺相比,首先我覺得小程式是個非常純粹的資料驅動的前端平臺。比如,現在去用 Web 或者 Android 開發,如果你需要在介面點一個按鈕來隱藏一個地方,或者點一個按鈕來展開某個地方,你會怎麼做?按剛剛 MVC 模型,你可能不會穿過 Controller 去到 Model,然後改了 Model 再回來,不會走這個路徑。你肯定會在前端用一些簡單的指令碼或技巧,讓這個東西隱藏起來、再展開,這樣不會影響核心資料模型,而且很便利。
但在整個小程式裡其實沒有這一塊東西,你不能操作任何 DOM 節點,這意味著不管你做什麼事情,包括改變介面的任何一點點狀態,都需要通過控制層傳到邏輯層,用 js 經過一些計算,再呼叫 setData 函式,觸發對 Data 變化的比對,重新對介面進行一次渲染,這是小程式整個的核心驅動模型。這個模型坦白講有很多部分非常重,會限制你做一些特別靈活的實現,但對我們開發者來講,只有理解這個模型,才會知道如果去做開發,哪些環節需要特別注意。
還有一個大家非常熟悉的點,小程式是傳統的 Web 元件混著一些原生元件,就是用本地語言,比如說 iOS 上的 Objective C 或者是 Android 上 Java 來實現的一個本地元件,這和 Web 上面實現的元件是不一樣的。小程式採取的技術路線,是所謂的分層渲染,底下是 Web 層,上面是原生層,當我滑動 Web 層時,原生層會接到一些事件,它會嘗試跟 Web 層聯動,但它們其實不是一起動的,中間有一個延遲,這個延遲會帶來很多麻煩事情,尤其是由於這個分層是原生層永遠活在 Web 層上面,這也會給互動設計、互動實踐帶來一些麻煩。
還有一個特色是單視窗,當然在移動時代其他平臺也如此,在一個互動頁面,我們只會跟一個視窗打交道。小程式特別的一點是它徹底取消了視窗這個概念,可能我們都感覺不到視窗的存在。如果大家做過 Android,就知道 Android 可以做懸浮窗,由於小程式平臺機制的限制,懸浮窗這種互動形態是無法實現的。
以上是我總結的小程式平臺互動設計的顯著特點,這也是我們後面圍繞小程式平臺做互動設計、互動實踐的一個很重要的起點。
輕芒的互動實踐案例一:列表的實現
下面跟大家分享一些輕芒的案例,首先講一個大家用得最多的:列表。列表為什麼重要?理清楚列表的邏輯對於整個產品後續的實現、對於工程師更好的去理解業務和設計,是非常重要的一個環節。在傳統的一般介面元件裡,列表是最複雜的一個模組,它會有很多資料的聯動,它的互動、資料傳遞、事件分發都會涉及到很多問題,這也是我們為什麼要妥善思考列表怎麼做。而且在很多場景裡,長列表的效能是很多效能瓶頸的來源,這也是需要額外注意的地方。
什麼叫列表?比如下圖左邊是非常顯然的列表,可以無限載入、不斷滾動的內容流,我們認為每一個卡片都是一個列表項。右圖看上去是一個內容詳情頁,在實踐中我們也把它做成了列表,我們會把它每個段落抽象出來,按照列表來渲染。
小程式裡面最大的特色是,它並沒有一個原生的列表元件。我們剛剛說 MVC,而 MVC 中最核心的是模型(Model)的設計,就是整個產品中的資料模型到底是什麼樣的,這是真正影響開發時間的。如果模型設計得好,就像種了一棵樹,樹上長著枝椏,如果你把所有的資料模型抽象的非常漂亮,那樹上的枝椏,也就是 View 和 Controller,實現起來會非常輕鬆。如果你的模型完全不做抽象,隨便收拾一下就開始做互動開發,那即便你在介面層選了很多漂亮的轉型、用很多 fancy 的框架,你也很難把程式碼複雜度降下來,整個產品的複用度也不會高,這也是我為什麼會說如果拿一個專案最好從列表入手,因為列表的資料模型通常是最複雜的,列表清楚了,整個產品也就清楚了。
在輕芒裡,比如剛剛那個瀑布流,我們會抽象成一個 event 物件,當然,我們不是在所有的小程式開發裡都這麼做,因為在其他小程式中業務不同,抽象模型也可能不一樣,我們配套的列表實現也會不一樣。在輕芒雜誌,因為有非常複雜的卡片型別或者不同型別的互動樣式,我們會把不同型別的資料統一抽象成一個 event 物件,每個 event 會有一個型別,然後會圍繞 event 來設計列表控制元件。具體的實現,我們用了微信的模板(Template)來做抽象和封裝,這是因為我們這個專案做太早了,我們做的時候沒有任何開源專案,只能夠全部自己封裝。
這裡面最重要的其實是對 event 物件的抽象,也就是產品中最核心的資料模型,如果這個搞清楚了,你會發現後面東西會變得很簡單。在這個程式碼中,每一個卡片的渲染,可能有兩個不同的實現方式,有一些卡片是用模板實現的,比如說 single-card-*,在實現中,我們會直接用 single-card 加上具體的 type 來實現列表項,比如說 single-card-article、single-card-image、single-card-video 等等。在微信有了自定義元件之後,我們把一些後來實現的新卡片放成了自定義元件,比如這裡的 universe card,我們把新的卡片用了新的方式來做。
早期因為我們用 Template 對介面元素做封裝,這種封裝包含了任何互動的實現。因為微信早期沒有提供任何可封裝機制,現在你也許可以通過不同的開源框架搞定,早期唯一的辦法就是把一些和介面互動相關的函式抽象成一些 Mixin 模組,然後在頁面配置裡直接整合進去,增強複用度。這個方案我們現在依然在用,這也是在小程式裡最常用的實踐方案之一。
另一方面,封裝列表也是為了統一相關聯的介面元件,比如統一的 loading 樣式,統一的空白頁樣式,翻頁的邏輯和多級巢狀等。舉例來說,所有資料的載入,在列表中定義了翻頁方式,就定義了產品的客戶端和服務端的互動方式。在輕芒,所有的服務端翻頁都會使用 Next Url 模式,服務端會告訴客戶端有沒有下一頁,有的話客戶端滾到底就會載入下一頁。這告訴我們,如果服務端具有統一的 API 模式,客戶端開發會變得很輕鬆,抽象和複用可以做得更充分。當然,這反向也約束了服務端,需要服務端提供統一的或者整合的 API,這樣落地到公司全部團隊,可以提升整體的效能。對前端來講,也只有這樣的方案才是能夠極大地簡化開發複雜度。
列表實現中,還有一個和小程式特性特別相關的點,就是如何在列表中嵌入一個原生元件,比如我想在列表裡播視訊,我想在列表裡嵌個地圖、嵌個輸入框,該怎麼辦?如果用微信原生元件的渲染機制,如果嵌一個視訊在內容流裡,它可能會遮住很多你想彈出來的對話方塊;有可能你想實現頁面快速滾動,視訊會有非常慢的拖影,這都很影響產品體驗。
在輕芒雜誌中,我們用過非常多的互動方案去嘗試這個東西,最後是團隊一起來解決的。這個解決方案的核心理念是:不要在列表中直接使用原生元件播放視訊,而是用設計的方案去規避,基於分層的方案來實現整個互動。當原生元件出現的時候,整個互動停止,比如看左邊這個視訊,在播放一個嵌入的視訊時,這個視訊好像是「長」在那個卡片上的,其實它是個虛擬的定位,只是大概在那個位置。使用者一旦滾動頁面,視訊立刻收起來不再播放。可能這聽上去很不「技術」,但實際上這是你對平臺的理解,你知道這是一個系統設計上的硬坑,如果平臺不搞定這點,無論誰來做都會碰到同樣的問題。與其這樣,不如我們理解平臺之後,和團隊商量,換種方式實現,把事情變得更簡單、更輕鬆。列表實現中還有一些問題,就是如何處理資料和狀態。每個列表項裡面會包含很多元素,比如列表項裡會有一篇文章的基本資訊,同時右下角有個互動按鈕,使用者點選之後按鈕狀態發生變化,對輕芒來講這就是「馬克」,而這些都不屬於原生資料,會隨著使用者的互動而變化,因此被稱為狀態。在早期小程式開發中,大家可能不太會區分狀態和資料,會把資料和狀態放到同一個資料模型上,因為實現邏輯非常複雜,在純資料驅動的模式下會碰到非常多問題。
而輕芒的實踐經驗,就是要讓資料和狀態分離。比如拿到資料後,我們會把資料分成兩部分,一部分是原生資料,它們不會隨著使用者互動而發生變化,用列表來進行儲存;而另一部分是狀態,它會隨著使用者互動發生變化,這裡我們會用字典進行儲存。圖示的程式碼中, ui-switch 和 subscribed 的物件都儲存了狀態資訊,當使用者對特定元素進行操作後,只需要在控制層改變 switch 對應 id 的某一個值,就可以對介面進行快速的更新。這看上去是一個非常小的優化,但如果你在所有介面互動中都能把控好這一點,帶來的收益是非常大的。它還有一個特點,是中心化。比如剛剛看到的視訊播放,我們同時只允許一個視訊播放,使用者點了某個視訊後其他視訊會停止播放,這時候你只需要在控制層把整個狀態清空,對互動物件的狀態重新設定即可,這都是一些聽上去比較細節,但從實踐來講非常重要的事情。
最後一個問題,是長列表效能優化,與其說是經驗分享,不如說教訓分享。在輕芒雜誌中,我們會用到大量的長列表,無限滾動的瀑布流、非常長的文章等等。這些都會導致列表變長,帶來卡頓。為什麼?從小程式的渲染機制來看,從設定資料到介面渲染呈現,也就是呼叫 setData 函式到 setData 函式返回,這裡面有兩件可能比較耗時的事情,一個是資料比對:小程式需要找到有哪些資料更新了,這些資料關聯了哪些介面元素。還有一件就是重新整理介面:把更新的資料呈現出來,先在虛擬節點上做渲染,重新放到前端來呈現。而小程式最大的問題,在於這兩個環節太「黑盒」了,它告訴開發者的資訊非常少,因此優化起來就比較困難。我們知道輕芒雜誌的渲染都花在這裡了,所以會有卡頓,但到底是哪裡耗了時間,其實我們不能知道。
於是我們做的效能優化方案就是試,不停地做二分,少做一部分邏輯,看看效能有沒有好轉。我們最早把效能點放在 setData 不能追加上,雖然 setData 可以修改一個列表中的元素,但它一旦要增刪元素,就要全部替換,這是現在微信整個資料驅動模型的一個核心問題。在這裡,我們嘗試使用固定的列表項個數,先虛擬一百個列表項,可能只有十個有資料,如果新的元素需要追加,就只用修改而不需要全部替換。最後的結論是,有效果,但沒太大的效果,而開發成本卻增加了不少。後來我們把效能問題,更多地放在了渲染上,發現一個有效的方案是,讓介面設計變簡單一些,需要渲染的元素變少,效能就好了不少。所以在長列表中,很大的瓶頸還是在 DOM 渲染上,小程式並沒有為列表提供一個元素回收機制,這導致它會完整渲染整個列表,它並不關心這個列表項需不需要在介面上呈現、需不需要給使用者看,這就會造成很多效能問題。
在這裡我們也沒有太完整的經驗,更多的是教訓。那對於大家來說,如果你能預見到你的產品會有很長列表,你可以提前考慮是不是要降低列表項的設計複雜度,控制每個列表項 DOM 節點的數量,減少一些 DOM 節點上繫結的資料和事件等等,這些可能會對產品的效能有所優所。當然整體來看小程式的渲染還是個黑盒,我們能用的優化手段也比較少。總結一下,其實最想和大家聊的是互動的開發和業務是密不可分的,需要去和設計、後端、產品,一起去改進,在技術設計中更多的去理解需求。
輕芒的互動實踐案例二:全域性視窗的實現
我們知道微信小程式是一個單視窗的互動平臺,那什麼是全域性視窗呢?比如,上面左邊這個分享卡片,它需要在任何輕芒雜誌的頁面都能被撥出,從而對頁面進行分享。右邊是我們自定義的 toast,很多時候我們需要的 toast 和微信小程式官方提供的不一樣,這時候就需要基於全域性視窗來實現。但因為在小程式中沒有全域性視窗機制,對我們而言唯一的辦法就是通過把元件放進每一個頁面,隨時可以撥出使用,模擬全域性視窗,我們把這個稱為全域性元件。在這個部分,我會重點來聊元件的封裝,在小程式中最好的元件方案是怎樣的。
剛剛大家聽了很多第三方的框架,在輕芒,我們主要還都是基於原生的機制來實現的。常見原生方式有兩種:基於模板(Template) 和基於自定義元件的,這兩種封裝方式我們都很常用。像這個程式碼展示的,我們有的卡片使用了自定義元件來進行封裝,有一些載入的進度條可能是模板來封裝的。但如果我把它做成全域性元件,讓每個頁面都包含,這兩種方式都需要我在各個頁面拷貝大量的重複程式碼,可能有上百行,一旦需要變更,就會需要同時修改整個小程式裡幾十個頁面。這種方案我們早期使用過,但發現效果不好,維護起來太麻煩了。於是我們重新設計了一下,發現在小程式中還有一些更簡單的封裝方案更適合,就是大家今天可能很少用的 include 方案,就相當於直接引入一個別的程式碼塊。這是一個非常傳統的一個方案,我們現在往往會強調元件,強調封裝,要封裝得漂亮,要讓元件邊界變得乾淨,但實際上在這樣的場景裡,我們打破一些簡單的元件的限制,反而會讓事情變得簡單。
我們會寫一個全域性視窗的檔案叫 global.wxml,把所有小程式中會用到的全域性元件的資料和實現都放在這裡面,圖上的程式碼是疊過的,其實展開還是比較長的,因為在 include 中不能再使用其他 Template 進行封裝了。對應的,還配套一些 js 程式碼,我們封裝了 action.js 的檔案,裡面會包括各種全域性元件的控制,比如模態對話方塊、輸入對話方塊、toast、分享卡片等等。在最後使用裡面,每一個頁面只要加兩行,一個是 include global,一個 Mixin 這個 action.js,這解決了問題。
這個例子非常小,背後想聊的主要還是元件封裝的思路。做技術設計,始終要考慮怎麼封裝元件、怎麼複用,因為這個對所有前端開發、服務端開發都是重要的,如果不復用、不去嘗試做一些抽象,那麼長此以往程式碼會變得非常散,難以維護、難以閱讀。但怎麼做封裝,怎麼做實踐?除了把元件抽象得非常漂亮外,也可以嘗試用一些邊界更模糊的封裝方案,可以顯著的讓程式碼變短變簡單,更易於變更和維護。比如,在前面例子中,我要變更某個全域性元件的樣式,只要修改 global.xml,而呼叫該檔案的地方是不用改的,用開閉原則來看,它是個非常滿足開閉原則的實現策略。
輕芒的互動實踐案例三:馬克
最後再跟大家分享一個案例,這是一個非常「輕芒」的互動實現策略。圖上的這個互動叫「馬克」。這個互動是輕芒雜誌的一個核心功能,我們之前在 Android、iOS 上都做過類似的實現,最麻煩的確實是在小程式上。要實現這個,首先需要去理解小程式的事件機制。小程式在早期只有繫結,在後期開放了事件捕獲流程,那就成了一個非常傳統的事件傳播機制,先是事件從父到子捕獲,然後是從子到父的事件冒泡。所有你想提供的互動,不管是左右滑動、翻頁、點選翻頁等等,都依賴這個事件傳播機制。 要實現這樣的馬克互動,首先不是處理事件,而是對資料模型進行抽象,由於小程式無法精確的知道元素的排版資訊,我們最早是做不出來這個效果的,最後做了一個模型抽象,把一個文章的段落切分成了句子,每個句子用一個 View 來進行渲染。當你進行馬克,需要選中一部分文字的時候,我們其實是直接讓你選擇一個句子,這樣可以避免去考慮句子內的排版,從而非常好的實現了馬克的效果,這也是和設計師圍繞技術可行性反覆討論出來的,小程式沒有辦法控制 DOM 節點,更沒有辦法拿到排版裡面的細節,從資料上解決這個問題,是唯一的思路,你可以去改變一些資料模型,為你後來的互動做一些準備。在我們掉過很多坑,當使用者移動手指去修改選中區域的時候,如果我們用了微信的條件判斷 wx:if 去改變底色之類的,很可能會讓整個控制元件樹的結構發生變化,整個互動介面會陷入一個非常可怕的狀態,完全失去響應,我們推測這是觸發了微信底層的 bug。所以在這樣的互動中,最好的方式是隻改變 CSS 的,儘可能不去改變介面元素的結構。
這裡有個細節,在觸發了調整馬克選中區域之後,我們再拖動手指的時候,整個介面是不滾動的,直到我們放開手指之後,整個介面才可以重新開始滾動。這時候就用到了我剛剛說過的事件傳播機制的流程處理,在小程式中,只要你設定了捕獲函式就必須捕獲事件,不像其他平臺,可以通過捕獲函式的返回值來控制是否需要捕獲,而如果不進行捕獲,後續又沒法中斷整個頁面的滾動。那怎麼辦?我們後來用了一些比較 Hack 的方式,我們去動態的修改繫結的捕獲函式名。當需要捕獲時,就把需要的函式設定上,而停止捕獲時,就把捕獲函式設定為空白字串,這樣可以繞過它的捕獲機制。這時候,就可以控制介面在什麼時候,可以滾動頁面,什麼時候只可以調整選中區域了。
這個馬克互動我們改版過很多次,早期由於沒有 query 任何 DOM 元素的機制,非常難以實現。而現在,微信提供了 query DOM 節點的 API,我可以大概知道 DOM 節點在哪裡,現在我們會反覆用到 query 函式,瞭解介面狀態,來調整可以進行的互動。整個馬克的實現過程中,工程師和設計師有非常多的討論,設計師根據技術限制重新想一些互動方案,工程上不斷地嘗試採取一些類似 Hack 的手段去落地這些方案。如果你希望把互動做得更往前,毫無疑問也會需要這樣進行設計和實現,也會踩到一些平臺的坑,這些經驗也可以供大家參考。
上面分享的三個案例,不全部是單純的技術,有不少技術外的事情。互動,看上去是一個純前端實現的問題,其實它的實現是整個團隊的事情,包括產品、設計、以及後端的 APIs 設計等等。也是平臺能力和產品設計的互動融合。
另外,在小程式這個平臺上尤其需要重視資料模型的設計,一定要讓整個資料模型理解好業務,把整個資料模型設計得清晰,什麼是資料、什麼是狀態、哪些應該隔離、哪些應該放在一起,把這些事情區分好,可以讓整個前端開發變得很輕鬆。此外,我們在處理原生元件時也要特別小心,需要規避掉一些設計方案,否則會帶來一系列 Bug。
這就是我今天跟大家分享的內容,這是輕芒的實踐,我相信和大家的具體的業務需求、目標會不完全一致。但也期望今天的分享可以給大家一些啟發,如果大家能運用到其中的一些,對我們來講就足夠了。
謝謝大家。