基於git的部落格(含站點與小程式)

1 效果

2 需求分析

2.1 做

  • 專註於寫markdown文檔,或者說專註於內容。
  • 一端書寫,多端同步:小程式、靜態站點
  • 快速的靜態託管、持續集成、頁面資源載入

2.2 不做

  • 不需要滿足隨時隨地寫文章,因為隨時隨地寫的大部分是隨筆、記錄一類的帖子,若要呈現出來,必然要經過整理。
  • 不需要自定義主題風格,部落格就主體業務類型(除了評論、點贊、收藏)而言受眾個性色彩不強

3 系統設計

3.1 概要設計

3.1.1 架構設計

主要思路是通過git管理文章(markdown類型),發布到小程式和靜態站點(適用於構建md文檔的框架如hexo、jeklly等)。
技術路線:

  1. 更新 => 通過git進行源端CURD操作
  2. 解析 => 通過serverless完成md解析至html
  3. 同步 => 通過CI完成構建和部署到對象存儲(靜態託管));通過webhook拉取git倉庫更新到雲存儲(小程式)
  4. 瀏覽 => 通過對象存儲觸發雲函數刷新CDN(靜態託管);通過rich-text組件解析html(小程式)

3.1.2 技術選型與開發框架

在開發框架上,由於初期面向微信小程式開發且可能存在未知問題,故使用原生開發,不使用多端或其他預編譯框架。在小程式UI上,參考但不依賴WeUI組件庫,因由於封裝不必要的特性可能造成程式碼包的冗餘。

類型 方案 備註
程式碼託管 Coding github api訪問較大概率慢且不穩定
雲開發 騰訊雲TCB 含小程式雲開發服務
持續集成 Coding CI 使用Jenkinsfile定義pipeline
靜態託管 騰訊雲COS 也可使用阿里雲OSS,或直接使用雲開發提供的靜態網站託管,使用對象存儲配合內容分發加速。
Markdown解析 markdown-it 也可使用markdjs,但markdown-it支援拓展插件
富文本渲染 parser 比原生rich-text功能豐富且效果穩定

3.1.3 介面設計

由於是內容類應用,需要格外注意視覺規範,以使用戶獲取較好的閱讀體驗。以下規範參考了WEDESIGNAnt Design,根據實際需要進行了修改和補充。
字體:

字型大小pt 像素px 顏色 用途
17 17 #000000 頁面內首要層級資訊,列表標題
17 17 #B2B2B2 時間戳與表單預設值
14 14 #888888 頁面內次要描述資訊,搭配列表標題
14 14 #353535 大段文本
13 13 #576b95 頁面輔助資訊,需弱化的內容如鏈接
13 13 #09bb07 完成字樣
13 13 #e64340 出錯字樣
11 11 rgba(0, 0, 0, 0.3) 說明文本,如版權資訊等不需要用戶關注的資訊

圖標:

類別 顏色 大小
導航類 可多色,但不多於三色,主色一致 28px
菜單操作類 單色,顏色統一 22px
操作提示類 與提示類型相關 30px
展示區分類 圖標固有色彩 與跟隨字體大小一致

響應式設計:
主要通過改變px為rpx實現,由於基本不涉及列表項目,不考慮自適應布局變換,僅做不同螢幕下元素呈現比例保持一致,以iphone-6作為標準,對於iphone-x類異形屏,重點考慮操作菜單(如貼頂、貼底、懸浮)的安全區域問題,主要通過CSS中calc(env(safe-area-inset-bottom))方式實現。

  • 圖片橫向鋪滿螢幕
  • 主體文字不需要
  • 由於已經有靜態站點,暫不考慮PC端適配

3.1.4 開發規範

  • 漸進式,先實現基本功能,再考慮抽離和組件化。
  • 能用簡單的邏輯實現就不抽離組件,能使用成熟庫就不自行創建組件,能通過配置或遷就性使用就不修改外部庫以保證平滑更新。
  • 對於功能實現的方式,要考慮服務角色,權衡計算複雜度、網路延時和用戶感知程度:

小程式端做簡單計算

  • canvas繪製海報
  • 基本格式轉換

服務端(雲開發)做複雜處理,非實時性計算,或可預生成內容

  • markdown轉html
  • TOC目錄
  • AI識別、處理
  • 對於讀寫資料庫,盡量將寫操作放在雲函數中。

3.2 詳細設計

3.2.1 數據源

安全校驗,保證雲函數觸發來源及方式可信:

// 查看請求頭
if (!req.headers['user-agent'].includes('Coding.net Hook') || 
    !('x-coding-signature' in req.headers) || req.headers['x-coding-signature'].indexOf('sha1=')
    !('x-coding-event' in req.headers) || 'POST' !== req.httpMethod ) {
  return false;
}
// 計算和比對簽名
const theirSignature = req.headers['x-coding-signature'];
const payload = req.body;
const secret = process.env.HOOKTOKEN;
const ourSignature = `sha1=${crypto.createHmac('sha1', secret).update(payload).digest('hex')}`;
return crypto.timingSafeEqual(Buffer.from(theirSignature), Buffer.from(ourSignature));

在每次commit推送新的程式碼時,WebHook會push以下資訊(限於篇幅,略去非必要資訊)

{
  "ref": "refs/heads/master",
  "commits": [
    {
    "id": "8a175afab1cf117f2e1318f9b7f0bc5d4dd54d45",
    "timestamp": 1592488968000,
    "author": {
      "name": "memakergytcom",
      "email": "[email protected]",
      "username": "memakergytcom"
    },
    "committer": {
      "name": "memakergytcom",
      "email": "[email protected]",
      "username": "memakergytcom"
    },
    "added": [
      "source/_drafts/site.md"
    ],
    "removed": [],
    "modified": [
      "package.json",
      "scripts/fix.js",
      "source/_posts/next.yml",
      "source/_posts/typesetting.md"
    ]}
  ],
  "head_commit":{...},
  "pusher",
  "sender",
  "repository"
}

保持最新狀態故關注head_commit.這些資訊包含了本次提交產生的變更,可以基於遍歷這些變更狀態,同步雲資料庫。但由於可能包含了非文章文件的變更,也可能非目標分支,故需要篩選:

if ('refs/heads/' + branch === ref) {
  if (filePath.indexOf(dirPrefix) || filePath.slice(-3) !== '.md') { // 路徑前綴和文章後綴
    continue;
  }
}

要建立資料庫文件與git倉庫文件的關聯,由於每次commit的文件沒有唯一id資訊,可以通過文件名來建立聯繫,將文件名作為slug欄位(主鍵)

let slug = filePath.match(new RegExp(dirPrefix + "([\\s\\S]+)\\.md"))[1];

由於Push 事件不包含文件內容,需要通過api發起請求

await axios({
  url: `${baseUrl}/${branch}/${filePath}`,
  method: 'get',
  headers: {
    'Authorization': `token ${process.env.CODINGTOKEN}` // 個人令牌
  }
});

3.2.2 數據處理

提取文章資訊:
由於要求在markdown開頭通過yaml格式寫明基本資訊,故在獲取到文件內容(String)後需要轉json。

const matter = require('hexo-front-matter');
let { title, date, tags, description, categories, _content, cover } = matter.parse(data);

其中cover欄位(封面圖)也可不聲明,而通過文章首圖來獲取

let cover = _content.match(/!\[.*\]\((.+?)\)/);

markdown解析html:
小程式端環境與傳統網頁有區別,讓markdown渲染在本地進行,其中還需要先轉為html,為了減少渲染時間,這一步在雲端提前進行:

const md = require('markdown-it')({
  html: true,// 允許渲染html
}).use(require('markdown-it-footnote')) // 腳註引用

生成目錄
為了保持一致,章節自行標號。目錄放在側邊欄不解析到html中,需另行處理。而markdown-it-anchor插件會使用header的值作為id(markdown-it-anchor),但id不能以數字開頭,不能含中文及encodeURIComponent(中文),但可以含-

// 為<h>標籤插入id
id = 'makergyt-' + crypto.createHash('md5').update(title).digest('hex');
// 獲取所有h2-h4生成目錄列表
const { tocObj } = require('hexo-util');
const data = tocObj(str, { min_depth:2, max_depth: 4 });

3.2.3 數據同步

在小程式的文檔中,觸發雲函數可以通過http api(invokeCloudFunction)的方式。但是invokeCloudFunction需要關鍵的access_token,需要兩小時內刷新獲取,webhook無法提前獲知。考慮設置中控伺服器統一獲取和刷新 access_token,webhook首先向中控伺服器發起請求,再向雲函數請求,但這樣顯然是不可能的,因其只能push一個地址一次,沒有上下文。其間再加一個中間函數,那麼這個中間函數又放在哪裡,如何請求…(同樣需要access_token)

這時,在騰訊雲-雲開發控制台,發現可以直接通過”雲接入HTTP觸發方式”觸發雲函數,這樣就可以直接該地址作為WebHook的Url。但需要關注業務和資源安全[1],上文在處理webhook push事件時已經做了安全檢驗,可以再將Coding的request domain加入到WEB安全域名列表中。

獲取到文章資訊和內容後就可以同步到雲資料庫的相應集合中,這裡循環中使用async/await遍歷,為了在每個調用解析之前保持循環,只使用for...of進行非同步[2]

for (const file of added) {
  await db.collection('sync_posts').add({
    data
  })
}
for (const file of modified) {
  await db.collection('sync_posts').where({
    slug
  }).update({
    data
  })
}
for (const file of removed) {
  await syncPosts.where({
    slug
  }).remove();
}

3.2.4 文本渲染

幾乎不太可能將原內容原封不動顯示出來, 經過markdown-it渲染後的html字元串沒有插入任何樣式,直接測試(根據標籤默認提供樣式)效果如下:

方案 效果
rich-text 程式碼塊缺失,長內容被截斷
wxparser 間距過大,表格、程式碼塊被截斷
towxml 程式碼塊被截斷
wemark 程式碼塊與引用部分不換行拉寬
Parser 表格溢出

Tips: 注意到騰訊Omi團隊開發的小程式程式碼高亮和markdown渲染組件Comi,實際上採用模板引入的方式使用。考慮隨後實測效果和對比渲染速度。

相比之下,都會出現溢出組件邊界,產生橫向滾動條問題。在使用上,存在不支援解析style標籤缺陷[3]
圖3-1 表格溢出

而Parser可以通過控制源html樣式的方法解決這種問題

var document = that.selectComponent("#article").document;
document.getElementsByTagName('table').forEach(tableNode => {
  var div=document.createElement("div");
  div.setStyle("overflow", "scroll");
  div.appendChild(tableNode);
  div._path = tableNode._path;
  tableNode = div;
});

Parser也提供了通過控制源html中標籤樣式來影響渲染效果,這樣就可以改變字體大小、行高、行間距等,以適應手機螢幕。

//post.wxml
<parser id="article" tag-style="{{tagStyle}}"/>
// post.js
tagStyle: {
  p: 'font-size: 14px;color: #353535;line-height: 2;font-family: "Times New Roman";',
  h2: 'font-size: 18.67px;color: #000;text-align:center;margin: 1em auto;font-weight: 500;font-family: SimHei;',
  h3: 'font-size:16.33px;color: #000;line-height: 2em;font-family: SimHei;',
  h4: 'font-size:14px;color: #000;font-family: SimHei;',
}

對於程式碼高亮,使用prism ,引入到該組件中。

const Prism = require('./prism.js');
...
highlight(content, attrs) {
  content = content.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/quot;/g, '"').replace(/&amp;/g, '&'); // 替換實體編碼
  attrs["data-content"] = content; // 記錄原始文本,可用於長按複製等操作
  switch (attrs[lan]) {
    case "javascript":
    case "js":
      return Prism.highlight(content, Prism.languages.javascript, "javascript");
  }
}  

數學公式Latex
對於latex渲染引擎,主要有兩種

引擎 特點
mathjax 語法豐富,渲染較慢
katex 支援語法較少,迅速,只能輸出mathml或html,需要搭配其CSS and font files使用

當然,這兩種都是網頁客戶端渲染,在小程式端天生不可用,考慮採用服務端渲染。問題有:

  • 服務端渲染如果使用外部介面,需encodeUrl(公式),但內部\被轉義消失,需要\\,replace(/\/g,’\’)無效
  • 服務端渲染如果使用mathjax-node,其依賴項mathjax版本^2.7.2,需將所有\替換為\\,會經常性出現SVG - Unknown character: U+C in MathJax_Main,MathJax_Size1,MathJax_AMS, 矩陣解析錯誤TeX parse error: Misplaced &
  • 如何比較精準的識別markdown中特定標記的Latex,不造成誤操作。

考慮在markdown解析html階段將其轉化為<img>,也是很多內容平台採取的方式,較為可靠可控。這裡使用markdown-it-latex2img插件

const md = require('markdown-it')({
  html: true,// Enable HTML tags in source
}).use(require('markdown-it-latex2img'),{
    style: "filter: opacity(90%);transform:scale(0.85);text-align:center;" // 優化顯示樣式
  })

圖3-2 markdown-it-latex2img效果

3.3 靜態託管

為git庫設置構建計劃,以使每次提交後同步到對象存儲。這裡使用hexo作為構建框架。

pipeline {
  agent any
  stages {
    stage('檢出') {
      steps {
        checkout([
          $class: 'GitSCM',
          branches: [[name: env.GIT_BUILD_REF]],
          userRemoteConfigs: [[url: env.GIT_REPO_URL, credentialsId: env.CREDENTIALS_ID]]
        ])
      }
    }
    stage('構建') {
      steps {
        echo '構建中...'
        sh 'npm install -g cnpm --registry=//registry.npm.taobao.org'
        sh 'cnpm install'
        sh 'npm run build'
        echo '構建完成.'
      }
    }
    stage('並行階段') {
      parallel {
        stage('部署到騰訊雲存儲') {
          steps {
            echo '部署中...'
            sh "coscmd config -a $TENCENT_SECRET_ID -s $TENCENT_SECRET_KEY -b $TENCENT_BUCKET -r $TENCENT_REGION"
            sh 'coscmd upload -r public/ /'
            echo '部署完成'
          }
        }
        stage('打包') {
          steps {
            sh 'tar -zcf blog.tar.gz public'
            archiveArtifacts(artifacts: 'blog.tar.gz', defaultExcludes: true, fingerprint: true, onlyIfSuccessful: true)
          }
        }
      }
    }
  }
}

構建後自動刷新CDN,

// refresh_cdn
const Key = decodeURIComponent(event.Records[0].cos.cosObject.key.replace(/^\/[^/]+\/[^/]+\//,""));
const cdnUrl = `${process.env.CDN_HOST}/${Key}`;
CDN.request('RefreshCdnUrl', {
    'urls.0': cdnUrl
}, (res) => {
  ...
})

4 系統實現

4.1 資料庫

文章:

sync_posts = [
  {
    _id: String,
    createTime: String,
    slug: String,
    title: String,
    tags: Array,
    description: String,
    cover: String, // url
    content: String, // html
  }
]
// 安全規則
{
  "read": true, // 公有讀
  "write": "get('database.user_info.${auth.openid}').isManager", // 僅管理員可以寫
}

用戶收藏

user_favorite = [
  {
    _id:String,
    userId:String,// openid
    postId: String,// 在表中加入冗餘數據直接查詢
    createTime: Date
  }
]
// 安全規則
{
  "read": "doc._openid == auth.openid",// 私有讀
  "write": "doc._openid == auth.openid"// 私有寫
}

用戶資訊

user_info = [
  {
    _id: String,
    _openid: String,
    ...userInfo,
    isManager: Boolean,
  }
]
// 安全規則
{
  "read": "doc._openid == auth.openid", // 私有讀
  "write": "doc._openid == auth.openid"// 私有寫
}

4.2 登錄

4.2.1 普通登錄

使用雲開發後,無需通過wx.login獲取登錄憑證(code)進而換取用戶登錄態資訊,因每次調用雲函數時會附帶調用者openid。同時由於可以直接通過open-data展示用戶資訊(不論是否授權),一些小程式因此繞過用戶登錄。有些小程式通過授權用戶資訊後保存到資料庫,後續操作均使用資料庫資訊,無法在用戶變更資訊後更新。如果用戶主動通過設置頁取消授權,但返回後卻還在展示使用用戶的資訊(顯示已登錄)。這是因為用戶態資訊是通過onLoad獲取的,返回操作時是onShow,故此時會產生矛盾。用戶在重新授權登錄時選擇使用其他昵稱和頭像,這時一些小程式會認為是新用戶登錄。還有一部分小程式不論業務中是否需要用戶資訊,均要求授權才可使用。實際上微信小程式最大的特點就是可以方便地獲取微信提供的用戶身份標識,快速建立小程式內的用戶體系,但上述情形均沒有妥善處理用戶登錄這一基本策略。

基於“來去自如”的原則,可以遊客瀏覽,也可登錄和登出。在涉及一些需要採集和輸入用戶資訊、或保存用戶記錄的功能時才要求用戶跳至登錄頁授權獲取資訊,會通過雲函數將其與上下文中的openid保存到資料庫,同時在回調中將用戶標識生成自定義登錄態快取到本地,如果用戶點擊退出會將其置空。

// cloudfunction/login
const openid = wxContext.OPENID
db.collection('user_info').where({
  _openid: openid
}).get().then(async (res)=> {
  if (res.data.length === 0) {
    db.collection('user_info').add({
      data: {
        _openid: openid,
        ...event.userInfo,
        createTime: db.serverDate(),
      }
    })
  }

在下次打開小程式時,會通過檢查快取中的自定義登錄態來判斷用戶是否登錄,同樣調用雲函數來更新用戶資訊和使用資訊(如打開時間、打開次數用於後續用戶分析)。在下次登錄時將不會彈出授權提示,當用戶自行取消授權(或者wx.openSetting時誤操作),這種情況概率很小,但一旦出現就是Bug。如果在onShow中檢測用戶,會與正常onLaunch產生重複的邏輯,但又需要檢測這種行為。實際上,打開設置頁必然會進入onHide,可以:

// app.js
onHide:function() {
  wx.onAppShow(()=> {
    if(this.globalData.hasLogin) {
      wx.getSetting({
        success: res => {
          if (!res.authSetting['scope.userInfo']) { // 取消了授權
            this.logout() // 返回後直接登出
          }
        }
      })
    }
    wx.offAppShow();
  })
},

4.2.2 管理員鑒權

管理員即文章作者,對於管理員標識,考慮到

  • 手機號: 目前該介面針對非個人開發者,且完成了認證的小程式開放
  • openid: 不使用前是未知的,無法提前綁定
  • 其他用戶資訊、密碼等會暴露管理入口

於是採取了最簡單直接的數據欄位標記isMaganer:true,這一欄位也用於資料庫的安全規則設定。

4.3 分享

分享無非兩種,直接分享到聊天和生成海報後引導分享到朋友圈,對於前者,需要考慮圖片大小為5:4,其他比例會產生空白或者裁切。這裡主要分析後者。在小程式端通過canvas繪製到倒出圖片比較慢,由於每篇文章分享內容基本固定,可以考慮預生成。但如果分享二維碼和分享者關聯,就仍然需要本地生成。這裡使用組件mini-share。對於小程式碼,目前採用雲調用方式,這種方式只能由小程式端觸發。

// 處理參數
const path = page +'?' + Object.keys(param).map(function (key) {
    return encodeURIComponent(key) + "=" + encodeURIComponent(param[key]);
}).join("&");
// 組織文件名
const fileName = 'wxacode/limitA-' + crypto.createHash('md5').update(path).digest('hex');
// 查找文件,如果找到直接返迴路徑
let getFileRes = await cloud.getTempFileURL({
  fileList: [fileID]
});
// 若未找到重新生成
const wxacodeRes = await cloud.openapi.wxacode.get({
  path,
  isHyaline:true
})
// 上傳到雲存儲
const uploadRes = await cloud.uploadFile({
  cloudPath: fileName + fileSuffix,
  fileContent: wxacodeRes.buffer,
});
// 獲取返回臨時路徑
getFileRes = await cloud.getTempFileURL({
  fileList: [uploadRes.fileID]
});

生成二維碼方式有三種,分析特性

類型 特點 適用場景
A+ C 個數有限、參數較長 生成後儲存 用於長期有效業務,可用於邀請碼一類用戶可長期關注使用的操作。
B 個數無限、參數較短 生成後可不保存,其scene與用戶短期行為關聯(如活動)。活碼,與資料庫關聯後可以轉換含義再次使用。

這裡由於文章的資料庫_id默認是32位,達到了B類的限制,並且還需要關聯其他資訊,故使用了A類(wxacode.get)

4.4 訂閱消息

對於個人主體,只能用戶經小程式發起訂閱(獲取下發許可權)後下發一次消息,這裡當用戶留言時,會訂閱一次回復通知,但無法發給作者(除非作者長期訂閱)。由於同時需要保存到資料庫,這裡使用雲調用實現。

// post.js
wx.requestSubscribeMessage({
  tmplIds: [TEMPLATE.REPLY]
})
// cloudfunction/sengMsg
let sendRes = await db.collection('user_msg').add({
  data: {
    _openid: wxContext.OPENID,
    msg:inputMsg,
    createTime:Date.parse(new Date())
  }
});
await cloud.openapi.subscribeMessage.send({
  data: format(data), // 由於各種類型資訊有長度格式限制,需要處理
  touser: wxContext.OPENID,
  templateId: TEMPLATE.REPLY
});

5 拓展總結

5.1 結合語雀

5.1.1 同步到語雀

  • 會在標題前插入<a name="tqO5w"></a>標籤
  • 編輯介面直接複製會圖片外鏈轉化,但是直接導入的不會轉化
  • 只可本地引入文件(圖片),均不支援外部鏈接引入,除了加入的第三方服務
  • 可以input任意類型,但output都是特有lake格式,且在更新文檔介面調用時,會返回
{
  "status": 400,
  "message": "抱歉,語雀不允許通過 API 修改 Lake 格式文檔,請到語雀進行操作。"
}

5.1.2 從語雀同步

可以藉助語雀良好的編輯體驗來寫文章,同步到其他平台。yuque的webhook會發送webhook.doc_detail可以直接獲取到內容。但是,在豐富文檔內容類型方面,語雀做了很多卓有成效的努力,使用這些特性,也就無法保證其他平台的兼容性。刪除操作返回的slug會變為trash-EJA8tL7W,與原slug無關,無法通過slug建立其他平台的關聯,即僅增改操作可以同步。因此,在語雀寫作,自動部署到其他平台的方案是不切實際和不必要的。

5.1.3 workflow

同步至語雀後,可以利用其豐富的支援類型完善文檔內容,比如將文本內容轉化為更直觀的流程圖、思維導圖,將demo和程式碼合併到codepen直觀演示,將可能涉及的資料直接以附件上傳方便獲取。
但要注意:

  • 很多內容平台往往會在擁有一定用戶基數後做圖片防盜鏈。
  • 目前的webhook設計不安全,沒有簽名驗證,可能由於Webhooks URL泄露被偽造請求

5.2 小程式開發已知問題

  • 真機初始動畫卡頓500ms
  • 原生TabBar隱藏會跳動,加動畫會黑屏,自定義TabBar切換時所有圖標會閃動,自動隱藏會顯示白條.
  • 簡單幾次來回navigate後,listeners of event onBeforeUnloadPage_17 have been added, possibly causing memory leak.
  • 在調用CameraFrameListener.start開始監聽幀數據後,必然有對像素data的獲取和處理,但這會導致介面所有的點擊(bindtap)事件失效,也就不能通過點擊觸發CameraFrameListener.stop停止函數
  • 雲控制台資料庫管理頁中數組更新操作符addToSet無效,對象元素傳入後不穩定,或生效或不生效

  1. Tencent Cloud.雲開發CloudBase文檔[EB/OL].//cloud.tencent.com/document/product/876/41136. 2020 ↩︎

  2. Tory Walker.The-Pitfalls-of-Async-Await-in-Array-Loops[EB/OL].//medium.com/dailyjs/the-pitfalls-of-async-await-in-array-loops-cf9cf713bfeb. 2020 ↩︎

  3. 金煜峰.小程式富文本能力的深入研究與應用[EB/OL].//developers.weixin.qq.com/community/develop/article/doc/0006e05c1e8dd80b78a8d49f356413. 2019 ↩︎