從一個前端埋點上報文件說起

設計和封裝一個前端埋點上報腳本级遭, 并逐步思考優(yōu)化這個過程肩碟。

主要內容:

  • 請求的方式:簡潔(fetch) | 高效(head) | 通用(post)
  • 批量打包上報
  • 無網(wǎng)絡延時上報
  • 更好的pv: visibilitychange
  • 更好的pv: 單頁應用hash監(jiān)聽

作用:

  • 統(tǒng)計平臺服務端若只提供上報接口刻伊,對于前端如何封裝數(shù)據(jù)上報可以借鑒
  • 使用第三方分析平臺的api的話,可以思考能否優(yōu)化和封裝
  • 不是規(guī)范蹂午,側重想法

final code:analytics.js

請求的方式:簡潔|高效|通用

我們先用最直接的方式來實現(xiàn)這個埋點上報腳本愿汰。

創(chuàng)建文件并命名為 analytics.js, 在腳本里面添加一個請求,稍微包一下:

export default function analytics (action = 'pageview') {
  var xhr = new XMLHttpRequest()
  let uploadUrl = `https://xxx/test_upload?action=${action}&timestamp=${Date.now()}`
  xhr.open('GET', uploadUrl, true)
  xhr.send()
}

這樣子就能通過調用analytics()阴汇,往我們的統(tǒng)計服務端提交一條消息数冬,并指明一個行為類型。

如果我們需要上報的數(shù)據(jù)確實不多,如只需要‘行為/事件’拐纱,‘時間’铜异,‘用戶(id)’,‘平臺環(huán)境’等,并且數(shù)據(jù)量在瀏覽器支持的url長度限制內秸架,那我們可以用簡化下這個請求:

// 簡潔的方式
export default function analytics (action = 'pageview') {
  (new Image()).src = `https://xxx/test_upload?action=${action}&timestamp=${Date.now()}`
}

用img發(fā)送請求的方法英文術語叫:image beacon

主要應用于只需要向服務器發(fā)送日志數(shù)據(jù)的場合揍庄,且無需服務器有消息體回應。比如收集訪問者的統(tǒng)計信息东抹。

這樣做和ajax請求的區(qū)別在于:

1.只能是get請求蚂子,因此可發(fā)送的數(shù)據(jù)量有限。

2.只關心數(shù)據(jù)是否發(fā)送到服務器缭黔,服務器不需要做出消息體響應食茎。并且一般客戶端也不需要做出響應。

3.實現(xiàn)了跨域

或者我們直接用新標準fetch方式上傳

// 簡潔的方式
export default function analytics (action = 'pageview') {
  fetch(`https://www.baidu.com?action=${action}&timestamp=${Date.now()}`, {method: 'get'})
}

考慮到上報數(shù)據(jù)過程我們并不關心返回值馏谨,只需要知道上報成功與否别渔,我們可以用Head請求來更高效地實現(xiàn)我們的上報過程:

// 高效的方式
export default function analytics (action = 'pageview') {
 fetch(`https://www.baidu.com?action=${action}&timestamp=${Date.now()}`, {method: 'head'})
}

head請求方式和參數(shù)傳遞方式與get請求一致,也會受限于瀏覽器惧互,但因為其不需要返回響應實體哎媚,其效率要比get方式高得多。單上述示例的簡單請求在chrome下表現(xiàn)大概就有20ms的優(yōu)化喊儡。

如果要上傳的數(shù)據(jù)確實比較多拨与,拼接參數(shù)后的url長度超出了瀏覽器的限制,導致請求失敗艾猜。則我們采取post的方式:

// 通用的方式 (可以采用fetch, 但fetch默認不帶cookie, 可能有認證問題)
export default function analytics (action = 'pageview', params) {
  let xhr = new XMLHttpRequest()
  let data = new FormData()
  data.append('action', action)
  for (let obj in params) {
    data.append(obj, params[obj])
  }
  xhr.open('POST', 'https://xxx/test_upload')
  xhr.send(data)
}

批量打包上報

