阿里巴巴集團前端委員會主席 @圓心 對前端未來期許有四點:搭建服務, Serverless,智能化,IDE。仔細想想,一個「可視化搭建系統」的想象空間,正能完美命中這些方面。 前端的邊界在哪裏,對於業務的價值又在哪裏,我們不妨靜下來,一起從「可視化搭建系統」的角度來思考。

—— 有人說前端「可視化搭建系統」說到底只是重複造輪子產生的玩具;有人說前端「可視化搭建系統」本質是組件枚舉,毫無意義。片面的認知必有其產生道理,但我們不妨從更高的角度出發,並真切落地實踐,也許你會發現:作爲 FEer,我們能做的事情也許更多。

頁面搭建技術流派概覽和彩蛋放送

據我觀察“幾乎每一個前端團隊,都會有一個頁面搭建系統”。 頁面搭建技術是一個老生常談的話題,可這個話題伴隨着前端技術的發展,歷久彌新。 究其原因,包括但不限於:

  • 運營活動頁面對於產品業務至關重要,是吸引流量、提高留存的關鍵手段
  • 高頻且重複度較高的活動頁面開發,對於前端意味着大量的時間和人力成本消耗

在此背景下,快速頁面搭建技術就顯得尤爲重要。

由於每個產品業務的特點、運營需求和設計規範不盡相同,因此頁面搭建平臺就出現了“百花齊放,百家爭鳴”的局面。我們在“閉門造車”的同時,博覽衆家之長,對比歸納,持續優化。爲此,我們分析了社區上幾乎所有開源產品和方案,包括但不限於:

相關技術分析文章:

其特點和技術方向可以各有特點,但總體可以歸納爲以下圖示:

按照目標受衆,可區分:

我們也從海量優秀方案中總結出解決這一類運營需求的 通用手段:將複雜頁面的搭建抽象成結構化數據,由結構數據驅動組件/模版的拼裝。 簡單的這樣一句話很好理解,按照這樣的想法也能構建出一個可用的平臺, 但能否更進一步,想在技術和業務上突破瓶頸,還需要打通更多環節:

  • 結構化數據如何設計才能兼顧優雅和高性能,且天然支持活動編輯時的“時光旅行 Redo/Undo”功能
  • 如何平衡頁面的自由發揮度和規範統一度
  • 如何突破原始模版引擎,借力框架(React、Vue 等)組件化思想,並做到 framework free
  • 如何優雅實現專題模版功能,一鍵導入功能以及插拔式編輯
  • 如何貼合自身業務特點,平衡實用性、適用性和可擴展性
  • 如何不斷持續迭代,以適應新的需求發展
  • 如何藉助社區的力量,做大做強
  • 如何最大化發揮可配置,如何最大化方便接入方擴展
  • 如何避免組件枚舉堆積的混亂

業界已有方案中,有的較好地解決了這些關鍵點中一個或多個問題,有的更像是一個練手的玩具。請讀者繼續閱讀,接下來我將介紹「結合編輯器技術的頁面搭建平臺」思路,整體如下圖:

當編輯器技術遇見頁面搭建需求

讓我們先回到一個寬泛而有趣的問題上:“ 前端開發的難點到底在什麼地方? ”。

在這個問題下,舊有 @於江水 提到兩個點:

  • 業務邏輯很複雜而且多變
  • 垂直領域解決方案並不簡單

這裏對其答案進行簡單搬運和擴展,原答案可參考: 於江水的回答。

順着這個思路我們來分析, 前面提到的運營活動頁面——單純開發這些頁面難度其實不高。但是對於前端團隊來說,如果高頻多變的運營需求在短時間內集中爆發,那麼就成了一個系統性的問題了。比如極端情況:對於淘寶雙十一、京東大促,簡單地堆人堆時間也只是杯水車薪。於是誕生了頁面搭建平臺。

這樣一個平臺涉及到的技術點是 網狀的:比如涉及到開發工具鏈、數據結構設計、渲染器和交互設計、數據源導入、頁面編譯構建、頁面生成、代碼發佈、活動發佈、版本管理、在線運營管理、權限管理、可視化“所見即所得”實現、後端存儲、CDN 同步、數據打點和統計、數據分析等。 後續結合平臺化能力,也會涉及到組件市場的設計,甚至 serverless,no/low code 技術。

