使用 React 寫個簡單的活動頁面運營系統 – 設計篇

LuzTiller 8年前發布 | 120K 次閱讀 React JavaScript開發

來自: http://www.alloyteam.com/2016/03/using-react-to-write-a-simple-activity-pages-design-of-operating-system-article/

介紹這個工具前不得不先介紹一下積木系統。

積木系統是 imweb 團隊出品、為產品運營而生的一套活動頁面發布系統,詳細介紹見PPT

簡單可以這么理解它的理念:

  1. 一個頁面 = 一個模板 + 多個組件
  2. 一個組件 = 一份代碼 + 一份數據
  3. 一個組件開發一次,復用多次
  4. 一個頁面使用多個組件拼裝后,實時預覽、快速發布上線

此前在阿里實習的時候也接觸過一個叫 TMS(淘寶內容管理系統)的系統, 專門用于快速搭建電商運營活動頁面.

這種系統可統一理解為運營活動頁面發布系統。

這種系統有以下特點:

  1. 靜態數據或輕后臺數據(輕量 CGI)

  2. 單頁(多圖、圖文混合偏多)

  3. 組件粒度小,可靈活拼裝頁面

  4. 活動頁面需要快速發布上線

積木系統已經經受了多個項目的考驗,目前也啟動了 2.0 的開發計劃, 作者 @江源 也曾在 PPT 中提到有開源的計劃,大家可以期待一下。

在這里我寫了一套類似的 Pager 系統,設計理念大同小異,只不過是想嘗試用新的技術棧快速實現。

項目地址是: https://github.com/laispace/pager

安裝環境比較麻煩,先來快速預覽下它的功能。

創建一個頁面, 添加可復用的組件,進行可視化編輯:

設置頁面信息:

生成頁面,可本地下載預覽:

發布上線,同步到遠程機器:

接下來,直接訪問 http://pages.laispace.com/demo-page2/ 就可以看到發布的頁面了。

當我把原型寫出來的時候我卻發現,ES6 和 React 帶來的一系列特性,讓我覺得代碼寫起來爽到飛起,所以給大家分享下有趣的東西。

目前這個代號為 Pager 的系統只實現了簡單的 組件編譯/頁面生成/頁面發布 的功能, 還不能用于生產環境.

所以本文先給大家介紹下設計思路 :( 項目完成后, 再給大家細細介紹它的實現.

項目設計

發布一個頁面上線的流程

這個流程的角色主要對應是產品運營經理, 所以操作必須簡單.

  1. 新建頁面, 配置頁面基礎信息(標題/分享信息等)
  2. 在頁面中添加組件并配置組件數據(實時預覽/頁面大小可拖拽)
  3. 新窗口打開預覽頁面(預覽效果就是生成后的頁面,需要與線上發布版本一致)
  4. 下載頁面到本地(不使用一鍵發布, 自行下載代碼使用其他系統發布)
  5. 發布頁面到服務器(一鍵發布, 需保證服務器配置好了對應目錄的訪問權限)

開發一個組件的流程

這個流程的角色主要對應是前端開發, 需要保證開發模式足夠舒暢.

  1. 新建組件, 編寫組件代碼
  2. 打開組件預覽頁面
  3. 修改組件配置和代碼
  4. 監聽修改, 實時預覽更新
  5. 開發完成,同步到系統中(重新編譯, 覆蓋上一個版本)

項目模塊劃分

系統承載多個項目, 項目中配置歸屬這個項目的頁面在發布時的一些配置信息.

一個頁面由多個組件構成, 每個組件為一個文件夾, 組件間相互獨立, 本地開發完成后, 編譯并導入到系統中.

注意:綠色為已有功能, 目前只提供了頁面創建相關功能, 還沒有鑒權/版本控制等模塊, 所以還不能用于生產環境.

接口設計

雖然前后端都自己寫, 可以采用自己喜歡的接口方式. 但考慮到語義化和拓展性, 還是建議使用前后端分離的 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
});

頁面輸入

  1. 頁面信息(title + meta + link + script)

一個 html 頁面, 從上往下是:

- title      頁面標題
- meta        頁面元信息
- link/style  外聯或內聯樣式(自定義樣式方便快速修復UI問題而不需要重新發布代碼版本)
- script      外聯或內聯腳本(自定義腳本方便快速添加上報點等非固話的操作)
  1. 多個模塊(component + data)

每個組件都有自己的模板, 對應一套數據, 遵循組件粒度化,一個模板套一份數據的原則.

  1. 發布配置(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);

本文是項目設計介紹, 歡迎大家多多指正. 等我把鑒權功能和版本管理加上,就可以用于生產環境啦, 敬請期待.

 本文由用戶 LuzTiller 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!