無論單個埋點的數(shù)據(jù)量多少买喧,現(xiàn)在假設頁面為了做用戶行為分析,有多處埋點匆赃,頻繁上報可能對用戶正常功能的訪問有一定影響岗喉。

解決這個問題最直接思路就是減少上報的請求數(shù)。因此我們來實現(xiàn)一個批量上傳的feature炸庞,一個簡單的思路是每收集完10條數(shù)據(jù)就打包上報:

// 每10條數(shù)據(jù)數(shù)據(jù)進行打包
let logs = []
/**
 * @params {array} 日志數(shù)組
 */
function upload (logs) {
  console.log('send logs', logs)
  let xhr = new XMLHttpRequest()
  let data = new FormData()
  data.append('logs', logs)
  xhr.open('POST', this.url)
  xhr.send(data)
}

export default function analytics (action = 'pageview', params) {
  logs.push(Object.assign({
    action,
    timeStamp: Date.now()
  }, params))
  if (logs.length >= 10) {
    upload(logs)
    logs = []
  }
}

在埋點的位置,我們先執(zhí)行個幾十次看看

import analy from '@/vendor/analytics1.js'
for (let i = 33; i--;) {
    analy1('pv')
}

ok, 正常的話應該上報成功了荚斯,并且每條請求都包含了10個數(shù)據(jù)埠居。

但問題很快也暴露了,這種湊夠N條數(shù)據(jù)再統(tǒng)一發(fā)送的行為會出現(xiàn)斷層事期,如果在沒有湊夠N條數(shù)據(jù)的時候用戶就關掉頁面滥壕,或者是超過N倍數(shù)但湊不到N的那部分,如果不處理的話這部分數(shù)據(jù)就丟失了兽泣。

一種直接的解決方案是監(jiān)聽頁面beforeunload事件绎橘,在頁面離開前把剩余不足N條的log全部上傳。因此,我們添加一個beforeunload事件称鳞,順便整理下代碼涮较,將其封裝成一個類:

export default class eventTrack {
  constructor (option) {
    this.option = Object.assign({
      url: 'https://www.baidu.com',
      maxLogNum: 10
    }, option)
    this.url = this.option.url
    this.maxLogNum = this.option.maxLogNum
    this.logs = []
    // 監(jiān)聽unload事件,
    window.addEventListener('beforeunload', this.uploadLog.bind(this), false)
  }
  /**
   * 收集日志冈止,集滿 maxLogNum 后上傳
   * @param  {string} 埋點行為
   * @param  {object} 埋點附帶數(shù)據(jù)
   */
  analytics (action = 'pageview', params) {
    this.logs.push(Object.assign({
      action,
      timeStamp: Date.now()
    }, params))
    if (this.logs.length >= this.maxLogNum) {
      this.send(this.logs)
      this.logs = []
    }
  }
  // 上報一個日志數(shù)組
  send (logs, sync) {
    let xhr = new XMLHttpRequest()
    let data = new FormData()
    for (var i = logs.length; i--;) {
      data.append('logs', JSON.stringify(logs[i]))
    }
    xhr.open('POST', this.url, !sync)
    xhr.send(data)
  }
  // 使用同步的xhr請求
  uploadLog () {
    this.send(this.logs, true)
  }
}

目前為止我們初步實現(xiàn)了功能狂票,在進一步新增feature前,先繼續(xù)優(yōu)化下當前代碼熙暴,結合前面的過程闺属,我們可以考慮優(yōu)化這幾點:

  1. 上報請求方式應可選:調用形式如analytics.head(單條上報), analytics.post(默認)
  2. 頁面unload時候,采用更好的sendBeacon方式周霉,并向下兼容

關于sendBeacon, 該方法可以將少量數(shù)據(jù)異步傳輸?shù)絎eb服務器掂器。在上述代碼的uploadLog方法中,我們使用了同步的xhr請求俱箱,這樣做是為了防止頁面因關閉或者切換国瓮,腳本來不及執(zhí)行導致最后的日志無法上報。

beforeunload的場景下匠楚,同步xhrsendBeacon的特點

  • 同步xhr: 離開頁面時阻塞一會腳本巍膘,確保日志發(fā)出
  • sendBeacon: 離開頁面時發(fā)起異步請求,不阻塞并確保日志發(fā)出芋簿。有瀏覽器兼容問題

值得一提的是峡懈,單頁應用中,路由的切換并不會對漏報造成太大影響与斤,只要確保上報腳本是掛載到全局肪康,并處理好頁面關閉和跳轉到其他域名的情況就好。

總之撩穿,根據(jù)這兩點優(yōu)化磷支,我們在增加新功能前再完善下代碼:

export default class eventTrack {
  constructor (option) {
    this.option = Object.assign({
      url: 'https://www.baidu.com',
      maxLogNum: 10
    }, option)
    this.url = this.option.url
    this.maxLogNum = this.option.maxLogNum
    this.logs = []

    // 拓展analytics,允許單個上報
    this.analytics['head'] = (action, params) => {
      return this.sendByHead(action, params)
    }
    this.analytics['post'] = (action, params) => {
      return this.sendByPost(action, params)
    }
    // 監(jiān)聽unload事件食寡,
    window.addEventListener('beforeunload', this.unloadHandler.bind(this), false)
  }

  /**
   * 收集日志雾狈,集滿 maxLogNum 后上傳
   * @param  {string} 埋點行為
   * @param  {object} 埋點附帶數(shù)據(jù)
   */
  analytics (action = 'pageview', params) {
    this.logs.push(JSON.stringify(Object.assign({
      action,
      timeStamp: Date.now()
    }, params)))
    if (this.logs.length >= this.maxLogNum) {
      this.sendInPack(this.logs)
      this.logs = []
    }
  }

  /**
   * 批量上報一個日志數(shù)組
   * @param  {array} logs 日志數(shù)組
   * @param  {boolean} sync 是否同步
   */
  sendInPack (logs, sync) {
    let xhr = new XMLHttpRequest()
    let data = new FormData()
    for (var i = logs.length; i--;) {
      data.append('logs', logs[i])
    }
    xhr.open('POST', this.url, !sync)
    xhr.send(data)
  }

  /**
   * POST上報單個日志
   * @param  {string} 埋點類型事件
   * @param  {object} 埋點附加參數(shù)
   */
  sendByPost (action, params) {
    let xhr = new XMLHttpRequest()
    let data = new FormData()
    data.append('action', action)
    for (let obj in params) {
      data.append(obj, params[obj])
    }
    xhr.open('POST', this.url)
    xhr.send(data)
  }

  /**
   * Head上報單個日志
   * @param  {string} 埋點類型事件
   * @param  {object} 埋點附加參數(shù)
   */
  sendByHead (action, params) {
    let str = ''
    for (let key in params) {
      str += `&${key}=${params[key]}`
    }
    fetch(`https://www.baidu.com?action=${action}&timestamp=${Date.now()}${str}`, {method: 'head'})
  }

  /**
   * unload事件觸發(fā)時,執(zhí)行的上報事件
   */
  unloadHandler () {
    if (navigator.sendBeacon) {
      let data = new FormData()
      for (var i = this.logs.length; i--;) {
        data.append('logs', this.logs[i])
      }
      navigator.sendBeacon(this.url, data)
    } else {
      this.sendInPack(this.logs, true)
    }
  }
}

無網(wǎng)絡延時上報

思考一個問題抵皱,假如我們的頁面處于斷網(wǎng)離線狀態(tài)(比如就是信號不好)善榛,用戶在這期間進行了操作,而我們又想收集這部分數(shù)據(jù)會怎樣呻畸?

  1. 假如斷網(wǎng)非常短暫移盆,腳本持續(xù)執(zhí)行并且未觸發(fā)打包上傳。由于log仍保留在內存中伤为,繼續(xù)執(zhí)行直到觸發(fā)可上傳數(shù)量后咒循,網(wǎng)絡已恢復,此時無影響。
  2. 斷網(wǎng)時間較長叙甸,中間觸發(fā)幾次上報颖医,網(wǎng)絡錯誤會導致上報失敗。之后恢復網(wǎng)絡蚁署,后續(xù)日志正常上報便脊,此時丟失了斷網(wǎng)期間數(shù)據(jù)。
  3. 斷網(wǎng)從某一刻開始持續(xù)到用戶主動關閉頁面光戈,期間日志均無法上報哪痰。

我們可以嘗試增加“失敗重傳”的功能,比起網(wǎng)絡不穩(wěn)定久妆,更多的情況是某個問題導致的穩(wěn)定錯誤晌杰,重傳不能解決這類問題。設想我們在客戶端進行數(shù)據(jù)收集筷弦,我們可以很方便地記錄到log文件中肋演,于是同樣的考慮,我們也可以把數(shù)據(jù)暫存到localstorage上面烂琴,有網(wǎng)環(huán)境下再繼續(xù)上報多望,因此解決這個問題的方案我們可以歸納為:

  1. 上報數(shù)據(jù)菊匿,navigator.onLine判斷網(wǎng)絡狀況
  2. 有網(wǎng)正常發(fā)送
  3. 無網(wǎng)絡時記入localstorage, 延時上報

我們修改下sendInPack, 并增加對應方法

sendInPack (logs, sync) {
    if (navigator.onLine) {
      this.sendMultiData(logs, sync)
      this.sendStorageData()
    } else {
      this.storageData(logs)
    }
  }
  sendMultiData (logs, sync) {
    console.log('sendMultiData', logs)
    let xhr = new XMLHttpRequest()
    let data = new FormData()
    for (var i = logs.length; i--;) {
      data.append('logs', logs[i])
    }
    xhr.open('POST', this.url, !sync)
    xhr.send(data)
  }
  storageData (logs) {
    console.log('storageData', logs)
    let data = JSON.stringify(logs)
    let before = localStorage['analytics_logs']
    if (before) {
      data = before.replace(']', ',') + data.replace('[', '')
    }
    localStorage.setItem('analytics_logs', data)
  }
  sendStorageData () {
    let data = localStorage['analytics_logs']
    if (!data) return
    data = JSON.parse(data)
    this.sendMultiData(data)
    localStorage['analytics_logs'] = ''
  }

注意navigator.onLine在不同瀏覽器開發(fā)環(huán)境下的問題,比如chrome下localhost訪問時候,navigator.onLine值總為false掰吕, 改用127.0.0.1則正常返回值

更好的pv: visibilitychange

PV是日志上報中很重要的一環(huán)杖们。

目前為止我們基本實現(xiàn)完上報了画饥,現(xiàn)在再回歸到業(yè)務層面蚂斤。pv的目的是什么,以及怎樣更好得達到我們的目的畔派?
推薦先閱讀這篇關于pv的文章:

為什么說你的pv統(tǒng)計是錯的

在大多數(shù)情況下铅碍,我們的pv上報假設每次頁面瀏覽(Page View)對應一次頁面加載(Page Load),且每次頁面加載完成后都會運行一些統(tǒng)計代碼, 然而這情況對于尤其單頁應用存在一些問題

  1. 用戶打開頁面一次线椰,而在接下來的幾天之內使用數(shù)百次胞谈,但是并沒有刷新頁面,這種情況應該只算一個 Page View 么
  2. 如果兩個用戶每天訪問頁面次數(shù)完全相同憨愉,但是其中一個每次刷新呜魄,而另一個保持頁面在后臺運行,這兩種使用模式的 Page View 統(tǒng)計結果應該有很大的不同么
  3. ···

為了遵循更好的PV莱衩,我們可以在腳本增加下列情況的處理:

  1. 頁面加載時,如果頁面的 visibilityState 是可見的娇澎,發(fā)送 Page View 統(tǒng)計笨蚁;
  2. 頁面加載時, 如果頁面的 visibilityState 是隱藏的,就監(jiān)聽 visibilitychange 事件,并在 visibilityState 變?yōu)榭梢姇r發(fā)送 Page View 統(tǒng)計括细;
  3. 如果 visibilityState 由隱藏變?yōu)榭梢娢焙埽⑶易陨洗斡脩艚换ブ笠呀?jīng)過了“足夠長”的時間,就發(fā)送新的 Page View 統(tǒng)計奋单;
  4. 如果 URL 發(fā)生變化(僅限于 pathname 或 search 部分發(fā)送變化, hash 部分則應該忽略锉试,因為它是用來標記頁面內跳轉的) 發(fā)送新的 Page View 統(tǒng)計;
    在我們的構造函數(shù)中增加以下片段:
this.option = Object.assign({
  url: 'https://api.djigo.com/api/test',
  maxLogNum: 10,
  stayTime: 2000, // ms, 頁面由隱藏變?yōu)榭梢娎辣簦⑶易陨洗斡脩艚换ブ笞銐蚓么舾牵梢砸暈樾聀v的時間間隔
  timeout: 6000   // 頁面切換間隔,小于多少ms不算間隔
}, option)
this.hiddenTime = Date.now()
···
 // 監(jiān)聽頁面可見性
document.addEventListener('visibilitychange', () => {
  console.log(document.visibilityState, Date.now(), this.hiddenTime)
  if (document.visibilityState === 'visible' && (Date.now() - this.hiddenTime > this.option.stayTime)) {
    this.analytics('re-open')
    console.log('send pv visible')
  } else if (document.visibilityState === 'hidden') {
    this.hiddenTime = Date.now()
  }
})
···

更好的pv: hash跳轉

考慮我們是一個hash模式的單頁應用贷笛,即路由跳轉以 ‘#’加路由結尾標識应又。
如果我們想對每個路由切換進行追蹤,一種做法是在每個路由組件的進行監(jiān)聽乏苦,也可以在上報文件中直接統(tǒng)一處理:

window.addEventListener('hashchange', () => {
  this.analytics()
})

但這樣子有個問題株扛,如何判別當前hash跳轉是個有效跳轉。比如頁面存在重定向邏輯汇荐,用戶從A頁面進入(棄用頁面)洞就,我們代碼把它跳轉到B頁面,這樣pv發(fā)出去了兩次掀淘,而實際有效的瀏覽只是B頁面一次旬蟋。又或者用戶只是匆匆看了A頁面一眼,又跳轉到B頁面繁疤,A頁面要不要作為一次有效PV?

一種更好的方式是設置有效間隔咖为,比如小于5s的瀏覽不作為一個有效pv,那由此而生的邏輯稠腊,我們需要調整我們的 analytics 方法:

// 封裝一個sendPV 專門用來發(fā)送pv
constructor (option) {
  ···
  this.sendPV = this.delay((args) => {
    this.analytics({action: 'pageview', ...args})
  })
    
  window.addEventListener('hashchange', () => {
    this.sendPV()
  })
  this.sendPV()
···
}

delay (func, time) {
    let t = 0
    let self = this
    return function (...args) {
      clearTimeout(t)
      t = setTimeout(func.bind(this, args), time || self.option.timeout)
    }
}

