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

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

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

簡單能夠這麼理解它的理念:python

  1. 一個頁面 = 一個模板 + 多個組件webpack

  2. 一個組件 = 一份代碼 + 一份數據nginx

  3. 一個組件開發一次,複用屢次git

  4. 一個頁面使用多個組件拼裝後,實時預覽、快速發佈上線github

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

這種系統可統一理解爲運營活動頁面發佈系統。apache

這種系統有如下特色:json

  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 類型就能夠解決, 好比一個頁面中包含多個組件, 每一個組件實際上是有本身的數據格式的, 我這裏並不想用兩張表來存儲(相似外鍵), 因此直接在一個頁面下就存儲了這個頁面須要的全部數據:

import mongoose from '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>
                     
                <button onClick={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 使用便可.

    // 導入所有
    import path from 'path';
    import Component from '../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 = await findOnePage(pageId);
    const project = await findOneProjectByName(page.project);

    能夠看到, 在使用 async/await 時, 少了回調, 少了嵌套, 代碼更加易讀. 固然這裏的代價是咱們須要封裝好供 await 使用的 promise(我以爲這裏仍是挺麻煩的), 不過咱們再也看不到回調地獄了, 咱們甚至能夠不使用 yield/generator 而直接過渡到 async/await 了.

    ES7? ES6 都沒普及, 你 TM 叫我用 ES7?

    這不是有 babel 嘛~ 用吧!

    @decorator 使用無侵入的裝飾器

    裝飾器其實也就是一個語法糖, 嘗試這麼理解: 咱們有 A/B/C 三個函數分別作了三個操做, 如今假設咱們忽然想在這些函數裏頭打印一些東西.

    去改動三個函數固然能夠, 但更好的方式是定一個一個 @D 裝飾器, 裝飾到三個函數前面, 這樣他們除了執行原有功能外, 還能執行咱們注入進去的操做.

    好比我在項目中, 不一樣的頁面都須要用到 snackbar(操做提示框), 每一個頁面都是同樣的, 沒有必要在每一個頁面都寫同樣的代碼, 只須要將這個組件以及對應的方法封裝爲一個裝飾器, 注入到每一個頁面組件中, 那麼每一個頁面組件就能夠直接使用這個 snackbar(操做提示框) 了.

    function withSnackbar (ComposedComponent) {
      return class withSnackbar extends Component {
        // ...
        render() {
          return (
            <div>
              <ComposedComponent {...this.props} />
              <Snackbar {...this.state}/>
            </div>
          );
        }
    
      };
    }
    import withStyles from '../../decorators/withStyles';
    import withViewport from '../../decorators/withViewport';
    import withSnackbar from '../../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 = await fetch(`/api/generate/`, {
            method: 'post',
            // 指定請求頭
            headers: {
              'Accept': 'application/json',
              'Content-Type': 'application/json'
            },
            // 指定請求體
            body: JSON.stringify(data)
          });
          // 返回的是一個 promise, 使用 await 去等待異步結果
          const json = await res.json();
          if (json.retcode === 0) {
            this.showSnackbar('生成成功');
          } else {
            this.showSnackbar('生成失敗');
          }
        } catch (error) {
          console.error(error);
          this.showSnackbar('生成失敗');
        }

    開發組件實時刷新

    本地開發一個組件時, 監聽文件變化, 使用 WebSocket 通知頁面更新.

    起一個 socket 服務, 監聽文件變化:

    async function 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);

    本文是項目設計介紹, 歡迎你們多多指正. 等我把鑑權功能和版本管理加上,就能夠用於生產環境啦, 敬請期待.

    相關文章
    相關標籤/搜索