而作爲垂直領域一個不可忽視的方向——編輯器開發,技術難度只會更高:除了編輯器本身的各種功能實現外,還需要兼顧兼容性,更要適應業務需求。同時, 編輯器就是生產工具,任何一箇中後臺系統似乎都必不可少,需求市場上,不管是石墨文檔、釘釘文檔、頭條飛書等都有着廣泛而強烈的需求。該領域值得深耕而優秀開發專家卻鳳毛麟角。

爲了解決「可視化搭建系統」, 我們嘗試把一個上述「複雜的業務平臺」和「垂直領域的富文本開發」這兩大難題結合起來,打造一個功能強大的編輯器,同時完成頁面搭建平臺的工作——這聽上去雖然是“難上加難”,但似乎兩大方向的融合是一種美妙的思路和創新。

具體來說,編輯器除了支持傳統富文本功能以外,需要加入對業務功能區塊的支持,這時候在數據結構上,選用 JSON base 的存儲方式:傳統富文本區塊以 JSON 字段存儲富文本內容,其它複合型自定義業務區塊存儲爲 JSON 對象結構。在此基礎上,我們實現對該 JSON 對象結構的解析,實現編輯器內“所見即所得”。

這裏單獨說一下富文本之外的“複合型自定義業務區塊”。我們知道最終搭建出來的頁面將會充滿各種 Sku 商品、自定義組件、用戶卡片等區塊,最終這些內容的輸出需要被 C 端渲染器所理解、所解析。

我們來結合下圖,進一步說明:

區塊 1 是傳統富文本內容,區塊 2 是一個複合型自定義業務區塊——Sku 卡片,區塊 3 是另一個複合型自定義業務區塊——用戶卡片。這樣一來編輯器不再是一個單一的富文本編輯器,而是最終輸出內容爲複雜 JSON 類型的多功能編輯器。

不同業務場景、特點,需要完全不同的前端解決方案,在開發這些垂直解決方案的時候,業務分析、技術選型、架構設計、開發落地是非常難的。接下來,就讓我們一步步探索,一步步實現一個基於併兼顧編輯器技術的多功能的頁面搭建平臺。

靈活強大的 Markdown 編輯器和頁面搭建創新嘗試

我相信現如今沒有程序員不知道 Markdown,它對程序員或者所有互聯網從業人員來說都非常友好。簡單說,Markdown 是一種輕量級標記語言,它允許我們使用易讀易寫的純文本格式編寫文檔。現如今許多網站都廣泛使用 Markdown 來撰寫幫助文檔或是用它來在社區上發表消息。比如:GitHub、Wikipedia、簡書、reddit 等。

除了易於編寫,Markdown 的 可擴展性和可轉換性 也是它收到追捧的重要原因。也正因爲如此,我們初期的運營活動頁面搭建就是基於 Markdown 編輯器實施的。具體流程如圖:

當然這只是一個非常粗略簡易版的流程示意圖,接下來我將分:

  • Markdown 擴展和自定義解析器
  • 完善使用體驗,打造頁面生成能力

兩個方面進行詳細解釋。

Markdown 擴展和自定義解析器

Markdown 原本使用場景是面向文檔和寫作,它支持的標記和語法並不能滿足所有場景需求。因此社區上存在不少 Markdown 解析器,其目的是對 Markdown 源內容進行解析和擴展。在衆多解析器當中,最出名的就是 marked.js 了。這裏簡單對 marked.js 這個庫原理進行分析,將會有助於理解後續我們的實現方案。

說起解析,其實就是經典的“編譯原理”套路。套用在 marked.js 上,如下圖:

工作機制很簡單,marked.js 接受輸入源文本字符串後,創建詞法解析器實例:

const lexer = new marked.Lexer()

詞法解析器實例 lexer 的使命是將輸入源進行分詞,解析出 tokens:

const tokens = lexer.lex(content)

如何理解分詞生成的 tokens 呢?其實 tokens 就是 AST 對象(或直接把它理解成 json 數據,它是樹形結構,表達出 Markdown 中段落,塊引用,列表,標題,規則和代碼塊等信息)。

接下來,marked.js 實例化一個解析器:

const parser = new marked.Parser()

該解析器 parser 接收 tokens,根據 tokens 生成 html 富文本:

const html = parser.parse(tokens)

