浅析前端缓存的应用,空间换时间

·  阅读 395

🏆 技术专题第八期 | 聊聊缓存的妙用和问题

读完这篇文章,我希望我可以给大家分享到的知识点

  • Vue.js 中那些空间换时间的操作
  • cache-loader的实现原理,缓存的应用
  • babel-loader,cacheDirectory=true时缓存的应用
  • 浏览器缓存

Vue.js 中那些空间换时间的操作

响应式操作

我们知道vue2.x是通过遍历对象的key去创建响应式对象的,如果一个对象足够大,那么递归遍历是非常耗时的

vue2.x中使用Object.defineProperty把对象变成响应式,一旦某个对象经过Object.defineProperty变成响应式对象后,会把响应式结果通过__ob__存储起来,这样一来,同样的对象如果再次执行observe,则从缓存的__ob__中直接拿到对应的响应式值并返回

vue对data进行处理,initState => initData => observe => 创建Observer对象 => def(value, 'ob', this)通过执行 def 函数把自身实例添加到数据对象 value 的 ob 属性上

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 如果存在__ob__,并且是一个Observer类型,直接从缓存中取,否则创建一个新的Observer对象
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
复制代码
export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    // 通过执行 def 函数把自身实例添加到数据对象 value 的 __ob__ 属性上
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
复制代码
/**
 * Define a property.
 */
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}
复制代码

keep-alive

在谈keep-alive前,先聊一个算法LRU,LRU是最近最少使用页面置换算法(Least Recently Used),也就是首先淘汰最长时间未被使用的页面!

  • 新数据插入到链表头部
  • 每当缓存命中(即缓存数据被访问),则将数据移到链表头部
  • 当链表满的时候,将链表尾部的数据丢弃

keep-alive实现了vue的组件实例缓存,2.x和3.0的实现策略一致,都是对LRU缓存策略的应用,可以去看下源码实现

  • vue2.x源码在vue/src/core/components/keep-alive.js
2.x的实现

export default {
	created () {
    	// 创建缓存对象
        this.cache = Object.create(null)
        // 创建收集key
        this.keys = []
  	},
    destroyed () {
        for (const key in this.cache) {
        	pruneCacheEntry(this.cache, key, this.keys)
        }
    },
    render () {
    	...省略其余源码...
        // 如果命中缓存,返回缓存重的组件实例
    	if (cache[key]) {
            vnode.componentInstance = cache[key].componentInstance
            // make current key freshest
            // 更新keys在队列中的位置
            remove(keys, key)
            keys.push(key)
        } else {
        	// 缓存vnode
            cache[key] = vnode
            // keys队列中添加当前组件key
            keys.push(key)
            // prune oldest entry
            // 超出最大max限制,从队头删除key
            if (this.max && keys.length > parseInt(this.max)) {
              	pruneCacheEntry(cache, keys[0], keys, this._vnode)
            }
        }
    }
}
复制代码
  • vue3.0源码在vue-next-master/packages/runtime-core/src/components/KeepAlive.ts
...省略其余源码...
const KeepAliveImpl = {
	setup(props: KeepAliveProps, { slots }: SetupContext) {
    	// 创建一个map以key=>value的形式创建缓存
        const cache: Cache = new Map()
        // 创建收集key
        const keys: Keys = new Set()
        return () => {
        	...省略其余源码...
            const key = vnode.key == null ? comp : vnode.key
            const cachedVNode = cache.get(key)

            // clone vnode if it's reused because we are going to mutate it
            if (vnode.el) {
                vnode = cloneVNode(vnode)
                if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
                  	rawVNode.ssContent = vnode
                }
            }
            // #1513 it's possible for the returned vnode to be cloned due to attr
            // fallthrough or scopeId, so the vnode here may not be the final vnode
            // that is mounted. Instead of caching it directly, we store the pending
            // key and cache `instance.subTree` (the normalized vnode) in
            // beforeMount/beforeUpdate hooks.
            pendingCacheKey = key
			// 如果命中缓存,返回缓存重的组件实例
            if (cachedVNode) {
                // copy over mounted state
                vnode.el = cachedVNode.el
                vnode.component = cachedVNode.component
                if (vnode.transition) {
                    // recursively update transition hooks on subTree
                    setTransitionHooks(vnode, vnode.transition!)
                }
                // avoid vnode being mounted as fresh
                vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
                // make this key the freshest
                keys.delete(key)
                // 更新keys在队列中的位置
                keys.add(key)
            } else {
                keys.add(key)
                // prune oldest entry
                // 超出最大max限制,从队头删除key
                if (max && keys.size > parseInt(max as string, 10)) {
                  	pruneCacheEntry(keys.values().next().value)
                }
            }
        }
	}
}
复制代码

