它具备如下特性:
地址:https://www.liuyiqi.cn/fe-kg/
简单来说,是为了锻炼自己的联想能力。当然,如果能服务广大前端同学,就更好了。
目前网络的上前端思维导图,本质是一个树,每个节点的入度唯一,也就是它的父节点。这是不科学的!因为很多知识点具备相关性,用树是无法表达的。
比如:我们在学习网络-应用层-http-响应头(content-type、cache-control……)时候,只知道它是 http 的知识,但是不知道它有很多应用,比如实现一个静态文件服务器,需要用到 content-type 和 cache-control。
再比如:我们在学 async、defer 时候会接触非阻塞的优化方式。我们在学 HTTP2/服务端推送时候,也会学到非阻塞。是不是应该将它们联系起来?
为了解决上述问题,图这个数据结构就能派上用场了!
我们可以用广度优先遍历和深度优先遍历两种方式来学习这个知识图谱。
广度优先遍历,简单来说就是先广后深来遍历图中的顶点。比如一个这样的图:
深度优先遍历,简单来说,深度优先遍历就是先深后广来遍历。如图:
了解两种遍历方法,我们只需要随便选择一个节点,就可以开始学习了。当然也可以从根节点“前端”开始遍历。
接下来,笔者会坚持从工作、博客、面试题等多个来源中提取节点和边,不断丰富这个知识图谱,争取打造一个全面、系统的前端知识图谱。
]]>一个记录函数调用的数据结构。当函数被调用时,会被 push 进栈顶;执行完返回时,从栈顶 pop 出。Javascript 主线程中只有一个执行栈,负责顺序执行主线程中代码。
上图先调用 a(push a 到栈),a 再调用 b(push b 到栈),所以报错信息里的 error stack 是 b->a->主函数;
如果上图不报错,那么:b 执行完了被 pop 出,然后 a 执行完了被 pop 出。
一个记录异步事件回调的队列数据结构。当有外部的异步事件 (setTimeout、ajax 等请求) 时,相应的回调函数会按照先后顺序存放在任务队列中。
ES6 之前任务比较简单只有 setTimeout / ajax 这类 web api 生成的异步事件,所有的这些内容都会被存放到事件队列中,我们称之为异步任务。后来 ES6 中引入了 Promise 之后,异步任务之间存在差异,执行的优先级也有区别。分为两类:微任务和宏任务。
宏任务:整体代码 script 、setTimeout、setInterval、DOM 操作、ajax; 微任务:Promise、async / await
下面代码的执行顺序是啥?
1 | setTimeout(function() { |
答:promise console then setTimeout
]]>子类的原型对象设置为父类的实例:
1 | function C1 (){} |
这样:每个子类实例就会继承父类实例的属性,但由于继承的属性在子类的原型上,所以继承的属性在不同实例之间都是共享的,没有隔离。
子类的构造函数内部,执行了父类的构造函数:
1 | function C1 (){} |
这样:每个子类实例的继承属性就隔离了,但继承不到父类的原型方法;
结合以上两种
1 | function C1 (){} |
这样:继承的私有属性隔离,继承的原型方法可以公用,但是执行了两次父类的构造函数,第二次是多余的。
结合前两种方法,但把子类的原型对象指向父类的原型对象,而不是实例。
1 | function C1 (){} |
这样:就避免执行了两次父类的构造函数,但破坏了父类的原型对象的 constructor
:C1.proptotype.constructor
,本来应该指向 C1 的,现在指向 C2 了。
将子类的原型对象设置为一个新的对象,该对象的 __proto__
指向父类的原型。
1 | function C1 (){} |
这样就完美了!
]]>防抖:当触发动作停够指定时间才触发事件
节流:不管触发动作多么密集,事件之间的必须要有足够的间隔时间
防抖:假如你有一个善变的老婆,如果她一会要你给她买品牌A,一会说要你给她买品牌B,你很烦,就对她说,当你做完决定,一天内不变,我就给你买。
节流:假如你有一个爱花钱的老婆,她一个月让你买 10 个包,你很烦,就对她说,每个月只能买 1 个包。
防抖:一边在输入框里打字,一边发请求校验,可以用防抖技术,让用户停止输入足够时间后,再发请求。
节流:监听滚动事件(比如图片懒加载)时候,可以用节流技术,减少触发事件的执行次数,减少性能消耗。
防抖:
1 | function debounce(func, wait) { |
利用柯里化消化 wait
参数,使用 setTimeout
延迟执行函数,如果新的触发动作进来,就取消上次的 setTimeout
。
节流:
1 | function throttle(func, wait) { |
利用柯里化消化 wait
参数,使用 pending
记录上次执行事件是否在等待中,如果是就返回,否则就开始新的等待。
1 | scrollTop |
1 | 页可见区域宽: document.body.clientWidth; |
思路:
1 | 初始化状态:图片使用默认图片地址; |
代码:
1 | var num = document.getElementsByTagName('img').length; |
主要是用 text-overflow
属性,但为了截断也要用 overflow
和 width
属性。
首先,用 flex: -webkit-box;
,将容器设置为弹性盒子模块,这是老版本的 flex,然后,设置 -webkit-box-orient: vertical;
和 -webkit-line-clamp: 3;
让其向下布局,并截断三行显示省略号,demo 同上。
块格式化上下文(Block Formatting Context,BFC)是Web页面的可视化CSS渲染的一部分,是布局过程中生成块级盒子的区域,也是浮动元素与其他元素的交互限定区域。
BFC 区域不会和 float box 发生重叠:
BFC 能够识别并包含浮动元素,当计算其区域的高度时,浮动元素也可以参与计算了(清除浮动):
在同一个 BFC 中,两个相邻的块级盒子的垂直外边距会发生重叠:
]]>1 替换和不可替换元素
从元素本身的特点来讲,可以分为替换和不可替换元素。
1.1 替换元素
替换元素就是浏览器根据元素的标签和属性,来决定元素的具体显示内容。
例如:浏览器会根据 <img>
标签的src属性的值来读取图片信息并显示出来,而如果查看(X)HTML代码,则看不到图片的实际内容;
又例如:根据<input>
标签的type属性来决定是显示输入框,还是单选按钮等。
(X)HTML中的<img>
、<input>
、<textarea>
、<select>
、<object>
都是替换元素。这些元素往往没有实际的内容,即是一个空元素,浏览器会根据元素的标签类型和属性来显示这些元素。可替换元素也在其显示中生成了框。
1.2 不可替换元素
(X)HTML 的大多数元素是不可替换元素,即其内容直接表现给用户端(例如浏览器)。段落<p>
是一个不可替换元素,文字“段落的内容”全被显示。
2 显示元素
除了可替换元素和不可替换元素的分类方式外,CSS 2.1中元素还有另外的分类方式:块级元素(block-level)和行内元素(inline-level,也译作“内联”元素)。
2.1 块级元素
在视觉上被格式化为块的元素,最明显的特征就是它默认在横向充满其父元素的内容区域,而且在其左右两边没有其他元素,即块级元素默认是独占一行的。
举例:典型的块级元素有:<div>
、<p>
、<h1>
到<h6>
,等等。
产生方式:通过CSS设定了浮动(float属性,可向左浮动或向右浮动)以及设定显示(display)属性为“block”或“list-item”的元素都是块级元素。但是浮动元素比较特殊,由于浮动,其旁边可能会有其他元素的存在。而“list-item”(列表项<li>
),会在其前面生成圆点符号,或者数字序号。
2.2 行内元素
行内元素不形成新内容块,即在其左右可以有其他元素,例如<a>
、<span>
、<strong>
等,都是典型的行内级元素。
产生方式:display属性等于“inline”的元素都是行内元素。几乎所有的可替换元素都是行内元素,例如<img>
、<input>
等等。不过元素的类型也不是固定的,通过设定CSS 的display属性,可以使行内元素变为块级元素,也可以让块级元素变为行内元素。
MDN 对行内元素这么定义:
一个行内元素只占据它对应标签的边框所包含的空间。
1 块级元素
width、 height、 margin的四个方向、 padding的四个方向都正常显示,遵循标准的css盒模型。例如:div
2 行内替换元素
width、 height、 margin的四个方向、 padding的四个方向都正常显示,遵循标准的css盒模型。 例如:img
3 行内非替换元素(重点)
width、 height不起作用,用line-height来控制高度。
padding左右起作用,上下不会影响行高,但是对于有背景色和内边距的行内非替换元素,背景可以向元素上下延伸,但是行高没有改变。因此视觉效果就是与前面的行重叠。(《css权威指南》 P249)
margin左右作用起作用,上下不起作用,原因在于:行内非替换元素的外边距不会改变一个元素的行高(《css权威指南》 P227)。
]]>当谈到继承时,JavaScript 只有一种结构:对象。每个实例对象( object )都有一个私有属性(称之为
__proto__
)指向它的构造函数的原型对象(prototype
)。该原型对象也有一个自己的原型对象(__proto__
) ,层层向上直到一个对象的原型对象为null
。根据定义,null
没有原型,并作为这个原型链中的最后一个环节。
以上是 MDN 的定义,简单来说就是一个链表,不过这个链表有点奇葩,就像这样:
如果 const a = new A();
那么:
1 | A Object |
来张完整版本的图:
完整版本的信息提取:
__proto__
, 但 null
没有。__proto__
只会指向它的构造函数的 prototype
对象,Object.__proto__
除外,它指向 null
。prototype
对象的 constructor
属性指回构造函数。看似复杂,也就三条信息。
如果 left instanceof right
,那么会沿着 left
的原型链一直往上找,如果找到 right.prototype
,就 return true,否则就 return false。说白了就是一个链表的的遍历。
var obj = {};
constructor
属性为构造函数的名称,设置新对象的 __proto__
属性指向构造函数的 prototype
对象;obj._proto_ = ClassA.prototype;
ClassA.call(obj);
arr instanceof Array
Array.isArray(arr)
Object.prototype.toString.call(arr) === '[object Array]'
在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
这是 wiki 百科的解释。简单来说,柯里化就是帮你消化一部分参数的函数。来张图吧~
本来计算 a + b + c,需要三个参数,结果柯里化函数帮你消化了一部分,每次只需要传递一个参数。
有的同学看到上面的嵌套函数,会想这不就是高阶函数吗?没错,柯里化一定是高阶函数,但高阶函数不一定是柯里化。因为高阶函数的定义是:
在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数: 接受一个或多个函数作为输入 输出一个函数。
比如,ES6 的 map、reduce 都是高阶函数(函数作为参数),但它们不是柯里化。
1 | Function.prototype.bind = function () { |
bind 先消化一部分参数,apply 再消化一部分参数。
满足:
1 | sum(1)(2).valueOf() // 3 |
sum 就是一个柯里化函数,因为它执行一次就消化了一部分参数,并返回了一个函数继续消化剩余的参数,最终还能把所有消化的参数计算出来。
先实现两个参数的版本:
1 | const sum = (a) => (b) => ({valueOf: () => a+b}) |
那么无限参数的版本难道是这样:
1 | const sum = (a) => (b) => (c) => ... => (n) => ({valueOf: () => a+b+...+n}) |
显然不行,所以用递归:
1 | const sum = (n) => { |
你是否遇到这样的场景,在你执行了页面上某个动作后,一些怪异的事情发生了:
那么这时候很有可能,你的 JS 代码里出现了死循环(Infinite Loop)。
死循环出现的原因很多:
死循环严重影响用户体验,甚至伤机器,我们要尽力避免,但完全规避是不可能的,毕竟程序员也是人,也会犯错。所以,今天我们要介绍如何手动终止死循环,以及如何用代码熔断死循环。
如果你尝试调用任务管理器,关闭浏览器进程,这样的操作成本较高,你还要重新打开浏览器,打开页面。但是不这么做,页面就会卡死,你“什么也做不了”。其实,在 Chrome 67及以上版本中,还是有方法可以在不杀死浏览器进程的前提下终止死循环的。方法如下:
手动终止只是减少杀死浏览器进程重启的成本,我们最好还能用代码来熔断一些死循环。下面是熔断函数:
1 | const loopBreaker = (function () { |
上述函数中 count
是循环执行次数,startTime
是首次执行函数的时间。如果循环超过 10000 次,且循环时间超过 1000 毫秒,那么就熔断。
使用方法:
1 | for (var i = 0; i < 1000000; i--) { |
还可以改写这个函数以支持更多的功能,如:日志格式、熔断阈值等,快去试试吧!
本文介绍了 JS 死循环的手动终止以及代码熔断方法。但解决问题的方法肯定不止于此,比如一些 Babel 插件可以转换所有循环代码,但就不再赘述了。最后,希望可以本文给遇到死循环的读者一些参考。
]]>深度优先搜索(Depth-First-Search,DFS)是一种用于遍历或搜索树或者图的算法。顾名思义,它的搜索的规则是深度优先:先访问根结点,如果有孩子节点(或者邻居节点)就优先访问孩子节点,并对孩子节点也进行上述递归访问。
DFS 可谓是 LeetCode 中考察最多的知识点了,另外由于动态规划算法可以和 DFS 算法相互转换(就像是所有的递归都可以用“栈”来改写一样),所以 DFS 的题目简直不能更多。
那么 DFS 在 JSON 操作中有什么用处呢?假如你想在网页上渲染一个 JSON,甚至想渲染出一个表单来编辑这个 JSON,那么就要用到 DFS 了。思路也很简单,先访问一个 JSON 的根结点,然后访问它的所有 key(也就是孩子节点),并对 key 也进行上述递归。
示例代码:
1 | const json = { a: { b: 'hello' }, c: [1, 2] }; |
结果如下:
可以发现 JSON 中每个节点都被遍历到了。
只需要更改上述 dfs
函数的参数,就可以渲染 JSON 树中的任意一项了,也可以渲染表单项来编辑它们。比如之前做的递归表单组件:
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针。
链表遍历及操作也是 LeetCode 考察非常多的题目。通常我们会定义一个变量作为指针,然后在循环里让它遍历链表的多个 next
。比如:
1 | let p = linkedList; |
那么链表指针在 JSON 操作中有什么用呢?我们可以把 JS 中 Object 的 key 当作链表中的 next
。那么如果知道一个叶子节点的路径,我们就可以用指针像遍历链表那样遍历到 JSON 的叶子节点处。比如:
1 | const json = { a: { b: 'hello' }, c: [1, 2] }; |
上述代码中,json
是我们要查找的 JSON 对象,path
是叶子节点的路径,point
是指针,通过遍历,point
最后指向了指定的叶子节点的值。
另外,由于 React Redux 的风行,不可变数据结构在前端用的非常多,有个不可变数据工具包叫 immutibility-helper ,它经常用到这样的结构来“不可变”地改变数据:
1 | update(obj, {a: {b: {c: {$set: 1}}}}); |
所以,还可以通过指针来将路径与它所需要的结构进行互转。
本文讲述的算法都非常简单,在 LeetCode 上应该属于 Easy 中的 Easy 级别的,但是将算法应用到实际工作中也是一件有趣的事情,故记录下来,作为总结,也抛砖引玉,分享给大家。
]]>本文仅讲解在 Mac 上的环境配置方法。
Appium 进行自动化的原理是:发送命令到各自系统对应的自动化驱动,来对相应的系统上的 App 进行自动化。这篇文章讲的是 IOS 自动化,对应驱动的名字叫 XCUITest。为了让驱动正常工作,我们要配置 XCUITest 的环境:
如果你的 Mac 已经安装 XCode,请忽略,否则去 App Store 里安装。
让有 IOS 开发者账号的人(可能是你或着你的 IOS 开发同事)把被测试的 iPhone 的 udid (udid 的获取办法请 Google)添加到开发者账户上。IOS 开发者都知道,如果你不是 IOS 开发就找他们做这一步,这里就不再赘述。
让有 IOS 开发者账号的人(可能是你或着你的 IOS 开发同事)把证书文件给你,你把它们装在 Mac 上。
文件清单:
三个文件都是双击安装,一路默认。
注意:
如果你的 Mac 已经有 Homebrew ,请忽略,否则执行此命令安装:
1 | /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" |
1 | brew install carthage |
1 | brew install libimobiledevice --HEAD |
1 | npm install -g ios-deploy |
或者
1 | brew install ios-deploy |
至此,驱动环境就搭建好了!
安装 Appium 有两种方式,NPM 和 桌面程序安装包,我们这次先选择前者:
1 | npm install -g appium |
打开 WebDriverAgent.xcodeproj:
1 | open $(npm root -g)/appium/node_modules/appium-xcuitest-driver/WebDriverAgent/WebDriverAgent.xcodeproj |
选择 WebDriverAgentRunner,并在下面两个 Signing 面板上选择之前安装的 provisioning 文件。
把 iPhone 插到 Mac 上。然后:
准备一份被测试 IOS App 文件,就是 ipa 结尾的安装包。
1 | mkdir appium-test |
添加 test.js 文件,并填写以下内容:
1 | // javascript |
在一个命令行中启动 appium:
1 | appium |
在另一个命令行中执行测试脚本:
1 | node test.js |
然后就会发现手机被安装了 xxx.ipa ,并打开了。
]]>本文仅讲解在 Mac 上的环境配置方法。
Appium 进行自动化的原理是:发送命令到各自系统对应的自动化驱动,来对相应的系统上的 App 进行自动化。这篇文章讲的是 Android 自动化,对应驱动的名字叫 UiAutomator2。为了让驱动正常工作,我们要配置 UiAutomator2 的环境:
如果你的 Mac 已经有 Homebrew ,请忽略,否则执行此命令安装:
1 | /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" |
1 | brew tap caskroom/versions |
编辑登陆脚本:
1 | vi ~/.bash_profile |
添加这两行:
1 | export JAVA_HOME="$(/usr/libexec/java_home)" |
使其生效:
1 | source ~/.bash_profile |
Android SDK 最好的安装方法是安装 Android Studio。安装过程一路默认就好。
安装完成后,点击这里查看 SDK 目录:
将 ANDROID_HOME 环境变量设置为上步的 SDK 目录地址:
编辑登陆脚本:
1 | vi ~/.bash_profile |
添加这两行(注意把 username 改为自己的):
1 | export ANDROID_HOME="/Users/username/Library/Android/sdk" |
使其生效:
1 | source ~/.bash_profile |
至此,驱动环境就搭建好了!
安装 Appium 有两种方式,NPM 和 桌面程序安装包,我们这次先选择前者:
1 | npm install -g appium |
打开手机开发者模式插到 Mac 上,输入此命令查看设备名称:
1 | adb devices |
1 | mkdir appium-test |
添加 test.js 文件,并填写以下内容:
1 | // javascript |
在一个命令行中启动 appium:
1 | appium |
在另一个命令行中执行测试脚本:
1 | node test.js |
然后就会发现手机被安装了 ApiDemos.apk ,并模拟点击了脚本中的命令。
]]>但是,我不想只是识别单个组件,最好能识别整张设计稿的多个组件。于是,花了两三天进行了这项技术的探索调研,并将过程记录下来。
识别多个组件的本质是定位,我首先想到的是像车牌定位,或者跳一跳外挂那样的利用颜色,进行二值化等处理进行识别,但设计稿中的组件并没有非常明显的边界,这种方法显然是不可行的。
后来发现了谷歌开放的 TensorFlow Object Detection API,顿时看到了希望。TensorFlow Object Detection API 可以创建一个精确的机器学习模型,该模型能够在单张图片中对多个物体进行定位、分类。看了几张效果图,认为应该有戏:
TensorFlow 可以在各个系统上跑,甚至可以在浏览器里运行和 retrain。但是如果要有更好的速度,最好选择可以用 GPU 的系统,由于 OSX 系统的在显卡方面的封闭性,TensorFlow 不支持在 OSX 上跑 GPU 版本,所以剩下的选择是:
由于云平台对我来说会增加一些熟悉成本,而我目前又只是急于知道多组件识别的可行性,所以就借了部门实习生的台式机来训练,正好这位实习生不习惯用 Windows 系统,自己带了 Macbook,所以就很爽快地借给我了。不过,不得不说集团给实习同学配置的电脑配置确实很基础,8g内存,Nvidia GeForce GT 730 的显卡,再差一点就跑不动 GPU 版本的 Tensorflow 了。
TensorFlow Object Detection API 提供了一些现成的模型来让你直接用或者重新训练,它们的区别在速度和精确度上。因为我的台式机配置较低,所以选择了速度较快,精度较低的模型(ssd_mobilenet_v1_coco)来重新训练。
我们要进行的是整张设计稿的识别,所以最初我用的训练图片是整张设计稿,但训练下来,发现根本识别不出来什么。
思考了一下,发现是因为:
于是我打算缩小图片尺寸,换成了表单项的识别,即在一个表单上识别出输入框、下拉框、文本框等。将尺寸缩小后,我在那台低配的台式机上训练了半个小时(没跑完),最终识别效果图如下:
至少证明是可行的了。但是还有一些表单项没有识别出来,所以我继续缩小训练图片的尺寸,将七个表单项的识别换成三个,训练了一个小时(也是没跑完),测试了一下,可以全部识别出来了:
首先,整张设计稿的多组件识别是可行的,但是需要几个前提条件:
有充足的 GPU 资源:如果你像我一样只有一台低配机器,那么精度高的模型,你跑都跑不动,显卡内存直接占满,程序崩溃。
有充足的设计稿素材:由于只是验证可行性,所以我只训练了十张图片,但是要达到很好的效果,至少得有一百张图片来训练,所以你得有足够多的设计稿素材。
有充足的人手和时间:训练 Object Detection 模型不像 Image Classification 那样简单,需要你手动标注位置,生成 xml 文件来给机器学习,这个工作非常无聊繁琐,所以如果要做,必须有足够多的人和时间。
暂时就写这么多了,笔者研究较浅,请多多批评!
]]>我之前参与了一位阿里前端专家架构的项目,这个项目的技术栈是 Angular1 ,经过几天的开发,我发现这是我来阿里后参与的开发体验最好的几个前端项目之一了。为何这么说呢?主要原因是这个项目使用了 Angular1 的指令(Angular1 的指令就是 Angular2 的组件,也等同于 React 的组件)将页面组件化,并且为每个单独的指令创建了一个 demo 页面,可以单独渲染展示每个指令。相当于一个复杂的项目被分为很多小项目,每个小项目都可以独立调试,这样的开发体验太好了!不仅如此,这么做还有很多好处:
既然有这么多好处,然后我就想,在 React 项目中能不能也将项目中的每个小组件,单独渲染出来呢?答案是可以的,不过稍微麻烦一点,因为 React 组件不像 Angular1 那样可以直接在浏览器里面运行,需要编译一下,所以我就开发了一个名为 render-react-components 的命令行工具,帮我做这件事。
render-react-components(简称 rrc) 是一个命令行工具,可以递归找出当前项目中所有的 React 组件(仅限于 src 目录下的所有组件),并为它们创建相互隔离的 demo 页面。
使用 rrc 非常简单,只需要:
1 | ## 本地或者全局安装 |
以下动图,演示了如何使用这个工具,先后做了这几件事:
find . -name *.js
(find
命令和本工具无关,只是为了对比展示文件的变化)列出原始项目中的 js。rrc init
,为项目中所有的 React 组件创建 demo 页面。再次运行 find . -name *.js
发现多了一些文件,不过放心,这只是一些 js、html 文件,不会给你添加多余的依赖,非常干净、非常隔离。rrc dev
,自动弹出一个页面,我们发现每个组件都可以展示了。并且,修改代码,页面会自动更新,非常方便。每个 React 组件的 props 都不同,需要我们单独编写。如果你想修改某个组件的 props ,只需要去项目根目录的 rrc 文件夹中找到组件对应的 demo 页面的入口文件即可。那么组件的对应的入口文件如何寻找呢?非常简单明了:
虽然在大多数情况下,你都不用操心 webpack 配置,但如果你实在想修改渲染组件的 webpack 配置,那么你可以直接在根目录下的 .rrc.js
中修改,具体配置可以在这里参考。
上面动图中真实小例子可以在这里找到。
本工具的 Github 地址: https://github.com/lewis617/render-react-components,欢迎 star、提 issue 和 pull request。
]]>注意,本文是给有一定端对端测试经验的测试工程师或前端工程师看的,如果你对端对端测试一无所知,请先阅读我之前写的关于端对端测试的文章。
最近想在公司内部一个非常复杂的后台系统中添加端对端测试。这个系统拥有很多页面,每个页面都有很多功能,在这些功能中,不仅涉及许多数据库操作,还包含一些对用户来说不可控的外部数据来源。给这样的系统添加端对端测试,我的内心是崩溃的,因为如果完全模拟用户操作,我会面临很多问题:
面对这些问题,我当时冒出了放弃的想法,我咨询了一些同事,他们有的人让我评估可行性、必要性,有的人说让端对端测试测一些简单的跨页功能即可,把复杂逻辑留给人工或单元测试。他们说的都很 reasonable。但我认为端对端测试还是有必要的,一些国外的大公司的端对端测试真的是测试了软件中用户所可能用到的每个功能。这确实是可行,而且有意义的,我们不应该偷懒或者放弃。
我第一次接触端对端测试是在 Angular 中,于是我看了很多 Angular 中端对端测试的例子,发现很多人面对和我一样问题时,所采取的办法是模拟 HTTP 请求。有人会说,这还算端对端测试吗?这已经不是在完全模拟真实的用户场景了!这种做法只有在后端没有任何 bug 的前提下才是有效的……这些说法都是对的,模拟 HTTP 请求确实是一种 trade off。但作为前端工程师,这样的做法至少能保证我负责的前端系统被测试到了,而且是集成测试,这就够了!后端完全可以另写针对后端的集成测试。当然,模拟 HTTP 请求是在那种迫不得已的情况下才做的,如果你的系统比较简单,比如这些类型:
那么就无需模拟 HTTP 了,毕竟我们还是希望能尽量还原真实场景。
好了,回归正题。那么如何在端对端测试中模拟 HTTP 请求?有几种方法:
request.continue
重写一些请求的 url,指向别的链接。你可以自己搭建一个测试服务器进行重写。简单好用,推荐!request.respond
拦截请求,并直接返回响应结果。简单好用,推荐!以上三种方法都是可行的,但是后两种更简单。其中,第二种适合那种拥有测试服务器的场景,你只需要对请求链接进行重定向即可。比如原来是 a.com
,你将其改为 b.com
。但这种方法还是不能非常灵活的模拟每个 case,这时候,第三种方法就更加推荐,你想返回什么都可以直接在函数中写出来。让我们快看看代码实现吧!
介绍了背景和方法,我们来看下真实的例子!例子代码在这里:
https://github.com/lewis617/fe-test/tree/master/puppeteer-demo/mock-demo
先说下运行方法:将整个项目 clone 下来后在根目录(不是 mock-demo 这个目录哦)执行以下命令开启服务。
1 | http-server -p 8081 |
然后就可以在 http://localhost:8081/puppeteer-demo/mock-demo
,看到程序了。
这个程序的功能是这样的:
1,在一个简单的 HTML 页面中进行 data1.json
这个文件的请求,并将 JSON 文件中的 name
字段的值显示在 h1
标签中。代码如下:
1 |
|
2,我们要做的是,运行 puppeteer,打开页面,并进行请求劫持,将 data1.json 的数据换成 data2.json 的数据。
另开一个终端,执行这些命令:
1 | yarn |
然后就会发现 Puppeteer 中显示的数据是 data2
。
我们看下测试脚本是如何进行拦截重写的:
1 | await page.setRequestInterception(true); |
上述代码,先设置可以进行请求拦截:await page.setRequestInterception(true);
。然后在 request
事件中进行 url 改写。另外,还可以换成 request.respond
方法:
1 | await page.setRequestInterception(true); |
就是这么简单。这太好用了!我们甚至可以在日常开发中也使用 Puppeteer 来模拟请求,不需要等待后端的工作。
最后再聊一下端对端测试和单元测试的比例问题。谷歌的测试团队曾经提出过一个测试金字塔的概念。大概就是单元测试应该最多,然后是集成测试(部分单元之间的集成,不像端对端那样完全黑盒),最少的应该是端对端测试:
为何会这样呢?因为他们认为端对端测试不能像单元测试那样快速的定位问题所在,端对端测试所发现的问题,可能存在系统中的任何位置,但单元测试的反馈定位就更加直接准确。另外,单元测试写起来更加简单快速,而端对端测试则需要整个系统部署好之后才能测试,这样比较慢,毕竟有时候开发周期还是很长的,人家开发一周前写完的代码,你现在才开始测试,有点拖后腿。以上说法非常有道理,我也认为单元测试非常好,但是端对端测试也是有意义的,它可以检测出所有单元连接后的问题,这些问题只能通过端对端测试才能测出来。所以,两者都要写,不要怕麻烦,后期的收益是很大的!
还记得上篇博客中的端对端测试的动图演示吗?
想实现这个效果,就需要将 Puppeteer 的 headless
选项设为 false
,并将 slowMo
设为 20-100 中的某个值,前者使得所有浏览器自动化操作可见,后者控制了动作之间的间隔,使其变慢,从而通过人眼可以看清每步操作。示例代码:
1 | browser = await puppeteer.launch({ |
这个操作太常用了!第一步是启动浏览器,那么第二步就是导航到某个页面,代码示例:
1 | page = await browser.newPage(); |
上述代码会开启一个新页面,并将其导航到 https://baidu.com
。
在进行某些页面操作前,我们必须要等待指定的 DOM 加载完成后才能操作,比如,一个 Input 没有加载出来时,你是无法在里面输入字符的等等。在 Puppeteer 中,你可以使用 page.waitForSelector
和选择器来等待某个 DOM 节点出现:
1 | await page.waitForSelector('#loginForm'); |
上述代码会等待 ID 为 loginForm
的节点出现。
有时候,你找不到某个特定的时刻,只能通过时间间隔来确定,那么此时你可以使用 page.waitFor(number)
来实现:
1 | await page.waitFor(500); |
上述代码会等待 500 毫秒。
有时候,你需要等待某个复杂的时刻,这个时刻只能通过一些复杂的 JavaScript 函数来判断,那么此时你可以使用 page.waitFor(Function)
来实现:
1 | await page.waitFor(() => !document.querySelector('.ant-spin.ant-spin-spinning')); |
上述代码会等待 Antd 中的旋转图标消失。
为了模拟用户登陆或仅仅就是输入某个表单,我们经常会向某个 Input 中输入字符,那么我们可以使用这个方法:
1 | await page.type('#username', 'lewis'); |
上述代码向 ID 为 username
的 Input 中输入了 lewis
。值得一提的是,该方法还会触发 Input 的 keydown
、keypress
, 和 keyup
事件,所以如果你有该事件的相关功能,也会被测试到哦,是不是很强大?
在 Puppeteer 中模拟点击某个节点,非常简单,只需要:
1 | await page.click('#btn-submit'); |
上述代码点击了 ID 为 btn-submit
的节点。
有时候我们需要在浏览器中执行一段 JavaScript 代码,此时你可以这样写:
1 | page.evaluate(() => alert('1')); |
上述代码会在浏览器执行 alert('1')
。
有时候我们需要获取某个 Input 的 value
,某个链接的 href
,某个节点的文本 textContent
,或者 outerHTML
,那么你可以使用这个方法:
1 | const searchValue = await page.$eval('#search', el => el.value); |
有时候我们需要获取某一类节点的某个属性集合,那么你可以这么写:
1 | const textArray = await page.$$eval('.text', els => Array.from(els).map(el => el.textContent)); |
上述代码将页面中所有类为 text
的节点中的文本拼装为数组放到了 textArray
中。
以上就是 Puppeteer 的一些常用操作,当然仅仅掌握这些是不够的,更多的操作请参考 Puppeteer 的 API 文档:
https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md
单个操作讲了这么多,我们来进行一次综合应用吧!我们依次让浏览器进行以下自动化操作:
刘一奇的个人博客
刘一奇的个人博客
logo
为刘一奇的个人博客
主页,归档,关于我
三项示例代码:
https://github.com/lewis617/fe-test/blob/master/puppeteer-demo/liuyiqi-blog.test.js
1 | const puppeteer = require('puppeteer'); |
效果图:
至此,端对端测试中常用的 Puppeteer 操作总结就讲完了。有更多操作请查阅官网文档,或给我发邮件,或在本文下方评论。
在前面很多文章中,我们都介绍了单元测试。如果你了解单元测试,或者读过我之前写的单元测试的文章,那么你一定知道,单元测试的测试对象是单独的、隔离的小代码片段或者代码单元。与单元测试不同,端对端测试的测试对象则是页面上的用户交互,我们对底层实现一无所知,也就是说我们的测试是黑盒的。另外,一些跨页测试,比如链接检查,登陆跳转等功能必须使用端对端测试才能检查出来,单元测试是无法测这些功能的。以前我只写单元测试,不写端对端测试,结果有一次所负责的页面上有个链接不能点了,还好及时修复,但还是让我感受到了端对端测试,或者说是自动化端对端测试的重要性。这是我在物流服务中做的端对端测试演示:
话不多说,让我们开始学习端对端测试吧!
Puppeteer 默认情况下,所有操作是不可见的,如果你想像我这样监视发生的一切,需要将 Puppeteer 的
headless
选项设为false
,具体操作将会在下篇博文中介绍。
我使用过很多端对端测试的轮子,比如 Selenium、Appium、Protractor、Zombie.js、Cypress、Nightmare、Puppeteer 等。但最终还是选择了 Puppeteer,因为 Selenium 和 Appium 太难用了,Protractor 则像是专门给 Angular 设计的,Zombie.js 太简单了,而且使用的浏览器内核不是市面上流行的任何一个,而是自定义的。Cypress 有平台依赖,我只是想要个本地运行的工具而已。只剩 Nightmare 和 Puppeteer 了,其实这两个都是好选择,但是我是个 star 控,Puppeteer 的 star 比 Nightmare 多,所以我选择了 Puppeteer。但事实上 Nightmare 更流行,因为我发现蚂蚁最新的那个 Antd Pro 就是用的 Nightmare,阿里一些其他端对端测试的工具也有基于 Nightmare 来做的。所以如果你想使用 Nightmare 来进行自动化端对端测试也是完全没有问题的。
使用 Puppeteer 非常简单,首先安装它:
1 | yarn add puppeteer |
然后就可以在 Node 脚本中使用它了!来个简单的导航并截屏例子吧!这个例子先启动浏览器,导航到 https://baidu.com
页面,然后截屏并保存为 baidu.png
,最后关闭浏览器。
1 | const puppeteer = require('puppeteer'); |
将上述代码写进 Node 脚本中,并运行就可以了!看下生成的截图:
是不是很简单?短短几行代码就做了这么多事。如果你对 async
、await
这种语法不熟悉,那么我强烈建议你去学习一下,这种语法在 Puppeteer 中使用率简直不要太高。不过也不要担心学习成本, async
、await
语法非常简单,就是 Promise 的一种新写法而已,让你的异步代码看起来就像是同步的一样。
要知道,Puppeteer 是一个浏览器自动化工具,它只能进行浏览器的自动化,本身并不具有测试功能。我说的测试功能指的是,断言啊,生成测试报告啊这些功能。如果你不熟悉这些概念,那么请移步:《Jest 单元测试入门》。所以,除了 Puppeteer 外,我们还需要使用一个测试工具,我选择了 Jest,理由在之前的博文中已经说过很多遍了,这里不再赘述。使用 Jest 非常简单,只需要
具体用法看之前的博文:《Jest 单元测试入门》。
讲完了 Puppeteer 和 Jest 的基本用法,我们来看一下,如何将两者结合起来使用。其实将 Jest 与 Puppeteer 结合使用非常简单,因为 Puppeteer 的本质就是个 NPM 模块而已,所以我们只需要在 Jest 测试脚本中引入它即可使用了。为何如此呢?因为测试脚本的本质其实也是 Node 脚本,既然是 Node 脚本那么当然可以直接引入 NPM 模块来用了!
需要注意的是,因为 Puppeteer 通常需要使用
async
、await
这种语法,如果你的 Node 版本在7.6及以上,那么恭喜你,直接大胆使用,否则需要在 Jest 中配置 Babel,来使其支持这种新语法。在 Jest 中配置 Babel 非常简单,你可以在这里找到具体方法。
让我们来个小例子吧!首先,我们打开百度页面,并断言百度页面的 title
是 百度一下,你就知道
。那么测试脚本应该这么写:
1 | const puppeteer = require('puppeteer'); |
看到 test
和 expect
两个全局函数了吗?这就是 Jest 所赋予的能力,让你可以编写测试用例和断言。最后在命令行输入 npm test
,即 jest
(这是在 package.json 中配置好的命令),即可看到生成的测试报告:
1 | $ npm test |
其中 screenshot.test.js
是截屏的那个例子,baidu-title.test.js
是断言百度首页 title 的例子。你可以在这里找到源码:
https://github.com/lewis617/fe-test/tree/master/puppeteer-demo
至此,使用 Jest 与 Puppeteer 来进行端对端测试的基本用法就讲完了。下篇博文我们将会集中讲解常用 Puppeteer 功能,比如模拟用户输入、执行 JavaScript 脚本、获取某个 DOM 节点中的文本等。
https://github.com/lewis617/practical-js/blob/master/src/weiboBackup.test.js
我使用过很多测试框架,比如 Karma、Mocha、Jest 等,但因为对 Facebook 开源项目的偏爱,我选择了 Jest 来测试,事实证明,Jest 确实最为简单,无需进行繁琐的浏览器环境模拟,就可以直接使用浏览器环境的各种 API,让我们一睹为快!不过先安装 Jest:
1 | yarn add --dev jest |
被测试文件原来是这样的:
weiboBackup.js
1 | /** |
为了方便测试,我们在底部添加一行代码,将其导出,方便测试。另外,为了让测试报告更纯净,我们把 console 注释掉:
1 | // console.log(textArray.join('\n')); |
前面说了 Jest 自带浏览器模拟环境,无需手动配置。所以我们直接添加用于测试的 html 即可:
这段 html 字符串相当于模拟数据,即假数据。在这里,相当于模拟一个微博评论。模拟数据你可以随意编写,但是通常需要和真实数据保持结构和规律上的一致,而且需要覆盖所有的情况,这样才能测试到所有的边界。
weiboBackup.test.js
1 | document.body.innerHTML = '\ |
然后直接调用被测试文件 weiboBackup.js,相当于运行了它:
1 | var textArray = require('./weiboBackup'); |
现在评论文本已经被保存到数组 textArray
中了,然后我们直接编写断言即可:
1 | expect(textArray).toEqual([ |
关于断言等测试的基础知识,如果你不了解,请看我之前写的 《Jest 单元测试入门》。
最后我们将上述代码包在 test
函数中,这个函数用于打包一个测试用例,并附带测试用例说明:
1 | test('getweiboBackup', () => { |
测试文件写好了,我们需要运行它,首先在 package.json 中添加:
1 | "scripts": { |
然后在命令行中运行:
1 | npm test |
最后就会看测试报告了:
1 | PASS ./weiboBackup.test.js |
尝试改变 html 中的测试文本,或断言中的预期文本,看看预期与结果不一致的情况。比如,将断言改为:
1 | expect(textArray).toEqual([]); |
结果测试报告变为这样:
1 | FAIL ./weiboBackup.test.js |
编写测试还是很重要的,可以保证你的代码质量,而你的代码质量关系到你的 KPI,所以我建议大家还是养成编写测试的好习惯。
]]>