Web应用加载性能的优化, 一直围绕着对静态资源地缓存。实现手段也从最初的浏览器和服务器缓存协商,到HTML5离线缓存机制使用localstorage缓存js和css。Google提出PWA(Progress Web Apps)的方案后,更证明了缓存的价值。

PWA被Google称为下一代web应用技术,对比传统的Web应用技术,其主要有如下特点:借助于Service Worker提供的可编程式缓存,实现更可靠地(不同网络状态下)、速度更快地用户体验;借助于Web App Manifest将Web应用像原生应用一样安装到系统主屏。由于各个浏览器对PWA的兼容实现进度不一,我们暂时无法大规模实施PWA方案;真正应该引起我们注意的是其背后的技术方案:Service Worker。本文我们借助于Service Worker对PWA进行部分实践,将静态资源的缓存技术提升一个维度。

HTML5标准中引入了web worker,它是运行在后台的JavaScript,独立于其他脚本,不会影响页面的性能。Service Worker就是Web Worker的一种实现,充当Web应用程序的本地代理服务器;在特定路径注册Service Worker后,我们可以拦截并处理该路径下的所有网络请求;本文中,我们就是借助于这种代理机制,实现对web页面核心资源可编程式缓存。

1. Service Worker基础知识

1.1 基本用法

(1) 注册Service Worker

在支持Service Worker的浏览器中,我们可以通过register方法,在当前域名的某个路径下注册一个service worker脚本:

navigator.serviceWorker.register('your_service_worker.js',{scope: 'your-path-name'}).then(function (registration) {
    console.log('[SW]: Registration succeeded.');
}).catch(function (error) {
    console.log('[SW]: Registration failed with.' + error);
});

(2) 注销Service Worker

同样地,我们可以通过getRegistration获取已经注册的Service Worker,并通过unregister取消已经注册的Service Worker脚本。

navigator.serviceWorker.getRegistration('your_service_worker.js',{scope: 'your-path-name'}).then(function (registration) {
    if (registration && registration.unregister) {
        registration.unregister().then(function (isUnRegistered) {
            if (isUnRegistered) {
                console.log('[SW]: UnRegistration  succeeded.');
            } else {
                console.log('[SW]: UnRegistration failed.');
            }
        });
    }
).catch(function (error) {
    console.log('[SW]: UnRegistration failed with. ' + error);
});

(3) Service Worker脚本

通过register方法,注册Service Worker脚本后,就可以通过监听Service Worker提供的生命周期方法来实现我们自己的业务逻辑了。如下的代码实现了一个简单的功能:监听Service Worker的install事件、activate事件和fetch事件,打印出所有的网络请求url。

self.addEventListener('install', event => {
  console.log('sw installing…');
});

self.addEventListener('activate', event => {
  console.log('sw now ready!');
});

self.addEventListener('fetch', event => {
  console.log(event.request.url);
  return;
});

2.2 生命周期

作为一个事件驱动的本地代理服务,Service Worker有着复杂的生命周期。如下图所示

sw lifecycle image

我们主要关注Service Worker脚本的下载、解析、安装、激活、废弃

(1) 下载并解析

在注册Service Worker后,浏览器首先从指定路径下载并解析工作线程脚本。需要注意的是,出于安全考量,工作线程脚本只能由HTTPS承载;为了方便调试,使用localhost域名可以本地在调试工作线程脚本。

(2) 安装

成功下载并正确解析后,浏览器开始安装工作线程脚本。如果安装成功,则Service Worker会触发install事件并进入等待激活(Activating)状态,安装失败则Service Worker进入废弃(Redundant)状态。

(3) 激活

处于等待激活的Service Worker,会在以下之一的情况下,会被触发激活 - 当前已无其他处于激活状态的Service Worker - 通过调用self.skipWaiting()强制激活 - 此前处于激活状态的Service Worker已经过期(通过刷新页面可以使旧的Service Worker过期)

激活成功后,Service Worker会触发active事件,并开始事实上接管页面的所有网络请求(fetch)。激活失败则Service Worker会进入废弃(Redundant)状态。

(4) 废弃(Redundant)

Service Worker可能以下之一的原因而被废弃(redundant) - installing 事件失败 - activating 事件失败 - 被新的Service Worker取代

Service Worker生命周期的任何过程出现错误都会导致其进入废弃(Redundant)状态,而处于废弃(Redundant)状态的Service Worker,对页面没有控制权,这样就保证了错误Service Worker不会干扰页面的正常加载流程。

2.3 主要依赖

Service Worker作为现代浏览器的高级特性,依赖于fetch、promise、CacheStoragecache、等浏览器基础能力。对每一个前端开发来说,fetch和promise应该都比较熟悉了。而Cache提供了Request / Response对象对的存储机制。而CacheStorage则提供了存储Cache对象的机制。

sw lifecycle image

3. 静态资源全缓存最佳实践

PWA(Progress Web Apps)倡导离线可用的WEB应用,缓存包括主资源HTML文档在内的所有资源;这就使得WEB应用的程序入口由HTML文档变成Service Worker本身,我们也因此失去了对web应用的控制权;而且不同浏览器对Service Worker更新机制的实现并不一致,这可能导致我们陷入WEB应用无法更新或者更新不及时的危险之中。本文中我们探讨一种更具有可行性的方案:依然把HTML文档作为Web应用的入口,考虑只把Service Worker作为可编程的缓存,使用它来缓存包括css、js等在内的页面核心静态资源。

使用Service Worker缓存资源的一般思路是:

监听install事件提前缓存静态资源
监听fetch事件,拦截网络请求并返回cache中已缓存的资源

如下所示:

var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
  '/',
  '/styles/main.css',
  '/script/main.js'
];

