前端测试,或者 UI 测试一直是业界一大难题。最近
Q.js
使用 Karma 作为测试任务管理工具,本文在回顾前端测试方案的同时,也分析下为什么Q.js
选用 Karma 而不是其他测试框架。
像素级全站对比
曾今有一批人做过这样的 UI 测试,即最终页面图像是否符合预期,通过图片差异对比来找出可能的问题。
如图所示,所谓像素级站点对比,即利用截屏图像前后对比来找出,站点前后差异,从而发现问题。
前一篇文章我们介绍了虚拟 DOM 的实现与原理,这篇文章我们来讲讲 React 的直出。
比起 MVVM,React 比较容易实现直出,那么 React 的直出是如何实现,有什么值得我们学习的呢?
为什么 MVVM 不能做直出?
对于 MVVM,HTML 片段即为配置,而直出后的 HTML 无法还原配置,所以问题不是 MVVM 能否直出,而是在于直出后的片段能否还原原来的配置。下面是一个简单的例子:
1 2 |
<sapn>Hello {name}!</span> |
上面这段 HTML 配置和数据在一起,直出后会变成:
1 2 |
<span>Hello world!</span> |
这时候当我们失去了 name 的值改变的时候会导致页面渲染这个细节。当然,如果为了实现 MVVM 直出我们可能有另外的方法来解决,例如直出结果变成这样:
1 2 |
<span>Hello <span q-text="name">world</span>!</span> |
这时候我们是可以把丢失的信息找回来的,当然结构可能和我们想象的有些差别。当然还有其他问题,例如直出 HTML 不一定能反向还原数据,由于篇幅问题,这里不展开讨论。
React 如何直出?
如图:
- React 的虚拟 DOM 的生成是可以在任何支持 Javascript 的环境生成的,所以可以在 NodeJS 或 Iojs 环境生成
- 虚拟 DOM 可以直接转成 String
- 然后插入到 html 文件中输出给浏览器便可
具体例子可以参考,https://github.com/DavidWells/isomorphic-react-example/,下面是其渲染路由的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<span class="comment">// https://github.com/DavidWells/isomorphic-react-example/blob/master/app/routes/coreRoutes.js</span> <span class="keyword">var</span> React = <span class="keyword">require</span>(<span class="string">'react/addons'</span>); <span class="keyword">var</span> ReactApp = React.createFactory(<span class="keyword">require</span>(<span class="string">'../components/ReactApp'</span>).ReactApp); module.exports = <span class="keyword">function</span>(app) { app.get(<span class="string">'/'</span>, <span class="keyword">function</span>(req, res){ <span class="comment">// React.renderToString takes your component</span> <span class="comment">// and generates the markup</span> <span class="keyword">var</span> reactHtml = React.renderToString(ReactApp({})); <span class="comment">// Output html rendered by react</span> <span class="comment">// console.log(myAppHtml);</span> res.render(<span class="string">'index.ejs'</span>, {reactOutput: reactHtml}); }); }; |
OK,我们现在知道如何利用 React 实现直出,以及如何前后端代码复用。
但还有下面几个问题有待解决:
- 如何渲染文字节点,每个虚拟 DOM 节点是需要对应实际的节点,但无法通过 html 文件生成相邻的 Text Node,例如下面例子应当如何渲染:
1 2 3 4 5 6 7 8 9 10 |
React.createClass({ render: <span class="keyword">function</span> () { <span class="keyword">return</span> ( <p> Hello {name}! </p> ); } }) |
- 如何避免直出的页面被 React 重新渲染一遍?或者直出的页面和前端的数据是不对应的怎么办?
相邻的 Text Node,想多了相邻的 span 而已
通过一个简单的例子,我们可以发现,实际上 React 根本没用 Text Node,而是使用 span 来代替 Text Node,这样就可以实现虚拟 DOM 和直出 DOM 的一一映射关系。
重复渲染?没门
刚刚的例子,如果我们通过 React.renderToString 拿到<Test />
可以发现是:
1 2 |
<p data-reactid=".0" data-react-checksum="-793171045"><span data-reactid=".0.0">Hello </span><span data-reactid=".0.1">world</span><span data-reactid=".0.2">!</span></p> |
我们可以发现一个有趣的属性 data-react-checksum
,这是啥?实际上这是上面这段 HTML 片段的 adler32 算法值。实际上调用 React.render(<MyComponent />, container);
时候做了下面一些事情:
- 看看 container 是否为空,不为空则认为有可能是直出了结果。
- 接下来第一个元素是否有
data-react-checksum
属性,如果有则通过 React.renderToString 拿到前端的,通过 adler32 算法得到的值和data-react-checksum
对比,如果一致则表示,无需渲染,否则重新渲染,下面是 adler32 算法实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<span class="keyword">var</span> MOD = <span class="number">65521</span>; <span class="comment">// This is a clean-room implementation of adler32 designed for detecting</span> <span class="comment">// if markup is not what we expect it to be. It does not need to be</span> <span class="comment">// cryptographically strong, only reasonably good at detecting if markup</span> <span class="comment">// generated on the server is different than that on the client.</span> <span class="keyword">function</span> adler32(data) { <span class="keyword">var</span> a = <span class="number">1</span>; <span class="keyword">var</span> b = <span class="number">0</span>; <span class="keyword">for</span> (<span class="keyword">var</span> i = <span class="number">0</span>; i < data.length; i++) { a = (a + data.charCodeAt(i)) % MOD; b = (b + a) % MOD; } <span class="keyword">return</span> a | (b << <span class="number">16</span>); } |
- 如果需要重新渲染,先通过下面简单的差异算法找到差异在哪里,打印出错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<span class="comment">/** * Finds the index of the first character * that's not common between the two given strings. * *<span class="phpdoc"> @return</span> {number} the index of the character where the strings diverge */</span> <span class="keyword">function</span> firstDifferenceIndex(string1, string2) { <span class="keyword">var</span> minLen = Math.min(string1.length, string2.length); <span class="keyword">for</span> (<span class="keyword">var</span> i = <span class="number">0</span>; i < minLen; i++) { <span class="keyword">if</span> (string1.charAt(i) !== string2.charAt(i)) { <span class="keyword">return</span> i; } } <span class="keyword">return</span> string1.length === string2.length ? -<span class="number">1</span> : minLen; } |
下面是首屏渲染时的主要逻辑,可以发现 React 对首屏实际上也是通过 innerHTML 来渲染的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
_mountImageIntoNode: <span class="keyword">function</span>(markup, container, shouldReuseMarkup) { (<span class="string">"production"</span> !== process.env.NODE_ENV ? invariant( container && ( (container.nodeType === ELEMENT_NODE_TYPE || container.nodeType === DOC_NODE_TYPE) ), <span class="string">'mountComponentIntoNode(...): Target container is not valid.'</span> ) : invariant(container && ( (container.nodeType === ELEMENT_NODE_TYPE || container.nodeType === DOC_NODE_TYPE) ))); <span class="keyword">if</span> (shouldReuseMarkup) { <span class="keyword">var</span> rootElement = getReactRootElementInContainer(container); <span class="keyword">if</span> (ReactMarkupChecksum.canReuseMarkup(markup, rootElement)) { <span class="keyword">return</span>; } <span class="keyword">else</span> { <span class="keyword">var</span> checksum = rootElement.getAttribute( ReactMarkupChecksum.CHECKSUM_ATTR_NAME ); rootElement.removeAttribute(ReactMarkupChecksum.CHECKSUM_ATTR_NAME); <span class="keyword">var</span> rootMarkup = rootElement.outerHTML; rootElement.setAttribute( ReactMarkupChecksum.CHECKSUM_ATTR_NAME, checksum ); <span class="keyword">var</span> diffIndex = firstDifferenceIndex(markup, rootMarkup); <span class="keyword">var</span> difference = <span class="string">' (client) '</span> + markup.substring(diffIndex - <span class="number">20</span>, diffIndex + <span class="number">20</span>) + <span class="string">'n (server) '</span> + rootMarkup.substring(diffIndex - <span class="number">20</span>, diffIndex + <span class="number">20</span>); (<span class="string">"production"</span> !== process.env.NODE_ENV ? invariant( container.nodeType !== DOC_NODE_TYPE, <span class="string">'You're trying to render a component to the document using '</span> + <span class="string">'server rendering but the checksum was invalid. This usually '</span> + <span class="string">'means you rendered a different component type or props on '</span> + <span class="string">'the client from the one on the server, or your render() '</span> + <span class="string">'methods are impure. React cannot handle this case due to '</span> + <span class="string">'cross-browser quirks by rendering at the document root. You '</span> + <span class="string">'should look for environment dependent code in your components '</span> + <span class="string">'and ensure the props are the same client and server side:n%s'</span>, difference ) : invariant(container.nodeType !== DOC_NODE_TYPE)); <span class="keyword">if</span> (<span class="string">"production"</span> !== process.env.NODE_ENV) { (<span class="string">"production"</span> !== process.env.NODE_ENV ? warning( <span class="keyword">false</span>, <span class="string">'React attempted to reuse markup in a container but the '</span> + <span class="string">'checksum was invalid. This generally means that you are '</span> + <span class="string">'using server rendering and the markup generated on the '</span> + <span class="string">'server was not what the client was expecting. React injected '</span> + <span class="string">'new markup to compensate which works but you have lost many '</span> + <span class="string">'of the benefits of server rendering. Instead, figure out '</span> + <span class="string">'why the markup being generated is different on the client '</span> + <span class="string">'or server:n%s'</span>, difference ) : <span class="keyword">null</span>); } } } (<span class="string">"production"</span> !== process.env.NODE_ENV ? invariant( container.nodeType !== DOC_NODE_TYPE, <span class="string">'You're trying to render a component to the document but '</span> + <span class="string">'you didn't use server rendering. We can't do this '</span> + <span class="string">'without using server rendering due to cross-browser quirks. '</span> + <span class="string">'See React.renderToString() for server rendering.'</span> ) : invariant(container.nodeType !== DOC_NODE_TYPE)); setInnerHTML(container, markup); } |
最后
尝试一下下面的代码,想想 React 为啥认为这是错误的?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<span class="keyword">var</span> Test = React.createClass({ getInitialState: <span class="keyword">function</span>() { <span class="keyword">return</span> {name: <span class="string">'world'</span>}; }, render: <span class="keyword">function</span>() { <span class="keyword">return</span> ( <p>Hello</p> <p> Hello {<span class="keyword">this</span>.state.name}! </p> ); } }); React.render( <Test />, document.getElementById(<span class="string">'content'</span>) ); |
前端可选的视频直播协议大致只有两种:
- RTMP(Real Time Messaging Protocol)
- HLS(HTTP Live Streaming)
其中RTMP
是 Adobe 开发的协议,无法在 iPhone 中兼容,故目前兼容最好的就是 HLS 协议了。
HTTP Live Streaming(HLS)是苹果公司实现的基于 HTTP 的流媒体传输协议,可实现流媒体的直播和点播。原理上是将视频流分片成一系列 HTTP 下载文件。所以,HLS 比 RTMP 有较高的延迟。
前端播放 HLS
- Native 支持
- Android 3.0+
- iOS 3.0+
- flash 支持
- Flowplayer(GPL
×
) - GrindPlayer(MIT)
- video-js-swf(Apache License 2.0)
- MediaElement.js(MIT)
- clappr(BSD IE10+
×
)
- Flowplayer(GPL
最后,由于 MediaElement 已经纳入 WordPress 的核心视音频库,以及其良好的兼容性(见下图),所以最后选择使用 MediaElement.js 来实现。
切片准备
可使用 m3u8downloader 下载一个 HLS 源,或者使用 node-m3u 生成 m3u8 索引和 MPEG-TS 切片,下面是我们准备切片:
注意看切片索引文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<span class="comment">#EXTM3U</span> <span class="comment">#EXT-X-TARGETDURATION:11</span> <span class="comment">#EXT-X-VERSION:3</span> <span class="comment">#EXT-X-MEDIA-SEQUENCE:0</span> <span class="comment">#EXT-X-PLAYLIST-TYPE:VOD</span> <span class="comment">#EXTINF:10.133333,</span> fileSequence0.ts <span class="comment">#EXTINF:10.000666,</span> fileSequence1.ts <span class="comment">#EXTINF:10.667334,</span> fileSequence2.ts <span class="comment">#EXTINF:9.686001,</span> fileSequence3.ts <span class="comment">#EXTINF:9.768665,</span> fileSequence4.ts <span class="comment">#EXTINF:10.000000,</span> fileSequence5.ts <span class="comment">#EXT-X-ENDLIST</span> |
其中 #EXT-X-ENDLIST
为切片终止标记,如果没有该标记,浏览器会在文件读取完后再请求索引文件,如果有更新则继续下载新文件,以此达到直播效果。
前端代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<!DOCTYPE html> <html> <head> <title>player</title> <link rel=<span class="string">"stylesheet"</span> href=<span class="string">"./player/mediaelementplayer.css"</span> /> <style> <span class="comment">/** 隐藏控制条 **/</span> .mejs-controls { display: none !important; } </style> </head> <body> <video width=<span class="string">"640"</span> height=<span class="string">"360"</span> id=<span class="string">"player1"</span>> <source type=<span class="string">"application/x-mpegURL"</span> src=<span class="string">"/m3u8/index.m3u8"</span>> </video> <script src=<span class="string">"http://7.url.cn/edu/jslib/jquery/1.9.1/jquery.min.js"</span>></script> <script src=<span class="string">"./player/mediaelement-and-player.js"</span>></script> <script> <span class="keyword">var</span> player = <span class="keyword">new</span> MediaElementPlayer(<span class="string">'#player1'</span>, { <span class="comment">// 禁止点击暂停</span> clickToPlayPause: <span class="keyword">false</span>, success: <span class="keyword">function</span> (media, ele, player) { <span class="comment">// 初始化后立刻播放</span> player.play(); } }); </script> </body> </html> |
效果
例子源码
作为
React
的核心技术之一Virtual DOM
,一直披着神秘的面纱。
实际上,Virtual DOM 包含:
- Javascript DOM 模型树(VTree),类似文档节点树(DOM)
- DOM 模型树转节点树方法(VTree -> DOM)
- 两个 DOM 模型树的差异算法(diff(VTree, VTree) -> PatchObject)
- 根据差异操作节点方法(patch(DOMNode, PatchObject) -> DOMNode)
接下来我们分别探讨这几个部分:
VTree
VTree 模型非常简单,基本结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{ <span class="comment">// tag的名字</span> tagName: <span class="string">'p'</span>, <span class="comment">// 节点包含属性</span> properties: { style: { color: <span class="string">'#fff'</span> } }, <span class="comment">// 子节点</span> children: [], <span class="comment">// 该节点的唯一表示,后面会讲有啥用</span> key: <span class="number">1</span> } |
所以我们很容易写一个方法来创建这种树状结构,例如 React 是这么创建的:
1 2 3 4 5 6 7 8 |
<span class="comment">// 创建一个div</span> react.createElement(<span class="string">'div'</span>, <span class="keyword">null</span>, [ <span class="comment">// 子节点img</span> react.createElement(<span class="string">'img'</span>, { src: <span class="string">"avatar.png"</span>, <span class="keyword">class</span>: <span class="string">"profile"</span> }), <span class="comment">// 子节点h3</span> react.createElement(<span class="string">'h3'</span>, <span class="keyword">null</span>, [[user.firstName, user.lastName].join(<span class="string">' '</span>)]) ]); |
VTree -> DOM
这方法也不太难,我们实现一个简单的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
<span class="keyword">function</span> create(vds, <span class="keyword">parent</span>) { <span class="comment">// 首先看看是不是数组,如果不是数组统一成数组</span> !<span class="keyword">Array</span>.isArray(vds) && (vds = [vds]); <span class="comment">// 如果没有父元素则创建个fragment来当父元素</span> <span class="keyword">parent</span> = <span class="keyword">parent</span> || document.createDocumentFragment(); <span class="keyword">var</span> node; <span class="comment">// 遍历所有VNode</span> vds.<span class="keyword">forEach</span>(<span class="keyword">function</span> (vd) { <span class="comment">// 如果VNode是文字节点</span> <span class="keyword">if</span> (isText(vd)) { <span class="comment">// 创建文字节点</span> node = document.createTextNode(vd.text); <span class="comment">// 否则是元素</span> } <span class="keyword">else</span> { <span class="comment">// 创建元素</span> node = document.createElement(vd.tag); } <span class="comment">// 将元素塞入父容器</span> <span class="keyword">parent</span>.appendChild(node); <span class="comment">// 看看有没有子VNode,有孩子则处理孩子VNode</span> vd.children && vd.children.length && create(vd.children, node); <span class="comment">// 看看有没有属性,有则处理属性</span> vd.properties && setProps({ style: {} }, vd.properties, node); }); <span class="keyword">return</span> <span class="keyword">parent</span>; } |
diff(VTree, VTree) -> PatchObject
差异算法是 Virtual DOM 的核心,实际上该差异算法是个取巧算法(当然你不能指望用 O(n^3) 的复杂度来解决两个树的差异问题吧),不过能解决 Web 的大部分问题。
那么 React 是如何取巧的呢?
- 分层对比
如图,React 仅仅对同一层的节点尝试匹配,因为实际上,Web 中不太可能把一个 Component 在不同层中移动。
- 基于 key 来匹配
还记得之前在 VTree 中的属性有一个叫 key 的东东么?这个是一个 VNode 的唯一识别,用于对两个不同的 VTree 中的 VNode 做匹配的。
这也很好理解,因为我们经常会在 Web 遇到拥有唯一识别的 Component(例如课程卡片、用户卡片等等)的不同排列问题。
- 基于自定义元素做优化
React 提供自定义元素,所以匹配更加简单。
patch(DOMNode, PatchObject) -> DOMNode
由于 diff 操作已经找出两个 VTree 不同的地方,只要根据计算出来的结果,我们就可以对 DOM 的进行差异渲染。
扩展阅读
具体可参考下面两份代码实现:
- @Matt-Esch 实现的:virtual-dom
- 我们自己做的简版实现,用于 Mobile 页面渲染的:qvd
Facebook’s challenges are applicable to any very complex websites with many developers. Or any situation where CSS is bundled into multiple files and loaded asynchronously, and often loaded lazily.
——@vjeux
将 Facebook 换成 Tencent 同样适用。
同行们是怎么解决的?
- Shadow DOM Style
Shadow DOM 的样式是完全隔离的,这就意味着即使你在主文档中有一个针对全部 <h3>
标签的样式选择器,这个样式也不会不经你的允许便影响到 shadow DOM 的元素。
举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<body> <style> button { font-size: <span class="number">18</span>px; font-family: <span class="string">'华文行楷'</span>; } </style> <button>我是一个普通的按钮</button> <div></div> <script> <span class="keyword">var</span> host = document.querySelector(<span class="string">'div'</span>); <span class="keyword">var</span> root = host.createShadowRoot(); root.innerHTML = <span class="string">'<style>button { font-size: 24px; color: blue; } </style>'</span> + <span class="string">'<button>我是一个影子按钮</button>'</span> </script> </body> |
这就很好地为 Web Component
建立了 CSS Namespace 机制。
- Facebook: CSS in JS
http://blog.vjeux.com/2014/javascript/react-css-in-js-nationjs.html
比较变态的想法,干脆直接不要用 classname,直接用 style,然后利用 js 来写每个元素的 style……
例如,如果要写一个类似 button:hover
的样式,需要写成这样子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
<span class="keyword">var</span> Button = React.createClass({ styles: { container: { fontSize: <span class="string">'13px'</span>, backgroundColor: <span class="string">'rgb(233, 234, 237)'</span>, border: <span class="string">'1px solid #cdced0'</span>, borderRadius: <span class="number">2</span>, boxShadow: <span class="string">'0 1px 1px rgba(0, 0, 0, 0.05)'</span>, padding: <span class="string">'0 8px'</span>, margin: <span class="number">2</span>, lineHeight: <span class="string">'23px'</span> }, depressed: { backgroundColor: <span class="string">'#4e69a2'</span>, borderColor: <span class="string">'#1A356E'</span>, color: <span class="string">'#FFF'</span> }, }, propTypes: { isDepressed: React.PropTypes.bool, style: React.PropTypes.object, }, render: <span class="keyword">function</span>() { <span class="keyword">return</span> ( <button style={m( <span class="keyword">this</span>.styles.container, <span class="comment">// 如果压下按钮,mixin压下的style</span> <span class="keyword">this</span>.props.isDepressed && <span class="keyword">this</span>.styles.depressed, <span class="keyword">this</span>.props.style )}>{<span class="keyword">this</span>.props.children}</button> ); } }); |
几乎等同于脱离了 css,直接利用 javascript 来实现样式依赖、继承、混入、变量等问题……当然如果我们去看看 React-native 和 css-layout,就可以发现,如果想通过 React 打通客户端开发,style 几乎成了必选方案。
我们的方案
我们期望用类似
Web Component
的方式去写 Component 的样式,但在低端浏览器根本就不支持Shadow DOM
,所以,我们基于 BEM 来搭建了一种 CSS Namespace 的方案。
我们的 Component 由下面 3 个文件组成:
- main.html 结构
- main.js 逻辑
- main.css 样式
可参考:https://github.com/miniflycn/Ques/tree/master/src/components/qtree
可以发现我们的 css 是这么写的:
1 2 3 4 5 6 7 8 9 |
.<span class="variable">$__title</span> { margin: <span class="number">0</span> auto; font-size: <span class="number">14</span>px; cursor: <span class="keyword">default</span>; padding-left: <span class="number">10</span>px; -webkit-user-select: none; } <span class="comment">/** 太长忽略 **/</span> |
这里面有长得很奇怪的.$__
前缀,该前缀是我们的占位符,构建系统会自动将其替换成 Component 名,例如,该 Component 为 qtree,所以生成结果是:
1 2 3 4 5 6 7 8 9 |
.qtree__title { margin: <span class="number">0</span> auto; font-size: <span class="number">14</span>px; cursor: <span class="keyword">default</span>; padding-left: <span class="number">10</span>px; -webkit-user-select: none; } <span class="comment">/** 太长忽略 **/</span> |
同样道理,在 main.html
和 main.js
中的对应选择器,在构建中也会自动替换成 Component 名。
这有什么好处呢?
- 基于路径的 Namespace,路径没有冲突,那么在该项目中 Namespace 也不会冲突
- Component 可以任意改名,或复制重构,不会产生任何影响,便于 Component 的重构和扩展
- Component 相对隔离,不会对外部产生影响
- Component 非绝对隔离,外部可以对其产生一定影响