编译

我们都知道.vue文件中的template是通过vue-template-compiler进行ast转换的,然而编译是非常耗时的,如果每次都重新分析ast,组装render函数,性能肯定大大降低,因此在设计的时候也用到了缓存,以空间换时间,达到了渲染优化的效果,这里以2.x版本为例

2.x
// vue/src/compiler/to-function.js

export function createCompileToFunctionFn (compile: Function): Function {
	const cache = Object.create(null)
    return function compileToFunctions (
      template: string,
      options?: CompilerOptions,
      vm?: Component
    ): CompiledFunctionResult {
    	...省略其余源码...
    	const key = options.delimiters
          ? String(options.delimiters) + template
          : template
        // 如果命中缓存,直接从缓存中取render函数
        if (cache[key]) {
          return cache[key]
        }
        const compiled = compile(template, options)
        ...省略其余源码...
        const res = {}
        const fnGenErrors = []
        res.render = createFunction(compiled.render, fnGenErrors)
        res.staticRenderFns = compiled.staticRenderFns.map(code => {
          return createFunction(code, fnGenErrors)
        })
        ...省略其余源码...
        // 编译成功后vue会给每一个模版添加缓存
        return (cache[key] = res)
    }
}
复制代码

cache-loader

webpack编译时优化,cache-loader是一个很好的应用,cache-loader作为webpack的loader,会在pitch和loader两个阶段分别做一些事情:

  1. pitch阶段:校验缓存文件是否可用
  2. loader阶段:判断当前loader的文件是否需要重新生成缓存

pitch阶段

webpack再次编译时,根据当前正在处理的文件,尝试读取.cache目录中对应的cache文件,处理当前文件以及所以来的文件,比较当前文件与上一次loader的产物

