前言
事情是這樣的,由於後臺給的介面是獲取源資料的,一開始只是拿來做一些簡單圖表的展示。但是後來需求越來越複雜,邏輯巢狀深,需要在各個圖表之間串聯依賴關係,把這一層放在前端來寫太蛋疼了,因為業務程式碼裡太多跟業務邏輯沒有關係的程式碼了。這種情況其實就挺適合用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
)就可以使用了:
啟用外掛後,我們就可以發現之前的請求就會帶上這個Cookie
和Referer
(我這個專案其實是不需要加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.js
和webpack.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')
}
複製程式碼
接著在相關的loader
裡exclude
掉src/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.最後談談
篇幅意想不到變得太長了,其中一半的內容都在講實際專案中遇到的大坑小坑,些許無聊,可能看完的人不多,也是通過自己分享實際工作中遇到的問題,希望能幫助到大家。有什麼問題大家可以留言交流交流。
最後打波廣告,近期部門缺人,以下是jd:
公司:虎牙直播
職位要求:
1.本科以上學歷
2.1~3年工作經驗
3.js基礎紮實
4.熟練vue,react等主流框架之一
5.熟悉http協議
6.對效能優化有自己的見解
7.會一些node
複製程式碼
大佬們感興趣的話,可以發一波簡歷到我郵箱裡:luoxianwei@huya.com
,備註掘金