暂时未有相关云产品技术能力~
一、Redis基础1)知识图和问题画像图 Redis知识全景图都包括“两大维度,三大主线”。“两大维度”就是指系统维度和应用维度,“三大主线”也就是指高性能、高可靠和高可扩展。 高性能主线,包括线程模型、数据结构、持久化、网络框架;高可靠主线,包括主从复制、哨兵机制;高可扩展主线,包括数据分片、负载均衡。 Redis 各大典型问题,同时结合相关的技术点,手绘了一张 Redis 的问题画像图。按照“问题 --> 主线 --> 技术点”的方式梳理出来。2)数据结构 底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。 压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。 跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位。 集合常见操作的复杂度:单元素操作是基础;范围操作非常耗时;统计操作通常高效;例外情况只有几个,例如压缩列表和双向链表都会记录表头和表尾的偏移量。3)单线程 Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。 多线程的开销,系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。 通常来说,单线程的处理能力要比多线程差很多,但是 Redis 却能使用单线程模型达到每秒数十万级别的处理能力。一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。 在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。4)AOF和RDB Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。 AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志。 AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操作。 AOF 也有两个潜在的风险。首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。二、实践1)string 当你保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码方式。 但是,当你保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,buf:字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个“\0”,这就会额外占用 1 个字节的开销。len:占 4 个字节,表示 buf 的已用长度。alloc:也占个 4 字节,表示 buf 的实际分配长度,一般大于 len。 另外,对于 String 类型来说,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针。 当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。2)统计模式 聚合统计,就是指统计多个集合元素的聚合结果,包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)。 Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。小建议:你可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计。 在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议你优先考虑使用 Sorted Set。 二值状态就是指集合元素的取值就只有 0 和 1 两种。Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。 基数统计就是指统计一个集合中不重复的元素个数。3)GEO GEO 类型的底层数据结构就是用 Sorted Set 来实现的。 Redis 采用了业界广泛使用的 GeoHash 编码方法,这个方法的基本原理就是“二分区间,区间编码”。 对于一个地理位置信息来说,它的经度范围是[-180,180]。GeoHash 编码会把一个经度值编码成一个 N 位的二进制值,我们来对经度范围[-180,180]做 N 次的二分区操作,其中 N 可以自定义。4)异步机制 和客户端交互时的阻塞点。复杂度高的增删改查操作肯定会阻塞 Redis。第一个阻塞点:集合全量查询和聚合操作。第二个阻塞点:bigkey 删除操作。第三个阻塞点:清空数据库。 和磁盘交互时的阻塞点。Redis 开发者早已认识到磁盘 IO 会带来阻塞,所以就把 Redis 进一步设计为采用子进程的方式生成 RDB 快照文件,以及执行 AOF 日志重写操作。第四个阻塞点了:AOF 日志同步写。 主从节点交互时的阻塞点。在主从集群中,主库需要生成 RDB 文件,并传输给从库。主库在复制的过程中,创建和传输 RDB 文件都是由子进程来完成的,不会阻塞主线程。第五个阻塞点:加载 RDB 文件。 Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责 AOF 日志写操作、键值对删除以及文件关闭的异步执行。5)内存碎片 Redis 释放的内存空间可能并不是连续的,那么,这些不连续的内存空间很有可能处于一种闲置的状态。 这就会导致一个问题:虽然有空闲空间,Redis 却无法用来保存数据,不仅会减少 Redis 能够实际保存的数据量,还会降低 Redis 运行机器的成本回报率。 内存碎片的形成有内因和外因两个层面的原因。简单来说,内因是操作系统的内存分配机制,外因是 Redis 的负载特征。 Redis 是内存数据库,内存利用率的高低直接关系到 Redis 运行效率的高低。为了让用户能监控到实时的内存使用情况,Redis 自身提供了 INFO 命令。 这里有一个 mem_fragmentation_ratio 的指标,它表示的就是 Redis 当前的内存碎片率。mem_fragmentation_ratio 大于 1 但小于 1.5。这种情况是合理的。6)替换策略 “八二原理”,有 20% 的数据贡献了 80% 的访问了,而剩余的数据虽然体量很大,但只贡献了 20% 的访问量。volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。allkeys-random 策略,从所有键值对中随机选择并删除数据;allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。7)原子操作 原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。 Redis 的原子操作采用了两种方法:把多个操作在 Redis 中实现成一个操作,也就是单命令操作;把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。 Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。 当然,Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。8)脑裂 脑裂就是指在主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。 主从切换后,从库一旦升级为新主库,哨兵就会让原主库执行 slave of 命令,和新主库重新进行全量同步。而在全量同步执行的最后阶段,原主库需要清空本地的数据,加载新主库发送的 RDB 文件,这样一来,原主库在主从切换期间保存的新写数据就丢失了。
早在2013年Luke Wroblewski就提出了骨架屏(Skeleton Screen)的概念,他认为骨架屏是一个页面的空白版本,通过这个空白版本来传递一种信息,即页面正在渐进式的加载中。骨架屏的布局能与页面的视觉呈现保持一致,这样就能引导用户的关注点聚焦到感兴趣的位置。如下图所示,左边是数据渲染后的页面,右边是骨架屏,可以看到相应的位置都能对起来。 在网上阅读了一些骨架屏原理的资料后,就自己想尝试一下,练练手,制作一个极简版本的骨架屏插件。因为简单,所以未来如要扩展,成本也会很低。上图是通过自己写的骨架屏插件得到的效果,对于公司简单结构的项目,还是游刃有余的。在编写插件时,参考了网上多篇资料分享的代码,站在巨人的肩膀上整合代码,省力了很多。插件的完整代码已上传至GitHub中,下面是其中的构造函数,以及三个常量,用到了ES6的一些概念,如对此不熟悉,可参考我之前整理的《ES6躬行记》。const NODE_ELEMENT = 1, //元素类型的节点常量 NODE_TEXT = 3, //文本类型的节点常量 NODE_COMMENT = 8; //注释类型的节点常量 /** * @param color 字体的背景色 * @param bgColor 带背景图模块的背景色 * @param rectHeight 指定区域的高度,默认为视口高度 * @param formFn 自定义表单着色规则 * @constructor */ function Skeleton({ color = "#DCDCDC", bgColor = "#F6F8FA", rectHeight = global.innerHeight, formFn = function() {} } = {}) { this.container = document.body; //骨架容器 this.color = color; this.bgColor = bgColor; this.rectHeight = rectHeight; this.formFn = formFn; }一、绘制骨架屏 由于对Node.js不熟,所以采用纯原生的JavaScript来绘制骨架屏。首先将页面中的元素分成三类:图像、文本和表单。1)图像 图像也就是元素,其src属性会被替换成一张灰色(色素是#EEE)的1*1的gif图。为了避免引入额外的请求,将该gif图转换成base64格式,写死在替换函数image()中,如下所示,呈现的效果如下图所示。image(element, isImage = true) { const { width, height } = getRect(element); //图像颜色 #EEE const src = "data:image/gif;base64,R0lGODlhAQABAPAAAPT09AA...."; if (isImage) element.src = src; else element.style.background = this.bgColor; element.width = width; element.height = height; } 由于image()函数声明在原型(prototype)之上,因此省略了function关键字。isImage是一个布尔值,表示是否是一个元素。当传入非元素时,就需要将其背景替换成初始化时的纯色。getRect()是一个辅助函数,用于获取元素的尺寸和坐标。function getRect(element) { return element.getBoundingClientRect(); }2)文本 处理文本是比较复杂的,因为文本长度是不定的,如下图所示,左边的文本是两行,骨架屏中也要变成两行,并且第二行不是满行的。 网上的资料对于最后一行都会做遮罩处理,也就是用一个白底的块定位到相应位置,把多余的灰底遮掉。当文本只有一行时,还需要做特殊处理。 而我在设计骨架屏插件的时候,采用了一个简单粗暴的方法,能够避免遮罩和单行的处理,那就是为所有文本节点添加元素。对于我这边不太复杂的HTML结构而言,能够大大简化代码的复杂度。具体方法如下所示,采用递归的方式逐个访问子节点,当节点是文本类型并且有内容时,就为其包裹标签。appendTextNode(parent) { //避免<span>中嵌套<span> if ( parent.childNodes.length <= 1 && parent.nodeName.toLowerCase() == "span" ) { return; } parent.childNodes.forEach(node => { if (node.nodeType === NODE_TEXT && node.nodeValue.trim().length > 0) { let span = document.createElement("span"); span.textContent = node.nodeValue; parent.replaceChild(span, node); } else { this.appendTextNode(node); } }); } 下面的第一个元素在调用了appendTextNode()方法后,就变成了第二个元素。<p>本活动最终解释权归上海易点时空网络有限公司所有</p> <!-- 骨架屏结构 --> <p><span>本活动最终解释权归上海易点时空网络有限公司所有</span></p> 为了让多行文本能呈现灰白相间的效果,就得借助CSS3的linear-gradient渐变属性来实现。如果对其不熟悉,可以参考之前的《CSS3中惊艳的gradient》一文。 下面的计算方式照搬了饿了么的page-skeleton-webpack-plugin插件,其中getStyle()函数用于获取元素的CSS属性或属性对象(CSSStyleDeclaration)。calculate(element) { let { fontSize, lineHeight } = getStyle(element); lineHeight = parseFloat(lineHeight); //解析浮点数 fontSize = parseFloat(fontSize); const textHeightRatio = fontSize / lineHeight, //字体占行高的比值 firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(2), //渐变的第一个位置,小数点后两位四舍五入 secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(2); //渐变的第二个位置 return ` background-image: linear-gradient( transparent ${firstColorPoint}%, ${this.color} 0, ${this.color} ${secondColorPoint}%, transparent 0); background-size: 100% ${lineHeight}; position: relative; color: transparent; `; } function getStyle(element, name) { const style = global.getComputedStyle(element); return name ? style[name] : style; } 首先读取字体大小和行高,然后计算字体占行高的比值(textHeightRatio),接着计算出渐变的两个位置(firstColorPoint和secondColorPoint),最后通过模板字面量输出文本的样式,字体颜色被设为了透明。 绘制文本的逻辑都封装到了text()方法中,具体如下所示。text(element) { //判断是否是只包含文本的节点 const isText = element.childNodes && element.childNodes.length === 1 && element.childNodes[0].nodeType === NODE_TEXT && /\S/.test(element.childNodes[0].textContent); if (!isText) { return; } const rule = this.calculate(element); //计算样式 element.setAttribute("style", rule); }3)表单 表单控件目前只处理了input、select和button,它们中的文本会变透明,添加背景色,placeholder属性变空,如下所示。form(element) { element.style.color = "transparent"; //内容透明 element.style.background = this.color; //重置背景 element.setAttribute("placeholder", ""); //清除提示 this.formFn && this.formFn.call(this, element); //执行自定义着色规则 } formFn是一个特殊的参数,在插件初始化时可传递进来,因为表单比较复杂,所以要自定义着色规则。例如一些页面的表单结构是下面这样的,那么就需要将也添加背景色。<ul> <li class="ui-flex"> <input type="text" /> li> <li class="ui-flex"> <input type="text" /> li> ul> 自定义的着色规则如下所示,其中matches()是一个选择器匹配方法。new Skeleton({ formFn: function(element) { while(element && !this.matches(element, "li.ui-flex")) element = element.parentNode; element && (element.style.background = this.color); } }); matches(element, selector) { if (!selector || !element || element.nodeType !== NODE_ELEMENT) return false; const matchesSelector = element.webkitMatchesSelector || element.matchesSelector; return matchesSelector.call(element, selector);}4)移除 因为骨架屏的特点是快速,所以在生成时需要移除多余的元素,例如指定区域外的元素、隐藏的元素和脚本元素,如下所示,其中isHideStyle()函数可判断是否是隐藏元素。removeElement(parent) { if (parent.children.length == 0) return; //有移除操作,所以未用Array.from()遍历 for (let i = 0; i < parent.children.length; i++) { const element = parent.children[i], { top } = getRect(element); if ( isHideStyle(element) || //隐藏元素 top >= this.rectHeight || //超出指定高度 element.nodeName.toLowerCase() == "script" //脚本元素 ) { element.remove(); i--; continue; } this.removeElement(element); } } function isHideStyle(element) { return ( getStyle(element, "display") == "none" || getStyle(element, "visibility") == "hidden" || getStyle(element, "opacity") == 0 || element.hidden ); } 本来是想用Array.from()遍历元素,但删除后会影响迭代逻辑,因此改成了for循环语句。 除了这三类元素之外,还得将注释节点也一并删除,如下所示。注意,childNodes与上面的children属性不同,它能够通过forEach()遍历。removeNode(parent) { if (parent.childNodes.length == 0) return; for (let i = 0; i < parent.childNodes.length; i++) { const node = parent.childNodes[i]; if (node.nodeType === NODE_COMMENT) { node.remove(); i--; continue; } this.removeNode(node); } }5)绘制 绘制就是调用上面所提到的方法,包括移除元素、着色、替换图像等,具体如下所示。function draw() { this.container.style.background = "#FFF"; //容器背景重置 //移除元素和节点 this.removeElement(this.container); this.removeNode(this.container); //为文本添加 this.appendTextNode(this.container); //处理普通元素 Array.from( this.container.querySelectorAll( "div,section,footer,header,a,p,span,form,label,li" ) ).map(element => { //背景图或背景颜色的处理 const hasBg = getStyle(element, "background-image") != "none" || getStyle(element, "background-color") != "rgba(0, 0, 0, 0)"; if (hasBg) { this.image(element, false); } //文本处理 this.text(element); }); //处理表单中的控件 Array.from(this.container.querySelectorAll("input,select,button")).map( element => { this.form(element); } ); //元素处理 Array.from(this.container.querySelectorAll("img")).map(img => { this.image(img); }); }二、Puppeteer 插件完成后,没有做到自动化,即需要在浏览器的控制台中手工执行骨架屏插件。翻阅资料后,大家都推荐使用Puppeteer。Puppeteer是一个Node库,它提供了一个高级API来通过DevTools协议控制Chromium或Chrome。也就是说,它是一个无头(headless)浏览器。 一边翻资料,一边查看demo,尝试着写Node.js,后面跌跌撞撞的写出了可以执行的脚本。 原理就是先打开无头浏览器;然后输入视口参数和页面地址,并添加插件地址;然后在打开的页面中执行插件,返回document.body中的HTML代码;最后将HTML写入到一个txt文件中。const puppeteer = require('puppeteer'), fs = require('fs'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); //视口参数 await page.setViewport({width: 375, height: 667}); // 事件监听,可用于调试 page.on('console', msg => console.log('PAGE LOG:', msg.text())); // waitUntil 参数有四个关键字:load、domcontentload、networkidle0和networkidle2 await page.goto('http://www.pwstrick.com/index.html', {waitUntil: 'networkidle2'}); await page.addScriptTag({url: 'http://www.pwstrick.com/js/skeleton.js'}); // 对打开的页面进行操作 const html = await page.evaluate(() => { let sk = new Skeleton(); sk.draw(); return document.body.innerHTML; }); //将骨架屏代码添加到content.txt文件中 fs.writeFileSync('content.txt', html); await browser.close(); })(); 本来是想在page.evaluate()中将插件以参数的形式传入,但一直不成功,后面就改成了page.addScriptTag(),引用插件的脚本。 到目前为止,只能算是半自动化。要做到自动化,就得编写webpack插件,在打包的时候,将生成的HTML代码嵌入到页面中的指定位置,并且还要做到参数可配置化,以适合更多的场景。 整个骨架屏插件只有200多行代码,去掉注释和空行只有160多行,本插件主要用于学习。
一、整体概况 Piwik的官网是matomo.org,使用PHP编写的,而我以前就是PHP工程师,因此看代码不会有障碍。目前最新版本是3.6,Github地址是matomo-org/matomo,打开地址将会看到下图中的内容(只截取了关键部分)。 打开js文件夹,里面的piwik.js就是本次要分析的脚本代码(如下图红色框出部分),内容比较多,有7838行代码。 先把系统的代码都下载下来,然后在本地配置虚拟目录,再开始安装。在安装的时候可以选择语言,该系统支持简体中文(注意下图中红色框出的部分)。系统会执行一些操作(注意看下图左边部分),包括检查当前环境能否安装、建立数据库等,按照提示一步一步来就行,比较简单,没啥难度。 安装完后就会自动跳转到后台界面(如下图所示),有图表,有分析,和常用的统计系统差不多。功能还没细看,只做了初步的了解,界面的友好度还是蛮不错的。 嵌到页面中的JavaScript代码与其它统计系统也类似,如下所示,也是用异步加载的方式,只是发送的请求地址没有伪装成图像地址(注意看标红的那句代码)。<script type="text/javascript"> var _paq = _paq || []; /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { var u="//loc.piwik.cn/"; //自定义 _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '1']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src='piwik.js'; s.parentNode.insertBefore(g,s); })(); </script> 在页面中嵌入这段脚本后,页面在刷新的时候,会有下图中的请求。在请求中带了一大堆的参数,在后面的内容中会对每个参数做释义。二、脚本拆分 7000多行的脚本,当然不能一行一行的读,需要先拆分,拆成一个一个的模块,然后再逐个分析。脚本之所以这么大,是因为里面编写了大量代码来兼容各个版本的浏览器,这其中甚至包括IE4、Firefox1.0、Netscape等骨灰级的浏览器。接下来我把源码拆分成6个部分,分别是json、private、query、content-overlay、tracker和piwik,如下图红线框出的所示,piwik-all中包含了全部代码,便于对比。代码已上传到Github。 json.js是一个开源插件JSON3,为了兼容不支持JSON对象的浏览器而设计的,这里面的代码可以单独研究。private.js包含了一些用于全局的私有变量和私有函数,例如定义系统对象的别名、判断类型等。query.js中包含了很多操作HTML元素的方法,例如设置元素属性、查询某个CSS类的元素等,它类似于一个微型的jQuery库,不过有许多独特的功能。content-overlay.js有两部分组成,一部分包含内容追踪以及URL拼接等功能,另一部分是用来处理嵌套的页面,这里面具体没有细看。tracker.js中只有一个Tracker()函数,不过内容最多,有4700多行,主要的统计逻辑都在这里了。piwik.js中内容不多,包含一些初始化和插件的钩子等功能,钩子具体怎么运作的还没细看。 虽然分成了6部分,但是各部分的内容还是蛮多的,并且内容之间是有联系的,因此短时间的话,很难搞清楚其中所有的门道。我就挑了一点我个人感觉最重要的先做分析。1)3种传送数据的方式 我原先只知道两种传送数据的方式,一种是通过Ajax的方式,另一种是创建一个Image对象,然后为其定义src属性,数据作为URL的参数传递给后台,这种方式很通用,并且还能完美解决跨域问题。我以前编写的一个性能参数搜集的插件primus.js,也是这么传送数据的。在阅读源码的时候,发现了第三种传送数据的方式,使用Navigator对象的sendBeacon()。 MDN上说:“此方法可用于通过HTTP将少量数据异步传输到Web服务器”。虽然这个方法有兼容问题,但我还是被震撼到了。它很适合统计的场景,MDN上又讲到:“统计代码会在页面关闭(window.onunload)之前向web服务器发送数据,但过早的发送数据可能错过收集数据的机会。然而, 要保证在页面关闭期间发送数据一直比较困难,因为浏览器通常会忽略在卸载事件中产生的异步请求 。在使用sendBeacon()方法后,能使浏览器在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一页的载入。这就解决了提交分析数据时的所有的问题:使它可靠,异步并且不会影响下一页面的加载,并且代码更简单”。下面是代码片段(注意看标红的那句代码),存在于tracker.js中。function sendPostRequestViaSendBeacon(request) { var supportsSendBeacon = "object" === typeof navigatorAlias && "function" === typeof navigatorAlias.sendBeacon && "function" === typeof Blob; if (!supportsSendBeacon) { return false; } var headers = { type: "application/x-www-form-urlencoded; charset=UTF-8" }; var success = false; try { var blob = new Blob([request], headers); success = navigatorAlias.sendBeacon(configTrackerUrl, blob); // returns true if the user agent is able to successfully queue the data for transfer, // Otherwise it returns false and we need to try the regular way } catch (e) { return false; } return success; }2)参数释义 下面的方法(存在于tracker.js中)专门用于搜集页面中的统计数据,将它们拼接成指定链接的参数,而这条链接中的参数最终将会发送给服务器。/** * Returns the URL to call piwik.php, * with the standard parameters (plugins, resolution, url, referrer, etc.). * Sends the pageview and browser settings with every request in case of race conditions. */ function getRequest(request, customData, pluginMethod, currentEcommerceOrderTs) { var i, now = new Date(), nowTs = Math.round(now.getTime() / 1000), referralTs, referralUrl, referralUrlMaxLength = 1024, currentReferrerHostName, originalReferrerHostName, customVariablesCopy = customVariables, cookieSessionName = getCookieName("ses"), cookieReferrerName = getCookieName("ref"), cookieCustomVariablesName = getCookieName("cvar"), cookieSessionValue = getCookie(cookieSessionName), attributionCookie = loadReferrerAttributionCookie(), currentUrl = configCustomUrl || locationHrefAlias, campaignNameDetected, campaignKeywordDetected; if (configCookiesDisabled) { deleteCookies(); } if (configDoNotTrack) { return ""; } var cookieVisitorIdValues = getValuesFromVisitorIdCookie(); if (!isDefined(currentEcommerceOrderTs)) { currentEcommerceOrderTs = ""; } // send charset if document charset is not utf-8. sometimes encoding // of urls will be the same as this and not utf-8, which will cause problems // do not send charset if it is utf8 since it's assumed by default in Piwik var charSet = documentAlias.characterSet || documentAlias.charset; if (!charSet || charSet.toLowerCase() === "utf-8") { charSet = null; } campaignNameDetected = attributionCookie[0]; campaignKeywordDetected = attributionCookie[1]; referralTs = attributionCookie[2]; referralUrl = attributionCookie[3]; if (!cookieSessionValue) { // cookie 'ses' was not found: we consider this the start of a 'session' // here we make sure that if 'ses' cookie is deleted few times within the visit // and so this code path is triggered many times for one visit, // we only increase visitCount once per Visit window (default 30min) var visitDuration = configSessionCookieTimeout / 1000; if ( !cookieVisitorIdValues.lastVisitTs || nowTs - cookieVisitorIdValues.lastVisitTs > visitDuration ) { cookieVisitorIdValues.visitCount++; cookieVisitorIdValues.lastVisitTs = cookieVisitorIdValues.currentVisitTs; } // Detect the campaign information from the current URL // Only if campaign wasn't previously set // Or if it was set but we must attribute to the most recent one // Note: we are working on the currentUrl before purify() since we can parse the campaign parameters in the hash tag if ( !configConversionAttributionFirstReferrer || !campaignNameDetected.length ) { for (i in configCampaignNameParameters) { if ( Object.prototype.hasOwnProperty.call(configCampaignNameParameters, i) ) { campaignNameDetected = getUrlParameter( currentUrl, configCampaignNameParameters[i] ); if (campaignNameDetected.length) { break; } } } for (i in configCampaignKeywordParameters) { if ( Object.prototype.hasOwnProperty.call( configCampaignKeywordParameters, i ) ) { campaignKeywordDetected = getUrlParameter( currentUrl, configCampaignKeywordParameters[i] ); if (campaignKeywordDetected.length) { break; } } } } // Store the referrer URL and time in the cookie; // referral URL depends on the first or last referrer attribution currentReferrerHostName = getHostName(configReferrerUrl); originalReferrerHostName = referralUrl.length ? getHostName(referralUrl) : ""; if ( currentReferrerHostName.length && // there is a referrer !isSiteHostName(currentReferrerHostName) && // domain is not the current domain (!configConversionAttributionFirstReferrer || // attribute to last known referrer !originalReferrerHostName.length || // previously empty isSiteHostName(originalReferrerHostName)) ) { // previously set but in current domain referralUrl = configReferrerUrl; } // Set the referral cookie if we have either a Referrer URL, or detected a Campaign (or both) if (referralUrl.length || campaignNameDetected.length) { referralTs = nowTs; attributionCookie = [ campaignNameDetected, campaignKeywordDetected, referralTs, purify(referralUrl.slice(0, referralUrlMaxLength)) ]; setCookie( cookieReferrerName, JSON_PIWIK.stringify(attributionCookie), configReferralCookieTimeout, configCookiePath, configCookieDomain ); } } // build out the rest of the request request += "&idsite=" + configTrackerSiteId + "&rec=1" + "&r=" + String(Math.random()).slice(2, 8) + // keep the string to a minimum "&h=" + now.getHours() + "&m=" + now.getMinutes() + "&s=" + now.getSeconds() + "&url=" + encodeWrapper(purify(currentUrl)) + (configReferrerUrl.length ? "&urlref=" + encodeWrapper(purify(configReferrerUrl)) : "") + (configUserId && configUserId.length ? "&uid=" + encodeWrapper(configUserId) : "") + "&_id=" + cookieVisitorIdValues.uuid + "&_idts=" + cookieVisitorIdValues.createTs + "&_idvc=" + cookieVisitorIdValues.visitCount + "&_idn=" + cookieVisitorIdValues.newVisitor + // currently unused (campaignNameDetected.length ? "&_rcn=" + encodeWrapper(campaignNameDetected) : "") + (campaignKeywordDetected.length ? "&_rck=" + encodeWrapper(campaignKeywordDetected) : "") + "&_refts=" + referralTs + "&_viewts=" + cookieVisitorIdValues.lastVisitTs + (String(cookieVisitorIdValues.lastEcommerceOrderTs).length ? "&_ects=" + cookieVisitorIdValues.lastEcommerceOrderTs : "") + (String(referralUrl).length ? "&_ref=" + encodeWrapper(purify(referralUrl.slice(0, referralUrlMaxLength))) : "") + (charSet ? "&cs=" + encodeWrapper(charSet) : "") + "&send_image=0"; // browser features for (i in browserFeatures) { if (Object.prototype.hasOwnProperty.call(browserFeatures, i)) { request += "&" + i + "=" + browserFeatures[i]; } } var customDimensionIdsAlreadyHandled = []; if (customData) { for (i in customData) { if ( Object.prototype.hasOwnProperty.call(customData, i) && /^dimension\d+$/.test(i) ) { var index = i.replace("dimension", ""); customDimensionIdsAlreadyHandled.push(parseInt(index, 10)); customDimensionIdsAlreadyHandled.push(String(index)); request += "&" + i + "=" + customData[i]; delete customData[i]; } } } if (customData && isObjectEmpty(customData)) { customData = null; // we deleted all keys from custom data } // custom dimensions for (i in customDimensions) { if (Object.prototype.hasOwnProperty.call(customDimensions, i)) { var isNotSetYet = -1 === indexOfArray(customDimensionIdsAlreadyHandled, i); if (isNotSetYet) { request += "&dimension" + i + "=" + customDimensions[i]; } } } // custom data if (customData) { request += "&data=" + encodeWrapper(JSON_PIWIK.stringify(customData)); } else if (configCustomData) { request += "&data=" + encodeWrapper(JSON_PIWIK.stringify(configCustomData)); } // Custom Variables, scope "page" function appendCustomVariablesToRequest(customVariables, parameterName) { var customVariablesStringified = JSON_PIWIK.stringify(customVariables); if (customVariablesStringified.length > 2) { return ( "&" + parameterName + "=" + encodeWrapper(customVariablesStringified) ); } return ""; } var sortedCustomVarPage = sortObjectByKeys(customVariablesPage); var sortedCustomVarEvent = sortObjectByKeys(customVariablesEvent); request += appendCustomVariablesToRequest(sortedCustomVarPage, "cvar"); request += appendCustomVariablesToRequest(sortedCustomVarEvent, "e_cvar"); // Custom Variables, scope "visit" if (customVariables) { request += appendCustomVariablesToRequest(customVariables, "_cvar"); // Don't save deleted custom variables in the cookie for (i in customVariablesCopy) { if (Object.prototype.hasOwnProperty.call(customVariablesCopy, i)) { if (customVariables[i][0] === "" || customVariables[i][1] === "") { delete customVariables[i]; } } } if (configStoreCustomVariablesInCookie) { setCookie( cookieCustomVariablesName, JSON_PIWIK.stringify(customVariables), configSessionCookieTimeout, configCookiePath, configCookieDomain ); } } // performance tracking if (configPerformanceTrackingEnabled) { if (configPerformanceGenerationTime) { request += "&gt_ms=" + configPerformanceGenerationTime; } else if ( performanceAlias && performanceAlias.timing && performanceAlias.timing.requestStart && performanceAlias.timing.responseEnd ) { request += "&gt_ms=" + (performanceAlias.timing.responseEnd - performanceAlias.timing.requestStart); } } if (configIdPageView) { request += "&pv_id=" + configIdPageView; } // update cookies cookieVisitorIdValues.lastEcommerceOrderTs = isDefined(currentEcommerceOrderTs) && String(currentEcommerceOrderTs).length ? currentEcommerceOrderTs : cookieVisitorIdValues.lastEcommerceOrderTs; setVisitorIdCookie(cookieVisitorIdValues); setSessionCookie(); // tracker plugin hook request += executePluginMethod(pluginMethod, { tracker: trackerInstance, request: request }); if (configAppendToTrackingUrl.length) { request += "&" + configAppendToTrackingUrl; } if (isFunction(configCustomRequestContentProcessing)) { request = configCustomRequestContentProcessing(request); } return request; } 统计代码每次都会传送数据,而每次请求都会带上一大串的参数,这些参数都是简写,下面做个简单说明(如有不正确的地方,欢迎指正),部分参数还没作出合适的解释,例如UUID的生成规则等。首先将这些参数分为两部分,第一部分如下所列:1、idsite:网站ID2、rec:1(写死)3、r:随机码4、h:当前小时5、m:当前分钟6、s:当前秒数7、url:当前纯净地址,只留域名和协议8、_id:UUID9、_idts:访问的时间戳10、_idvc:访问数11、_idn:新访客(目前尚未使用)12、_refts:访问来源的时间戳13、_viewts:上一次访问的时间戳14、cs:当前页面的字符编码15、send_image:是否用图像请求方式传输数据16、gt_ms:内容加载消耗的时间(响应结束时间减去请求开始时间)17、pv_id:唯一性标识 再列出第二部分,用于统计浏览器的功能,通过Navigator对象的属性(mimeTypes、javaEnabled等)和Screen对象的属性(width与height)获得。1、pdf:是否支持pdf文件类型2、qt:是否支持QuickTime Player播放器3、realp:是否支持RealPlayer播放器4、wma:是否支持MPlayer播放器5、dir:是否支持Macromedia Director6、fla:是否支持Adobe FlashPlayer7、java:是否激活了Java8、gears:是否安装了Google Gears9、ag:是否安装了Microsoft Silverlight10、cookie:是否启用了Cookie11、res:屏幕的宽和高(未正确计算高清显示器) 上面这11个参数的获取代码,可以参考下面这个方法(同样存在于tracker.js中),注意看代码中的pluginMap变量(已标红),它保存了多个MIME类型,用来检测是否安装或启用了指定的插件或功能。/* * Browser features (plugins, resolution, cookies) */ function detectBrowserFeatures() { var i, mimeType, pluginMap = { // document types pdf: "application/pdf", // media players qt: "video/quicktime", realp: "audio/x-pn-realaudio-plugin", wma: "application/x-mplayer2", // interactive multimedia dir: "application/x-director", fla: "application/x-shockwave-flash", // RIA java: "application/x-java-vm", gears: "application/x-googlegears", ag: "application/x-silverlight" }; // detect browser features except IE < 11 (IE 11 user agent is no longer MSIE) if (!new RegExp("MSIE").test(navigatorAlias.userAgent)) { // general plugin detection if (navigatorAlias.mimeTypes && navigatorAlias.mimeTypes.length) { for (i in pluginMap) { if (Object.prototype.hasOwnProperty.call(pluginMap, i)) { mimeType = navigatorAlias.mimeTypes[pluginMap[i]]; browserFeatures[i] = mimeType && mimeType.enabledPlugin ? "1" : "0"; } } } // Safari and Opera // IE6/IE7 navigator.javaEnabled can't be aliased, so test directly // on Edge navigator.javaEnabled() always returns `true`, so ignore it if ( !new RegExp("Edge[ /](\\d+[\\.\\d]+)").test(navigatorAlias.userAgent) && typeof navigator.javaEnabled !== "unknown" && isDefined(navigatorAlias.javaEnabled) && navigatorAlias.javaEnabled() ) { browserFeatures.java = "1"; } // Firefox if (isFunction(windowAlias.GearsFactory)) { browserFeatures.gears = "1"; } // other browser features browserFeatures.cookie = hasCookies(); } var width = parseInt(screenAlias.width, 10); var height = parseInt(screenAlias.height, 10); browserFeatures.res = parseInt(width, 10) + "x" + parseInt(height, 10); }除了上述20多个参数之外,在系统官网上可点击“Tracking HTTP API”查看到所有的参数,只不过都是英文的。
网站性能优化工具大致分为两类:综合类和RUM类(实时监控用户类),WebPageTest属于综合类。WebPageTest通过布置一些特定的场景进行测试,例如不同的网速、浏览器、位置等。测试完成后,能获得优化等级、性能参数、请求瀑布图、网页幻灯片快照等,更多信息可以参考《WebPageTest快速入门》。一、总览输入网址后,首先进入视野中的就是下面这张画面。1)原理根据WebPageTest的《概述》了解到,WebPageTest是一个PHP网站,用户输入网址、地点、自定义脚本等信息后,参数发送到后台。后台做些逻辑处理,再通过浏览器相关的代理程序,启动Chrome、Firefox或IE,浏览器执行完后。将数据传回给后台,后台再将数据保存起来,最后通过各种形式(图、表格、列等),将分析数据过的数据,呈现给用户。2)视觉进展WebPageTest会测量视觉进展,也就是展示每个时间显示多少百分比的页面,一些数据测量就是根据这个来的,具体可以参考《Speed Index》。有两种测量方法:1. 先将页面显示的过程捕获,保存成多张图片,再通过图片分析工具将每个像素与最终图像比较,算出百分比,不过页面每个像素移动都会改变比对结果2. 现在有新的方法,使用绘画事件的可视进展,不过需要Webkit内核的浏览器才支持。3)扩展WebPageTest还支持扩展开发,只要申请到一个key后,就可以根据提供的API做开发。不过调用次数都会有限制,所以如果要做还是在自己本地或内网布置一个WebPageTest的环境。后面我会专门写几篇布置环境的文章,WebPageTest在windows中布置起来简单一点。4)导航栏1. TEST RESULT:能看到最新的一个测试。2. TEST HISTORY:能查看到测试历史记录。3. FORUMS:论坛信息,里面有许多提问和回答,覆盖面非常广,下图是论坛的首页。4. DOCUMENTATION:工具文档,英文版,并且挂在google域名下,自己翻译了一下,挂在了github上。5. ABOUT:给出了WebPageTest的Github地址,以及发布版的下载地址等信息。 二、普通配置1)Test Location和Browser配置测试地址,美帝、欧洲、亚洲、非洲、美洲,各个地方都有服务器,不过还是选择一个近点的比较好,可以选香港或扬州。点击Select from Map,弹出的是google地图,你懂得,不做点措施是显示不了的。不同地点,可以选择的Browser(浏览器)将不同,例如香港服务器可以选择Chrome、Firefox和IE11,扬州就不支持IE11。 三、高级配置(Advanced Settings)1)Test SettingsConnection:网速(Connection)有光纤(Cable)、DSL或者自定义。RTT(Round Trip Time):一个数据包从发出去到回来的时间。自定义设置中可以设置:下行带宽(BW Down),上行带宽(BW Up),延迟(Latency),丢包率(Packet Loss)。Repeat View:选择“First View and Repeat View”后,就启动重复视图,每次测试有两个视图,第二个的时候,就可以模拟有缓存的情况。2)Advanced高级设置中的高级设置,可以修改访问代理信息、自定义头信息,能够模拟更多实际的情况。3)Chrome针对Chrome浏览器的设置,可以调用浏览器中的模拟器、捕获开发工具时间轴。4)AuthHTTP基本授权,输入用户名和密码后,这些信息经过base64编码,以HTTP请求首部的形式发送。这种技术称为HTTP基本验证(HBA),使用这种方式,需要服务器支持HBA,所以这并不是一个稳妥的方法。授权的请求首部信息类似于下面:Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=5)Script自定义脚本,网站文档《脚本》中有更多设置,非常强大,不过根据浏览器不同,能使用的脚本参数也会不同。6)Block请求阻塞,多个关键字可用空格分割,如果请求的URL中与输入的关键词匹配,那么请求将被阻塞。与下面的SPOF最大的区别是不会发生请求超时,因为这个请求根本没有创建。这个测试的目的就是简单的评估资源缺失对页面造成的影响。7)SPOF单点故障,只需将要限制的域名写在输入框中即可,一个域名一行。目的就是请求超时,对网站的影响,这是一种非常简便的检测第三方托管资源有效性的方法。8)Custom设置自定义指标,网站文档《自定义指标》有详细说明。设置完成后可以在“detail -》Custom Metrics”中查看到,有个测试案例可以查看。
当我们ajax提交一个按钮的时候,给那个按钮来个Loading效果会高端很多,体验也会上升个层次。既能让用户知道正在提交中,也能防止二次提交,好处多多呢。上面的这个圈圈是会滚动的。简单点的话,可以直接用GIF动态图片实现。不过现在已经有了CSS3和HTML5了,多了好几种高大上的实现方式。这里先来介绍几个动画的在线demo,第一个是HTML5 Boilerplate中的Effeckt.css,第二个是Animate.css。下面一一列出,如果要结合按钮的话,可自行修改下CSS或JS等文件。当要嵌入到实际项目中的时候,可能会改动一些地方,以实际情况为准了。 一、PNG图片+CSS3动画<div class="pull-up pull-up-loading"> <span class="pull-icon"></span>正在载入中.... </div>.pull-up-loading .pull-icon { background-position: 0 100%; /*chrome*/ -webkit-transform: rotate(0deg) translateZ(0); -webkit-transition-duration: 0ms; -webkit-animation-name: loading; -webkit-animation-duration: 2s; -webkit-animation-iteration-count: infinite; -webkit-animation-timing-function: linear; } /*chrome*/ @-webkit-keyframes loading { from { -webkit-transform: rotate(0deg) translateZ(0); } to { -webkit-transform: rotate(360deg) translateZ(0); } }点击查看在线实例:只有当加上pull-up-loading,才会出现滚动添加一个动画keyframes,叫loading,是在做transform: rotate操作,下面的CSS省略了firefox中的动画代码,为了看的清晰点,实例中有完整的firefox代码这里有个在线生成Loading的纯CSS代码,cssload.net。样式选择还是挺多的,就是对于老一点的浏览器的兼容性方面不是很强比如IE6、IE7、IE8等。再来几个不同的款式:点击可查看源码 二、spin.js 这是一个脚本文件。不依赖任何库,可以独立执行,挺好用的,我在实际项目中使用过这个插件,当时我把所有的ajax提交都调用了这个插件,结合jQuery库,做到Loading效果和防止二次提交。而且这个库的浏览器兼容性很好,甚至兼容古老的IE6,而且不用引入额外的CSS或图,可配置的参数也很多。 我粗略的看过代码,标准的浏览器就用脚本写CSS3来做动画,对于古老点的浏览器就用setTimeout来模拟动画。里面还会初始化一个VML标签,这个是针对IE的。 看代码的时候看到了个很有趣的符号“~~”,后面一查,说是将变量转换成数字的一个方法,挺高级的。 这个插件还提供了一个在线配置的小网站,点击查看:showAjaxLoading: function(btn) { if (btn == null || btn == undefined || $(btn).length == 0) return; var left = $(btn).offset().left; var top = $(btn).offset().top; var width = $(btn).outerWidth(); var height = $(btn).height(); var opts = { lines: 9, // The number of lines to draw length: 0, // The length of each line width: 10, // The line thickness radius: 15, // The radius of the inner circle corners: 1, // Corner roundness (0..1) rotate: 0, // The rotation offset direction: 1, // 1: clockwise, -1: counterclockwise color: '#000', // #rgb or #rrggbb or array of colors speed: 1, // Rounds per second trail: 81, // Afterglow percentage shadow: false, // Whether to render a shadow hwaccel: false, // Whether to use hardware acceleration className: 'spinner', // The CSS class to assign to the spinner zIndex: 2e9, // The z-index (defaults to 2000000000) top: '50%', // Top position relative to parent left: '50%' // Left position relative to parent }; $('#ajax_spin').remove(); $('body').append('<div id="ajax_spin" style="position:absolute;background:#FFF;filter:alpha(opacity=30);opacity:0.3"><div id="ajax_spin_inner" style="position:relative;height:50px;"></div></div>'); $('#ajax_spin').css({ 'top': top, 'left': left, 'width': width, 'height': height }); var target = document.getElementById('ajax_spin_inner'); var spinner = new Spinner(opts).spin(target); //return spinner; }, stopAjaxLoading: function() { $('#ajax_spin').remove(); //new Spinner(opts).spin(target) //spinner.stop(); }上面那段代码是我在一个实际项目中写的,就是显示和移除Loading效果,并且在按钮上面覆盖这层效果防止二次提交。btn就是按钮jQuery对象left,top找到按钮的左右位移,width和height获取按钮的宽和高,width用的是outerWidth$('body')加入一个能够覆盖按钮的层初始化一个Spinner对象,并加入到那个覆盖层中 三、Ladda可以单独使用,或者结合上面的插件spin一起结合使用。demo页面的效果挺高大上的,但用到实例可能还是需要些修改的。点击查看主页下图随便选了几个例子,可以实现不同尺寸的按钮大小,不同方向的滚动,将按钮变成原型,或带进度条的按钮。挺多样性的。点击查看demo页面: HTML代码如下:<button class="ladda-button" data-style="expand-right"><span class="ladda-label">Submit</span></button>// Automatically trigger the loading animation on click Ladda.bind( 'input[type=submit]' ); // Same as the above but automatically stops after two seconds Ladda.bind( 'input[type=submit]', { timeout: 2000 } );结构看上去不是很复杂,JS脚本的引入也不是很难。不过在引入实际项目中肯定还是需要做些修改的。相比spin插件,这插件要引入的文件就多了,不但要引入JS还要引入CSS。 点击查看codepen上复制的代码 我本来想在codepen页面中,把demo页面重现一次,在把github里面的dist/CSS/ladda.min.css文件复制到codepen中,JS中的ladda.js和spin.js也复制过来。发生了点意外,那个滚动条老是会往下面一点。CSS都是全部复制的,很奇怪。后面发现是CSS的问题,真的是实际应用一下才会看到具体情况。 demo页面的CSS:.ladda-button .ladda-spinner { position: absolute; z-index: 2; display: inline-block; width: 32px; height: 32px; top: 50%; margin-top: -17px; opacity: 0; pointer-events: none } Github上的CSS:区别就是margin-top的不一样。.ladda-button .ladda-spinner { position: absolute; z-index: 2; display: inline-block; width: 32px; height: 32px; top: 50%; margin-top: 0; opacity: 0; pointer-events: none }四、Sonic.js这个插件是创建一个canvas画布来实现Loaing动画效果。 款式也比较多,如下图所示:点击查看在线demo在线demo中还展示了用CSS3动画+CSS Sprite技术实现动画
2022年04月