當然,這只是很粗略的流程,但細心的讀者可以窺出端倪: 如果想擴展 Markdown 語法:我們可以修改 lexer 生成 tokens 的函數,目的是加入我們的自定義 Markdown 語法解析成新類型 token 的能力;同時修改 parser 解析函數,根據新 token 類型,生成我們預期結果。 這裏我不在深入贅述這個過程,事實上,我們採用的方案也沒有 fork 去修改 marked.js 代碼,而是自己基於 marked.js,封裝了更上層的解析器。

完善使用體驗 打造頁面生成能力

由上可知,我們的頁面搭建需求主要集中在插入各種組件卡片,插入帶鏈接 banner 圖片等複合型自定義業務區塊。這每一個需求都應該對應一個 Markdown 的新語法規則。

比如,輸入:

<SkuCell>live@12345@rondStyle</SkuCell>

則表示頁面中插入一個 id 爲 12345 的 Sku 卡片。

如果讓運營同學手動輸入上述語法內容無疑是痛苦且不可接受的。因此我們設計了 Markdown 編輯器的按鈕:「添加 Sku Cell」,點擊按鈕之後,會彈出表單對話框,由運營輸入 Sku 類型和 id ,即可自動在 Markdown 編輯器中光標所在位置插入一行內容:

<SkuCell>live@12345@rondStyle</SkuCell>

這樣的設計方便運營使用和記憶。因此對於使用者來說,只需要瞭解基本的 Markdown 語法,而不需要再去記牢和手動輸入新型語法。

爲了滿足“所見即所得”需求,我們需要在運營鍵入內容時,同時進行對輸入源的解析。解析的過程需要逐行進行:

  • 如果解析當前行內容符合 Markdown 原始語法,則用 marked.js 進行解析,得到解析出來的富文本結果,推入結果數據棧(這裏的數據棧是一個 result 數組)
  • 如果解析當前行內容符合新擴展的 Markdown 語法,則使用自己的解析器函數(暫且命名爲 feParse)對該行進行解析( 解析器函數實現是一個簡易的編譯分詞過程
  • feParse 函數接收擴展新語法內容,對於不同表意方式使用不同的 helper 處理,比如處理 <SkuCell>live@12345@rondStyle</SkuCell> 將會被 skuCellHelper 函數處理
  • skuCellHelper 函數解析內容,分析得到分詞結果(標記爲 formData):
type: 'live',
sku_id: 12345,
style: 'rondStyle'
  • 根據上面分詞結果,請求後端接口,獲取該 Sku 對應的數據,比如該 id 爲 12345 的 live 數據(標記爲 liveData):
author: 'live 作者名',
id: 12345,
created_date: '2019 10-12 20:34',
description: 'live 介紹',
duration: '20mins',
// ...
  • 根據以上兩種數據:formData 和 liveData,利用 React 服務端渲染能力,獲得該 Sku 組件對應的富文本 skuRichText:
const skuRichText = ReactDOMServer.renderToString(<SkuCell data={... formData, ... liveData} />)
  • 將 skuRichText 推入結果數據棧 result

最終我們逐行解析的結果產出爲:

result = [
    '第一行富文本內容',
    '第二行 Sku 卡片對應的富文本內容',
    // ...
]

合併 result 內容,渲染出富文本,顯示在頁面右側,實現所見即所得效果。

總結一下實現“所見即所得效果”的要點爲:

  • 自定義 Markdown 語法解析器
  • 利用 React 服務端渲染能力得到特殊組件的富文本內容

需要指出的是,在實際實施當中: 運營在編輯器中,保存並提交給後端的數據區別於上述 result,它也是一個數組:submitData,用來表示運營輸入的內容。對於原始 Markdown 語法,我們直接使用其對應的富文本內容;對於新的擴充語法,我們並沒有使用其對應的富文本內容,而是使用了上述 formData 的數據結構 ,最終提交類似內容:

submitData = [
    {
        type: 'richText',
        content: '<p>XXXX</p>'
    },
    {
        type: 'sku',
        content: {
            type: 'live',
            sku_id: 12345,
            style: 'rondStyle'
        }
    },
    // ...
]

這樣的考慮是爲了 C 端用戶在請求頁面時,能夠獲得最新的實時 Sku 數據。如何理解實時 Sku 數據呢?在運營編輯頁面時,假設插入一條 Sku 的標題信息爲“標題一”。再一天後,該 Sku 的標題信息變成了“標題二”。如果我們保存並使用了運營編輯時使用的富文本信息,那麼 C 端頁面一定是“標題一”,而不是最新的“標題二”。因此我們只提交該 Sku 的 id。當有 C 端用戶請求頁面時,由後端通過 RPC/Http 調用,獲取最新的數據,並由組件在服務端渲染出內容,最終返回給前端。

整個流程如下:

到此爲止,我們實現了一款基於 Markdown,利用 Markdown 語法靈活性,擴展而成的編輯器。這個編輯器中內置了諸如「插入 Sku 卡片」、「插入 Banner 圖」等一系列的業務功能。

基於這套思想,我們完成了幫助運營快速搭建活動頁面的複合型編輯器和頁面生成器,它的優點非常明顯:

  • 輸入即所見,所見即所得
  • 支持靈活擴展,可以基於解析器支持所有類型的語法和任意組件
  • 運營只需要熟悉基本的 Markdown 語法即可,擴展語法由點按按鈕完成

最終效果圖:

技術方案都是在不斷演化推進當中發展並完善的。在該平臺運行半年多之後,我們大膽進行了創新優化,並最終用更高效的方案實現了全面替換。感興趣的讀者請繼續閱讀。

不止是富文本編輯器

上面我們提到了已有複合型編輯器即頁面生成器的優點,經過半年多的線上服務後,我們再去深入分析一下它的缺點:

  • 編輯器內 Markdown 語法內容,對於運營仍然較爲晦澀難懂
  • 運營還是需要一定的學習和使用成本
  • 依賴實時解析和渲染的“所見即所得”
  • 對於每一種新的組件,都要創建一種新的 Markdown 語法

這些缺點很好理解,這裏着重講一下“所見即所得”。 上面我們提到“所見即所得”,實際依賴了實時解析內容源爲全量富文本,並實時渲染富文本的能力。 雖然滿足了需求,但是這樣的做法性能成本較高,即便加上常用的“防抖和截流”手段,對於瀏覽器的壓力仍然不小。能不能像“積木系統”、“拖拽搭建頁面系統”一樣,直接在“畫布”上修改,做到更加真實的“所見即所得”呢?

“拖拽系統”優缺點鮮明。

首先,以大量 H5 生成工具爲代表的拖拽系統雖然看上去功能強大,但是本質上卻是依靠組件的堆積和無窮盡的配置擴展,最終產出的數據形態和功能野蠻生長下去,比較容易出現“失控”的局面,而逐漸被邊緣化。

這裏的失控既指運營側、產品設計側沒有統一約束,也包含了代碼膨脹後的維護角度的失控。另一方面,從最終結果上看,拖拽系統將頁面的拼接轉嫁到運營身上,這些“搬磚”的工作量對於運營其實也並不算小,同時它缺少“規範化”的強制約束,不利於視覺設計的統一,運營同學“自我發揮”反倒不一定完全是好事。退一步來說,社區上已經存在不少可用的拖拽系統,重複造輪子也毫無意義。

結合我們的需求特點:頁面區塊和設計樣式固定、組件形態固定、頁面排版固定、重文字和圖片內容、頁面交互並不複雜,我們認爲, 多功能富文本編輯器將會是一個值得深入試水的方向。

傳統的富文本編輯器就是一個強大的“超級文字加工廠”,類似我們常用的 word,運營可以在其上“肆意揮灑”。如何在富文本編輯器上,加入設計規範,並實現業務組件添加呢?

首先,富文本編輯器是前端一個非常值得深入研究的重要方向,社區上各類開源富文本編輯器也不在少數,但是從時間和開發成本的角度來看,我們既不想重新實現一個融入了自己業務的增強型富文本編輯器;又不想做各種魔改已有方案。

無法找到一個合適的解決方案,還是讓我們先從需求角度分析:

  • 新型多功能富文本編輯器,需要支持歷史上的 Markdown 語法數據,否則會出現歷史數據不兼容的線上問題
  • 新型多功能富文本編輯器,不僅爲頁面生成器服務,也要能夠支持多類型橫向業務以及純富文本編輯器業務
  • 新型多功能富文本編輯器,要支持所有富文本的特性,包括複製粘貼內容等
  • 新型多功能富文本編輯器,要支持插入自定義組件和區塊,比如 Sku 卡片等
  • 新型多功能富文本編輯器,應該插件化,可插拔
  • 新型多功能富文本編輯器,要做到完全的所見即所得
  • 新型多功能富文本編輯器,要支持模版形式快速搭建頁面
  • 新型多功能富文本編輯器,要接入格式自動規範機制,自動實現標點擠壓、統一排版等功能

綜上需求和設計方案,我們選用了 Draft.js 作爲這套多功能編輯器的底層框架,一句話足以總結做出該選擇的原因: Draft.js 實際上並不是一個富文本編輯器,它其實是一個用於構建富文本內容和富文本編輯器的基礎設施。 做個比喻:如果把富文本內容比作一幅畫,Draft.js 只提供了畫紙和畫筆,至於怎麼畫,開發者享有很大的自由 ——(出自文章: Draft.js 在知乎的實踐 )。

這正符合我們的需要:我們不要一個完整的解決方案,而需要一個舞臺。至於如何解析內容,如何渲染內容,如何生成數據,應該全部由開發者把控。事實證明,這樣的創新設計對於頁面搭建生成器以及傳統編輯業務場景非常貼合,我們最終實現了目前服務於後臺系統的強大多功能編輯器 —— Versatile Editor。

Versatile 譯爲“多才多藝的;有多種技能的;多面手的;多用途的,多功能的”。目前 Versatile Editor 已經全面接管了所有後臺系統編輯需求。它的技術設計和體系也非常清晰。下面我們主要從

  • 數據結構設計
  • 插件體系設計
  • 多數據源支持
  • 使用體驗設計
  • 頁面模版支持
  • 其他細節

六個方面進行分析。

別具匠心的數據結構

數據結構的設計思想是:使用結果數據棧(數組)存儲每一個 Draft.js 編輯器塊級內容,數據每一項都順序對應每一個塊元素。這些塊元素分爲兩大類:純富文本內容和純自定義組件內容。對於純富文本內容,我們重新實現了將 Draft.js 的不可變數據結構解析轉換爲富文本的工具函數 draftToHtml;對於純自定義組件,我們只提取出組件最小還原數據(比如 Sku Cell 組件的 sku id 等信息)。

運營在編輯器側提交流程如下圖:

具體說明一下圖中的核心 contentState。contentState 是 ContentState 類型的對象,它規定了如何存儲具體的富文本內容,包括文字、塊級元素、行內樣式、元數據等。

這裏需要注意的一點是:在輸出數據上,我們至少提交兩種數據給後端存儲:

  • rawContent
  • renderTreeData

其中 rawContent 是根據不可變數據 contentState 進行序列化後的結果,rawContent 可以通過數據表示出當前編輯器內所有內容。 我們提交 rawContent 的目的是用於編輯還原。當運營再次打開編輯器時,編輯器可以根據 rawContent 迅速渲染出上一次提交的所有內容,以供編輯。

而 renderTreeData 是經過計算並處理後提交的數據,它的目的是存儲到數據庫中,用於後端返回給 C 端頁面,C 端頁面最終根據 renderTreeData 由渲染器渲染出完整的活動運營頁面。由上圖可知,renderTreeData 的生成,我們開發了 RenderTreeGenerator 的實例上 generate 方法:

new RenderTreeGenerator(
  contentState,
  getToHtmlOptions(contentState, this.props.editorConfig),
  this.customBlockModules
).generate()

如圖:

RenderTreeGenerator 接受 Draft.js 的不可變數據類型 contentState 作爲第一個參數,自定義配置項作爲第二個參數,React 組件集合 this.customBlockModules 作爲第三個參數。this.customBlockModules 是一個數組,包含了所有自定義區塊 React 組件名,在自定義區塊類型命中該數組時,需要啓動自定義區塊,並生成結構化數據。

generate 方法簡單僞代碼說明如下:

generate() {
    this.output = []
    this.blocks = this.contentState.getBlocksAsArray()
    this.totalBlocks = this.blocks.length
    this.currentBlock = 0
    this.indentLevel = 0
    this.wrapperTag = null
    this.richTextArray = []
    this.finalOutput = []

    const processRichText = () => {
      this.output.push({
        type: 'RICHTEXT',
        data: this.processRichText()
      })
    }

    while (this.currentBlock < this.totalBlocks) {
      const block = this.blocks[this.currentBlock]
      let blockType = block.getType()
      let type = blockType
    
      // 對於 atomic 類型,如果當前類型在 this.customBlockModules 當中,則 export 出渲染數據以及當前 type
      if (block.getEntityAt(0)) {
        const entity = this.contentState.getEntity(block.getEntityAt(0))
        type = entity.getType()
    
        if (this.customBlockModules.has(type)) {
          const entityData = entity.getData()
    
          this.output.push({
            type,
            data: entityData
          })
    
          this.currentBlock += 1
        } else {
          // 不在 this.customBlockModules 當中,仍按照富文本導出
          processRichText()
        }
      } else {
        processRichText()
      }
    }

    // 其他美化或清理工作,比如連續富文本區塊的合併

    return this.finalOutput
}

這裏不同於前期 Markdown 編輯器的關鍵點主要有兩處:

  • 我們監聽編輯器區塊的 onBlur 事件,在此事件觸發時,開始生成結果數據
  • “所見即所得”——不再需要在手動實時解析渲染實現。因爲 Draft.js 是一個基於 React 的編輯器,我們可以直接在編輯器中渲染出一個 React 組件

如下圖:

以上兩個特徵也正是基於 Draft.js 的多功能編輯器優於 Markdown 編輯器的關鍵點。

可插拔、可移植的插件化和組件化設計

多功能編輯器的多功能不是說說而已,爲了支持海量功能需求,且考慮到方便第三方功能擴展,我們設計了良好的編輯器插件體系。 目前項目中使用了 11 個插件,它們涵蓋了: 插入代碼、插入公式、插入鏈接、插入引用、插入視頻、複製粘貼還原內容、插入圖片、插入重點樣式、插入註解等。 項目還沉澱出來海量業務組件,包括: 頁面喵點組件、Banner 圖組件、Sku 卡片組件、各類按鈕組件、滾動列表組件、圖片畫廊組件等。所有的組件和插件原則上都是可以面向社區、面向第三方使用的,同時後續計劃只需要一個 NPM 包即可接入一個新的功能或新的自定義組件類型。**這也爲後續的組件市場設計、no/low code 設計打下了基礎。

在編輯器初始化時,我們註冊並實例化各種插件以及自定義組件。因爲我們多功能編輯器的理念就包括了結構化和數據化,所有的這些插件和組件都可以依賴 decorator 進行解析,這也就意味着: 從另外一處編輯器實例中複製任何內容(包括自定義組件)到當前編輯器,都可以直接還原數據,無縫完美支持組件的複製粘貼功能。

多數據源支持

任何一項技術創新和更迭,都要考慮歷史包袱和歷史債務的解決。多功能編輯器也不例外,前面提到,歷史編輯內容是使用 Markdown 格式的。 以運營頁面生成器場景爲例,歷史活動頁面 A 對應的後端存儲數據是 Markdown 字符串。我們在使用新的多功能編輯器替換舊的 Markdown 編輯器後,如果運營同學想再次編輯活動頁面 A,新的多功能編輯器上自然就要兼容歷史內容。

爲此我們的方案是:在編輯器中接收到數據源後,如果嗅探爲歷史 Markdown 格式,那麼先利用 marked.js 將此 Markdown 格式內容轉換爲富文本內容,再根據富文本內容轉換爲 Draft.js 支持的不可變數據結構。

總結一下,對於編輯器初始化時的數據源(rawContent)處理流程如下圖:

對於編輯器獲取的數據 rawContent,我們使用 isDraftJson 工具函數判斷該 rawContent 是否可以被多功能編輯器以 Draft.js 支持的數據解析:如果可以,則證明 rawContent 爲由新的多功能編輯器提交的數據,可以直接使用並恢復出編輯器內容。如果 isDraftJson(rawContent) 判別爲 false,那麼就表示無法被 Draft.js 解析,需要兼容歷史 Markdown 語法,由 marked.js 解析出富文本後再交給 Draft.js 處理,由富文本生成 Draft.js 的不可變數據;如果解析都失敗,則直接將 rawContent 視爲 textarea 內容,直接填入到編輯器當中。

圖中並未畫出如果 rawContent 爲空(或不存在)時的處理方式。實際上,如果 rawContent 爲空,我們使用 ContentState.createFromText('') 方法生成一個初始化爲空內容的不可變數據。

實際過程由於歷史包袱原因,對於多數據源的支持實現更爲複雜,這過於特殊,我們不再展開。

持續打磨使用體驗

編輯器一個非常重要的話題就是體驗。相信很多人都經歷過編輯器的體驗之殤:“輸入卡頓、詭異的光標位置”等,但這裏我認爲沒有必要分析傳統編輯器的體驗優化話題,更有意義的是從我們特有的多功能編輯器特點入手,聊一聊用戶體驗。

舉一個例子:按照 Draft.js 的設計,每一個區塊之間上下都會有個空行。如圖:

這樣會導致提交編輯器內容時,生成的自定義區塊數據前後會包含了兩個空區塊數據,最終導致渲染出的頁面也會包含兩個空白行,直接影響頁面設計效果。社區上關於這個設計的 issue 討論不少,比如 Empty line on adding atomic block

事實上,這是爲了靈活地在自定義區塊前後添加或刪除內容。設想,如果我們連續添加了三個自定義區塊——Sku 卡片 A,Sku 卡片 B,Sku 卡片 C。 如果 A,B,C 之間沒有空行,那麼我們如何在卡片 A 和卡片 B 之間插入一個新的卡片 D 呢? 如果 ABC 卡片彼此之間保持一個空行,那麼使用者可以用光標定位到 AB 之間的空行,再插入卡片 D。 這就是自定義區塊前後自動存在空行的意義。

有的開發者可能會想:我們可以保持這個空行的存在,在最終生成的數據時,自動將空行刪除不就可以了嗎?事實上,拿到 Draft.js 編輯器的數據時,我們無法判斷是用戶自主回車創建的預期中的空行,還是自定義區塊自帶的前後空行,因此無法直接在結果數據上粗暴地移除空行。

爲了達到更好的使用體驗:我們開發的 FocusPlugin 插件,優雅地解決了問題:依然是每一個自定義區塊前後不保留空行,但是利用 FocusPlugin 插件,使得每一個自定義區塊都可以被點擊選中,或者用鍵盤上下鍵遍歷選中,選中之後可以直接摁下回車鍵,添加空行,甚至可以摁下 delete 鍵,刪除該區塊。如圖:當自定義區塊被選中時:

最終這套基於 FocusPlugin 插件的方案使得交互更加順暢自然,達到了更好的效果。基於此,我們可以非常順利地完成自定義區塊的更改:比如當前選中區塊爲一個 id 是 1234 的 Sku 卡片,如果運營需要替換爲 id 是 5678 的 Sku 卡片,只需要選擇當前區塊,選中之後在右側出現的編輯區中更改 id 內容,確定後即完成替換,如圖所示:

基於 FocusPlugin 插件,以修改當前 Sku 卡片 id 爲例,id 進行修改後,發送獲取新的 id 的數據,並在數據成功獲取後調用 modifyAtomicBlock(entityKey, data) 方法,觸發 replaceEntityData(editorState, entityKey, data) 方法進行編輯器不可變數據的更新,並由 handleEditorStateChange 方法一併更新狀態,最終反應在編輯器視圖中。

這一編輯發生過程總結圖爲:

使用體驗確實不是一蹴而就的的事情,這是一個需要持續迭代優化的過程。經過不斷地打磨,Versatile Editor 最終趨於穩定。目前 Versatile Editor 已經支持了數百量級的頁面搭建,以知乎投放的頁面爲例,包括但不限於:

)

