一種小拖大的jssdk加載方案

背景

jssdk 是在前端中完成某些業務功能的 JavaScript 函數庫,一般由 sdk 的開發者開發完畢後,交給業務的頁面來引入使用。例如:css

<head>
    <script src="//hm.baidu.com/hm.js?XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"></script>
</head>

在一些特殊的場景(例如聯盟廣告)下,咱們一般須要把一個 jssdk 地址 交付給另一個團隊的頁面來引入。對於大型廣告聯盟商來講,通常是提供本身的聯盟廣告平臺,在平臺上,開發者能夠申請到廣告appid,並按照文檔引入廣告 sdk 到本身的頁面中使用。一切比較順利。html

但在公司內部,我所在的商業化部門並無造成如此成熟的平臺。這時咱們採用的是比較原始的辦法,我所在的商業化部門要把廣告sdk開發完成後,部署到cdn。而後咱們部門將 cdn地址如 「http://a.b.c/12345.js」 告知對方業務部門的相關開發,讓對方放置個人js到對方的頁面當中。每次開發、測試、部署,咱們的js資源地址必然會發生變化,結果就是每次都要找對方部門溝通協調部署問題,部署成本巨大。在沒有良好的機制協調下,每每會形成開發和測試發佈成本上升,效率低端低下。前端

爲了解決該問題,我在現狀的基礎上,設計了一種「小拖大」的jssdk加載方案,完全解決對對方部門的依賴。webpack

歷史背景

先看下現狀咱們的工做模式是怎樣的:git

image.png

我,做爲廣告團隊的開發,要麼是把廣告組件作成npm包交給對方;要麼是把廣告作成js放cdn交給對方。每一種方式都要找對方聯調和溝通,npm包的方式對方還要編譯到對方業務中,其成本和出錯的機率更大。最蛋疼的是,第二種cdn交付方式,每次通知對方後,對方須要去後臺配置一下我給他的js地址,而後他下發後,客戶端瀏覽器纔會真正的請求最新的js地址。web

工做模式畫成時序圖,以下:npm

image.png

其缺點比較明顯:json

  • 架構上: 不符合如今分團隊的開發模式
  • 流程上: 多了冗餘的溝通,例如找後臺同窗配置
  • 技術上: jssdk下發方式不夠標準,不夠原生,不夠靈活

拋出問題

如何可以減小依賴,下降溝通成本呢。其實最簡單的方法就是讓對方引入一個固定的js地址就行了。目標很明確!後端

即,我指望實現JSSDK,在不依賴頁面方的狀況下自更新?api

技術方案

咱們最容易想到的方案即是:給對方一個固定js地址,每次咱們更新的廣告代碼,咱們就在此地址上更新js。但這樣的話,有幾個問題:

  • 咱們的廣告組件便沒有了版本的概念,回滾時只能回滾git
  • 咱們的廣告jssdk,完全沒有了緩存。若是咱們的jssdk體積增大,那麼用戶每次打開頁面都要下載一個大js
  • 也沒法利用cdn就近的優點

基於這種考量,我設計了一種 「小拖大」 的方案,這種方案放棄了20%的緩存能力,但能保留住80%的緩存能力。用20%的緩存放棄,換來開發效率極大的提高,對於廣告場景來講是比較適合的,由於廣告並非一個頁面中最核心的性能訴求,頁面最關鍵的是基本功能的性能和展現,其次纔是廣告的正常展現和渲染,所以廣告適當少許的延遲並不會有太大的影響。

如下是我對幾種方案的對比圖:image.png

因而,一個新的更適應我當前場景的jssdk加載方案,其時序圖是這樣的:

image.png

文字描述一個完整的首次廣告請求以下:

  • 首先將咱們的 「種子sdk」 地址放入對方業務頁面(種子sdk將是一個固定不變的jssdk地址),
  • 對方業務頁面被用戶打開後,會發起對種子jssdk的請求
  • 種子sdk請求到達我方 sdk server 後,我方 sdkserver實時生成一個 seed.js ,其中會放入當前各個廣告組件最新版本的真實cdn地址的一個「資源映射表」
  • 當頁面收到服務端返回的 seed.js。頁面中能夠根據業務廣告的需求,隨意建立任何類型的廣告。例如建立一個文中廣告:new ArticleAd()
  • 此時,seed.js 發現業務要實例化一個 ArticleAd 的廣告,則seed.js會查詢資源映射表,找到 ArticleAd 的真正cdn地址並完成廣告代碼加載和初始化渲染