function pitch(remainingRequest, prevRequest, dataInput) {
	...省略其余源码...
	data.cacheKey = cacheKeyFn(options, data.remainingRequest);
  	readFn(data.cacheKey, (readErr, cacheData) => {
    	...省略其余源码...
    }
}
function read(key, callback) {
    fs.readFile(key, 'utf-8', (err, content) => {
        if (err) {
            callback(err);
            return;
        }

        try {
            const data = BJSON.parse(content);
            callback(null, data);
        } catch (e) {
          	callback(e);
        }
    });
}
复制代码

loader阶段

判断当前文件是否需要重新生成缓存,判断逻辑:如果pitch阶段的判断当前文件的缓存失效了,那么loader阶段就要去生成缓存

function loader(...args) {
	...省略其余源码...
    // 默认开启缓存
    let cache = true
    ...省略其余源码...
    async.parallel(
      [
        (cb) => async.mapLimit(dependencies, 20, toDepDetails, cb),
        (cb) => async.mapLimit(contextDependencies, 20, toDepDetails, cb),
      ],
      (err, taskResults) => {
        if (err) {
            callback(null, ...args);
            return;
        }
        if (!cache) {
            callback(null, ...args);
            return;
        }
        const [deps, contextDeps] = taskResults;
        writeFn(
            data.cacheKey,
            {
              remainingRequest: pathWithCacheContext(
                options.cacheContext,
                data.remainingRequest
              ),
              dependencies: deps,
              contextDependencies: contextDeps,
              result: args,
            },
            () => {
              // ignore errors here
              callback(null, ...args);
            }
        );
    }
}

function write(key, data, callback) {
    const dirname = path.dirname(key);
    const content = BJSON.stringify(data);
	// const directories = new Set();
    if (directories.has(dirname)) {
        // for performance skip creating directory
        fs.writeFile(key, content, 'utf-8', callback);
    } else {
        mkdirp(dirname, (mkdirErr) => {
            if (mkdirErr) {
                callback(mkdirErr);
                return;
            }

            directories.add(dirname);

            fs.writeFile(key, content, 'utf-8', callback);
        });
    }
}
复制代码

babel-loader缓存的应用

webpack配置中使用babel-loader解析源码时可以配置cacheDirectory=true来开启缓存,执行babel-loader中定义的cache方法

async function loader(source, inputSourceMap, overrides) {
	...省略其余源码...
    const {
        cacheDirectory = null,
        cacheIdentifier = JSON.stringify({
          options,
          "@babel/core": transform.version,
          "@babel/loader": version,
        }),
        cacheCompression = true,
        metadataSubscribers = [],
    } = loaderOptions;

    let result;
    if (cacheDirectory) {
        result = await cache({
            source,
            options,
            transform,
            cacheDirectory,
            cacheIdentifier,
            cacheCompression,
        });
    } else {
      	result = await transform(source, options);
    }
}
复制代码

cache方法的核心实现在handleCache中,实现原理和cache-loader类似,尝试读取缓存,存在就返回,否则继续创建缓存目录,通过babel.transform转译代码,执行gzip压缩并写入缓存

module.exports = async function (params) {
  ...省略其余源码...
  return await handleCache(directory, params);
};

const handleCache = async function (directory, params) {
	try {
      // No errors mean that the file was previously cached
      // we just need to return it
      return await read(file, cacheCompression);
    } catch (err) {}
	 // Make sure the directory exists.
    try {
      	await makeDir(directory);
    } catch (err) {
        if (fallback) {
          	return handleCache(os.tmpdir(), params);
        }
      	throw err;
    }

    // Otherwise just transform the file
    // return it to the user asap and write it in cache
    const result = await transform(source, options);

    try {
      	await write(file, cacheCompression, result);
    } catch (err) {
        if (fallback) {
            // Fallback to tmpdir if node_modules folder not writable
            return handleCache(os.tmpdir(), params);
        }
      	throw err;
    }
    return result;
}
复制代码

浏览器缓存

缓存位置

  • service worker
  • memory cache
  • disk cache
  • push cache

缓存类型

强缓存

  • Expires

用来指定资源到期的时间,是服务端的具体时间点,Expires 是 HTTP/1 的产物,受限于本地时间,如果修改了本地时间,可能会造成缓存失效

  • Cache-Control

已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-control,Cache-Control的优先级高于Expires,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求,与Expires的区别就是前者是绝对时间,而后者是相对时间

Cache-control: max-age=86400
复制代码
指令作用
public响应可以由任何缓存存储,即使响应通常是不可缓存的
private响应可能仅由浏览器的缓存存储,即使响应通常是不可缓存的
no-cache响应可以由任何缓存存储,即使响应通常是不可缓存的。但是,在使用存储的响应之前,必须首先通过源服务器的验证,因此,不能将no cache与immutable结合使用
no-store响应不能存储在任何缓存中
max-age=<seconds>资源被认为是新鲜的最长时间。与Expires不同,此指令与请求的时间有关
s-maxage=<seconds>作用同max-age
max-stale=<seconds>指示客户端将接受过时的响应。以秒为单位的可选值表示客户端将接受的过期上限
min-fresh=<seconds>指示客户端希望响应至少在指定的秒数内保持新鲜
stale-while-revalidate=<seconds>指示客户端将接受过时响应,同时在后台异步检查新响应
stale-if-error=<seconds>指示如果对新响应的检查失败,客户端将接受过时的响应
must-revalidate指示一旦资源变为过时,如果未在源服务器上成功验证,缓存就不能使用其过时副本
proxy-revalidate必须重新验证,但仅限于共享缓存(例如代理)
immutable指示响应主体不会随时间而更改
no-transform中间缓存或代理无法编辑响应正文、内容编码、内容范围或内容类型
only-if-cached由客户端设置,以指示响应的“不使用网络”

协商缓存

Last-Modified & If-Modified-Since

  1. 服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如

Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT

  1. 浏览器将这个值和内容一起记录在缓存数据库中

  2. 下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的 Last-Modified 的值写入到请求头的 If-Modified-Since 字段

  3. 服务器会将 If-Modified-Since 的值与 Last-Modified 字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据

缺陷:

  • 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
  • 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用

Etag & If-None-Match

Etag 存储的是文件的特殊标识(一般都是 hash 生成的),服务器存储着文件的 Etag 字段。之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match。服务器同样进行比较,命中返回 304, 不命中返回新资源和 200。

Etag 的优先级高于 Last-Modified

缓存机制

  • 强缓存优先于协商缓存
  • 协商缓存失效,返回200,重新返回资源和缓存标识
  • 协商缓存生效,返回304,继续使用缓存

缓存策略

  • 频繁变动的资源
  • 不常变动的资源

参考链接

CI环境下cache-loader的局限性以及修复方式

从构建进程间缓存设计 谈 Webpack5 优化和工作原理

cache-loader文档

keep-alive

Cache-Control

一文读懂前端缓存

深入理解浏览器的缓存机制

深入理解浏览器的缓存机制

浏览器缓存原理以及本地存储

浅析 Vue.js 中那些空间换时间的操作

分类:
前端
标签: