Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

B1anker發表於2018-07-07

前言

    事情是這樣的,由於後臺給的介面是獲取源資料的,一開始只是拿來做一些簡單圖表的展示。但是後來需求越來越複雜,邏輯巢狀深,需要在各個圖表之間串聯依賴關係,把這一層放在前端來寫太蛋疼了,因為業務程式碼裡太多跟業務邏輯沒有關係的程式碼了。這種情況其實就挺適合用node來做一箇中間層來解決這一問題。

    但是用node來做中間層會增加維護成本,因為公司的運維並不支援node,而且node作為單執行緒處理一些併發量計算量大的請求會比較吃力。思前想後,Service Worker可以攔截請求,似乎可以做箇中間層的樣子,加上系統是對內的,可以限制使用者只用chrome瀏覽器,相容性問題可以忽略。所以最終決定嘗試用Service Worker來模擬一箇中間層試試。最終通過自己蹩腳的功夫,成功實踐了這一需求,並使其工程化。其中遇到了很多問題,也一一解決了,感覺自己學到不少東西。

前面幾個章節都是講的一些工程化的東西,核心部分在第7章裡

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

開工

1.在你的專案的src目錄下建立一個sw.js的檔案:

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

2.先往裡面稍微寫點東西以示尊敬:

這裡用到了google封裝的sw-toolbox.js,我把它下載下來放到了src/service-worker/lib裡。具體用法可以到官網看,我就不再贅述了,畢竟用法跟express類似。深入點的就得看原始碼了,不然百思不得其解,別問我為什麼知道。

self.importScripts('/service-worker/lib/sw-toolbox.js')
const cacheVersion = '20180705v1'
const staticCacheName = 'static' + cacheVersion
const staticAssetsCacheName = '/' + cacheVersion
const vendorCacheName = 'verdor' + cacheVersion
const contentCacheName = 'content' + cacheVersion
const maxEntries = 100

// 本身的這個sw.js不使用快取,每次都通過網路請求
self.toolbox.router.get(
  '/sw.js',
  self.toolbox.networkFirst
)
// 快取static下的靜態資源
self.toolbox.router.get('/static/(.*)', self.toolbox.cacheFirst, {
  cache: {
    name: staticCacheName,
    maxEntries: maxEntries
  }
})

// 快取根目錄下的js檔案
self.toolbox.router.get("/(.js)", self.toolbox.cacheFirst, {
  cache: {
    name: staticAssetsCacheName,
    maxEntries: maxEntries
  }
})

self.addEventListener("install", function (event) {
  return event.waitUntil(self.skipWaiting())
})

self.addEventListener("activate", function (event) {
  return event.waitUntil(self.clients.claim())
})

複製程式碼

3.我們來修改一下webpack配置,使得其可以在開發時使用:

    一般現在專案裡都有個webpack.base.conf.js的檔案,那我們先從這裡開刀。給它加個plugin,這樣既能在開發的時候拷貝到記憶體裡,又能在編譯時拷到對應的目錄裡。

new CopyWebpackPlugin([
  {
    from: path.resolve(__dirname, '../src/sw.js'),
    to: path.resolve(__dirname, config.build.assetsRoot)
  }
])

複製程式碼

4.我們來改一些index.html來引用這個sw.js

// index.html
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
        .then(function(registration) {
          // Successful registration
          console.log('Hooray. Registration successful, scope is:', registration.scope);
        }).catch(function(err) {
          // Failed registration, service worker won’t be installed
          console.log('Whoops. Service worker registration failed, error:', err);
        });
  }
複製程式碼

5.我們準備些開發環境

因為Service Worker的功能太過強大,所以瀏覽器對其做了些限制:

  • 只有在localhost或者https並且證照有效的情況下才能使用
  • 我們在開發的過程中難免會遇到跨域的問題,有的通過proxy轉發後端請求(可以用localhost),有的通改本地host(可以用http/https)

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

那我們分情況來解決這個問題:

5.1 首先是localhost的情況

這個情況是可以直接用的,瀏覽器開啟localhost:8080就可以註冊sw.js了:

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

因為我的後臺介面都是通過cookie來驗證使用者登入態的,請求就帶不上後臺介面域名下的cookie給伺服器,而是預設本地伺服器設定的cookie,這個時候就會遇到遇到跨域的問題:

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

可以看到後臺判斷我沒有登入...

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

我再次就介紹其中一種簡單點的解決方法: 給chrome新增ModHeader外掛(應該要翻牆吧,微笑臉):

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

然後啟用,在右上角可以點選對應圖示,在彈窗裡填入Cookie(如有需要可以其他頭資訊,如Referer)就可以使用了:

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

啟用外掛後,我們就可以發現之前的請求就會帶上這個CookieReferer(我這個專案其實是不需要加Referer的)

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)
Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

然後就請求成功了,伺服器根據這個Cookie判斷我們是登入態

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

Cookie失效了只能重新生成,然後更新外掛裡面的值

5.1 首先是https的情況

因為我們本地開發一般來說啟動的都是http服務,那麼我們這個時候就要啟動https伺服器了,首先我們需要準備一下證照。這裡我們用到了mkcert這個工具

用命令列生成證照

mkcert '*.example.com'
複製程式碼

然後可以得到一個祕鑰和一個公鑰,並把它們弄到專案的config檔案下

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

左邊的為祕鑰,右邊的為公鑰

我們開啟公鑰,然後會提示匯入失敗

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)
Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)
這個時候需要我們在鑰匙串裡雙擊這個證照並信任他

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

我用的mac系統,所以其他系統並沒有實踐過,小夥伴可以搗鼓搗鼓,應該沒什麼坑吧!(大霧...)

然後我們來改下配置啟動一下https伺服器

  • 本地開發伺服器為express的情況: 修改dev-server.js
const https = require('https')
const SSLPORT = 8081 // 寫個合理的值就好
// 引入祕鑰
const privateKey  = fs.readFileSync(path.resolve(__dirname, '../config/key/_wildcard.xxx.com-key.pem'), 'utf8')
// 引入公鑰
const certificate = fs.readFileSync(path.resolve(__dirname, '../config/key/_wildcard.xxx.com.pem'), 'utf8')
const credentials = {
  key: privateKey,
  cert: certificate
}
...
// 然後再app.listen(port)之後新增
httpsServer.listen(SSLPORT, function () {
  console.log('HTTPS Server is running on: https://localhost:%s', SSLPORT);
})
複製程式碼

我們來重啟服務一波,就會發現開啟了https服務了:

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

  • 本地開發伺服器為webpack-dev-server的情況: 我們修改一下webpack.dev.conf.js這個檔案,在devServer這個欄位里加點東西
// webpack.dev.conf.js
devServer: {
    ...,
    https: {
      key: fs.readFileSync(path.resolve(__dirname, '../config/key/_wildcard.xxx.com-key.pem', 'utf8'),
      cert: fs.readFileSync(path.resolve(__dirname, '../config/key/_wildcard.xxx.com.pem', 'utf8')
    }
}
複製程式碼

最後我們在瀏覽器輸入地址https://xxx.xxx.com,就可以發現證照生效了

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

最後我們來對比下這兩者的優缺點

方式 優點 缺點
localhost 方便快捷啟動 生成Cookie替換過程麻煩
https 啟動過程麻煩,需要生成並引用證照 可以自動生成Cookie

6.我們給Service Worker加點環境引數

首先修改一下config/index.js,往裡面新增點東西:

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

然後我們給webpack.dev.conf.jswebpack.prod.conf.js裡的HtmlWebpackPlugin這個外掛加入剛剛修改的config

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

這樣我們就可以在index.html裡面利用ejs語法引入config裡面新設定的引數了,我們在head標籤裡面加段指令碼:

<script>
  __GLOBAL_CONFIG__  = JSON.parse('<%= JSON.stringify(htmlWebpackPlugin.options.config) %>')
  __NODE_ENV__ = __GLOBAL_CONFIG__.env
</script>
複製程式碼

然後我們就可以對Service Worker插入一些環境引數,修改原來的在index.html下的有關Service Worker程式碼:

// index.html
<script>
  if ('serviceWorker' in navigator) {
    const ServiceWorker = __GLOBAL_CONFIG__.ServiceWorker
    // 根據配置是否開啟Service Worker
    if (ServiceWorker.enable) {
      // 開啟則引入sw.js
      navigator.serviceWorker.register('/sw.js')
        .then(function(registration) {
          // Successful registration
          const messageChannel = new MessageChannel()
          // 通過postMessage注入環境引數
          navigator.serviceWorker.controller.postMessage({
            type: 'environment',
            __NODE_ENV__
          }, [messageChannel.port2]);
          console.log('Hooray. Registration successful, scope is:', registration.scope);
        }).catch(function(err) {
          // Failed registration, service worker won’t be installed
          console.log('Whoops. Service worker registration failed, error:', err);
        });
    } else {
      // 不開啟則登出掉以前的快取
      navigator.serviceWorker.getRegistrations().then(function (regs) {
        for (var reg of regs) {
          reg.unregister()
        }
      })
    }
  }
</script>
複製程式碼

7.我們封裝下中間層

7.1封裝中間層程式碼

我們在src/service-worker目錄下建立一個model的資料夾,用於開發Service Worker的中間層模組,最終被webpack打包生成src/service-worker/model.js,從而被sw.js引用

然後來對sw.js動下刀,來使得Service Worker的開發可以工程化:

// sw.js
self.importScripts('/service-worker/lib/sw-toolbox.js')
const cacheVersion = '20180705v1'
const staticCacheName = 'static' + cacheVersion
const staticAssetsCacheName = '/' + cacheVersion
const vendorCacheName = 'verdor' + cacheVersion
const contentCacheName = 'content' + cacheVersion
const maxEntries = 100
self.__NODE_ENV__ = ''
// 從index.html的postMessage裡接受訊息
self.addEventListener('message', function (event) {
  const data = event.data
  const { type } = data
  if (type === 'environment') {
    // 這樣就成功在Service Worker環境裡設定好了環境引數
    self.__NODE_ENV__ = data.__NODE_ENV__
    self.toolbox.options.debug = false
    self.toolbox.options.networkTimeoutSeconds = 3

    self.toolbox.router.get(
      '/sw.js',
      self.toolbox.networkFirst
    )
    // 這個model.js我們需要通過編譯打包src/service-worker/model裡的檔案生成
    self.toolbox.router.get(
      '/service-worker/model.js',
      self.toolbox.networkFirst
    )
  
    self.toolbox.router.get('/static/(.*)', self.toolbox.cacheFirst, {
      cache: {
        name: staticCacheName,
        maxEntries: maxEntries
      }
    })
  
    self.toolbox.router.get("/(.js)", self.toolbox.cacheFirst, {
      cache: {
        name: staticAssetsCacheName,
        maxEntries: maxEntries
      }
    })

    self.importScripts('/service-worker/model.js')
  }
})


self.addEventListener("install", function (event) {
  return event.waitUntil(self.skipWaiting())
})

self.addEventListener("activate", function (event) {
  return event.waitUntil(self.clients.claim())
})

複製程式碼

下面我們來具體講解一下src/service-worker/model這個資料夾裡要怎麼封裝程式碼,暫時來說還比較簡單,所以就直接用單例模式來封裝程式碼了

首先我們來建立index.js來攔截一下請求,然後分發請求給不同的model 程式碼裡的self.MODEL_BASE_URL = __NODE_ENV__ === 'development' ? '/api' : ''是重點,為什麼我們要煞費苦心把環境變數弄進sw.js裡來,就是為了適應開發和生產環境對api地址的變動

// index.js
// 具體的model
import Check from './check'
// 為了適應開發和生產環境對api地址的變動
self.MODEL_BASE_URL = __NODE_ENV__ === 'development' ? '/api' : ''

// 只要請求是以/api/v1開頭的,都會在這裡被攔截到
self.toolbox.router.post('/api/v1/(.*)', async function (request, values, options) {
  const body = await request.text()
  const { url } = request
  // 通過正則來提取出model和api
  const [ model, api ] = url.match(/(?<=api\/v1\/).*/)[0].split('/')
  // 分發model
  if (model === 'check') {
    return await Check.startCheckQuque(body)
  }
})
複製程式碼

然後在裡面建立http.js來封裝一下fetch

// http.js
class Http {
  fetch (url, body, method) {
    return fetch(url, {
      method,
      body,
      // 加上這個,fetch請求才會帶上Cookie
      credentials: 'include',
      // 這裡要看你後臺具體是怎麼接受資訊的了
      headers: {
        'Content-Type': 'application/json'
      }
    })
    .then((res) => {
      return res.json()
    })
  }

  // get請求
  get (url, params) {
    return this.fetch(url, params, 'GET')
  }

  // post請求
  post (url, body) {
    return this.fetch(url, body, 'POST')
  }
  
  ...

  // 最終要返回一個Response給sw-toolbox.js的路由裡
  response (result) {
    return new Response(JSON.stringify(result), {
      headers: { 'Content-Type': 'application/json' }
    })
  }
}

export default Http
複製程式碼

然後我們來擼一擼具體的某個model

這個model的作用就是攔截所有的/api/v1/check請求,然後做了個佇列,往裡面push請求資訊,並把多個check請求合併成一個listCheck請求,這樣可以減少http請求次數,這個具體要看場景,我這裡是因為後臺支援這種場景才這麼做的,具體邏輯我就不說了,不過可以看下下面程式碼的一些註釋,是一些細節點。不同的model可以幹不同的事情,具體怎麼搞就看大家發揮了。

// check.js
import Http from './http'
// 繼承一下Http
class Check extends Http {
  constructor () {
    super()
    this.CheckQuqueIndex = 0
    this.CheckQuqueStore = []
    this.OK = []
    this.timer = []
    this.result = {}
  }

  async startCheckQuque (body) {
    let index
    this.CheckQuqueStore.push(JSON.parse(body))
    index = this.CheckQuqueIndex++
    return await this.listCheck(index)
  }

  sleep (group) {
    return new Promise((resolve, reject) => {
      const timer = setInterval(() => {
        if (this.OK[group] === true) {
          resolve()
          clearInterval(timer)
        }
      }, 30)
    })
  }

  forceBoot (index, group) {
    return new Promise((resolve, reject) => {
      if ((index + 1) % 5 === 0) {
        resolve(true)
      } else {
        setTimeout(() => {
          resolve(true)
        }, 50)
      }
    })
  }

  async listCheck (index) {
    const group = Math.floor(index / 5)
    await new Promise(async (resolve, reject) => {
      const forcable = await this.forceBoot(index, group)
      if (forcable && ((index + 1) % 5 === 0 || (index + 1) === this.CheckQuqueIndex)) {
        this.OK[group] = false
        this.result[group] = await this.post(
          // 實際介面的地址,self.MODEL_BASE_URL在src/service-worker/mode/index.js裡有定義
          `${self.MODEL_BASE_URL}/listCheck`,
          JSON.stringify(this.CheckQuqueStore.slice(index - 4, index + 1))
        )
        this.OK[group] = true
      } else {
        await this.sleep(group)
      }
      resolve()
    })
    const id = this.CheckQuqueStore[index].requestId
    const { code, msg } = this.result[group]
    // 我們把之前的資料處理完,通過http類裡的response方法把運算結果返回去
    return this.response({
      code,
      msg,
      data: {
        series: this.result[group].data.series.filter((res) => {
          return res.requestId === id
        })
      }
    })
  }
}

export default new Check()
複製程式碼

7.2打包編譯model

到此,我們還要編寫一個webpack配置來打包編譯這個/src/service-worker/model,我這裡就省點功夫,把開發和生產模式都寫到一起了。由於能Service Worker肯定就能用es6,所以就不要用任何loader了,只需要合併壓縮一下程式碼即可

// webpack.sw.conf.js
const path = require('path')
const rm = require('rimraf')
const ora = require('ora')
const chalk = require('chalk')
const util = require('util')
const webpack = require('webpack')
const watch = require('watch')
// uglify2
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')

const env = process.env.NODE_ENV
const rmPromise = util.promisify(rm)

const resolve = function (dir) {
  return path.resolve(__dirname, '..', dir)
}


const webpackConfig = {
  entry: resolve('src/service-worker/model'),

  watchOptions: {
    aggregateTimeout: 300,
    poll: 1000
  },

  output: {
    path: resolve('src/service-worker'),
    filename: 'model.js'
  },

  resolve: {
    extensions: ['.js']
  },

  plugins: []
}

function boot () {
  const spinner = ora('building for production...')
  spinner.start()
  rmPromise(resolve('src/service-worker/model.js'))
  .then(() => {
    webpack(webpackConfig, function (err, stats) {
      spinner.stop()
      if (err) {
        throw err
      }
      process.stdout.write(stats.toString({
        colors: true,
        modules: false,
        children: false,
        chunks: false,
        chunkModules: false
      }) + '\n\n')
    
      if (stats.hasErrors()) {
        console.log(chalk.red('  Build failed with errors.\n'))
        process.exit(1)
      }
    
      console.log(chalk.cyan('  Build complete.\n'))
      console.log(chalk.yellow(
        '  Tip: built files are meant to be served over an HTTP server.\n' +
        '  Opening index.html over file:// won\'t work.\n'
      ))
    })
  })
  .catch((err) => {
    throw err
  })
}

if (env === 'development') {
  watch.watchTree(resolve('src/service-worker/model'), function (f, curr, prev) {
    boot()
  })
} else {
  webpackConfig.plugins.unshift(new UglifyJsPlugin())
  boot()
}

複製程式碼

然後還需要修改一下webpack.base.conf.js 首先來把編譯後的model.js導進去,也就是往之前在webpack.base.conf.js加的CopyWebpckPlugin裡再加一項:

{
  from: resolve('src/service-worker/model.js'),
  to: path.resolve(__dirname, config.build.assetsRoot, 'service-worker')
}
複製程式碼

接著在相關的loaderexcludesrc/service-worker/model,因為這些檔案的改動並不需要被編譯到專案裡,是用的另外一個webpack

我們在package.json里加點script來啟動它

package.json
{
    "scripts": {
        // 開發環境
        "dev:sw": "cross-env NODE_ENV=development node build/webpack.sw.conf.js",
        // 生產環境
        "build:sw": "node build/webpack.sw.conf.js"
    }
}
複製程式碼

最後我們來看看執行情況怎麼樣

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)
好像還可以,可以看到我們所有的/api/v1/check請求都是從service worker裡返回來的

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

8.最後談談

篇幅意想不到變得太長了,其中一半的內容都在講實際專案中遇到的大坑小坑,些許無聊,可能看完的人不多,也是通過自己分享實際工作中遇到的問題,希望能幫助到大家。有什麼問題大家可以留言交流交流。

最後打波廣告,近期部門缺人,以下是jd:

公司:虎牙直播
職位要求:
1.本科以上學歷
2.1~3年工作經驗
3.js基礎紮實
4.熟練vue,react等主流框架之一
5.熟悉http協議
6.對效能優化有自己的見解
7.會一些node
複製程式碼

大佬們感興趣的話,可以發一波簡歷到我郵箱裡:luoxianwei@huya.com,備註掘金

Re0:在Vue裡用Service Worker來搞箇中間層(React同理)(超詳細)

相關文章