因爲cdn上的真正的廣告js是強緩存的,所以用戶在大部分狀況下,都將會使用本地緩存的廣告jssdk。惟一的缺點是 seed.js 是須要每次都發起請求的(因爲廣告不會每小時都在更新,所以這裏也能夠將 seed.js 設置爲強緩存1天或1小時)。

因爲 seed.js 核心代碼僅僅有不到100行,所以其體積微乎其微,加載時間也很是的快。

種子js的實現

下面咱們來看整個架構中比較核心的 seed.js 是如何實現的。這裏要考慮以下一些問題:

  • seed如何實現異步加載組件和組件註冊?
  • 如何保證屢次加載時避免重複加載?
  • 組件加載完成後如何通知seed繼續執行?
  • seed加載器如何知道js資源最新地址?
  • 組件版本更新後如何第一時間更新頁面?
  • 如何方便頁面調試?

種子js 並非一個靜態的 js,因爲它須要內置一個最新版本的資源映射表。所以他是由 server 端動態來生成的,咱們 server 端能夠採用 Node.js 配合模板引擎來實現。

參考下 webpack 的動態import原理

webpack 中有個 動態 import 的能力,便可以讓咱們在代碼中書寫:

import('abc.js')

這樣的代碼。而後瀏覽器中加載時,會動態遠程加載並將abc.js的導出做爲本地webpack的一個模塊來使用。

這個思路就有點相似於咱們本文所述的 seed.js 要完成的功能,所以咱們來看看它webpack是如何實現動態 import 的:
image.png

其底層邏輯仍是比較簡單的。

實現簡單版本

可是個人seed.js並不想實現的那麼重,也不須要有 require loader 這樣的概念。

  1. webpack 有chunk概念,對咱們來講我用不到。
  2. 須要主調模塊中寫明被調組件名和哈希地址,他是在編譯期實現進行代碼分割。而個人seed.js但願簡化邏輯且不該該存在調用代碼,且要支持後端任意動態新增組件。

因而,我在此基礎上實現了一個更適用於本場景的簡單的版本。其大概邏輯以下:

  1. 我在服務端會將當前的 "廣告資源cdn地址映射表" 插入到下圖的 RES_MAP 這個對象當中。

image.png

  1. 實現一個generate函數,等待對方業務調用

image.png
該函數的功能是:當對方業務調用 generate('ArticleAd') 這樣的函數時,則意味是要建立並初始化一個 ArticleAd 的廣告,那麼seed.js須要去主動加載 ArticleAd 廣告的js資源,並完成初始化。
其中 _loadModule 函數會去 RES_MAP映射表中尋找資源地址,並完成js資源加載和內存緩存(防止屢次調用generate)
image.png

如何給開發者屏蔽開發細節

有了 seed.js 去負責加載真正的廣告js。那麼,咱們廣告開發者的工做只需關注在:如何開發一個真正的能夠被 seed.js 加載的 廣告sdk便可。

那麼,如何能讓真正的廣告sdk開發更有效率呢? 個人指望是這樣的:

image.png

我指望如上圖,每個廣告組件是一個標準的目錄結構。如上圖綠色部分是一個廣告組件,紅色部分是另一個廣告組件。每一個廣告組件都有固定的編寫模式和規範,包括:

  • index.html 是本地調試的demo頁面
  • img存放圖片資源
  • jsapi.js 放置工具函數
  • main.js 是你廣告sdk的執行入口
  • style.scss是樣式代碼
  • template.art 是你廣告dom的模板

其中main.js 會被webpack編譯,並打包成一個 bundle.js。而這個 bundle.js就是你所開發的廣告組件的sdk,他將被seed.js加載並執行。

經過 webpack loader 生成主js

問題來了。咱們一個廣告組件的 main.js 不可能無緣無故就能夠被 seed.js加載執行,他須要有必定的配合才能夠。就我目前的場景來講,個人廣告js中的main.js須要以下的樁代碼來完成主動向 seed.js 來註冊本身:

// 把當前組件註冊到 seed.js 中
      (function(root) {
        if (root && root.tnfa && root.tnfa.cache && !root.tnfa.cache[{{comp-name}}.compName]) { // 最後一個條件是防止用戶屢次調用屢次並行load,會把cache中的組件類替換掉
          root.tnfa.cache[{{comp-name}}.compName] = {{comp-name}}
        }
      })(window)`;

但是,總不能讓廣告組件的開發者每一個人都記得在 main.js 底部寫上這樣一段代碼。所以,我使用 webpack 的 loader 來實現自動給 main.js chunk 添加樁代碼,loader 的實現以下:

module.exports = function (source) {
  if (/src[\/\\]ads-comp[\\\/]([-\w])+[\/\\]main\.js/.test(this.resourcePath)) {
      // 若是是組件入口,則添加註冊代碼
      const code = `
      // 把當前組件註冊到 seed.js 中
      (function(root) {
        if (root && root.tnfa && root.tnfa.cache && !root.tnfa.cache[{{comp-name}}.compName]) { // 最後一個條件是防止用戶屢次調用屢次並行load,會把cache中的組件類替換掉
          root.tnfa.cache[{{comp-name}}.compName] = {{comp-name}}
        }
      })(window)`;
      // 分析組件名
      const regRes = /src[\/\\]ads-comp[\\\/]([-\w]+)[\/\\]main\.js/.exec(this.resourcePath)
      const compFolder = regRes[1]
      const result = source + `\n${code.replace(/{{comp-name}}/g, upcaseFirstLetter(compFolder))}`;
      return result
  }
  return source;
}

function upcaseFirstLetter(word) {
  return  word.replace(/((^\w)|(-\w))/g, function(m) {
      return m.toUpperCase()
  }).replace(/-/g, '')
}

經過 webpack 插件生成資源配置表

文中開頭有提到,咱們的 seed.js 每次給用戶返回時,都會將一個最新資源映射表放置到 seed.js 中的 RES_MAP 對象上。那麼這個資源映射表是怎樣造成的呢。

這裏,咱們能夠藉助 webpack 插件來將每次開發廣告的同窗編譯或CI出來的最新 sdk 地址記錄下來,並最終輸出爲一份資源映射表。

webpack 插件的實現代碼以下:

const pluginName = 'genMetaJson'
const path = require('path')

class GenMetaJson {
  // apply 被 webpack compiler 在打包前調用,用於註冊上咱們的插件處理邏輯吧
  apply(compiler) {
    // 註冊相應事件的插件處理邏輯

    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      const filenames = Object.keys(compilation.assets).filter(name => {
        return path.extname(name) === '.js'
      })

      let meta = {}
      filenames.forEach(name => {
        const baseName = path.basename(name)
        const preName = baseName.slice(0, baseName.indexOf('.'))
        meta[preName] = name
      })

      console.log('result', meta)
      if (meta && Object.keys(meta)) {
        meta = JSON.stringify(meta, null, '\t')
        compilation.assets['meta.json'] = {
          source: function() {
            return meta
          },
          size() {
            return meta.length
          }
        }
      }
      callback()
    })
  }
}


module.exports = GenMetaJson

最終在meta.json中,咱們將會看到這樣的結果:

{
    "ArticleAd": "article-ad.2df9c7eb94554e20.js",
    "VideoAd": "video-ad.5ea7aaf787bfe15c.js",
}

全部文件名,廣告資源名,都是按照咱們廣告組件開發的約定自動由webpack生成的。

至此,開發同窗只需在接到一個廣告開發需求時,打開咱們的項目,新建一個對應的文件夾如 「my-ad」。按照約定建立響應的文件,開發過程當中使用 npm run comp:dev 預覽。開發結束後走 CI,CI執行 npm run comp:build生成資源映射表。而後咱們將映射表配置到 seed.js server便可。

配合上 CI 流水線的話,就會更加簡便了:

image.png

sdk的加載方式

最後咱們再來思考下廣告jssdk交給對方頁面引用時,最好是用何種方式引用呢?

咱們能夠這樣思考:對於業務來講,頁面的核心訴求是保證基本功能的使用。其次纔是統計和廣告等附加需求。

所以,在業界統計和廣告jssdk一般儘可能採用異步的方式來加載,例如百度提供的異步加載方式:

<script>
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "//hm.baidu.com/hm.js?XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>

這種方式相似於script標籤的 async 屬性的功能,可讓js腳本的加載和執行不阻塞當前腳本所在位置的html dom樹構造和渲染。

所以,我也建議在咱們開發各種jssdk以後,交給用戶使用時,能夠建議對方使用相似上面這樣的 async 加載方式,從而最大限度的下降對用戶頁面的性能影響。