ok, 到這里就差不多了躁染,完整示意在這里 analytics.js,加了點調用測試
考慮到不同業(yè)務場景架忌,我們還有有更多空間可以填補吞彤,數(shù)據(jù)閉環(huán)其實也是為了更好的業(yè)務分析服務,雖然是一個傳統(tǒng)功能叹放,但值得細細考究的點還是挺多的吧

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末饰恕,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子井仰,更是在濱河造成了極大的恐慌埋嵌,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,039評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件俱恶,死亡現(xiàn)場離奇詭異雹嗦,居然都是意外死亡范舀,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,426評論 3 395
  • 文/潘曉璐 我一進店門了罪,熙熙樓的掌柜王于貴愁眉苦臉地迎上來锭环,“玉大人,你說我怎么就攤上這事泊藕「ū纾” “怎么了?”我有些...
    開封第一講書人閱讀 165,417評論 0 356
  • 文/不壞的土叔 我叫張陵娃圆,是天一觀的道長玫锋。 經(jīng)常有香客問我,道長踊餐,這世上最難降的妖魔是什么景醇? 我笑而不...
    開封第一講書人閱讀 58,868評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮吝岭,結果婚禮上三痰,老公的妹妹穿的比我還像新娘。我一直安慰自己窜管,他們只是感情好散劫,可當我...
    茶點故事閱讀 67,892評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著幕帆,像睡著了一般获搏。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上失乾,一...
    開封第一講書人閱讀 51,692評論 1 305
  • 那天常熙,我揣著相機與錄音,去河邊找鬼碱茁。 笑死裸卫,一個胖子當著我的面吹牛,可吹牛的內容都是我干的纽竣。 我是一名探鬼主播墓贿,決...
    沈念sama閱讀 40,416評論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼蜓氨!你這毒婦竟也來了聋袋?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,326評論 0 276
  • 序言:老撾萬榮一對情侶失蹤穴吹,失蹤者是張志新(化名)和其女友劉穎幽勒,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體港令,經(jīng)...
    沈念sama閱讀 45,782評論 1 316
  • 正文 獨居荒郊野嶺守林人離奇死亡代嗤,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,957評論 3 337
  • 正文 我和宋清朗相戀三年棘钞,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片干毅。...
    茶點故事閱讀 40,102評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖泼返,靈堂內的尸體忽然破棺而出硝逢,到底是詐尸還是另有隱情,我是刑警寧澤绅喉,帶...
    沈念sama閱讀 35,790評論 5 346
  • 正文 年R本政府宣布渠鸽,位于F島的核電站,受9級特大地震影響柴罐,放射性物質發(fā)生泄漏徽缚。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,442評論 3 331
  • 文/蒙蒙 一革屠、第九天 我趴在偏房一處隱蔽的房頂上張望凿试。 院中可真熱鬧,春花似錦似芝、人聲如沸那婉。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,996評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽详炬。三九已至,卻和暖如春寞奸,著一層夾襖步出監(jiān)牢的瞬間呛谜,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,113評論 1 272
  • 我被黑心中介騙來泰國打工枪萄, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留隐岛,地道東北人。 一個月前我還...
    沈念sama閱讀 48,332評論 3 373
  • 正文 我出身青樓呻引,卻偏偏與公主長得像礼仗,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子逻悠,可洞房花燭夜當晚...
    茶點故事閱讀 45,044評論 2 355

推薦閱讀更多精彩內容

  • 在這篇文章里面元践,我們會對數(shù)據(jù)采集的一些基本概念進行闡述,然后童谒,會針對目前市面上新增的一些前端埋點技術单旁,如可視化埋點...
    言射手閱讀 6,606評論 1 52
  • 翻譯自 Google 工程師 Philip Walton 的文章象浑。共 3754 字蔫饰,讀完需 7 分鐘。合格的工程師...
    王仕軍閱讀 3,447評論 1 10
  • 中國互聯(lián)網(wǎng)用戶群已經(jīng)成為世界最大的互聯(lián)網(wǎng)群體愉豺。與此同時篓吁,中國互聯(lián)網(wǎng)網(wǎng)站的發(fā)展也歷經(jīng)了幾個階段,從單純的網(wǎng)絡媒體到現(xiàn)...
    零一間閱讀 4,265評論 1 41
  • 1.什么是架構 架構可以理解為是對系統(tǒng)高層視角的一個抽象,是系統(tǒng)實現(xiàn)預期的一個藍圖驰贷。就像建筑行業(yè)的藍圖一樣盛嘿,其是對...
    Taven_葛閱讀 351評論 0 0
  • 橫店文榮醫(yī)院麻醉科掠影 小微因為某種機緣,因為心底深處一直都在的想法括袒,所以有了6月初的一次“走...
    海緒清風閱讀 635評論 0 0