等高流量內容。

頁面模版支持

Daft.js 編輯器內容是完全基於數據狀態的,它使用了不可變數據庫進行數據的更新操作,秉承純函數式更新,因而天然對於“時光旅行(Undo/Redo)”的特性能夠良好支持。另一方面,一切皆數據也讓我們實現“頁面模版”功能非常簡單而巧妙。

我們可以將所有模版拆分爲幾個大的自定義區塊,並創建這個活動模版所對應的數據:比如對於模版 A:頭部爲一個頭圖 Banner,我們可以編輯器中創建一個由佔位圖表示的 Banner 圖片;第二區塊爲電子書榜單 Top10,即可在編輯器中創建一個 Ranking 組件,並由任意佔位 10 個電子書數據填充,以此類推。提交數據之後,即可獲得描述這個頁面模版的數據。

當運營在創建頁面,並選擇使用「排行榜模版 A」時,我們就用已經提前預製的數據作爲 rawContent 進行編輯器初始化。得到模版後,運營即可添加修改,快速完成模版頁面創建。

整體流程如下:

其他細節

到此爲止,我們介紹了社區方案和我們自己持續迭代的方案。其中還有一些小的細節在這裏簡要帶過,主要包括:預覽、排版、安全性、配置系統幾個方面說明。

“所見即所得”使得運營編輯活動效率大幅提高,但是在編輯器提交發布和推廣之前,還是需要一個完整的可預覽頁面地址供進一步迴歸。由於這些推廣頁面都是面向移動端, 因此我們在這個多功能編輯器兼頁面生成器的產品設計上,預留有頁面發佈地址和二維碼生成功能,進一步優化運營使用體驗。如圖:

另一方面,我們對於頁面文字的編審有着嚴格的要求,比如:不能使用中文引號,需要使用「」;英文和數字與其他漢字之間需要預留一個空格;甚至標點的位置也有嚴格規範,需要實現傳統類似“標點懸掛、標點擠眼”等一系列排版需求。因此, 該多功能編輯器兼頁面生成器配置了可插拔的自動排版能力,主要完成自動排版規範的審校和修正,如圖:

一個頁面往往無法只由編輯器生成,可能還包括配置內容。這些配置需求我們用進入編輯器之前的表單來承載,表單填寫完畢,生成基礎配置數據後,再進入編輯器進行創作。 表單是頁面中數據交互的基本形式,對於非開發人員使用也沒有使用門檻,但是切記不可將表單設計的過於複雜。同時要注意,編輯系統和配置系統需要解偶的原則。

前面提到編輯器就是生產工具,編輯器的效能就意味着生成效率。一旦編輯器出現線上問題,那麼就會直接影響正常的生產活動。因此,爲了保障編輯器的安全性和強健性,我們加入了測試環節。主要包括:單元測試,UI 測試。單元測試主要驗證關鍵函數和方法的正確性,比如上面提到的 autoFormat 方法,各種插件的輸入和輸出正確性校驗,數據修改的工具方法校驗等;UI 測試主要依靠 Enzyme ,來保證關鍵交互的正常運行。

