Re0:在Vue里用Service Worker来搞个中间层(React同理)(超详细)

7,408 阅读11分钟

前言

    事情是这样的,由于后台给的接口是获取源数据的,一开始只是拿来做一些简单图表的展示。但是后来需求越来越复杂,逻辑嵌套深,需要在各个图表之间串联依赖关系,把这一层放在前端来写太蛋疼了,因为业务代码里太多跟业务逻辑没有关系的代码了。这种情况其实就挺适合用node来做一个中间层来解决这一问题。

    但是用node来做中间层会增加维护成本,因为公司的运维并不支持node,而且node作为单线程处理一些并发量计算量大的请求会比较吃力。思前想后,Service Worker可以拦截请求,似乎可以做个中间层的样子,加上系统是对内的,可以限制用户只用chrome浏览器,兼容性问题可以忽略。所以最终决定尝试用Service Worker来模拟一个中间层试试。最终通过自己蹩脚的功夫,成功实践了这一需求,并使其工程化。其中遇到了很多问题,也一一解决了,感觉自己学到不少东西。

前面几个章节都是讲的一些工程化的东西,核心部分在第7章里

开工

1.在你的项目的src目录下创建一个sw.js的文件:

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)

那我们分情况来解决这个问题:

5.1 首先是localhost的情况

这个情况是可以直接用的,浏览器打开localhost:8080就可以注册sw.js了:

因为我的后台接口都是通过cookie来验证用户登录态的,请求就带不上后台接口域名下的cookie给服务器,而是默认本地服务器设置的cookie,这个时候就会遇到遇到跨域的问题:

可以看到后台判断我没有登录...

我再次就介绍其中一种简单点的解决方法: 给chrome添加ModHeader插件(应该要翻墙吧,微笑脸):

然后激活,在右上角可以点击对应图标,在弹窗里填入Cookie(如有需要可以其他头信息,如Referer)就可以使用了:

启用插件后,我们就可以发现之前的请求就会带上这个CookieReferer(我这个项目其实是不需要加Referer的)

然后就请求成功了,服务器根据这个Cookie判断我们是登录态

Cookie失效了只能重新生成,然后更新插件里面的值

5.1 首先是https的情况

因为我们本地开发一般来说启动的都是http服务,那么我们这个时候就要启动https服务器了,首先我们需要准备一下证书。这里我们用到了mkcert这个工具

用命令行生成证书

mkcert '*.example.com'

然后可以得到一个秘钥和一个公钥,并把它们弄到项目的config文件下

左边的为秘钥,右边的为公钥

我们打开公钥,然后会提示导入失败

这个时候需要我们在钥匙串里双击这个证书并信任他

我用的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服务了:

  • 本地开发服务器为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,就可以发现证书生效了

最后我们来对比下这两者的优缺点

方式 优点 缺点
localhost 方便快捷启动 生成Cookie替换过程麻烦
https 启动过程麻烦,需要生成并引用证书 可以自动生成Cookie

6.我们给Service Worker加点环境参数

首先修改一下config/index.js,往里面添加点东西:

然后我们给webpack.dev.conf.jswebpack.prod.conf.js里的HtmlWebpackPlugin这个插件加入刚刚修改的config

这样我们就可以在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"
    }
}

最后我们来看看运行情况怎么样

好像还可以,可以看到我们所有的/api/v1/check请求都是从service worker里返回来的

8.最后谈谈

篇幅意想不到变得太长了,其中一半的内容都在讲实际项目中遇到的大坑小坑,些许无聊,可能看完的人不多,也是通过自己分享实际工作中遇到的问题,希望能帮助到大家。有什么问题大家可以留言交流交流。