//service worker安装成功后开始缓存所需的资源
self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

//监听浏览器的所有fetch请求,对已经缓存的资源使用本地缓存回复
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        //该fetch请求已经缓存
        if (response) {
          return response;
        }
        return fetch(event.request);
      }
    )
  );
});

上述简单的示例描述了Service Worker的基本用法,但依然有不少问题,下面我们详细的讨论如何借助Service Worker实现一套安全、可靠的缓存系统。

3.1 版本控制

使用Service Worker作为缓存系统的一个核心优势之一,就是它的细粒度可控性;但这意味着我们需要自己处理缓存的版本管理问题。cacheStorage为此提供了简单的API,方便我们遍历所有的cache、找出过期的cache并删除:

function deleteObsoleteCache() {
    return caches.keys().then(function (keys) {
        var all = keys.map(function (key) {
            if (key.indexOf(CACHE_PREFIX) !== -1 && key.indexOf(CACHE_VERSION) === -1){
                  console.log('[SW]: Delete cache:' + key);
                  return caches.delete(key);
            }
        });
        return Promise.all(all);
    });
}

上述代码中,我们通过使用CACHE_PREFIX和CACHE_VERSION来标志web应用的缓存版本。其中CACHE_PREFIX是应用的cache前缀,CACHE_VERSION是本次cache的版本号,CACHE_PREFIX+CACHE_VERSION就构成了本次cache的key。我们遍历cacheStorage中所有的key,并找出该web应用已经过期的key,然后从cacheStorage中删除对应的cache。

3.3 白名单控制

默认情况下,Service Worker会代理页面中的所有网络请求;而比较安全的做法是通过通过白名单控制需要代理的网络请求url,并对其他请求使用浏览器默认行为。

//资源白名单,一般通过构建工具(webpack)生成
var allAssets = [
  '//your.cdn.com/app.css',
  '//your.cdn.com/common.js',
  '//your.cdn.com/index.js'
];

//白名单匹配策略
function matchAssets(requestUrl) {
    var urlObj = new URL(requestUrl);
    var noProtocolUrl = urlObj.href.substr(urlObj.protocol.length);
    if (allAssets.indexOf(noProtocolUrl) !== -1) {
        return true;
    }
    return false;
}