最後,其他涉及點比如:一鍵換膚、字數統計等由於篇幅原因,這裏都不在詳述。

富文本編輯器是一個深坑,Draft.js 雖然背靠 Facebook 團隊,但也一直在深坑中掙扎,我們此間開發過程確實是一部血淚史,但我們團隊也在此方向積累了豐富的經驗,後續技術細節也會一一進行分享,請持續關注訂閱。

總結

我一直在思考,什麼樣的文章能夠給讀者帶來真正的思考和啓迪。一方面入木三分講解語言特性和設計,深入技術細節,庖丁解牛般的分析是我們所需要的,這類文章需要靠代碼說話;另一方面,總結梳理技術趨勢,從更高的角度敘述方案的落地和演進,更是對大局觀和格局的培養,這對於團隊的技術規劃和舵向同樣至關重要。

這篇文章粗淺總結了業界在「可視化頁面搭建」技術探索的方方面面,並整理了各種相關技術博客和分析文章。 我們還介紹了編輯器技術和編輯器技術所能給「可視化頁面搭建」帶來的破局和創新。 在此基礎上,我們更是 從一個自研的公司級「可視化頁面搭建系統」入手,從探索階段到成熟階段的演進歷史進行了介紹。

事實上,「可視化頁面搭建系統」的話題還遠爲結束: 我們正在此方向上探索更多可能,「微組件/微前端」,「頁面歸因能力」、「no/low code 技術」、「自定義組件埋點以及 A/B 流量能力」、「運行時的組件構建和渲染方案」,甚至「Serveless」、「雲端 IDE」 等。後續我們將會繼續產出相關文章,請讀者持續關注: 技術博客 ,我們也在 廣泛求賢。

回到文章開篇所提到的那個問題上:“前端開發的難點到底在什麼地方?”,我想已有答案的開發者將持續優化答案,仍然未知的開發者很快將會找到自己的答案。

Happy coding!

相關文章