使用 React 寫個簡單的活動頁面運營系統 – 設計篇
介紹這個工具前不得不先介紹一下積木系統。
積木系統是 imweb 團隊出品、為產品運營而生的一套活動頁面發布系統,詳細介紹見PPT
簡單可以這么理解它的理念:
- 一個頁面 = 一個模板 + 多個組件
- 一個組件 = 一份代碼 + 一份數據
- 一個組件開發一次,復用多次
- 一個頁面使用多個組件拼裝后,實時預覽、快速發布上線
此前在阿里實習的時候也接觸過一個叫 TMS(淘寶內容管理系統)的系統, 專門用于快速搭建電商運營活動頁面.
這種系統可統一理解為運營活動頁面發布系統。
這種系統有以下特點:
-
靜態數據或輕后臺數據(輕量 CGI)
-
單頁(多圖、圖文混合偏多)
-
組件粒度小,可靈活拼裝頁面
-
活動頁面需要快速發布上線
積木系統已經經受了多個項目的考驗,目前也啟動了 2.0 的開發計劃, 作者 @江源 也曾在 PPT 中提到有開源的計劃,大家可以期待一下。
在這里我寫了一套類似的 Pager 系統,設計理念大同小異,只不過是想嘗試用新的技術棧快速實現。
項目地址是: https://github.com/laispace/pager
安裝環境比較麻煩,先來快速預覽下它的功能。
創建一個頁面, 添加可復用的組件,進行可視化編輯:
設置頁面信息:
生成頁面,可本地下載預覽:
發布上線,同步到遠程機器:
接下來,直接訪問 http://pages.laispace.com/demo-page2/ 就可以看到發布的頁面了。
當我把原型寫出來的時候我卻發現,ES6 和 React 帶來的一系列特性,讓我覺得代碼寫起來爽到飛起,所以給大家分享下有趣的東西。
目前這個代號為 Pager 的系統只實現了簡單的 組件編譯/頁面生成/頁面發布 的功能, 還不能用于生產環境.
所以本文先給大家介紹下設計思路 :( 項目完成后, 再給大家細細介紹它的實現.
項目設計
發布一個頁面上線的流程
這個流程的角色主要對應是產品運營經理, 所以操作必須簡單.
- 新建頁面, 配置頁面基礎信息(標題/分享信息等)
- 在頁面中添加組件并配置組件數據(實時預覽/頁面大小可拖拽)
- 新窗口打開預覽頁面(預覽效果就是生成后的頁面,需要與線上發布版本一致)
- 下載頁面到本地(不使用一鍵發布, 自行下載代碼使用其他系統發布)
- 發布頁面到服務器(一鍵發布, 需保證服務器配置好了對應目錄的訪問權限)
開發一個組件的流程
這個流程的角色主要對應是前端開發, 需要保證開發模式足夠舒暢.
- 新建組件, 編寫組件代碼
- 打開組件預覽頁面
- 修改組件配置和代碼
- 監聽修改, 實時預覽更新
- 開發完成,同步到系統中(重新編譯, 覆蓋上一個版本)
項目模塊劃分
系統承載多個項目, 項目中配置歸屬這個項目的頁面在發布時的一些配置信息.
一個頁面由多個組件構成, 每個組件為一個文件夾, 組件間相互獨立, 本地開發完成后, 編譯并導入到系統中.
注意:綠色為已有功能, 目前只提供了頁面創建相關功能, 還沒有鑒權/版本控制等模塊, 所以還不能用于生產環境.
接口設計
雖然前后端都自己寫, 可以采用自己喜歡的接口方式. 但考慮到語義化和拓展性, 還是建議使用前后端分離的 restful 接口形式.
一個名詞對應一個資源, 一個動詞對應一個操作:
- 增加一個組件, POST /components/
- 刪除一個組件, DELETE /components/:Id
- 查找所有組件, GET /components/
- 查找一個組件, GET /compnents/:Id
- 修改一個組件, PUT /components/:Id
數據模型
前后端通信是 JSON 數據格式, 同時使用 mongoose 定義一些數據模型, 方便快速地增刪查改, 建立項目原型.
像嵌套比較深的數據, 有時我們并不想定義太多, 那直接用一個 Mixed 類型就可以解決, 比如一個頁面中包含多個組件, 每個組件其實是有自己的數據格式的, 我這里并不想用兩張表來存儲(類似外鍵), 所以直接在一個頁面下就存儲了這個頁面需要的所有數據:
importmongoosefrom 'mongoose'; const Schema = mongoose.Schema; const schema = new mongoose.Schema({ name: String, description: String, components: [Schema.Types.Mixed], // 組件, 混合的數據格式 project: String, config: Object });
頁面輸入
- 頁面信息(title + meta + link + script)
一個 html 頁面, 從上往下是:
- title 頁面標題 - meta 頁面元信息 - link/style 外聯或內聯樣式(自定義樣式方便快速修復UI問題而不需要重新發布代碼版本) - script 外聯或內聯腳本(自定義腳本方便快速添加上報點等非固話的操作)
- 多個模塊(component + data)
每個組件都有自己的模板, 對應一套數據, 遵循組件粒度化,一個模板套一份數據的原則.
- 發布配置(publishIp+publishDir+rsync)
不同的項目下生成不同的頁面, 最終使用 rsync 將頁面目錄同步到遠程機器, 遠程機器使用 nginx/apache 配置下代理, 就實現了頁面發布.
注意: rsync 權限, 建議在遠程服務器上創建對應的目錄, 給予 rsync 賬戶只能訪問這個目錄, 以免帶來不必要的安全問題.
編碼小結
這個項目使用 React+ES6 寫的, 和大家分享一些小心得.
React 單向數據流降低程序復雜度
我對 React 最重要的理解是單向的自頂向下的組件嵌套和數據流動, 帶來了數據的一致性保障. 對于一些不是非常復雜的單頁應用, 其實一個頁面就是一個組件, 不需要用太多的 flux/redux 等方案也足矣.
state = {name: 'simple', age: 18} addAge = () => { this.setState({ age: this.state.age++ }) } render : () => { return ( <div> 名字:<div>{this.state.name} </div> 年齡:<div>{this.state.age} </div> <buttononClick={this.addAge}>點擊加一歲</button> </div> ) }
大膽使用ES6/7
ES6 帶來了非常多的特性, 我在使用的過程中感覺比較好玩的是以下幾個.
- import 帶來真正的模塊化
- async/await 同步方式寫異步代碼
- @decorator 無侵入的裝飾器
- ()=>{} 箭頭函數簡化代碼、保留 this 作用域
- babel+webpack 為新特性保駕護航
import 帶來真正的模塊化
模塊化的方案, 以前有 AMD/CMD 甚至是 UMD, 遇上不同的項目就可以用到不同的模塊化方案, 自然帶有不同的學習成本.
ES6 提供的 import/export 帶來的是更舒暢的模塊化, 就像在寫 python 一樣, 一個文件就是一個模塊, 純粹.
有了 babel 將 ES6 無縫地轉化為 ES5 代碼后, 我覺得如果不考慮轉化后的代碼體積偏大的問題, 我們在項目中就應該擁抱 ES6.
如果需要兼容以前的 AMD/CMD 模塊, 配上 webpack 使用即可.
// 導入全部 importpathfrom 'path'; importComponentfrom '../models/component'; // 導入局部 import { getComponent, getComponents } from '../utils/resources';
async/await 同步方式寫異步代碼
是異步的操作就應該使用 promise, 配合 ES7 的 async/await 語法糖, 舒服地編寫同步的代碼風格表示異步的操作, 爽.
首先需要定義多個異步操作,返回 Promise:
const findOnePage = (pageId) => new Promise((resolve, reject) => { Page.findOne({_id: pageId}).then(page => { resolve(page); }); }); const findOneProjectByName = (name) => new Promise((resolve, reject) => { Project.findOne({name: name}).then(project => { resolve(project); }); });
接著使用 await 獲取異步操作的結果:
const page = awaitfindOnePage(pageId); const project = awaitfindOneProjectByName(page.project);
可以看到, 在使用 async/await 時, 少了回調, 少了嵌套, 代碼更加易讀. 當然這里的代價是我們需要封裝好供 await 使用的 promise(我覺得這里還是挺麻煩的), 不過我們再也看不到回調地獄了, 我們甚至可以不使用 yield/generator 而直接過渡到 async/await 了.
ES7? ES6 都沒普及, 你 TM 叫我用 ES7?
這不是有 babel 嘛~ 用吧!
<h4>@decorator 使用無侵入的裝飾器 </h4>
裝飾器其實也就是一個語法糖, 嘗試這么理解: 我們有 A/B/C 三個函數分別做了三個操作, 現在假設我們突然想在這些函數里頭打印一些東西.
去改動三個函數當然可以, 但更好的方式是定一個一個 @D 裝飾器, 裝飾到三個函數前面, 這樣他們除了執行原有功能外, 還能執行我們注入進去的操作.
比如我在項目中, 不同的頁面都需要用到 snackbar(操作提示框), 每個頁面都是一樣的, 沒有必要在每個頁面都寫一樣的代碼, 只需要將這個組件以及對應的方法封裝為一個裝飾器, 注入到每個頁面組件中, 那么每個頁面組件就可以直接使用這個 snackbar(操作提示框) 了.
function withSnackbar (ComposedComponent) { return class withSnackbar extends Component { // ... render() { return ( <div> <ComposedComponent {...this.props} /> <Snackbar {...this.state}/> </div> ); } }; }
importwithStylesfrom '../../decorators/withStyles'; importwithViewportfrom '../../decorators/withViewport'; importwithSnackbarfrom '../../decorators/withSnackbar'; // 裝飾器 @withViewport @withStyles(styles) @withSnackbar class Page extends Component { // ... }
箭頭函數簡化代碼、保留 this 作用域
匿名函數使用箭頭函數可以這么寫:
const emptyFunction = () = > { /*do nothing*/ };
有了箭頭函數, 媽媽再也不怕 this 突變了…
const socket = io('http://localhost:9999'); socket.on('connect', () => { socket.on('component', (data) => { // 這里的 this 不會突變到指向 window this.showSnackbar('本地組件已更新, 自動刷新'); this.getComponent(data.project, data.component); } }); });
大膽使用fetch
使用 fetch 加 await 替代 XHR.
fetch 比起 xhr, 做的事情是一樣的, 只是接口更加語義化, 且支持 Promise.
配合 async/await 使用的話, 那叫一個酸爽!
try { const res = awaitfetch(`/api/generate/`, { method: 'post', // 指定請求頭 headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, // 指定請求體 body: JSON.stringify(data) }); // 返回的是一個 promise, 使用 await 去等待異步結果 const json = awaitres.json(); if (json.retcode === 0) { this.showSnackbar('生成成功'); } else { this.showSnackbar('生成失敗'); } } catch (error) { console.error(error); this.showSnackbar('生成失敗'); }
開發組件實時刷新
本地開發一個組件時, 監聽文件變化, 使用 WebSocket 通知頁面更新.
起一個 socket 服務, 監聽文件變化:
asyncfunction watchResources() { var io = require('socket.io')(9999); io.on('connection', function (socket) { event.on('component', (component) => { socket.emit('component', component); }); }); console.log('watching: ', path.join(__dirname, '../src/resources/**/*')); watch(path.join(__dirname, '../src/resources/**/*')).then(watcher => { watcher.on('changed', (filePath) => { console.log('file changed: ', filePath); // [\/\\] 是為了兼容 windows 下路徑分隔的反斜杠 const re = /resources[\/\\](.*)[\/\\]components[\/\\](.*)[\/\\](.*)/; const results = filePath.match(re); if (results && results[1] && results[2]) { event.emit('component', { project: results[1], component: results[2] }); } }); }); }
預覽組件的頁面監聽文件變化, 變化后重新向服務器拉取最新編譯好的組件, 進行更新.
componentDidMount = () => { const socket = io('http://localhost:9999'); socket.on('connect', () => { socket.on('component', (data) => { if ((data.project === this.state.component.project) && (data.component === this.state.component.name)) { console.log('component changed: ', data.project, data.component); this.showSnackbar('本地組件已更新, 自動刷新'); // 重新向服務器拉取最新編譯好的組件, 進行更新 this.getComponent(data.project, data.component); } }); }); }
子頁面數據實時更新
生成頁面時需要預覽頁面, 為了避免頁面樣式被系統樣式影響, 應該使用內嵌 iframe 的方式來隔離樣式.
父頁面使用 postMessage 與子頁面進行通信:
const postPageMessage = (page) => { document.getElementById('pagePreviewIframe').contentWindow.postMessage({ type: 'page', page: page }, '*'); }
子頁面監聽父頁面數據變化, 更新頁面:
window.addEventListener("message", (event) => { // if(event.origin !== 'http://localhost:3000') return; console.log('previewPage receives message', event); if (event.data.type === 'page') { this.setState({ page: event.data.page }); } }, false);
本文是項目設計介紹, 歡迎大家多多指正. 等我把鑒權功能和版本管理加上,就可以用于生產環境啦, 敬請期待.