//监听fetch事件,并只代理白名单中的GET网络请求
self.addEventListener('fetch', function (event) {
    try{
        var requestUrl = event.request.url;
        var isGET = event.request.method === 'GET';
        var assetMatches = matchAssets(requestUrl);
        if (!assetMatches || !isGET) {
            return;
        }
        var resource = cacheFirstResponse(event);
        event.respondWith(resource);
    }catch(ex){
        console.error('[SW]: handle fetch event error, fallback');
        return;
    }
});

一般情况下,我们会在构建阶段通过构建工具生成资源白名单;上述代码中,我们了实现白名单匹配策略;然后使用Service Worker监听fetch事件,有选择地只对白名单中的网络请求进行处理。

3.4 安全性问题规避

(1)跨域请求支持

Service Worker可以拦截它管辖范围内的所有请求,跨域资源也不例外。但是浏览器默认对跨域资源发起的是ncors请求,得到的response是opaque的,这导致我们无法判断跨域请求是否成功,以便进行缓存。 对于跨域请求,我们需要修改fetch请求头,添加mode:'cros'标记。

//初始化请求参数,添加跨域头
var fetchInitParam = {
    mode: 'cors'
};


function fetchCros(request) {
    //add cros header
    return fetch(request.url, fetchInitParam);
}

(2)避免缓存错误的结果

由于更新机制的问题,如果Service Worker缓存了错误的结果,将会对web应用造成灾难性的后果。我们必须小心翼翼的检查网络返回是否准确。一种常见的做法是只缓存满足如下条件的结果:

1. 响应状态码为200;避免缓存304、404、50x等常见的结果
2. 响应类型为basic或者cors;即只缓存同源、或者正确地跨域请求结果;避免缓存错误的响应(error)和不正确的跨域请求响应(opaque)

如下代码所示:

fetchCros(request).then(function (response) {
  //严格判断缓存是否成功
  if (response.status === 200 && (response.type === 'basic' || response.type === 'cors')) {
    console.log('[SW]: URL [' + request.url + '] from network');
    cache.put(event.request, response.clone());
  } else {
    console.log('[SW]: URL [' + event.request.url + '] wrong response: [' + response.status + '] ' + response.type);
  }
  return response;
});

(3) http缓存穿透

需要注意的是在Service Worker脚本中,使用fetch发起网络请求时,依然会使用浏览器和服务端的缓存协商机制. 而在使用Service Worker缓存js、css等资源文件时,我们尤其需要注意,因为这些文件都是放在CDN中的,往往都使用了强缓存策略。 这样就会导致我们对资源文件的fetch请求,大部分会返回304状态。为了避免缓存错误的结果,我们只能放弃对该资源的缓存,从而导致Service Worker工作机制失效。往往通过对请求添加时间戳,就可以绕过浏览器的默认缓存协商机制:

function applyCacheBust(assetURL) {
    var hasQuery = assetURL.indexOf('?') !== -1;
    return assetURL + (hasQuery ? '&' : '?') + '__bust=' + encodeURIComponent(CACHE_TAG) + '-' + new Date().getTime();
}

除了上述几点之外,我们还需要注意避免在install阶段缓存过多资源,以防止install失败;而且需要注意在fetch事件处理出现异常时,跳过Service Worker,而使用浏览器默认请求,以降低Service Worker对Web应用可用性的影响;当然,在极端情况下,我们还需要提供快速取消Service Worker注册的机制。

https://developers.google.com/web/progressive-web-apps/

https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/

https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API

https://bitsofco.de/the-service-worker-lifecycle/

https://w3ctech.com/topic/866

https://github.com/ruanyf/articles/blob/master/dev/web/serviceworker.md

https://github.com/pazguille/offline-first

https://github.com/NekR/offline-plugin

https://github.com/GoogleChrome/sw-precache

https://jakearchibald.com/2016/caching-best-practices/

https://github.com/lyzadanger/serviceworker-example/blob/master/03-versioning/serviceWorker.js

https://googlechrome.github.io/sw-toolbox/usage.html

https://www.smashingmagazine.com/2016/02/making-a-service-worker/

https://x5.tencent.com/tbs/guide/serviceworker.html

https://developer.mozilla.org/en-US/docs/Web/API/Response/type

在我的一篇文章中:捕获页面中全局Javascript异常,介绍了通过AST(抽象语法树)技术,借助于UglifyJs提供的AST的API,对源文件进行预处理,对每个函数自动地添加try catch包裹代码,从而捕获生产环境中的JS异常。通过使用try-catch-global.js,可以简单的如下源代码:


var test = function(){
    console.log('test');
}

转换成

var test = function() {             
    try {                           
        console.log("test");        
    } catch (error) {               
        (function(error) {          
            //your logic to handle error     
        })(error);                  
    }                               
};                 

但是由于我们发布到生产环境中的代码,往往都经过压缩和混淆,文件名、函数名、变量名已经不具有可读性了,捕获的异常堆栈信息的价值有限,简单的通过这些异常信息,我们依然很难定位到源文件中错误出处。本文我们就试图解决这个问题:依然基于AST(抽象语法树)对源代码进行try catch包裹,但会在catch语句中收集更多源文件的信息(包括文件名、函数名、函数起始行号等),最后借助于babel和webpack的插件体系,提供一个工程化的解决方案。

1. 自定义babel-plugin实现try catch包裹

在文章捕获页面中全局Javascript异常中,我们使用的是UglifyJS提供的操作Javascript语法树的API,这套API比较底层,需要发力气才能啃透,不适合初学者使用。babel提供了抽象层次更高的操作语法树的API:babylon,并且提供了一系列工具babel-templatebabel-helper-function-name等。

在使用Babel对Javascript源文件进行处理时,有三个主要步骤,分别是: 解析(parse),转换(transform),生成(generate)。Babel首先会将源文件转换为抽象语法树(AST),然后对抽象语法树进行转换,最后由抽象语法树生成新的源代码,如下图所示。在转换(transform)阶段,Babel提供了非常便利的插件机制,开发者可以在插件中实现自己的AST转换。关于如何开发Babel插件,最好的教程就是官方文档

babel插件转换AST

在对AST的转换阶段,Babel使用babel-traverse对AST进行深度优先遍历,它的插件机制使得我们可以针对某个特定类型的语法树节点(比如,函数、条件语句等)注册钩子函数,从而完成我们对语法树的转换工作。

  visitor: {
    Function: {
      //遍历到函数时
    },
    ClassMethod: {
      //遍历catch语句块时
    }
    ......
  }

通过插件机制,我们可以对所有的函数和类方法节点进行转换,插入try catch包裹代码;同时,在babel解析后的语法树中包含了详细的源文件的元信息,我们可以将这些源文件信息透传到自定义的错误处理函数中。对于函数,我们可以如下处理:

visitor: {
        //只处理函数和类方法节点
        "Function|ClassMethod" {
            exit: function exit(path, state) {
                //深度优先搜索会遍历两次,需要避免重复
                if (shouldSkip(path, state)) {
                    return;
                }

                //如果函数体为空则不处理
                var body = path.node.body.body;
                if (body.length === 0) {
                    return;
                }

                //收集函数名
                var functionName = 'anonymous function';
                babelHelperFunctionName2(path);
                if (path.node.id) {
                    functionName = path.node.id.name || 'anonymous function';
                }

                //收集类方法名
                if(path.node.key){
                    functionName = path.node.key.name || 'anonymous function';
                }

                //函数起始行号
                var loc = path.node.loc;

                //异常变量名
                var errorVariableName = path.scope.generateUidIdentifier('e');

                //使用函数模板进行try catch包裹,需要注意的是AST无法获取到文件名信息,需要外部传入
                path.get('body').replaceWith(wrapFunction({
                    BODY: body,
                    FILENAME: t.StringLiteral(filename),
                    FUNCTION_NAME: t.StringLiteral(functionName),
                    LINE: t.NumericLiteral(loc.start.line),
                    COLUMN: t.NumericLiteral(loc.start.column),
                    REPORT_ERROR: t.identifier(reportError),
                    ERROR_VARIABLE_NAME: errorVariableName
                }));
            }
        },

babel提供了非常便利的工具babel template,其中隐藏了AST转换的细节,简单的使用函数模板就可以对函数进行任意转换,如下代码我们使用template对函数进行try catch包裹:

const wrapFunction = template(`{
  try {
    BODY
  } catch(ERROR_VARIABLE_NAME) {
    REPORT_ERROR(ERROR_VARIABLE_NAME, FILENAME, FUNCTION_NAME, LINE, COLUMN)
    throw ERROR_VARIABLE_NAME
  }
}`)

通过使用babel插件,在babel对源代码进行处理时注册针对特定AST节点的钩子函数(本文我们只关心函数类型节点),使用bable-template对函数进行try catch包裹,并在catch语句中预埋入从AST中收集到的源文件信息。

处理前的代码:

 function testA(){
    console.log(1);
}


class A {
    testB(){
        console.log(1);
    }
}

var testD = function(){
    console.log(1)
}

处理后的代码:

function testA() {
    try {
        console.log(1);
    } catch (_e) {
        reportError(_e, "test.js", "testA", 4, 0);
    }
}

class A {
    testB() {
        try {
            console.log(1);
        } catch (_e2) {
            reportError(_e2, "test.js", "testB", 10, 4);
        }
    }
}

var testD = function testD() {
    try {
        console.log(1);
    } catch (_e4) {
        reportError(_e4, "test.js", "testD", 19, 12);
    }
};

转换后的代码通过后续的混淆、压缩后发布到生产环境,生产环境中的代码发生异常时,catch语句中的reportError会将异常上报到日志平台,上报的信息中包含了我们从AST中预埋入的变量名、函数名、函数函数起始行号、文件名等信息,通过这些信息我们就可以快速定位到源代码中的异常位置。

2. 使用webpack loader进行工程化构建

上文讲到使用babel插件对Javascript源代码生成的AST进行转换,最终对所有的函数生成try catch包裹代码。本小节我们考虑将构建流程集成到webpack中。webpack首先使用loader对源代码进行处理,然后将入口文件以及其依赖打包到一个chunk中。在webpack的编译流程中,我们可以借助于自定义的loader来实现对源代码的AST转换。

编写一个自定义的webpack的loader非常简单,简单的教程请参考官方文档,下文是一个非常简单的loader,其接收源代码内容作为输入,并转转换后的源代码作为输出。

module.exports = function(source) {
  //your logic to change source
  return source;
};

我们也实现了一个webpack的loader:babel_try_catch_loader,它借助于babel插件babel-plugin-try-catch-wrapper,通过AST技术对源代码进行处理。

var tryCatchWrapper = require('babel-plugin-try-catch')
...
module.exports = function (source, inputMap) {
    ......
    var transOpts = {
        plugins: [
            [tryCatchWrapper, {
                filename: filename,
                reportError: userOptions.reporter,
                rethrow: userOptions.rethrow
            }]
        ],
        sourceMaps: true
    };
    var result = babel.transform(source, transOpts);
    this.callback(null, result.code, result.map);
    ......
};

只需要在webpack配置文件中使用babel_try_catch_loader,我们就可以通过一行配置文件来将项目中源代码中所有的函数进行try catch包裹了。

loaders: [
            ......
            {
                test: /\.jsx|\.js$/,
                loader: 'babel-try-catch-loader?rethrow=true&verbose&reporter=reportError&tempdir=.tryCatchResult!babel-loader',
                exclude: /node_modules/
            }
            ......
        ],

devtool: 'source-map'

上文中的配置文件,对项目中的所有源代码使用babel-loader进行转换,然后使用babel-try-catch-loader处理babel-loader产生的代码和source map,保证了经过后续的混淆、压缩并发布到线上环境的生产代码仍然具有较强的debug能力。详细的配置文件请参考webpack-try-catch-demo。实现原理请参考babel-try-catch-loader.