GithubHelp home page GithubHelp logo

blog's Introduction

思考,记录。

blog's People

Contributors

fwon avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

Number 和 parseInt 转化数值有何不同?

parseInt 为解析函数,传递的参数需为字符串,非字符串参数会首先被强制类型转换为字符串。解析按照从左到右的顺序,如果遇到非数字字符就停止。

Number为转换函数,不允许出现非数字字符,否则会失败并返回NaN,具体的转化规则请参考其他问题中的分析。

var a = "42";
var b = "42px";

Number(a); //42
parseInt(b); //42

Number(b); //NaN
parseInt(b); //42

关于parseInt一些看似诡异的问题,只要明白参数会被转化为字符串再进行解析,就不会觉得奇怪了,试试看能不能理解下面这些运算的返回值。

parseInt(0.000008);  //0
parseInt(0.0000008); //8
parseInt(false, 16); //250
parseInt(parseInt, 16); //15
parseInt("0x10"); //16
parseInt("103", 2); //2

从零开始开发一款H5小游戏(二) 创造游戏世界,启动发条

上一节介绍了canvas的基础用法,了解了游戏开发所要用到的API。这篇文章开始,我将介绍怎么运用这些API来完成各种各样的游戏效果。这个过程更重要的是参透一些游戏开发的思路和想法,而不是仅仅知道怎么写代码来完成这个游戏。

先用一张图来了解一下整个游戏的构成。

roadmap.path

Map表示整个背景地图,作用很简单,就是渲染黑色背景。
Player 表示玩家粒子,它尾巴中带有生命点,我们用Life类来表示。
Enemy为红色的敌人粒子,因为技能粒子和Enemy粒子具有很多共性,所以Skill粒子继承自Enemy粒子。
粒子之间撞击后有爆炸效果,用Paticle来表示爆炸粒子。

简单来说,游戏就是一帧一帧图像的叠加播放,并通过捕获用户反馈来实现游戏中的人机交互。

图像的逐帧播放可以类比为放映电影,通过在荧幕上连续投放图像来产生动作的效果。

首先要创建这样一个荧幕, 并设置银幕的大小。

//index.js
const canvas = document.getElementById('world');
canvas.width = window.innerWidth > 1000 ? 1000 : window.innerWidth;
canvas.height = window.innerHeight;

在游戏中,荧幕对应一个地图,我们将这个地图抽象为一个类,并提供基本的渲染方法。

//Map.js
/**
 * 地图类
 */
class Map {
    init(options) {
        this.canvas = options.canvas;
        this.ctx = this.canvas.getContext('2d');
        this.width = options.width;
        this.height = options.height;
    }
    clear() {
        this.ctx.clearRect(0, 0, this.width, this.height);
    }
    render() {
        this.clear();
        this.ctx.fillStyle = "black";
        this.ctx.fillRect(0, 0, this.width, this.height);
    }
}
export default new Map();

在入口处初始化地图

map.init({
    canvas,
    width: window.innerWidth > 1000 ? 1000 : window.innerWidth,
    height: window.innerHeight
});

荧幕准备好后,怎么放映图像,对应于游戏中的放映机是什么呢?

想想在js中用于定时执行的方法有哪些,setInterval, setTimeout, requestAnimationFrame?

setInterval这个方法在游戏中是不能用的。由于js是单线程,setInterval开启的定时循环间隔会受到CPU使用情况的影响,同时电脑对setInterval的最短间隔也有不同的要求。由于游戏对帧率的要求比较高,所以在游戏中应该避免使用setInterval来执行定时任务。由于无法把握每帧执行的具体时间,setTimeout也有会遇到类似的问题。

懂的人已经懂了,现代的H5游戏开发都是通过requestAnimationFrame来执行循环播放的。它的优势就是能根据浏览器的实时渲染帧率来执行函数,使的动画播放比较流畅。而不会因为函数的执行时间跟定时器时间不同导致的播放卡顿现象。

一般requestAnimationFrame每帧的绘制时间是1000/60 ms。也就是每秒能绘制60帧。好就好在时间不需要我们自己设置,而是浏览器的内在机制。在不同的浏览器中方法名会有所不同,我们通过下面的方法来定义一个requestAnimationFrame函数

const raf = window.requestAnimationFrame
  || window.webkitRequestAnimationFrame
  || window.mozRequestAnimationFrame
  || window.oRequestAnimationFrame
  || window.msRequestAnimationFrame
  || function(callback) {
    window.setTimeout(callback, 1000 / 60); //每帧1000/60ms
  };

有个这个方法,我们如有神助。只需要在一个动画方法中使用raf调用自身方法。就能实现循环调用的功能,并且如丝般顺滑。使用如下:

(function animate() {
    map.render();
    raf(animate);
})();

这样就会不断调用map的render方法,实现逐帧播放。只不过map的render方法只是把画布涂黑,所以看起来并没有什么变化。

我们的游戏中有玩家粒子,敌人粒子,还有技能粒子,撞击爆破等效果。我们的游戏就是不断地往animate这个方法中添加内容,在每一帧中渲染多个不同东西,看起来就是整个游戏画面了。我们可以想象一下未来啊animate方法是这样的。

(function animate() {
    map.render();
    player.render();
    enemy.render();
    skill.render();
    effect.render();
    raf(animate);
})();

我们需要扩展player, enemy...等等的render方法。让它们表现出不同的效果。

这样渲染出来的画面还是死的,怎样让每一帧渲染出来的图像有所不同,实现动画的效果呢?

在每个物体类中,都有一个update方法,该方法用于改变物体的位移形状等,所以每一帧渲染出来的画面都会不一样。

//通过update方法来控制物体位移或形态变化
update() {
    this.x += 1;
    this.y += 1;
}
render() {
    cxt.fillRect(this.x, this.y, 10, 10);
}

在animate中,我们需要在每次render后调用update方法

(function animate() {
    map.render();
    player.render();
    player.update();
    raf(animate);
})();

这样,借助于游戏的发条,player就动起来了!我们前面所过,游戏就是逐帧播放和人机交互。那怎样来处理玩家反馈呢?

在PC和手机中的所谓玩家反馈通常是鼠标的点击滑动以及手势等动作。通过监听鼠标或手势事件来改变物体的属性,达到控制物体变化的目的。例如让player跟随鼠标移动。

window.addEventListener('mousemove', (e) => {
    self.x = e.clientX;
    self.y = e.clientY;
});

达到的效果跟update方法本质上是一致的。

至此整个游戏基本原理已经讲得差不多了,下一节要讲的是如何创建各种粒子,还有player那条会动的尾巴。敬请期待《从零开始开发一款H5小游戏(三) 攻守阵营,赋予粒子新的生命》

一条命令解决接口Mock

背景

在前端或客户端开发中,经常需要依赖后端同学开发的API。为了能够前后端并行开发,前端通常会采用Mock数据的方法,通过模拟api假数据进行页面渲染。等到后端API开发完毕再接入真实API进行调试。

API数据Mock的方法有很多,常用的有以下几种:

前端打包工具安装Mock插件

比如Grunt, Gulp, Webpack等等都有相应的mock插件,开发者需要根据不同的项目类型来做配置。其原理是启动一个本地服务器实现接口请求。由于是模拟网络请求,所以有时还需要根据需求对一些打包脚本进行修改。该方法的缺点是针对不同的工具需要有不同的方案,有一定的安装和适配成本,并且只针对使用打包工具的项目。

外部Mock服务平台

业界有一些Mock的解决方案,提供一个第三方平台,开发者在平台上配置api接口,然后直接调用平台接口进行调试。比较出名的如 easy-mock

这种方法的优点是无任何插件依赖,所有项目都可以直接访问使用,无需安装,所以对客户端也是通用的。但是带来另外一个问题是数据的泄露,一些隐私度比较高的项目可能会觉得把模拟数据放到其他平台上也是不安全的。当然easy-mock也考虑到这个问题,开源并支持用户直接部署项目到公司内网。但这个需要主机,数据库等资源的支持,落实难度较大。

写死数据

第三种是比较直接的,就是在前端写死数据,接口不发出请求而是直接返回。这种方法对于一些小的活动项目也许是不错的选择。但是构造的数据模式有限,也无法模拟真实的网络环境

在以前的开发中使用的基本是这三类方法,但是这些方法都有上述提到的明显的局限性。

另一种方式

为了避免以上方案中的缺点,本文介绍另外一种mock的思路

对于Mock数据,我们希望它在项目中的存在只是一个文件夹,里面定义了每个接口的返回数据。开发者不需要配置项目插件,每个项目都可以简单的通过一个命令启动mock服务,并监听这个目录。这个Mock服务需要具备以下功能:

  1. 模拟接口请求,灵活配置返回数据
  2. 在开发人员间共享模拟数据,而不是只存在本地,也不是放在外网
  3. 不要繁琐的安装配置,最好一个命令开启即用
  4. 对于所有类型的前端项目都是通用的
  5. 提供一些复杂需求的拓展功能

基于以上思路开发了一个工具 l-mock

实现数据mock只需三步:

  1. 全局安装
npm i l-mock -g
  1. 初始化mock目录, init命令在project根目录下生成mock目录,并放置demo接口
cd path/to/project
lmock init
  1. 运行, 进入生成的mock目录,运行start命令,直接访问localhost:3000/a 则可看到/a接口返回
cd mock
lmock start

第一次初始化后,后面的开发只需要在mock目录中运行lmock start就可以开启接口模拟。

对于有package.json的前端项目,可以直接配置在npm命令中,往后就运行 npm run mock

"scripts": {
  "mock": "cd mock && lmock start",
}

怎么来编写一个模拟接口呢?举一个例子:
path/t0/project/mock/a.js 模拟了一个get请求

module.exports = {
  url: '/a',
  method: 'get',
  result: {
    'status|1': ["no_login", "OK", "error", "not_registered", "account_reviewing"],
    'msg': '@csentence()',
    'data': {
      a: 2
    }
  }
}

以上js返回的json内容如下

roadmap.path

params description
url 配置API地址, 支持正则匹配模式
method 配置请求方法,支持RESTFUL
result 定义返回内容

result 可以直接返回json内容,通过Mockjs,可以生成动态的数据内容,比如上面的@csnetence()会随机生成一个段落的文字

Mockjs的语法很多,可以参考其文档

该工具还有一些拓展功能,需要用到复杂接口逻辑的,可以在result返回方法中配置。

详细的使用方法可以参考工具文档 l-mock

如果你的项目中正好需要用到数据Mock,不妨试一试。

正则表达式用法总结

正则表达式用法总结


正则表达式的字符类

字符 匹配
[...] 方括号内的任意字符
[^...] 不在方括号内的任意字符
. 除换行符和其他Unicode行终止符之外的任意字符
\w 任何ASCII字符组成的单词,等价于[a-zA-Z0-9]
\W 任何不是ASCII字符组成的单词,等价于[^a-zA-Z0-9]
\s 任何Unicode空白符
\S 任何非Unicode空白符的字符,注意\w和\S不同
\d 任何ASCII数字,等价于[0-9]
\D 除了ASCII数字之外的任何字符,等价于[^0-9]
[\b] 退格直接量

正则表达式的重复字符语法

字符 匹配
{n,m} 匹配前一项至少n次,但不能超过m次
{n,} 匹配前一项n次或者更多次
{n} 匹配前一项n次
匹配前一项0次或者1次,也就是说前一项是可选的,等价于{0,1}
+ 匹配前一项1次或多次,等价于{1,}
* 匹配前一项0次或多次,等价于{0,}

非贪婪的重复

我们同样可以使用正则表达式进行非贪婪匹配。只须在待匹配的字符后跟随一个问号即可:"??"、"+?"、"*?" 或"{1,5}?"。
正则表达式/a+/可以匹配一个或多个连续的字母a。当使用"aaa"作为匹配字符串时,正则表达式会匹配它的三个字符。但是/a+?/也可以匹配一个或多个连续字母a,但它是尽可能少地匹配。我们同样将"aaa"作为匹配字符串,但后一个模式只能匹配第一个a。
但当使用"aaab"作为匹配字符串时,它会匹配整个字符串,和该模式的贪婪匹配一模一样。这是因为正则表达式的模式匹配总是会寻找字符串中第一个可能匹配的位置。由于该匹配是从字符串的第一个字符开始的,因此在这里不考虑它的子串中更短的匹配。

选择、分组和引用

字符"|"用于分隔供选择的字符。例如/ab|cd|ef/
选择项的尝试匹配次序是从左到右,直到发现了匹配项。如果左边的选择项匹配。就忽略右边的匹配项,即时它产生更好的匹配。因此,当正则表达式/a|ab/匹配字符串"ab"时,它只能匹配第一个字符。

正则表达式中的圆括号作用

  • 把单独的项组合成子表达式,例如/java(script)?/可以匹配字符串"java",其后可以有"script"也可以没有。/(ab|cd)+|ef/可以匹配字符串"ef",也可以匹配字符串"ab"或"cd"的一次或多次重复。
  • 在完整的模式中定义子模式。当一个正则表达式成功地和目标字符串相匹配时,可以从目标串中抽出和圆括号中的子模式相匹配的部分。
  • 允许在同一正则表达式的后部引用前面的子表达式。这是通过在字符""后加一位或多位数字来实现的。这个数字指定了带圆括号的子表达式在正则表达式中的位置。例如,\1引用的是第一个带圆括号的子表达式,\3引用的是第三个带圆括号的子表达式。
    注意:因为子表达式可以嵌套另一个子表达式,所以它的位置是参与计数的左括号的位置。
    例如,在下面的正则表达式中,嵌套的子表达式([Ss]cript)可以用\2来指代: /([Jj]ava([Ss]cript)?)\sis\s(fun\w*)/
    对正则表达式中前一个子表达式的引用,并不是指对子表达式模式的引用,而指的是与那个模式相匹配的文本的引用。
    如果要匹配左侧和右侧的引号,可以使用如下的引用: /(['"])[^'"]*\1/
    \1匹配的是第一个带圆括号的子表达式所匹配的模式。在这个例子中,存在这样一条约束,那就是左侧的引号必须和右侧的引号相匹配。

在正则表达式中不用创建带数字编码的引用,也可以对子表达式进行分组。它不是以"("和")"进行分组,而是以"(?:"和")"来进行分组,比如,考虑下面这个模式:
/([Jj]ava(?:[Ss]cript)?)\sis\s(fun\w*)/
这里,子表达式(?:[Ss]cript)仅仅用于分组,因此复制符号"?"可以应用到各个分组。这种改进的圆括号并不生成引用,所以在这个正则表达式中,\2引用了与(func\w*)匹配的文本。

正则表达式的选择,分组和引用字符

字符 匹配
(...) 组合
(?:...) 只组合,把项组合到一个单元,但不记忆与该项相匹配的字符
\n 和第n个分组第一次匹配的字符相匹配

指定匹配位置

我们使用单词的边界\b来代替真正的空格符\s进行匹配(或定位)。这样正则表达式就写成了/\bJava\b/。元素\B将把匹配的锚点定位在不是单词边界之处。因此,正则表达式/B[Ss]cript/与"JavaScript"和"postscript"匹配,但不与"script"和"Scripting"匹配。
零宽正向先行断言:"(?="和")"之间加入一个表达式,说明括号内的表达式必须正确匹配,但不是真正意义上的匹配。可以使用/[Jj]ava([Ss]cript)?(?=:)/。这个正则表达式可以匹配"JavaScript:The Definitive Guide"中的"JavaScript",但是不能匹配"Java in a Nutshell"中的"Java",因为他后面没有冒号。
零宽负向先行断言:"(?!"和")"中间插入一个表达式,说明括号内的表达式
不允许匹配

正则表达式中的锚字符

字符 匹配
^ 字符串开头或一行的开头
$ 字符串结尾或一行结尾
\b 匹配一个单词的边界
\B 匹配非单词边界的位置
(?=p) 零宽正向先行断言,要求接下来的字符都与p匹配,但不能包括p的那些字符
(?!p) 零宽负向先行断言,要求接下来的字符不与p匹配,只能用在匹配符的后面

正则表达式修饰符

字符 匹配
i 不区分大小写
g 全局匹配
m 多行匹配模式

例子1

function replace(content){
  var reg = '\\[(\\w+)\\]',
    pattern = new RegExp(reg, 'g');
  return content.replace(pattern, '<img src="img/$1.png">');
}
//或
function replace(content){
  return content.replace(/\[(\w+)\/g, '<img src="img/$1.png">');
}

例子2

//zero-width look behind的替换方案
//(?<=...)和(?<!...)
//方法一:反转字符串,用lookahead进行搜索,替换以后再倒回来,例如:
String.prototype.reverse = function () {
    return this.split('').reverse().join('');
}
//模拟'foo.bar|baz'.replace(/(?<=\.)b/, 'c') 即将前面有'.'的b换成c
'foo.bar|baz'.reverse().replace(/b(?=\.)/g, 'c').reverse() //foo.car|baz

//方法二:不用零宽断言,自己判断
//模拟'foo.bar|baz'.replace(/(?<=\.)b/, 'c') 即将前面有'.'的b换成c
'foo.bar|baz'.replace(/(\.)?b/, function ($0, $1) {
    return $1 ? $1 + 'c' : $0;    
}) //foo.car|baz
//模拟'foo.bar|baz'.replace(/(?<!\.)b/, 'c') 即将前面没有'.'的b换成c
'foo.bar|baz'.replace(/(\.)?b/, function ($0, $1) {
    return $1 ? $0 : 'c';    
}) //foo.bar|caz
//这个方法在一些比较简单的场景下有用,并且可以和lookahead一起用
//但也有很多场景无效,例如:
//'tttt'.replace(/(?<=t)t/g, 'x') 结果应该是'txxx'
'tttt'.replace(/(t)?t/g, function ($0, $1) {
    return $1 ? $1 + 'x' : $0;
}) // txtx

例子3

$&符号的使用

function escapeRegExp(str) {
  return str.replace(/[abc]/g, "($&)");
}

var str = 'a12b34c';
console.log(escapeRegExp(str)); //(a)12(b)34(c)

例子4

贪婪与非贪婪,加上?为非贪婪

var s = '1023000'.match(/(\d+)(0*)/);
s
["1023000", "1023000", ""]

var s = '1023000'.match(/^(\d+)(0*)$/);
s
["1023000", "1023000", ""]

var s = '1023000'.match(/^(\d+?)(0*)$/);
s
["1023000", "1023", "000"]

var s = '1023000'.match(/(\d+?)(0*)/);
s
["10", "1", "0"]

[] + {} 和 {} + []一样吗?

这道题跟之前提到的[] == ![] 有异曲同工之妙,都是涉及到了隐式的强制类型转换。同样,我们直接开门见山,总结下加号操作符的运算规则。下面规则判断权重由上往下。

  1. 两个操作数都是数值 时,执行常规的数值加法计算。但有几个值的考虑
  • 如果有一个操作数是NaN, 则结果是NaN;
  • Infinity 加 Infinity 结果是 Infinity;
  • -Infinity 加 -Infinity 结果是 -Infinity;
  • Infinity 加 -Infinity 结果是NaN;
  • +0 加 +0 结果是+0;
  • -0 加 -0 结果是 -0;
  • +0 加 -0 结果是+0;
  1. 当有 一个操作数是字符串 时,应用如下规则:
  • 如果两个操作数都是字符串,则将两个字符串拼接起来;
  • 如果只有一个操作符是字符串,则两另一个操作符转换为字符串(toString),然后再将两个字符串拼接起来。

前面两条规则都非常简单,不会有混淆。对于其他情况,我总结了下面两条规则:

  1. 有一个操作数是复杂数据类型(对象,数组) 时,将两个操作数都转换为字符串(ToString)相加。
  2. 有一个操作数是简单数据类型(true/false, null,undefined) 时,同时不存在复杂数据类型和字符串,则将两个操作数都转换成数值(ToNumber)相加。
  3. 另外还有一种特殊情况{} + 头 的相加式,有些浏览器会将{}视为一个块符号,所以不会参与相加,而是把+符号视为转换符(Number)将后面的操作数转换为数值。

注意上面的规则的权重是从上到下的,每执行一步要从第一条规则开始再进行判断。

下面是改规则的一个判断流程图:

roadmap.path

我们来看看这几道题
1.[] + {}
根据规则,[] 和 {} 都是复杂数据类型,满足有一个操作符是复杂数据类型, 所以将两个值都转换为字符串,调用其toString方法,得到:
"" + "[object Object]" = "[object Object]"

2.1+{}
同样满足第三条规则,结果为
"1" + "[object Object]" = "1[object Object]"

3.'1' + false
其中false满足第4条规则,但同时满足第2条规则'1'是字符串,优先处理第2条规则。所以处理结果应该是将false转为字符串
"1" + "false" = "1false"

看看题目中的
{} + []
按规则计算结果应该是
"[object Object]"
但是控制台打印出来的结果却是0,别忘了第5条,当{}+开头的时候,{}并不参与计算,只是被单做一个空的代码块,所以{}+[]实际上是+[], 即Number([]) => Number("") => 0

那么{}+{}就是+{},等于Number({}) => Number("[object Object]") => NaN 然而我们看到结果再次出乎我们的意料,控制台输出的是
"[object Object][object Object]"
到底是怎么回事?

原来对于{}+{}
不同浏览器会有不同的处理结果,在chrome中会输出"[object Object][object Object]",在firefox会输出NaN
这应该是不同浏览器的js引擎解析差异引起的。我们只要记住这个特殊情况就行了。

其实这些特殊值的计算我们平时都很少接触到,也没有多大的意义。关键还是要加深对JS中对数值转换的理解,以不变应万变。到真正遇到问题的时候,不至于摸不着头脑。

为什么[] == ![] ?

类似标题中的问题还有很多,例如:

**为什么 [ ] == false 而 !![ ] == true **?

or

[1] == [1] 是true 还是 false

如果对 == 操作符一知半解,就很难解答类似的问题。我们直接开门见山,看看==是如何工作的,这里的难点主要涉及到js中的隐式强制类型转换。

判断步骤如下:

  1. 如果有一个操作数是布尔值,则在比较相等性之前先将其转换为数值----false转换为0,而true转换为1。
  2. 如果一个操作数是字符串,另一个操作数是数值,在比较相等性之前先将字符串转换为数值。
  3. 如果一个操作数是对象,另一个操作数不是,则调用对象的valueOf()方法,如果得到的值不是基本类型值,则基于返回值再调用toString方法(这个过程即ToPrimitive),用得到的基本类型值按照前面的规则进行比较。
  4. 如果两个操作数都是对象,则比较他们是不是同一个对象。如果两个操作数指向同一个对象,则相等操作符返回true, 否则返回false。

这两个操作符在进行比较时则要遵循下列规则。

  1. null 和 undefined 是相等的。
  2. 要比较相等性之前,不能将null和undefined转换成其他任何值
  3. 如果有一个操作数是NaN,则相等操作符返回false, 而不相等操作符则返回true。NaN != NaN

我画了一个图来表示这个过程:

roadmap.path

根据上面的步骤,来分析[] == ![] 为什么会返回true

[] == ![]

!运算符的优先级大于 ==,所以实际上这里还涉及到!的运算。这个比较简单!会将后面的值转化为布尔值。即![]变成!Boolean([]), 也就是!true, 也就是false。

实际上是对比 [] == false;

运用上面的顺序,false是布尔值,所以转化为数值Number(flase), 为0。

对比[] == 0;

满足第三条规则[] 是对象(数组也属于对象),0不是对象。所以ToPrimitive([])是""

对比 "" == 0;

满足第二条规则,"" 是字符串,0是数值,对比Number("") == 0, 也就是 0 == 0

所以得出 [] == ![]

我们可以用同样的方法对上面提到的两个等式例子进行判断,都能得出结论。虽然过程有点麻烦,但是本质上就是将两边的比较值转化为数值进行比较。读者可以自行尝试实践。

Grunt vs Gulp

grunt vs gulp

虽然gulp已经出来很久了,但是一直没有去使用过。得益于最近项目需要,就尝试了一下,以下从几个要点讲一下grunt和gulp使用的区别,侧重讲一下在使用gulp过程中发现的问题。而两种工具孰优孰劣由读者自己判断。

1. 书写方式

grunt 运用配置的**来写打包脚本,一切皆配置,所以会出现比较多的配置项,诸如option,src,dest等等。而且不同的插件可能会有自己扩展字段,导致认知成本的提高,运用的时候要搞懂各种插件的配置规则。
gulp 是用代码方式来写打包脚本,并且代码采用流式的写法,只抽象出了gulp.src, gulp.pipe, gulp.dest, gulp.watch 接口,运用相当简单。经尝试,使用gulp的代码量能比grunt少一半左右。


2. 任务划分

grunt 中每个任务对应一个最外层配置的key, 大任务可以包含小任务,以一种树形结构存在。举个栗子:

uglify: {
    one: {
        src: 'src/a.js',
        dest: 'dest/a.min.js'
    },
    two: {
        src: 'tmp/b.js',
        dest: 'dist/b.min.js'
    }
}

将uglify划分子任务的好处是,我们在封装不同的task时可以分别对'uglify:one'或'uglify:two'进行调用,这对于某些需要在不同时间点调用到uglify的task相当有用。

gulp 中没有子任务的概念,对于上面的需求,只能通过注册两个task来完成

gulp.task('uglify:one', function(){
    gulp.src('src/a.js')
        .pipe(uglify())
        .dest('dest/a.min.js')
});
gulp.task('uglify:two', function(){
    gulp.src('tmp/b.js')
        .pipe(uglify())
        .dest('dist/b.min.js')
});

当然这种需求往往可以通过调整打包策略来优化,并不需要分解子task,特殊情况下可以用这种方法解决。

3. 运行效率

grunt 采用串行的方式执行任务,比如我们注册了这样一个任务:
grunt.register('default', ['concat', 'uglify', 'release'])
grunt是按书写的顺序首先执行cancat,然后是uglify,最后才是release,一派和谐的气氛,谁也不招惹谁。而我们知道某些操作时可以同步执行的,比如cssmin和uglifyjs。这时grunt无法通过简单地更改配置来达到并行执行的效果,通常的做法是手动写异步task,举个栗子:

grunt.registerTask('cssmin', 'async cssmin task', function() {
  var done = this.async();
  cssmin(done);
});

在cssmin操作完成后传入done方法告知程序,但这需要插件支持。

gulp 基于并行执行任务的**,通过一个pipe方法,以数据流的方式处理打包任务,我们来看这段代码:

gulp.task('jsmin', function () {
    gulp.src(['build/js/**/*.js'])
        .pipe(concat('app.min.js'))
        .pipe(uglify()
        .pipe(gulp.dest('dist/js/'));
});

程序首先将build/js下的js文件压缩为app.min.js, 再进行uglify操作,最后放置于dist/js下。这一系列工作就在一个task中完成,中间没有产生任何临时文件。如果用grunt,我们需要怎样写这个任务?那必须是有两个task配置,一个concat,一个uglify,中间还必须产生一个临时文件。从这个角度来说,gulp快在中间文件的产生只生成于内存,不会产生多余的io操作。
再来看看前面的问题,如何并行执行uglify和cssmin?其实gulp本身就是并发执行的,我们并不需要多什么多余多工作,只需

gulp.task('default', ['uglify', 'cssmin']);

gulp该怎么快就怎么来,并不会等到uglify再执行cssmin。
是不是觉得gulp秒杀grunt几条街了呢?且慢,坑还在后面...
首先我们需要问一个问题,为什么要用并发?
为了快?那什么时候可以快,什么时候又不能快?
假设我们有这样一个任务:

gulp.task('jsmin', ['clean', 'concat']);

需要先将文件夹清空,再进行合并压缩,根据gulp的并发执行的方式,两个任务会同时执行,虽然从指令上看是先执行了clean再执行concat,然而clean还没结束,concat就执行了,导致代码合并了一些未被清理的文件,这显然不是我们想要的结果。
那这个问题有没有什么解决方案呢?
gulp官方API给出了这样的方法:

  • 给出一个提示,来告知 task 什么时候执行完毕
  • 并且再给出一个提示,来告知一个 task 依赖另一个 task 的完成

官方举了这个例子:
让我们先假定你有两个 task,"one" 和 "two",并且你希望它们按照这个顺序执行:

  1. 在 "one" 中,你加入一个提示,来告知什么时候它会完成:可以再完成时候返回一个 callback,或者返回一个 promise 或 stream,这样系统会去等待它完成。
  2. 在 "two" 中,你需要添加一个提示来告诉系统它需要依赖第一个 task 完成。

因此,这个例子的实际代码将会是这样:

var gulp = require('gulp');

// 返回一个 callback,因此系统可以知道它什么时候完成
gulp.task('one', function(cb) {
    // 做一些事 -- 异步的或者其他的
    cb(err); // 如果 err 不是 null 或 undefined,则会停止执行,且注意,这样代表执行失败了
});

// 定义一个所依赖的 task 必须在这个 task 执行之前完成
gulp.task('two', ['one'], function() {
    // 'one' 完成后
});

gulp.task('default', ['one', 'two']);

task one执行完毕后需要调用cb方法来告知task two我已经执行完成了,你可以干你的事了。
那在我们实际运用中,通常是这样的:

gulp.task('clean', function (cb) {
    gulp.src(['tmp'])
        .pipe(clean())
});

这个时候clean结束的cb要写在哪呢?是这样吗?

gulp.task('clean', function (cb) {
    gulp.src(['tmp'])
        .pipe(clean())
    cb();
});

对于理解什么叫异步的人来说这种方法肯定是不行的,clean还没完成,cb已经执行了。好在!!!
好在我们可以利用gulp中的时间监听来做结束判断:

gulp.task('clean', function (cb) {
    gulp.src(['tmp'])
        .pipe(clean()),
        .on('end', cb);
});
gulp.task('concat', [clean], function(){
    gulp.src('blabla')
        .pipe('blabla')
        .dest('blabla');
});

由于gulp是用node实现的,所以必然绑定了数据流的监听事件,我们通过监听stream event end来达到这个目的。
而不得不吐槽的是通过在task后面写[]依赖的方式也并不优雅,通常可以通过其他插件来达到顺序执行的效果,写法如同grunt,但是每个task的end事件的监听也是少不了的。
如果你的任务不多的时候,直接在回调后面执行concat也是可以的:

gulp.task('clean', function(){})
gulp.task('concat', function(){})
gulp.task('clean-concat', ['clean'], function(){
    gulp.start('concat');
})

4. 其他要交代的

  1. gulp真的只有src, pipe, dest, watch, run这几个API吗?
    不,由于gulp继承了Orchestrator(<4.0),所以具备了另外一些API,包括start等。当然这些API是官方不推荐使用的。会导致代码的复杂度提升,所以并没有出现在官方文档中。
  2. 不建议将多个操作写在同个task中,这样程序并不知道任务及时结束,如:
gulp.task('test', function(cb) {

  gulp.src('bootstrap/js/*.js')
    .pipe(gulp.dest('public/bootstrap'))
    .on('end', cb);

  gulp.src('jquery.cookie/jquery.cookie.js')
    .pipe(gulp.dest('public/jquery'))
    .on('end', cb);
});
  1. 尽量减少task的数量,很多任务其实可以在一个task中用多个pipe来执行,只需要我们在打包等时候规划好文件夹及任务流。

对了,gulp4.0会带给我们很多惊喜(wtf!),虽然它还是迟迟未发布... 暂时不想去踩坑。读者可自行Google。

参考链接:
gulpjs/gulp#755
gulpjs/gulp#82
robrich/orchestrator#10

细说Unicode(三) Unicode 番外之附加字符

在各种论坛上,经常会看到一些奇怪的字符,它们的内容会超出显示范围,

举个例子:

'Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞'

常见的还有一些有泰文字符组成的。这里就不举例子了。这些看似乱文的字符是怎么形成的呢?

其实它们并不是乱文,尝试输出上面那个例子的字符长度

'Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞'.length; //75

发现竟然包含了75个字符!我们用Array.from输出这些字符:

Array.from('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞');
//["Z", "͑", "ͫ", "̓", "ͪ", "̂", "ͫ", "̽", "͏", "̴", "̙", "̤", "̞", "͉", "͚", "̯", "̞", "̠", "͍", "A", "ͫ", "͗", "̴", "͢", "̵", "̜", "̰", "͔", "L", "ͨ", "ͧ", "ͩ", "͘", "̠", "G", "̑", "͗", "̎", "̅", "͛", "́", "̴", "̻", "͈", "͍", "͔", "̹", "O", "͂", "̌", "̌", "͘", "̨", "̵", "̹", "̻", "̝", "̳", "!", "̿", "̋", "ͥ", "ͥ", "̂", "ͣ", "̐", "́", "́", "͞", "͜", "͖", "̬", "̰", "̙", "̗"]

再查看其中某个字符的Unicode码点:

Array.from('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞')[10].codePointAt(0);//793,即16进制的0x0319

根据Unicode映射表查找出0x0319对应的字符,发现U+0300~U+036F称为结合附加符号,那么结合附加符号又是什么?
roadmap.path

附加符号,是添加在字母上面的符号,以更改字母的发音或者以区分拼写相似词语。例如汉语拼音字母“ü”上面的两个小点,或“á”、“à”字母上面的标调符。变音符号可以放在字母的上方或下方,也可以放在其他的位置。当多个附加符号叠加的时候,就形成了看起来像乱码的符号。

而在泰文中,字符的组成也是由一些元音符号和声调符号组成的
roadmap.path
所以多个元音符号或声调符号叠加时也会有类似的效果。这里就不再做阐述。

在网页开发中,特别是评论区,如果遇到太多的"插楼"字符,就会对其他用户造成阅读障碍,影响阅读体验,那怎么避免这种情况呢。这里提供两种方法。

第一种是对字符串文字区域设置最大高度,超出的部分自动隐藏。

p {
	height: 20px;
	overflow: hidden;
}

另一种方式就是对这种特殊字符做过滤操作。将附加字符进行过滤,这种方法在某种程度上会误杀一些需要正常显示的附加符号。但一般也不会影响整体功能,利大于弊。

var regexSymbolWithCombiningMarks = /([\0-\u02FF\u0370-\u1DBF\u1E00-\u20CF\u2100-\uD7FF\uDC00-\uFE1F\uFE30-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF])([\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]+)/g;

function getSymbolsIgnoringCombiningMarks(string) {
	// 删除附加符号:
	var stripped = string.replace(regexSymbolWithCombiningMarks, function($0, symbol, combiningMarks) {
		return symbol;
	});
	
	return stripped;
}

getSymbolsIgnoringCombiningMarks('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞'); //"ZALGO!"

讲到这里,我们对Unicode已经有了比较细致的了解。相信在开发中碰到问题也能找出根源所在了。

通过学习编码的历史,原理以及查询映射表,我们知道了乱码是怎么产生的,并且利用ES6或正则表达式,来解决绝大多数编码问题。

参考文章:
https://zh.wikipedia.org/wiki
https://mathiasbynens.be/notes/javascript-unicode

怎么在模块化前端项目使用Mocha?

前段时间为部门的项目引入了前端单元测试,主要是基于seajs引入mocha,现在把这篇文章修改了一下发布出来。

为什么引入单元测试?

在采用mvc框架做开发的过程中,我们不断积累出自己的一些核心组件和通用组件。
我们面临了越来越明显的代码维护问题,如果不提早针对这些组件开发单元测试,将来会面临更严重的代码管理和维护问题,
前人开发的组件没做单元测试,后人用了错误的组件却无法定位问题,不能形成一个完善的前端系统。所以我们有必要开始考虑这方法的问题。

为什么选择 mocha + chai ?

mocha的作者是大名鼎鼎的TJ Holowaychuk。该人同时是express,jade, stylus的作者。所以mocha框架也自然很受欢迎...
mocha的优点主要有如下几个:

  1. 异步方法的测试更容易
  2. 支持before,after(即beforeAll/afterAll)
  3. 与nodejs结合更自然

我们的项目总是会朝着前后端分离的方向走,所以不管是nodejs的引入,ApiServer的抽离,都会让mocha发挥它强大的作用。
至于chai,它是一个很好用的断言库,支持多种断言风格,能给你无缝链接的赶脚。该方案的详细使用方法可参考这里

前端单元测试实践

那么选定好框架后,怎样引入到项目中,并且我们要用它做哪些工作呢?

1. 目录结构
我们的所有前端代码在webapp中,测试代码放在tests,和statics位于同一级。

├── Gruntfile.js
├── package.json
└── webapp
    ├── statics
    └── tests

tests中的文件结构如下:

├── test.html
├── unit
│   ├── base
│   │   ├── api.base.js
│   │   ├── app.js
│   │   ├── cache.js
│   │   ├── global.js
│   │   ├── message.js
│   │   ├── model.base.js
│   │   ├── region.js
│   │   ├── view.base.js
│   │   └── view.js
│   ├── component
│   └── utils
│       ├── algorithm.js
│       ├── date.js
│       ├── message.js
│       ├── random.js
│       ├── sync.js
│       └── uievent.js
├── vendor
    ├── chai.js
    ├── mocha.css
    └── mocha.js

test.html 为入口文件,掌管所有测试用例,
vendor 提供一些框架支持,目前为mocha和chai。
unit 为单元测试的文件夹,
base 存放的是基类的测试用例,
utils 存放的是工具类的测试用例,
component 存放的是组件的测试用例。
其实这里的utils也可以理解为组件的一种,为了和我们的项目结构统一,区分出了utils。
2. 怎么加载测试用例
由于框架的基类基本是很少改动的,所以其测试用例也相对比较少改动。更多的是我们在编写公用组件或工具类的时候,要自己编写相对应的测试用例,
让其他同学能放心使用你的组件。在编写测试用例的过程中,你才能发现它的不足之处,才能考虑它可能出现的异常情况,所以不要认为自己编写的
测试用例肯定是能跑通自己的代码,错了,编写测试用例的过程就是让你的代码更健壮的过程!废话不多说了,我们来看一下代码怎么写。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Mocha FE tests</title>
<link rel="stylesheet" media="all" href="vendor/mocha.css">
</head>
<body>
<div id="mocha"><p><a href=".">Mocha FE Unit Test</a></p></div>
<div id="messages"></div>
<div id="fixtures"></div>
<div id='testElement'>
    <h1></h1>
</div>
<script src="vendor/mocha.js"></script>
<script src="vendor/chai.js"></script>
<script>
    mocha.setup('bdd');
</script>
</body>
</html>
<script src="../statics/js/seajs/sea.js"></script>
<script>
    var path = '../';
    seajs.use([
        path + '/tests/unit/base/view.base'
    ], function() {
        mocha.run();
    });
</script>

因为我们的模块化开发是基于seajs的,所以为了方便,这里也采用seajs分别对不同模块进行测试。
首先要引入mocha.js和chai.js,接着调用mocha.setup方法设置改测试为bdd模式。然后我们才可以开始加载测试用例。为了能在测试用例中方便引入被测试模块,我们故意引用了与其同目录下的seajs。
所以要在入口重新配置测试用例的路劲,添加path前缀。
加载完测试用例后,不要忘记回调 mocha.run() 这样才能让它跑起来。
3. 怎么编写测试用例

define(function(require){
    'use strict';
    var View = require('view.base'),
        $ = require('core/selector'),
        expect = chai.expect,
        assert = chai.assert;
    describe('View.base', function() {                          //View.base为模块名
        describe('constructor', function() {                    //contructor为方法名
            it('constructor without arguments', function() {    //对方法名进行覆盖测试
                var view = new View();                          //实例化
                expect(view.name).to.equal('base');             //输出断言
                expect(view.tagName).to.equal('div');           //输出断言
            });
        });
    });
});

mocha采用describe关键字进行用例描述,it编写用例代码。相当简洁和符合bdd风格。
看一下浏览器运行的结果:
roadmap.path

4. 无界面跑测试用例
显然每次运行测试用例都要打开浏览器是不现实的,那怎么在项目构建的时候自动跑测试用例呢?由于目前项目是基于grunt打包。
所以可以利用grunt的mocha插件。在Gruntfile.js中加入下面代码:

mocha: {
    test: {
        options: {
            timeout: 10000,
            run: false
        },
        src: ['webapp/tests/test.html']
    }
}
grunt.loadNpmTasks('grunt-mocha');
grunt.registerTask('unit', ['mocha']); 

在命令行执行 grunt unit
看看输出什么内容:
roadmap.path
显示102个通过,1个等待中,8个失败。并且下面会列出执行失败测试用例的位置和原因,绿色表示期望值,红色表示实际值。
5. 后话
到这里整个单元测试的基本流程就完成了,在开发测试用例的过程中,也发现了基类几个潜在的bug。
目前项目中主要完成了基类和工具类的测试用例,而组件类的需要在后续开发中补上。该工作将在组件整理后进行。
测试用例看起来都很简单,也就是一大堆断言。实际上它要求你本身对代码有比较深刻的理解,才能写出更细致更有价值的测试用例。
所以测试用例的编写往往也是很费时间的,对于代码开发者,编写测试用例则能够考虑到更多潜在的情况。
养成编写测试用例的好习惯,能让你的代码更健壮,能让更多人接受它并传播它。

Math.floor 和 ~~ 作用一样吗?

不一样,~~只适用于32位数字,更重要的是它对负数的处理与Math.floor(..)不同。

Math.floor(-49.6); //-50
~~-49.6;           //-49

Node.js之session实践


基本运用

用Node.js开发过网站的人想必对session都有一定的了解,最近自己也做了这方面的实践,把在开发中发现的问题记录下来,也提供解决问题的思路。

最基本的session实现方式,需要引入两个中间件 cookie-parserexpress-session

var express = require('express');
var app = express();
var cookieParser = require('cookie-parser');
var session = require('express-session');

app.use(cookieParser());
app.use(session({secret: '1234567890QWERTY'}));

app.get('/home', function(req, res) {
    console.log("Cookies: ", req.cookies)
    if(req.session.lastPage) {
        res.write('Last page was: ' + req.session.lastPage);
    }
    req.session.lastPage = '/home';
    res.send('Hello World');
});

这样就可以在代码中引用和操作请求中的携带的cookie信息和session信息。

现在比较大型的网站一般都采用了集群系统,通常我们需要在不同的机器上共享session。为了保证不同进程间能够共享session,我们必须采用另外一种方式来保存session。通常会有两种方法:

  1. 应用服务器间的session复制共享。
  2. 基于缓存或数据库存储的session共享。

第一种实现的逻辑一般比较复杂,而且不同服务器间session的复制会带来性能的损耗。目前业界多数采用的是第二种解决方案,实现真正的高可用。而相对于保存数据库,Node.js更普遍的做法是保存在缓存系统中,如memcached和redis。由于这种缓存系统都是直接存储在内存空间的,读取效率比操作数据库都要高出许多,当然没中过方案都有各自的优缺点,就要根据具体项目选择最适合自己的解决方案。这里就不科普这几个方案的具体优缺点了。

下面介绍一下基于memcached的Node.js后端session实现。
我们要使用的是connect-memcached组件,该组件依赖于node-memcached。

var express = require('express');
var app = express();
var cookieParser = require('cookie-parser');
var session = require('express-session');
var MemcachedStore = require('connect-memcached')(session);

app.use(cookieParser());
app.use(session({
    secret: '1234567890QWERTY',
    key: 'test',
    proxy: 'true',
    store: new MemcachedStore({
        hosts: ['127.0.0.1:11211']
    })
}));

app.get('/home', function(req, res) {
    console.log("Cookies: ", req.cookies)
    if(req.session.lastPage) {
        res.write('Last page was: ' + req.session.lastPage);
    }
    req.session.lastPage = '/home';
    res.send('Hello World');
});

当然你必须提前安装好memcached客户端,并开启服务。memcached的安装可以参考这里

多后台session共存

在某次重构中面对了一个难题,由于后端是用java开发的,针对某些新功能,我们需要用Node重新实现后端,而就功能任然保留java后端,所以就存在两种语言处理session的问题。好在项目之前就是采用memcached来存储session做负载均衡的。我们想到了两种解决方案。

  1. Node直接与memcached打交道,不管java后端实现,拿到memcached的session数据后,通过某种解码算法,将session反序列化得到对象。因为memcached是直接存储在内存空间的,而且往往会部署在同个服务器中,该实现方案的性能较好。但难点就在实现session数据的decoder。
  2. 在java中实现一个外部接口给Node调用,通过实现类似getSessionById(sid)的外部接口,Node能获取到session的格式化数据,因此Node不必关心java中session serialize和unserialize的实现细节。该方案也有比较明显的缺点,每次获取session都要另外做一次网络请求。带来一定的性能损耗。

解决方案:
相对来说第一种实现方案优于第二种实现方案,不会对性能造成影响,但是这种session的反序列化还是有一定的实现难度,目前好像还木有可用的组件。考虑到这些点,我们可以有一个折中的方案,就是将基于java实现的session存储为json格式。然后我们再采用第一种方案,从memcached中读取json格式存储的session,再解析json获取对象数据。这是一个比较理想的方案。不官管后端使用什么语言实现,memcached存储的是一种标准化的统一的格式。读取解析的道理都是一样的,不必去实现不同的decode方法。但对于这种session存储方式带来的问题暂时还没做研究。

负载均衡

为什么这里要讲负载均衡呢,本来只是打算讨论session的运用和共享方案。但有后端的同学提出了问题,“听说Node是单线程的,即使实现了登录模拟和session存储,你怎么做负载均衡呢?” 下面就总结一下Node在负载均衡的一些常规的玩法。
将负载均衡前,有必要扯一扯单线程和多线程。下面两幅图能很好说明多线程和单线程服务器请求的区别:

多线程模型
roadmap.path
Node.js模型
roadmap.path

Node.js的单线程让程序猿不必再去烦恼多线程对变量的加锁解锁,也避免了多线程切换造成的开销,同时还减少了内存的暂用,看起来好像更安全了。
但是,在对于cpu密集型的计算时,Node.js就会暴露出其缺点,尽管Node.js使用的是异步的编程范式,也有可能因为大量的计算造成线程阻塞。
那么Node.js是怎样来解决这类问题的呢?
要让Node.js支持多线程,官方的做法就是通过libuv库来实现的。

libuv是一个跨平台的异步I/O库,它主要用于Node.js的开发,同时他也被Mozilla's Rust language,
Luvit, Julia, pyuv等使用。它主要包括了Event loops事件循环,Filesystem文件系统,Networking网络支持,Threads线程,Processes进程,Utilities其他工具。

Node.js核心api中的异步多线程大多是用libuv来实现的。
cluster
除了用libuv实现多线程,还可以通过cluster创建子进程,充分利用多核cpu,脑洞全开。

var cluster = require('cluster');
var http = require('http');
var numCPUs = require('os').cpus().length;

if (cluster.isMaster) { //process.env.NODE_UNIQUE_ID is undefined
  // Fork workers.
  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', function(worker, code, signal) {
    console.log('worker ' + worker.process.pid + ' died');
  });
} else {
  // Workers can share any TCP connection
  // In this case its a HTTP server
  http.createServer(function(req, res) {
    res.writeHead(200);
    res.end("hello world\n");
  }).listen(8000);
}

numCPUS实际上为CPU的核数。当判断cluster.isMaster主进程时,会创建numCPUs个子进程。cluter通过IPC(Inter-Process Communication,进程间通信)实现master进程和子进程间的通信。

Nginx
Nginx是一个高性能的HTTP和反向代理服务器。我们可以使用Nginx开处理静态文件和反向代理。如开启多个进程,每个进程绑定不同的端口,用Nginx 做负载均衡以减少Node.js的负载。虽然Node.js也有一些如http-proxy的代理模块可以实现,但这种基础性的工作,更应该交给nginx来做。这里就不讲解ngnix的具体使用了。

本文待深入...

认识 WebAssembly

自从Brendan Eich用十天时间创造了JavaScript,人们对它的吐槽就从未间断过。众所周知JavaScript是一门动态语言。运行于JavaScript引擎中,我们熟悉的有Mozilla的SpiderMonkey,Safari的JavaScriptCore,Edge的Chakra还有大名鼎鼎的V8。V8引擎将JavaScript的运行效率提升到一个新的level。所以后来的Nodejs也采用V8作为引擎,实现了用js进行后端开发的愿景。

然而JavaScript发展到今天,其语言基因中存在的缺陷并不能得到根本性的改变。比如常见的加法操作

function add(a, b) {
    return a + b;
}

这段代码在浏览器中的运行过程比你想象的复杂。
add在被调用前,js引擎并不能提前预判传入参数的类型,需要在运行时对参数进行如下一连串的类型判断和转换操作。

pic

对js加法运算的详细操作(keng)有兴趣的可以看这篇文章

V8再快也难以逾越语言本身的瓶颈。这种问题是动态语言的弊端,对于此类问题,业界已经出现了非常多的解决方案。

而本文要讲的正是目前最为前沿的一种 ------ WebAssembly

WebAssembly这个概念其实2015年就提出来了,而就在不久之前,四大浏览器厂商,Chrome, Firefox, Edge, Safari 在新版的浏览器中才全部默认支持Webassembly(Chrome, Firefox早于后两者),这种技术很快将在前端高性能开发领域中大放异彩。

WebAssembly是什么?

下面是来自官方的定义:

WebAssembly or wasm is a new portable, size- and load-time-efficient format suitable for compilation to the web.

关键词:”format",WebAssembly 是一种编码格式,适合编译到web上运行。

事实上,WebAssembly可以看做是对JavaScript的加强,弥补JavaScript在执行效率上的缺陷。

  • 它是一个新的语言,它定义了一种AST,并可以用字节码的格式表示。
  • 它是对浏览器的加强,浏览器能够直接理解WebAssembly并将其转化为机器码。
  • 它是一种目标语言,任何其他语言都可以编译成WebAssembly在浏览器上运行。

想象一下,在计算机视觉,游戏动画,视频编解码,数据加密等需要需要高计算量的领域,如果想在浏览器上实现,并跨浏览器支持,唯一能做的就是用JavaScript来运行,这是一件吃力不讨好的事情。而WebAssembly可以将现有的用C,C++编写的库直接编译成WebAssembly运行到浏览器上, 并且可以作为库被JavaScript引用。那就意味着我们可以将很多后端的工作转移到前端,减轻服务器的压力。这是WebAssembly最为吸引人的特性。并且WebAssembly是运行于沙箱中,保证了其安全性。

为什么要有WebAssembly?

如果只是想让C,C++,Java等原生语言编写的模块运行在浏览器上。我们只需要一个转换器,将源语言转换为目标语言JavaScript,而这种技术其实很早就有了。

例如将Java转换成JavaScript的Google Web Toolkit (GWT)

将python转换成JavaScript的pyjamas 等等。

但是这并没有解决JavaScript执行慢的问题,这跟直接用JavaScript来重写代码库是一样的作用。这就是为什么Electron能直接运行Node.js但对比传统桌面应用依然弱鸡的原因。

要理解JavaScript为什么运行慢,就要理解它在引擎中的处理过程。
传统JavaScript在V8引擎中的编译过程是这样的:首先JavaScript会被编译成AST,然后引擎再将AST, 转化为机器语言交给底层执行。

V8的pipeline结构会进一步先将AST转化为一种中间代码,再对中间代码再次生成优化后的机器码,从而实现更快的执行速度。

pic

对于WebAssembly来说,前面的parser, optimize 全部省了,直接编译到机器码。

pic

浏览器通过增加一种语言格式的编译支持,来实现执行效率的突破。

WebAssembly除了运行快之外,其特殊的二进制表示法也大大减小了代码包的大小。同时提升了浏览器的加载速度。

如何使用WebAssembly?

现在你已经能在这些浏览器中使用WebAssembly了。

WebAssembly这么快,但并不意味着JavaScript这门语言要从此绝迹了。

如前面所说,WebAssembly和JavaScript之间是可以相互调用的。

假设我们用C写了这段代码

include <math.h>
int add(int a, int b) {
    return a + b;
}

首先将其转化为wasm文件, 这里运用一个线上的工具 WasmFiddle

将转化的add.wasm下载下来。wasm是一个十六进制表示的字节码。

0061 736d 0100 0000 018b 8080 8000 0260
017f 0060 027f 7f01 7f02 9280 8080 0001
0365 6e76 0a63 6f6e 736f 6c65 4c6f 6700
0003 8280 8080 0001 0104 8480 8080 0001
7000 0005 8380 8080 0001 0001 0681 8080
8000 0007 9080 8080 0002 066d 656d 6f72
7902 0003 6164 6400 010a 9380 8080 0001
8d80 8080 0000 2001 2000 6a22 0110 0020
010b 

由于目前还没支持 <script src=“abc.wasm" type="module" />的引入方式。所以不能直接在html引入,我们可以通过JS fetch来请求文件。

先封装一个fetch方法:

function fetchAndInstantiateWasm (url, imports) {
    return fetch(url)
    .then(res => {
        if (res.ok)
            return res.arrayBuffer();
        throw new Error(`Unable to fetch Web Assembly file ${url}.`);
    })
    .then(bytes => WebAssembly.compile(bytes))
    .then(module => WebAssembly.instantiate(module, imports || {}))
    .then(instance => instance.exports);
}

用定义好的fetchAndInstantiateWasm方法请求add.wasm文件,并在回调中调用C中定义的add方法,成功输出结果15。

fetchAndInstantiateWasm('add.wasm', {})
.then(m => {
    console.log(m.add(5, 10)); // 15
});

同样通过js import,也能够在C中调用js的方法。

fetchAndInstantiateWasm('program.wasm', {
    env: {
      consoleLog: num => console.log(num)
    }
})
.then(m => {
    console.log(m.add(5, 10)); // 15
});

上面在js代码中定义了consoleLog, 并传入了wasm文件,在C中就可以调用consoleLog方法往控制台输出信息,你也可以执行一些你想要的其他操作。

#include<stdio.h>
void consoleLog(int num);
int add(int num1, int num2) {
    int result = num1 + num2;
    consoleLog(result);
    return result;
}

可直接下载demo代码执行查看效果 demo

运行python -m SimpleHTTPServer后访问localhost:8000, 查看log中输出信息。

前面说WebAssembly是一门新的语言,但上面引入的wasm只是一种字节码,是作为其他语言编译的目标语言,完全没有可读性。其实WebAssembly是有自己的语法的,文件格式为wast。下面是add方法编译成的WebAssembly版本。

(module
  (type $FUNCSIG$vi (func (param i32)))
  (import "env" "consoleLog" (func $consoleLog (param i32)))
  (table 0 anyfunc)
  (memory $0 1)
  (export "memory" (memory $0))
  (export "add" (func $add))
  (func $add (param $0 i32) (param $1 i32) (result i32)
    (call $consoleLog
      (tee_local $1
        (i32.add
          (get_local $1)
          (get_local $0)
        )
      )
    )
    (get_local $1)
  )
)

wast是可编辑的,它同样可以直接转化为wasm, 用于浏览器引入。

上面只是一个最简单的例子,实际上利用WebAssembly实现的应用已经可以相当酷炫。

官方展示的demo游戏

还有一个运用webassembly实现的浏览器视频编辑器

和其他类似技术的区别?

asm.js

可能对前端比较关注的同学有听说过asm.js。它是Mozilla开发的一个JavaScript的子集。就是在JavaScript的基础上,加入了静态类型的支持。
asm.js是Mozilla开发的,所以只支持自家浏览器Firefox。当然代码也可以兼容运行于其他浏览器,但是就没有了优化效果。

asm.js 提供一种语法来表示变量类型

var first = 5;
var second = first;

对于上面这段JavaScript代码,在asm.js里是这样写的

var first = 5;
var second = first | 0;

在first后面加上|0,我们就将first标记为32位整数,而被赋值的second也为被定义为32位整数。
在Mozilla引擎编译代码的时候,遇到这些标志就会提前知道变量的类型,提前优化代码。而这些标记也不影响其他引擎的运算结果。

然而说到底它还是JavaScript,只不过我们提前为优化做了准备。代码还是要经过JavaScript Code ->AST->Optimize的过程。
另外asm.js也是支持将C,C++转化为asm.js的,有兴趣的可以参考这里

TypeScript

大家应该也知道微软的TypeScript,TypeScript做的工作其实跟asm.js有点类似,只不过TypeScript是更加High-Level的。他是JavaScript的一个超集,就是在JavaScript的基础上支持了类型和类等语法。并且能直接编译为JavaScript。TypeScript在于能在开发阶段就进行类型检查,保证代码开发效率和安全性。但是从浏览器运行效率上来看并没有优化效果,因为浏览器并不原生支持。

相同功能的还有facabook的Flow,也是在开发阶段加入类型的支持。

结语

目前WebAssembly由W3C WebAssembly Community Group负责开发与标准定制,而该组织的成员正是来自Google, Microsoft, Mozilla等浏览器开发人员。几个大厂同时投入到WebAssembly的开发中,相信不久WebAssembly就会成为一种浏览器网站&应用的通用优化技术。

参考资料

  1. https://medium.com/javascript-scene/what-is-webassembly-the-dawn-of-a-new-era-61256ec5a8f6
  2. https://medium.com/javascript-scene/why-we-need-webassembly-an-interview-with-brendan-eich-7fb2a60b0723
  3. https://www.youtube.com/watch?v=6v4E6oksar0
  4. http://blog.techbridge.cc/2017/06/17/webassembly-js-future/
  5. https://github.com/WebAssembly/design

一段代码理解Promise/Deferred模式

一段代码理解Promise/Deferred模式

Promise/Deferred模式包含两个部分,即Promise和Deferred,从语义上理解,Deferred为延迟对象,Deferred主要用于内部,用于维护异步模型的状态;Promise则作用于外部,通过then()方法暴露给外部以添加自定义逻辑。

Promises/A模型是这么定义的:

  • Promise操作只会处在这3种状态中的一种:未完成状态、完成状态和失败态。
  • Promise的状态只会出现从未完成态向完成态或失败态转化,不能逆反。完成态和失败态不能相互转化。
  • Promise的状态一旦转化,将不能被更改。

我们都曾饱受传统回调雪崩问题的困扰,下面以一段代码来诠释异步模型怎么解决这个问题的。

var Deferred = function(){
    //声明promise为Deferred对象
    this.promise = new Promise();
};

//完成态
Deferred.prototype.resolve = function(obj) {
    var promise = this.promise;
    var handler;
    //遍历then链式中的回调
    while((handler = promise.queue.shift())) {
        //处理回调
        if(handler && handler.fulfilled(obj)) {
            var ret = handler.fulfilled(obj);
            //判断回调返回的结果是否为Promise对象,如果是,则跳出while循环,
            //等待该Promise的callback方法的执行,则会再次进入while循环
            if(ret && ret.isPromise) {
                ret.queue = promise.queue;
                //promise重新指向当前promise
                this.promise = ret;
                return;
            }
        }
    }
};

//失败态,原理如resolve
Deferred.prototype.reject = function(err) {
    var promise = this.promise;
    var handler;
    while((handler = promise.queue.shift())) {
        if(handler && handler.error) {
            var ret = handler.error(err);
            if(ret && ret.isPromise) {
                ret.queue = promise.queue;
                this.promise = ret;
                return;
            }
        }
    }
};

//生成回调函数
Deferred.prototype.callback = function() {
    var that = this;
    //then中的回调方法会自动执行,调用deferred的reject或resolve
    return function(err, file) {
        if(err) {
            return that.reject(err);
        }
        that.resolve(file);
    };
};

var Promise = function() {
    //队列用于存储待执行的回调函数
    this.queue = [];
    this.isPromise = true;
};

Promise.prototype.then = function(fulfilledHandler, errorHandler, progressHandler) {
    //handler对象存储成功回调和失败回调
    var handler = {};
    if(typeof fulfilledHandler === 'function') {
        handler.fulfilled = fulfilledHandler;
    }
    if(typeof errorHandler === 'function') {
        handler.error = errorHandler;
    }
    //放入队列
    this.queue.push(handler);
    return this;
};

var readFile1 = function(file, encoding) {
    var deferred = new Deferred();
    fs.readFile(file, encoding, deferred.callback());
    return deferred.promise;
};
var readFile2 = function(file, encoding) {
    var deferred = new Deferred();
    fs.readFile(file, encoding, deferred.callback());
    return deferred.promise;
};

//一开始queue中就完成了所有then回调的push操作,接着第一个readFile1执行
//fs.readFile(file, encoding,deferred.callback());
//完成时,deferred.callback()会被调用。加入操作成功,会调用到deferred的resolve方法,
//开始操作queue中的回调,var ret = handler.fulfilled(obj);
//则执行了return readFile2(file1.trim(),'utf8');该方法返回一个Promise对象,
//此时resolve方法跳出while循环,第二个then任为执行,
//等待readFile2完成操作,再次调用deferred.callback(),再次进入resolve的while循环,
//此时执行console.log(file2);返回非Promise对象,queue也同时结束遍历。*/

readFile1('file1.txt', 'utf8').then(function(file1) {
    return readFile2(file1.trim(), 'utf8');
}).then(function(file2){
    console.log(file2);
});

属性模块

// Non-standard and deprecated way

var o = {};
o.__defineGetter__("gimmeFive", function() { return 5; });
console.log(o.gimmeFive); // 5


// Standard-compliant ways

// Using the get operator
var o = { get gimmeFive() {return 5}};
console.log(o.gimmeFive); // 5
o.__lookupGetter__('gimmeFive');
//function gimmeFive() {return 5}

// Using Object.defineProperty
var o = {}
Object.defineProperty(o, 'gimmeFive', {
    get: function() {
        return 5;
    }
});
console.log(o.gimmeFive); // 5

继承和原型链

继承和原型链


javascript中的每个对象都有一个内部私有的链接指向另一个对象 ,这个对象就是原对象的原型. 这个原型对象也有自己的原型, 直到对象的原型为null为止(也就是没有原型)最上一层为Object. 这种一级一级的链结构就称为原型链.

我们最常见的实现原型继承的方式如下:

function Animal() {
    this.name = 'Animal';
}

Animal.prototype.sleep = function() {
    console.log('i can sleep');
}

function Dog(name) {
    this.name = name;
}

Dog.prototype = new Animal(); //原型链指向Animal.prototype
Dog.prototype.constructor = Dog; //将constructor指向自己
var dog = new Dog('wangwang');
dog.name; //wangwang
dog.sleep(); //i can sleep

有些人不理解这句话的意义:

Dog.prototype.constructor = Dog;

Dog将构造函数指向了自己,这是因为在执行了上面一句之后,会有:

Dog.prototype.constructor == Animal; //true
Dog.prototype.constructor == Dog; //false

这显然是不符合逻辑的,那么是否意味这这是一种hack的继承方式,有木有更好更自然的继承方法呢?别着急,慢慢来。
我开始好奇new Animal()的时候js内部做了什么操作,既然Dog.prototype继承了Animal.prototype中的方法,那么我们来做个尝试,修改代码:

function Animal() {
    this.name = 'Animal';
}

Animal.prototype.sleep = function() {
    console.log('i can sleep');
}

function Dog(name) {
    this.name = name;
}

Dog.prototype = Animal.prototype; //将原型Animal.prototype直接赋值给Dog
Dog.prototype.constructor = Dog; //将constructor指向自己
var dog = new Dog('wangwang');
dog.name; //wangwang
dog.sleep(); //i can sleep

输出正常,好,我们再给狗多加一个技能:

Dog.prototype.eatShit() {
    console.log('i can eat shit');
}
dog.eatShit(); //i can eat shit
Animal.prototype.eatShit(); //i can eat shit 

但是我们发现Animal的原型中也多了eatShit这个技能,其他动物如果也继承了Animal,那么他也会eatShit了,这明显不太科学。

这个问题是怎么导致的呢?
由于我们将原型直接赋值,所以Animal和Dog共用了同一个原型,你可以理解为指向同一个内存地址,那么只要其中一个更改了,就会影响到另外一个的值。

那神秘的new Animal()到底悄悄地做了什么呢?

var animal = new Animal();
animal.__proto__ === Animal.prototype; //true

1.创建一个通用对象 var o = new Object();
2.将o作为关键字this的值传递给Animal的构造函数,var returnObject = Animal.constructor(o, arguments); this = o; 这个过程中构造函数显式地设置了name的值为“Animal”(执行Animal(),返回给o),隐式地将其内部的__proto__属性设置为Animal.prototype的值。即o.proto = Animal.protorype;
3.返回新创建的对象returnObject并将animal的值指向该对象。

这个过程中,__proto__提供了一个钩子,当请求prototype上的属性someProp时,JavaScript首先检查对象自身中是否存在属性的值,如果有,则返回该值。如果不存在,则检查Object.getPrototypeOf(o).someProp是否存在,如果仍然不存在,就继续检查Object.getPrototypeOf(Object.getPrototypeOf(0)).someProp,依次类推。

所以存在这样的关系:animal.proto = Animal.prototype;
有没有发现,我们实现继承的最简单方法就可以简化为:

Dog.prototype.__proto__ = Animal.prototype;

ok,能理解么?既然new是将父类的prototype赋值给子类的__proto__,那么我们只要对__proto__赋值就能够继承原型链了。
but,遗憾的是这种方法并不能够继承到父类的私有属性。

var animal = {};
animal.__proto__ = Animal.prototype;
animal.name; //undefined

并且这只适用于可扩展对象,一个不可扩展对象的__proto__属性是不可变的。

var obj = {};
Object.preventExtensions(obj);
obj.__proto__ = {}; //抛出异常TypeError

ECMAScript5中引入一个新方法:Object.create.可以调用这个方法来创建新对象,新对象的原型就是调用create方法时传入的第一个参数:

Object.create(proto [, propertiesObject ])

proto: 一个对象,作为新创建对象的原型。
propertiesObject: 一个对象值,可以包含若干个属性。
对于第一个参数的实现原理如下:

if (!Object.create) {
    Object.create = function (o) {
        if (arguments.length > 1) {
            throw new Error('Object.create implementation only accepts the first parameter.');
        }
        function F() {}
        F.prototype = o;
        return new F();
    };
}

其本质也是在内部定义了一个中间量F,并进行原型的赋值和new操作,实现原型的继承。
下面这个例子采用Object.create()完整实现继承:

function Animal(name) {
    this.name = name;
}

Animal.prototype = {
    name: null,
    doSomething: function() {
        //...
    }
}

function Dog(name, age) {
    Animal.call(this, name); //私有属性继承
    this.age = age;
}

Dog.prototype = Object.create(Animal.prototype, {
    age: {
        value: null,
        enumerable: true,
        configurable: true,
        writable: true
    },
    doSomething: {
        value: function() {
            Animal.prototype.doSomething.apply(this, arguments); //call super
        },
        enumerable: true,
        configurable: true,
        writable: true
    }
});

var dog = new Dog();
dog.doSomething();

性能

在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。

遍历对象的属性时,原型链上的每个属性都是可枚举的。

检测对象的属性是定义在自身上还是在原型链上,有必要使用hasOwnProperty方法,该方法由所有对象继承自Object.proptotype。

hasOwnProperty是JavaScript中唯一一个只涉及对象自身属性而不会遍历原型链的方法。

细说Unicode(二) Unicode与JavaScript的纠葛

大家对上一篇文章中提到的UCS编码可能比较陌生。殊不知这就是JavaScript采用的编码方法。

既然Unicode已经统一了天下,为什么JavaScript不采用UTF的编码方法呢?原因很简单,因为JavaScript诞生的时候UTF-8还尚未成熟,UTF-16更是到后面才出现,而此时UCS已经先行一步地完成了UCS-2。所以JavaScript采用了比UTF更早的UCS。也就是UCS-2。(记住只是编码方法,实际上字符集还是Unicode字符集)

UCS-2 与 UTF-16

从命名上看,我们很容易猜出UCS-2占用2个字节。而UTF-16占用16位,也是2个字节,那他们的编码方式有什么不同呢?
对于2个字节的码点,UCS-2和UTF-16是没有什么区别的。在基本平面上(2^16),UTF-16沿用了UCS-2的编码,另外在辅助平面上,UTF-16还定义了4个字节的表示方法。简单来说,UTF-16可看成是UCS-2的父集。在没有辅助平面字符前,UTF-16与UCS-2所指的是同一的意思。但当引入辅助平面字符后,就称为UTF-16了。

由于JavaScript只能处理UCS-2编码,造成所有字符都是2个字节,如果是4个字节的字符,会被当做两个双字节的字符处理。受到这个的影响,JavaScript中的字符操作函数某些情况无法返回正确的结果。

对于两个字节的字符,js能够根据码点直接输出对应字符。例如小写字母'a'的Unicode编码就是U+0061。
roadmap.path

U+0000 - U+00FF的码点,还有另外一种表示方法,称为16进制转义序列。用'\x'开头,后面跟两位的16进制符。

roadmap.path

大于两个字节的码点,JavaScript就有点力不从心了。例如字符 roadmap.path

这个符号的字符码点为 "U+1F4A9", 控制台的输出结果是这样的

roadmap.path

这显然不是正确的结果,那么roadmap.path这个符号是怎么产生的呢?

由于UCS-2每次只能读取两个字节,所以 "U+1F4A9"被解读为U+1F4A 和 9, 查阅Unicode映射表U+1F4A 对应的是希腊语的扩展,就是是符号0加一点。

roadmap.path

roadmap.path

剩下的9则被识别为普通的字符串符号'9'输出了。

既然JavaScript无法处理大于两个字节的符号,那对于互联网上成千上万的复杂字符和表情,岂不是束手无策?

非也!

我们在控制台输出这个码点:”\uD83D\uDCA9″

roadmap.path

神奇的事情发生了,”\uD83D\uDCA9″竟然也能输出roadmap.path符号。

如果我们单独输出这两个码点,看会输出什么字符:
roadmap.path

两个字符单独输出都是乱码,Unicode无法识别对应的字符。再次查阅映射表。
roadmap.path

发现这两个码点分别落在了UTF-16的高半位和低半位。

原来UTF-16碰到第一个双字节码点在D800-DBFF之间时,代码不会直接读取符号,而是将其存储为高半区,再往下读取两个字节的低半区,合在一起再输出符号。而这也是UCS-2的处理方式。

那么 "U+1F4A9"怎么转化为高低位”\uD83D\uDCA9"呢,下面是转换公式:

H = Math.floor((0x1F4A9-0x10000)/0x400)+0xD800 = 0xD83D

L = (0x1F4A9-0x10000) % 0x400+0xDC00 = 0xDCA9

既然我们已经能够在JavaScript中输出辅助平面的字符了,那不是万事大吉了吗?

常见问题

考虑一个常用的前端场景——输入框,通常会规定最大输入字数。尝试输出上面的符号长度, 发现长度是2。

roadmap.path

这与我们的认知有点不同,我们通常认为一个表情符号也是一个字符,长度为1。而如果通过"xxx".length 来判断字符串长度显然是不够准确的。这个问题在ES6中能迎刃而解:

ES6中通过Array.from能准确读取字符长度

roadmap.path

然而Array.from不是完美的,在某些场景下也无法满足需求,况且还存在ES6的浏览器兼容性问题。

在ES5中,我们通过正则的判断,也能得到Array.from的效果,而且扩展性更高:

var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;

function countSymbols(string) {
	return string
		// 替换掉辅助平面的连字符
		.replace(regexAstralSymbols, '_')
		.length;
}

countSymbols('\uD835\uDC00'); //1

另外,JavaScript也提供了从码点到字符的转换函数。

//这里直接输入进制数0x0061或97,而不是字符串
String.fromCharCode(0x0061); //a
//输出为10进制数
'a'.charCodeAt(0);//97 (16进制0x0061)

而对于附加平面的符号,JavaScript又要跪了, 直接输出低位 U+F4A9的字符,而该字符位于Unicode的私用区,未定义,所以输出''。

String.fromCharCode(0x1F4A9);//''

roadmap.path

同样的,我们将符号U+1F4A9变为高地位输入,就能成功输出roadmap.path符号

roadmap.path

对于fromCharCode和charCodeAr这两个方法,ES6 也提供了新的接口,对应fromCodePoint和codePointAt,问题得到解决:

roadmap.path

roadmap.path

在处理字符串逆转,正则的匹配上,附加字符都会有问题,要处理这些问题,只有一条准则,就是要对附加码点做特殊处理。在ES6还没全面支持的情况下,只能通过定义各种hack方法来解决。

关于Unicode跟JavaScript的纠葛就讲到这,乱码问题让人费解,但是只要了解了基本原理,问题往往就能迎刃而解。

参考文章:
https://zh.wikipedia.org/wiki
https://mathiasbynens.be/notes/javascript-unicode
http://www.ruanyifeng.com/blog/2014/12/unicode.html

nextTick, setTimeout 以及 setImmediate 三者的执行顺序?

包括执行顺序和Event Loop的问题。

下面这个例子比较典型:

setImmediate(function(){
    console.log(1);
},0);
setTimeout(function(){
    console.log(2);
},0);
new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});
console.log(6);
process.nextTick(function(){
    console.log(7);
});
console.log(8);

//输出结果是3 4 6 8 7 5 2 1

macro-task: script (整体代码),setTimeout, setInterval, setImmediate, I/O, UI rendering.
micro-task: process.nextTick, Promise(原生),Object.observe,MutationObserver

除了script(整体代码) ,可以理解为待执行的所有代码。
micro-task的任务优先级高于macro-task的任务优先级。

所以执行顺序如下:

第一步. script整体代码被执行,执行过程为

  • 创建setImmediate macro-task
  • 创建setTimeout macro-task
  • 创建micro-task Promise.then 的回调,并执行script console.log(3); resolve(); console.log(4); 此时输出3和4,虽然resolve调用了,执行了但是整体代码还没执行完,无法进入Promise.then 流程。
  • console.log(6)输出6
  • process.nextTick 创建micro-task
  • console.log(8) 输出8

第一个过程过后,已经输出了3 4 6 8

第二步. 由于micro-task 的 优先级高于micro-task。
此时micro-task 中有两个任务按照优先级process.nextTick 高于 Promise。
所以先输出7,再输出5

第三步,micro-task 任务列表已经执行完毕,家下来执行macro-task. 由于setTimeout的优先级高于setIImmediate,所以先输出2,再输出1。

整个过程描述起来像是同步操作,实际上是基于Event Loop的事件循环。

关于micro-task和macro-task的执行顺序,可看下面这个例子(来自《深入浅出Node.js》):

//加入两个nextTick的回调函数
process.nextTick(function () {
	console.log('nextTick延迟执行1');
});
process.nextTick(function () { 
	console.log('nextTick延迟执行2');
});
// 加入两个setImmediate()的回调函数
setImmediate(function () {
	console.log('setImmediate延迟执行1'); 
	// 进入下次循环 
	process.nextTick(function () {
		console.log('强势插入');
	});
});
setImmediate(function () {
	console.log('setImmediate延迟执行2'); 
});

console.log('正常执行');

书中给出的执行结果是:

正常执行
nextTick延迟执行1
nextTick延迟执行2
setImmediate延迟执行1
强势插入
setImmediate延迟执行2

process.nextTick在两个setImmediate之间强行插入了。
但运行这段代码发现结果却是这样:

正常执行
nextTick延迟执行1
nextTick延迟执行2
setImmediate延迟执行1
setImmediate延迟执行2
强势插入

朴老师写那本书的时候,node最新版本为0.10.13,而我的版本是6.x

老版本的Node会优先执行process.nextTick。 当process.nextTick队列执行完后再执行一个setImmediate任务。然后再次回到新的事件循环。所以执行完第一个setImmediate后,队列里只剩下第一个setImmediate里的process.nextTick和第二个setImmediate。所以process.nextTick会先执行。

而在新版的Node中,process.nextTick执行完后,会循环遍历setImmediate,将setImmediate都执行完毕后再跳出循环。所以两个setImmediate执行完后队列里只剩下第一个setImmediate里的process.nextTick。最后输出"强势插入"。

具体实现可参考[]Node源码](https://github.com/nodejs/node/blob/master/lib/timers.js#L586)。

关于优先级的另一个比较清晰的版本:

观察者优先级

在每次轮训检查中,各观察者的优先级分别是:

idle观察者 > I/O观察者 > check观察者。

idle观察者:process.nextTick

I/O观察者:一般性的I/O回调,如网络,文件,数据库I/O等

check观察者:setImmediate,setTimeout

setImmediate 和 setTimeout 的优先级
上面说到setTimeout 的优先级比 setImmediate的高,其实这种说法是有条件的。

看下面这个例子:

setImmediate(function () {
    console.log('1'); 
});
setTimeout(function () {
    console.log('2'); 
}, 0);

console.log('3');

//输出结果是3 2 1

我们知道现在HTML5规定setTimeout的最小间隔时间是4ms,也就是说0实际上也会别默认设置为最小值4ms。我们把这个延迟加大

setTimeout延迟20ms再执行,而setImmediate是立即执行,竟然2比1还先输出??

试试打印出这个程序的执行时间:

var t1 = +new Date();
setImmediate(function () {
    console.log('1'); 
});
setTimeout(function () {
    console.log('2'); 
},20);

console.log('3');
var t2 = +new Date();
console.log('time: ' + (t2 - t1));
//输出
3 
time: 23 
2 
1

程序执行用了23ms, 也就是说,在script(整体代码)执行完之前,setTimeout已经过时了,所以当进入macro-task的时候setTimeout依然优先于setImmediate执行。如果我们把这个值调大一点呢?

var t1 = +new Date();
setImmediate(function () {
    console.log('1'); 
});
setTimeout(function () {
    console.log('2'); 
},30);

console.log('3');
var t2 = +new Date();
console.log('time: ' + (t2 - t1));
//输出
3 
time: 23 
1 
2

setImmediate早于setTimeout执行了,因为进入macro-task 循环的时候,setTimeout的定时器还没到。

以上实验是基于6.6.0版本Node.js测试,实际上在碰到类似这种问题的时候,最好的办法是参考标准,并查阅源码,不能死记概念和顺序,因为标准也是会变的。包括此文也是自学总结,经供参考。

参考:
https://www.zhihu.com/question/36972010
https://segmentfault.com/a/1190000007936922
http://www.jianshu.com/p/837b584e1bdd

从零开始开发一款H5小游戏(三) 攻守阵营,赋予粒子新的生命

每个游戏都会包含场景和角色。要实现一个游戏角色,就要清楚角色在场景中的位置,以及它的运动规律,并能通过数学表达式表现出来。

场景坐标

canvas 2d的场景坐标系采用平面笛卡尔坐标系统,左上角为原点(0,0),向右为x轴正方向,向下为y轴正方向,坐标系统的1个单位相当于屏幕的1个像素。这对我们进行角色定位至关重要。
roadmap.path

Enemy粒子

游戏中的敌人为无数的红色粒子,往同一个方向做匀速运动,每个粒子具有不同的大小。
入口处通过一个循环来创建Enemy粒子,随机生成粒子的位置x, y。并保证每个粒子都位于上图坐标系所在象限中。由于 map.width <= x <= 2 * map.width,所以粒子最开始是看不到的。

//index.js
function createEnemy(numEnemy) {
    enemys = [];
    for (let i = 0; i < numEnemy; i++) {
        const x = Math.random() * map.width + map.width;
        const y = Math.random() * map.height;
        enemys.push(new Enemy({x, y}));
    }
}

接下来只要在update中给粒子一个位移偏量speed,粒子就会做匀速运动。speed越大,速度越快。

update() {
    this.x -= this.speed; //speed为位移偏量
    this.y += this.speed;
}

由于红色粒子看起来是无穷无尽的,而我们只是创建了有限个粒子,所以需要在粒子离开视界的时候重置粒子的位置。视界之外的位置开始运动,并保证该位置的随机性。

//Enemy.js
update() {
    this.x -= this.speed; //speed为位移偏量
    this.y += this.speed;

    //粒子从左边离开视界
    if (this.x < -10) {
        this.x = map.width + 10 + Math.random() * 30;
    }
    //粒子从底部离开视界
    if (this.y > map.height + 10) {
        this.y = -10 + Math.random() * -30;
    }
}

可以用一张图来直观地表示Enemy粒子的运动过程
roadmap.path

Player粒子

玩家粒子则由鼠标控制,在上一节中我们已经简单介绍了游戏中的鼠标交互。
而在手机上的实现还略有差别。手机上的做法是监听手指的位移量并让Player粒子做偏移。而不是每次touch都重置粒子的位置,这样体验就会好很多。

//Player.js
if (isMobile) {
    self.moveTo(self.x, self.y);
    window.addEventListener('touchstart', e => {
        e.preventDefault();
        self.touchStartX = e.touches[0].pageX;
        self.touchStartY = e.touches[0].pageY;
    });
    //手机上用位移计算位置
    window.addEventListener('touchmove', e => {
        e.preventDefault();
        let moveX = e.touches[0].pageX - self.touchStartX;
        let moveY = e.touches[0].pageY - self.touchStartY;
        self.moveTo(self.x + moveX, self.y + moveY);
        self.touchStartX = e.touches[0].pageX;
        self.touchStartY = e.touches[0].pageY;
    });
} else {
    let left = (document.getElementById("game").clientWidth - 
            document.getElementById("world").clientWidth)/2;
    window.addEventListener('mousemove', (e = window.event) => {
        self.moveTo(e.clientX - left - 10, e.clientY - 30);
    });
}

Player 粒子值得一讲的就是它飘逸的尾巴。在经过反复尝试了多次后才实现这个效果。

首先想到要让尾巴长度固定,那么在每次render的时候,都在尾部渲染固定数量的粒子。那粒子的位置怎么判断呢?
在每次render的时候,我们往数组添加一个粒子,记录此时的Player坐标,当数组达到一定长度时,删除尾部粒子,添加新粒子。这样尾巴就记录了Player一个短时间内的各个时间点位置。看起来就像是"跟随"在Player粒子后面了。

//Player.js
render() {
    self.recordTail();
}

recordTail() {
    let self = this;
    //保持尾巴粒子个数不变
    if (self.tail.length > self.tailLen) {
        self.tail.splice(0, self.tail.length - self.tailLen);
    }
    self.tail.push({
        x: self.x,
        y: self.y
    });
}

这样只是记录了一些尾巴上点的位置,我们需要把各个点连起来。这里需要用到lineTo方法。

具体代码实现:

//Player.js
renderTail() {
    let self = this;
    let tails = self.tail, prevPot, nextPot;
    map.ctx.beginPath();
    map.ctx.lineWidth = 2;
    map.ctx.strokeStyle = self.color;

    for(let i = 0; i < tails.length - 1; i++) {
        prevPot = tails[i];
        nextPot = tails[i + 1];
        if (i === 0) {
            map.ctx.moveTo(prevPot.x, prevPot.y);
        } else {
            map.ctx.lineTo(nextPot.x, nextPot.y);
        }

        //保持尾巴最小长度,并有波浪效果
        prevPot.x -= 1.5;
        prevPot.y += 1.5;
    }

    map.ctx.stroke();

    self.renderLife();
}

如果只是连接各点,那只能画出Player划过的轨迹,我们还要给尾巴加上惯性效果,注意到上面有这两行代码

prevPot.x -= 1.5;
prevPot.y += 1.5;

每一次render中,让尾巴中的每个点x-1.5, y-1.5。实际上就是让粒子沿着左下方的方向运动,这跟Enemy粒子的方向是一致的。实现了尾巴惯性摆动的效果。

接下来就是添加尾巴上的生命点,这个就比较简单,只需在尾巴上间隔的某些点,画出圆形就可以了

//Player.js
//渲染生命值节点
renderLife() {
    let self = this;
    for(let j = 1; j <= self.livesPoint.length; j++) {
        let tailIndex = j * 5;
        let life = self.livesPoint[j - 1];
        life.render(self.tail[tailIndex]);
    }
}

//Life.js
render(pos) {
    let self = this;

    //粒子撞击后不渲染
    if (!this.dead) {
        map.ctx.beginPath();
        map.ctx.fillStyle = self.color;
        map.ctx.arc(pos.x, pos.y, 3, 0, 2 * Math.PI, false);
        map.ctx.fill();
    }
}

Skill粒子
Skill粒子实际上可以看做是Enemy中的一种特殊粒子,具有和Enemy一样的运动规律。代码中的Skill也是继承自Enemy的(这有点奇怪..)

Skill粒子具有不同的属性和颜色, 实现起来也很简单。

//Skill.js
const COLORS = {
    shield: '#007766',
    gravity: '#225599',
    time: '#665599',
    minimize: '#acac00',
    life: '#009955'
};
const TEXTS = {
    shield: '盾',
    gravity: '力',
    time: '慢',
    minimize: '小',
    life: '命'
};

render() {
    var self = this;

    map.ctx.beginPath();

    self.color = COLORS[self.type];

    map.ctx.fillStyle = self.color;
    map.ctx.arc(self.x, self.y, self.radius, 0, Math.PI*2, false);
    map.ctx.fill(); 
}

到此游戏中的角色都介绍完了,下一节要讲的是 《从零开始开发一款H5小游戏(四) 撞击吧粒子-炫酷技能的实现 》

手机调试利器 - 总结与实践

一些调试工具

说起手机端调试,相比大家都不陌生。

由于手机浏览器没有像PC端浏览器一样有开发调试工具,所以一般手机端的调试都要借助于电脑,现在的调试方式通常有以下几种。

  1. 直接在chrome, firefox等开启模拟器调试
    简单直接,还能模拟网络等,但是仍然无法100%还原手机的真实情况。
  2. 实现一套pc调试面板
    采用这种实现方式有weinre,weinre很早前就比较流行了,使用也比较广泛,运行后会在PC上生成一个像chrome开发工具一样的调试器。能对手机进行远程调试,能操作DOM,打印console输出等。
  3. 通过与远程服务器通信,传递打印消息
    比较流行的有jsconsole,它是在远程部署一个服务器,并生成一个具有唯一标识远程文件给本地调用,本地嵌入该文件后,会在页面上生成一个iframe。通过使用postMessage实现本地与远程调试器的通信。调试的时候可以在远程页面上打印console输出。
  4. 直接将调试信息输出在手机屏幕上
    这种实现方式的也比较多,如js-mobile-console,还有微信的vConsole
  5. 安装各种虚拟机sdk, 在电脑上进行手机调试。
  6. chrome上可以设置远程调试功能,手机使用数据线连接电脑。

优缺点分析

以上这些方法在开发中都尝试过了,各有各的优缺点。

  1. chrome模拟器最为方便,然而模拟器和真是机器还是经常有很多差别的,有时候模拟器运行正常,到真机上就懵逼了。
  2. weinre安装和开启会比较繁琐,PC和手机同时调试的时候需要关注两个调试面板,效率不是很高。
  3. jsconsole这种调试没有提供DOM的操作,只是单纯的进行log输出,然而实际使用中需要使用到DOM操作的比较少,大部分的工作都可以通过模拟器来完成,如果手机上显示稍有不同,只要更改代码,自动刷新查看效果就可以了,真正需要的功能是打印出手机上值。而个人认为jsconsole的缺点就是需要跟远程地址通信,打印速度受到一定影响,在需要测试scroll等的输出时会打印不及时。而且需要另外开启一个tab查看打印信息。
  4. 直接将信息输出到屏幕上应该是最简单粗暴的方法,但是需要在手机这么小的屏幕上打印信息,信息会挡住操作元素不说,就是查看复杂数据结构的log也很不方便。个人认为这种不太实用。
  5. 电脑上安装手机虚拟机就不多说了,虽能比较真实模拟手机,但是安装繁琐,操作不方便,无法模拟真实的手势操作。
  6. chrome的远程调试弊端也比较明显,导致使用的人并不多。首先是需要连接数据线,其次是设置比较繁琐,而且还限制了android手机。对于IOS的调试则可能要使用Safari的另一套工具。

一般开发中手机的远程调试不是强需求,除非遇到一些手机上的奇葩bug, 比如浏览器引擎对js的实现方式差异,需要打印真实数据,chrome模拟器都可以解决90%的问题。

但是每当遇到这种问题时,我还是会纠结到底使用哪个工具来做调试。原因很简单,我只是想把手机的信息打印到电脑浏览器上,不想打断PC端的调试,不想开启其他附属功能,仅此而已。因此我自己写了一个手机端打印的命令行工具,功能和实现都比较简单。

小而简单的工具 m-console

m-console 灵感来自livereload,livereload的实现应该是通过WebSocket来进行浏览器跟本地的通信。页面中引入一个客户端版本的livereload.js文件,当本地文件修改被watch进程捕获后,会通知livereload的WebSocket服务器,服务器通知客户端文件已更新,浏览器中引入的文件监听到这次更新,则调用window.location.reload实现浏览器刷新。

那么,显然我们能用Websocket来做远程调试,通知手机端通知浏览器打印log。

原理如下:

  1. 开启一个WebSocket作为服务端。
  2. 在浏览器中引入一个脚本用于连接服务端。
  3. 当判断在手机端访问时,重写console方法,发送log到服务端。
  4. 服务端接收到手机发来的消息,把消息广播给所有客户端。
  5. 客户端监听服务端,将消息打印出来。

具体实现可查看代码,该命令行工具有以下特点:

  1. 直接将信息打印到PC浏览器的调试工具的console面板,不必开启另外的打印页面。
  2. 支持所有console类型,支持js报错打印。
  3. 本地开启服务器,打印速度比较快。
  4. 使用简单,只需一个命令。

细说Unicode(一) Unicode初认识

网站开发中经常会被乱码问题困扰。知道文件编码错误会导致乱码,但对其中的原理却知之甚少。偶然从某篇文章了解了Unicode,发现从这条线出发也牵引出了一系列缺失的知识点。通过研读文章,基本了解了一些以前不明白的问题,所以整理了几篇,从几个角度介绍下Unicode, 并聊聊一些相关的问题。

roadmap.path

ASCII

上世纪60年代,美国人采用了一种编码来表示英语以及各种符号,该编码方式只有一个字节,能表示256(2^8)个字符。至今为止才定义了128个字符。包括33个控制字符和95个可显示字符,这些可显示字符涵盖了大小写英文字母和一些符号,这就是大名鼎鼎的ASCII编码

GB 2312

然而随着计算机的发展,各个国家的语言符号多不胜数,在**光中文字符就有7000多个,还不包括繁体中文,ASCII显然无法满足这么多字符编码需求。所以**人自己创造了一种字符编码,每个汉字和符号用两个字节来表示。第一个字节称为"高位字节",第二个字节称为"低位字节"。高位字节使用了0xA1 - 0xF7, "低位字节"使用了0xA1 - 0xFE。同时该编码方式兼容了ASCII的编码,对于小于127的字符即0x00 - 0x7F的字符予以保留。这种编码方式就是中文编码GB 2312

GBK

然而GB 2312能表示的文字也比较有限,对于一些人名,古汉语和繁体字也无能为力。所以我们改进了GB 2312的编码方式,扩展了GB 2312 中不使用的字节,使其同时包括了GB2312的所有内容,又新增了近20000个新的汉字,包括繁体字。该编码就是我们熟悉的GBK。后来由于又加了少数名族的文字,又推出了GB18030,用于取代GBK。而目前为止我们使用最广泛的中文编码还是GBK。

Unicode

再后来,由于不同的国家地区之间都使用不同的编码,导致计算机文件的读取都需要安装不同的解码软件。经常照成文件读取乱码。于是有一些组织决定制定出一个方案,通过统一的编码解决这个难题。于是其中一个团队发明了UCS编码,还有另一个团队发明了Unicode。后来两者达成一致,只发布一套字符集,那就是Unicode 。而UCS的码点将与Unicode保持一致。

Unicode最初规定用16位的编码空间,这16位编码空间称为统一码。这样理论上一共最多有2^16(65536)个字符。基本满足各种语言的使用。实际上当前版本的统一码并未完全使用这16位编码,而是保留了大量空间以作为特殊使用或将来扩展。

目前的Unicode字符分为17组编排,每组称为一个平面(Plane),而每平面拥有65536(即2^16)个码点。上述16位统一码字符称为基本多文种平面(BMP),写成16进制就是从U+0000到U+FFFF。 剩下还有16个辅助平面(SMP),码点范围从U+010000一直到U+10FFFF。这17个平面结合起来至少需要占据21位的空间(2^16 x 2^5),也就是差不多3个字节(24位),而辅助平面实际上是用4个字节表示,方便以后向后扩展。

上面讲到的几种编码都是编码方式,规定了从码点到字符的映射关系,例如 Unicode中U+0061 对应的就是小写字母 "a", 我们可以在浏览器控制台中输入码点查找对应的字符:
roadmap.path

UTF
Unicode的实现方式不同于编码方式。一个字符的Unicode编码是确定的。但是在实际传输过程中,由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对Unicode编码的实现方式有所不同。Unicode的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)。

网页开发中比较熟悉和常用的编码实现是UTF-8。那么这种实现方式有什么优势呢?UTF-8是一种变长的编码方法。字符长度从1字节到4字节不等。最前面的128个字符,只使用1个字节表示,延续了ASCII的用法。其他分段的字节数如下:

roadmap.path

计算机在读取数据的时候都是从高位到地位或从地位到高位。当计算机读到一个3字节字符时,怎么判断是输出1位字符,还是继续读取接下来的2位并合并为一个字符呢?这就要涉及到UTF-8的具体实现了。

UTF-8是这样做的:

  1. 单字节的字符,字节的第一位设为0,对于英语文本,UTF-8码只占用一个字节,和ASCII码完全相同;

  2. n个字节的字符(n>1),第一个字节的前n位设为1,第n+1位设为0,后面字节的前两位都设为10,这n个字节的其余空位填充该字符unicode码,高位用0补足。

这样就形成了如下的UTF-8标记位:

0xxxxxxx
110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

当读到第一位时,发现是0开头,就读一个字节。发现是110,就读两个字节,发现是1110就读三个字节,以此类推,再根据Unicode规则找到对应的符号输出。这种变长的编码方式,能根据字符采用不同位数的码点,能够有效减少文件的体积。

如果采用Unicode的编码方式直接作为实现方法。那么每个字符都是定长的码点,对于只需要一个字节的字符,需要在前面补0. 这样就照成了空间的浪费,文件就会变大。

UTF编码除了UTF-8,还有UTF-16:最小的码点为2个字节;UTF-32:每个码点固定用4个字节表示。由于UTF-32传输场进下会照成文件空间浪费,HTML5标准规定,网页不得编码成UTF-32。

关于Unicode的介绍就到这。UCS的相关知识,将在下一章结合JavaScript一起讲到。

参考文章:
https://zh.wikipedia.org/wiki
http://www.ruanyifeng.com/blog/2014/12/unicode.html
https://www.zhihu.com/question/23374078

Object.create 和 Object.assign 的区别?

以下内容引用自MDN

Object.create

Object.create() 方法使用指定的原型对象和其属性创建了一个新的对象。

语法

Object.create(proto, [ propertiesObject ])

参数

proto

一个对象,应该是新创建的对象的原型。

propertiesObject

可选。该参数对象是一组属性与值,该对象的属性名称将是新创建的对象的属性名称,值是属性描述符(这些属性描述符的结构与Object.defineProperties()的第二个参数一样)。注意:该参数对象不能是 undefined,另外只有该对象中自身拥有的可枚举的属性才有效,也就是说该对象的原型链上属性是无效的。

抛出异常

如果 proto 参数不是 null 或一个对象值,则抛出一个 TypeError 异常。

例子

使用Object.create实现类式继承

下面的例子演示了如何使用Object.create()来实现类式继承。这是一个单继承。

//Shape - superclass
function Shape() {
  this.x = 0;
  this.y = 0;
}

Shape.prototype.move = function(x, y) {
    this.x += x;
    this.y += y;
    console.info("Shape moved.");
};

// Rectangle - subclass
function Rectangle() {
  Shape.call(this); //call super constructor.
}

Rectangle.prototype = Object.create(Shape.prototype);

var rect = new Rectangle();

rect instanceof Rectangle //true.
rect instanceof Shape //true.

rect.move(1, 1); //Outputs, "Shape moved."

如果你希望能继承到多个对象,则可以使用混入的方式。

function MyClass() {
     SuperClass.call(this);
     OtherSuperClass.call(this);
}

MyClass.prototype = Object.create(SuperClass.prototype); //inherit
mixin(MyClass.prototype, OtherSuperClass.prototype); //mixin

MyClass.prototype.myMethod = function() {
     // do a thing
};

mixin函数会把超类原型上的函数拷贝到子类原型上,这里mixin函数没有给出,需要由你实现。一个和 mixin 很像的函数是 jQuery.extend。

使用Object.create 的 propertyObject 参数

var o;

// 创建一个原型为null的空对象
o = Object.create(null);


o = {};
// 以字面量方式创建的空对象就相当于:
o = Object.create(Object.prototype);


o = Object.create(Object.prototype, {
  // foo会成为所创建对象的数据属性
  foo: { writable:true, configurable:true, value: "hello" },
  // bar会成为所创建对象的访问器属性
  bar: {
    configurable: false,
    get: function() { return 10 },
    set: function(value) { console.log("Setting `o.bar` to", value) }
}})


function Constructor(){}
o = new Constructor();
// 上面的一句就相当于:
o = Object.create(Constructor.prototype);
// 当然,如果在Constructor函数中有一些初始化代码,Object.create不能执行那些代码


// 创建一个以另一个空对象为原型,且拥有一个属性p的对象
o = Object.create({}, { p: { value: 42 } })

// 省略了的属性特性默认为false,所以属性p是不可写,不可枚举,不可配置的:
o.p = 24
o.p
//42

o.q = 12
for (var prop in o) {
   console.log(prop)
}
//"q"

delete o.p
//false

//创建一个可写的,可枚举的,可配置的属性p
o2 = Object.create({}, { p: { value: 42, writable: true, enumerable: true, configurable: true } });

Polyfill

本polyfill的实现基于Object.prototype.hasOwnProperty。

if (typeof Object.create != 'function') {
  // Production steps of ECMA-262, Edition 5, 15.2.3.5
  // Reference: http://es5.github.io/#x15.2.3.5
  Object.create = (function() {
    //为了节省内存,使用一个共享的构造器
    function Temp() {}

    // 使用 Object.prototype.hasOwnProperty 更安全的引用 
    var hasOwn = Object.prototype.hasOwnProperty;

    return function (O) {
      // 1. 如果 O 不是 Object 或 null,抛出一个 TypeError 异常。
      if (typeof O != 'object') {
        throw TypeError('Object prototype may only be an Object or null');
      }

      // 2. 使创建的一个新的对象为 obj ,就和通过
      //    new Object() 表达式创建一个新对象一样,
      //    Object是标准内置的构造器名
      // 3. 设置 obj 的内部属性 [[Prototype]] 为 O。
      Temp.prototype = O;
      var obj = new Temp();
      Temp.prototype = null; // 不要保持一个 O 的杂散引用(a stray reference)...

      // 4. 如果存在参数 Properties ,而不是 undefined ,
      //    那么就把参数的自身属性添加到 obj 上,就像调用
      //    携带obj ,Properties两个参数的标准内置函数
      //    Object.defineProperties() 一样。
      if (arguments.length > 1) {
        // Object.defineProperties does ToObject on its first argument.
        var Properties = Object(arguments[1]);
        for (var prop in Properties) {
          if (hasOwn.call(Properties, prop)) {
            obj[prop] = Properties[prop];
          }
        }
      }

      // 5. 返回 obj
      return obj;
    };
  })();
}

Object.assign

Object.assign() 方法用于将所有可枚举的属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

语法

Object.assign(target, ...sources)

参数

target

目标对象。

sources

(多个)源对象。

返回值

目标对象。

描述

如果目标对象中的属性具有相同的键,则属性将被源中的属性覆盖。后来的源的属性将类似地覆盖早先的属性。

Object.assign 方法只会拷贝源对象自身的并且可枚举的属性到目标对象身上。该方法使用源对象的 [ [ Get ] ] 和目标对象的 [ [ Set ] ],所以它会调用相关 getter 和 setter。因此,它分配属性而不是复制或定义新的属性。如果合并源包含了 getter,那么该方法就不适合将新属性合并到原型里。假如是拷贝属性定义到原型里,包括它们的可枚举性,那么应该使用 Object.getOwnPropertyDescriptor() 和 Object.defineProperty() 。

String类型和 Symbol 类型的属性都会被拷贝。

注意,在属性拷贝过程中可能会产生异常,比如目标对象的某个只读属性和源对象的某个属性同名,这时该方法会抛出一个 TypeError 异常,拷贝过程中断,已经拷贝成功的属性不会受到影响,还未拷贝的属性将不会再被拷贝。

注意, Object.assign 会跳过那些值为 null 或 undefined 的源对象。

示例

复制一个 object

var obj = { a: 1 };
var copy = Object.assign({}, obj);
console.log(copy); // { a: 1 }

深度拷贝问题
针对深度拷贝,需要使用其他方法,因为 Object.assign() 拷贝的是属性值。假如源对象的属性值是一个指向对象的引用,它也只拷贝那个引用值。

function test() {
  let a = { b: {c:4} , d: { e: {f:1}} }
  let g = Object.assign({},a)
  let h = JSON.parse(JSON.stringify(a));
  console.log(g.d) // { e: { f: 1 } }
  g.d.e = 32
  console.log('g.d.e set to 32.') // g.d.e set to 32.
  console.log(g) // { b: { c: 4 }, d: { e: 32 } }
  console.log(a) // { b: { c: 4 }, d: { e: 32 } }
  console.log(h) // { b: { c: 4 }, d: { e: { f: 1 } } }
  h.d.e = 54
  console.log('h.d.e set to 54.') // h.d.e set to 54.
  console.log(g) // { b: { c: 4 }, d: { e: 32 } }
  console.log(a) // { b: { c: 4 }, d: { e: 32 } }
  console.log(h) // { b: { c: 4 }, d: { e: 54 } }
}
test();

合并 objects

var o1 = { a: 1 };
var o2 = { b: 2 };
var o3 = { c: 3 };

var obj = Object.assign(o1, o2, o3);
console.log(obj); // { a: 1, b: 2, c: 3 }
console.log(o1);  // { a: 1, b: 2, c: 3 }, 注意目标对象自身也会改变。
拷贝 symbol 类型的属性

var o1 = { a: 1 };
var o2 = { [Symbol("foo")]: 2 };

var obj = Object.assign({}, o1, o2);
console.log(obj); // { a: 1, [Symbol("foo")]: 2 }

继承属性和不可枚举属性是不能拷贝的

var obj = Object.create({foo: 1}, { // foo 是个继承属性。
    bar: {
        value: 2  // bar 是个不可枚举属性。
    },
    baz: {
        value: 3,
        enumerable: true  // baz 是个自身可枚举属性。
    }
});

var copy = Object.assign({}, obj);
console.log(copy); // { baz: 3 }

原始类型会被包装为 object

var v1 = "abc";
var v2 = true;
var v3 = 10;
var v4 = Symbol("foo")

var obj = Object.assign({}, v1, null, v2, undefined, v3, v4); 
// 原始类型会被包装,null 和 undefined 会被忽略。
// 注意,只有字符串的包装对象才可能有自身可枚举属性。
console.log(obj); // { "0": "a", "1": "b", "2": "c" }

异常会打断接下来的拷贝任务

var target = Object.defineProperty({}, "foo", {
    value: 1,
    writable: false
}); // target 的 foo 属性是个只读属性。

Object.assign(target, {bar: 2}, {foo2: 3, foo: 3, foo3: 3}, {baz: 4});
// TypeError: "foo" is read-only
// 注意这个异常是在拷贝第二个源对象的第二个属性时发生的。

console.log(target.bar);  // 2,说明第一个源对象拷贝成功了。
console.log(target.foo2); // 3,说明第二个源对象的第一个属性也拷贝成功了。
console.log(target.foo);  // 1,只读属性不能被覆盖,所以第二个源对象的第二个属性拷贝失败了。
console.log(target.foo3); // undefined,异常之后 assign 方法就退出了,第三个属性是不会被拷贝到的。
console.log(target.baz);  // undefined,第三个源对象更是不会被拷贝到的。

拷贝访问器(accessor)

var obj = {
  foo: 1,
  get bar() {
    return 2;
  }
};

var copy = Object.assign({}, obj); 
// { foo: 1, bar: 2 }
// copy.bar的值来自obj.bar的getter函数的返回值 
console.log(copy); 

// 下面这个函数会拷贝所有自有属性的属性描述符
function completeAssign(target, ...sources) {
  sources.forEach(source => {
    let descriptors = Object.keys(source).reduce((descriptors, key) => {
      descriptors[key] = Object.getOwnPropertyDescriptor(source, key);
      return descriptors;
    }, {});

    // Object.assign 默认也会拷贝可枚举的Symbols
    Object.getOwnPropertySymbols(source).forEach(sym => {
      let descriptor = Object.getOwnPropertyDescriptor(source, sym);
      if (descriptor.enumerable) {
        descriptors[sym] = descriptor;
      }
    });
    Object.defineProperties(target, descriptors);
  });
  return target;
}

var copy = completeAssign({}, obj);
// { foo:1, get bar() { return 2 } }
console.log(copy);

Polyfill

由于 ES5 里压根就没有 symbol 这种数据类型,所以这个 polyfill 也没必要去支持 symbol 属性(意思就是说,有 symbol 的环境一定有原生的 Object.assign):

if (typeof Object.assign != 'function') {
  Object.assign = function(target) {
    'use strict';
    if (target == null) {
      throw new TypeError('Cannot convert undefined or null to object');
    }

    target = Object(target);
    for (var index = 1; index < arguments.length; index++) {
      var source = arguments[index];
      if (source != null) {
        for (var key in source) {
          if (Object.prototype.hasOwnProperty.call(source, key)) {
            target[key] = source[key];
          }
        }
      }
    }
    return target;
  };
}

CustomEvent-传统DOM事件

CustomEvent-传统DOM事件

如今大多数前端框架都添加了对事件系统的支持,通常的做法是定义一个事件存储器,当时间绑定时,将事件以及对于的回调存储起来,触发事件的时候,从存储器取出回调执行,其简单实现代码如下:

var list = {}; //容器

function bind(event, fn) {
    list[event] = fn;
}
function trigger(event) {
    list[event]();
}

bind("event1", callback); //绑定
trigger("event1"); //触发

实现原理比较简单,但是一个健壮的事件系统往往就是一个框架的核心。DOM3中的事件系统除了我们熟知的UIEvent外,还定义了一个CustonEvent事件,用于自定义DOM的事件。

event

下面代码演示了怎么实现一个简单的CustomEvent。

// 添加事件监听
document.addEventListener("eventName", function(e) {
    console.log(e.detail);
});

// 创建事件
var event = new CustomEvent("eventName", {"detail":{"msg":true}});

//触发事件
document.dispatchEvent(event);

基于这样的设计,有利于把事件独立开来,不与DOM本身耦合在一起,有利于事件的分发,多个DOM可以共用同一个事件。

利用它我们也能够建造一个事件系统,不过其本质是基于DOM的监听,跟我们上面基于容器的事件系统相比,性能上是没有可比性的,在一个页面中添加过多的DOM事件是比较消耗性能的。

JavaScript中的赋值和参数传递是值复制还是引用复制?

基本数据类型 总是通过值复制的方式来赋值/传递,包括null,undefined,字符串,数字,布尔值和ES6中的symbol。

复杂数据类型 总是通过引用复制的方式来赋值/传递。即对象(包括数组和封装对象)和函数

例子:

var a = 2;
var b = a; //b是a的值的一个副本
b++;
a; //2
b; //3

var c = [1,2,3];
var d = c; //d是[1,2,3]的一个引用
d.push(4);
c; //[1,2,3,4]
d; //[1,2,3,4]

在引用复制的方式中,由于引用指向的是值本身而非变量,所以一个引用无法改变另一个引用的指向,什么意思呢?

var a = [1,2,3];
var b = a;
a; //[1,2,3]
b; //[1,2,3]

//然后
b = [4,5,6];
a; //[1,2,3]
b; //[4,5,6]

b = [4,5,6] 并不影响a指向值[1,2,3], 因为b指向的是数组的引用,而不是a的指针,所以并不会对a的指向值照成影响,只能通过操作值本身,比如调用push函数来改变a指向的值。

一工具让你的网站支持iOS13 Darkmode 模式

一工具让你的网站支持iOS13 Darkmode 模式

最近iOS13 发布了darkmode模式。虽然本人觉得次此功能呼声大于实际,但作为一个以用户体验为己任的前端,当然不能坐视不管,我们总该做点什么。

在进行了一番调研了解后,我们有几种支持darkmode模式的方法。

首先了解到一个叫 Darkmode.js 的工具,该工具通过配置一些通用的颜色,能让网站实现一键切换darkmode模式,简单方便。但缺点就是没办法实现比较细微的界面定制,只能在一些颜色上做处理。

如果你的设计师在设计稿上另外做了一版暗黑模式的,并且细节上除了颜色还有一些图片的修改,这时候可以用到css的媒体查询,对正常模式下的一些样式做覆盖。举个例子

.body {
	color: #000;
	background: url('normal.png');
}

在媒体查询中,我们覆盖需要修改的属性

/* b.css */
.body {
	color: #000;
	background: url('normal.png');
}

@media (prefers-color-scheme: dark) {
	.body {
		color: #fff;
		background: url('dark.png');
	}
}

在支持暗黑模式媒体查询的浏览器中,会自动渲染出media中的样式,实现样式覆盖。

该方法需要我们每次写一个样式都要在媒体查询中输入同样的选择器,如果选择器嵌套比较深或者当样式表比较长的时候,需要来回切换位置或文件,比较麻烦。基于这点,我开发了一个小工具,支持在正常样式的旁边,以注释的方式写上暗黑模式的样式,后续工具会自动编译成media的格式,无需用户手动编写。

举个例子,我们的代码可以这样写:

.body {
	color: #000;
	background: url('normal.png');
	/* dm[color: #fff;background:url('dark.png');] */
}

该文件最后会被编译成b.css中的格式。
如果每次都需要输入/* dm[] */会比较繁琐,你可以在IDE中创建一个snippet. 方便输入。

更多详细的用法,请移步至 css-plugin-darkmode

YAHOO网站加速最佳实践

80-90% 的终端用户响应时间花在下载页面组件,包括images, stylesheets, scripts, Flash, 等等.
1. 减少HTTP请求。
①文件合并压缩。
将js和css压缩为一个文件,在雀彩中根据业务逻辑模块划分压缩。避免加载所有逻辑代码。
解决方案:FIS,LESS
②CSS Sprites。
将背景图片做成雪碧图,减少http请求。用background-image 和 background-position 实现。
③Image maps。
④Inline images。
将图片用base64编码实现 data: URL scheme,缺点是会增加html代码的体积,可以将其放在stylesheet里解决此问题。

2. CDN内容分发网络。效果显著。

3.添加Expire头和Cache-Control头
①对静态文件添加“永不过期”
②对动态文件添加一定时间的Expire
4.Gzip

5.将css放在head,将js放在底部

6.避免css表达式

7.外部引用css和js文件

8.减少DNS查找

9.避免重定向
①写全链接,避免出现重定向。

10.避免重复加载代码

11.ajax缓存请求

12.耗时组件后加载

13.预加载
①如分页应用

14.优化DOM结构,减少个数
①document.getElementsByTagName('*').length (YAHOO 700)

javascript常见设计模式一览

Singleton(单例)模式
限制了类的实例化次数只能为一次

getInstance = function () {
  if (this._instance == null) {
    this._instance = new Singletance();
  }
  return this._instance;
}

Mediator(中介者)模式
Mediator模式促进松耦合的方式是确保组件的交互是通过这个中心点来处理的,
而不是通过显式地应用彼此,这种模式可以帮助我们解耦并提高组件的可重用性。
通常可理解为Observer的共享目标。或Event模块。

Command(命令)模式
封装一个execute方法统一执行命令。

Factory(工厂)模式
提供一个通用的接口来创建对象

function MotoFactory (option) {
  if(option.type === 'car') {
    this.product = Car;
  } else {
    this.product = Truck;
  }
  return new this.product;
}

抽象工厂:先抽象再细化

Mixin(混入)模式
用于函数复用

Decorator(装饰者)模式
多见于类扩展中,与Mixin类似,是另一个可行的对象子类化的替代方案。
jQuery.extend

Composite(组合)模式
Composite模式描述了一组对象,可以使用与处理对象的单个实例同样的方式来进行处理。
addClass()可以应用于单个item或组

$("#singleItem").addClass("active");
$(".item").addClass("active");

Adapter(适配器)模式
适配器模式讲对象或类的接口(Interface)转变为特定的系统兼容的接口。
jQuery.fn.css()方法,提供了标准化的接口,使我们能够使用简单的语法适配浏览器。

Facade(外观)模式
外观模式为更复杂的代码提供一个更简单的接口
jQuery.ajax 封装了get, post, getJSON, getScript等外观接口

Observer(观察者)模式
在系统中提供订阅和发布功能
jQuery.on, jQuery.off, jQuery.trigger

Iterator(迭代器)模式
迭代器顺序访问聚合对象的元素,无需公开其基本形式。
jQuery.each, 也被视为一种特殊的Facade

Proxy(代理)模式
控制对象访问权限和上下文。
jQuery.proxy()
如:

$("button").on("click", function () {
  //proxy将外层的this传递进去
  setTimeout($.proxy(function () {
    //获取到正确的上下文,避免变成window
    $(this).addClass("active");
  }, this), 500);
})

MutationObserver用法浅析

最近看了fex团队的一篇关于前端xss攻击的文章,感觉非常精彩。
里面对于MutationEvent的运用让人眼前一亮。所以顺便学习并记录了一下该事件的相关用法。
Mutation events 包括DOMNodeInserted事件,其用法如下:

document.addEventListener('DOMNodeInserted', function(e) {
    console.log('DOMNodeInserted:', e);
}, true);

var el = document.createElement('script');
el.src = 'http://www.xxx.com/xss/out.js?dynamic';
document.body.appendChild(el);

当程序动态往document添加script节点时,MutationEvent捕抓到了该DOMNodeInserted事件,并触发了回调,输出‘DOMNodeInserted:xxx’。

类似的事件还有:

  • DOMAttrModified
  • DOMAttributeNameChanged
  • DOMCharacterDataModified
  • DOMElementNameChanged
  • DOMNodeInserted
  • DOMNodeInsertedIntoDocument
  • DOMNodeRemoved
  • DOMNodeRemovedFromDocument
  • DOMSubtreeModified

但是使用MotationEvent有两个缺点:
一是会影响DOM操作的执行效率,可能会有高达1.5-7倍的延迟!而且移除监听器的操作,也会带来性能上的影响。
二是会存在跨浏览器兼容的问题。
对于第二点我们都知道是因为各浏览器厂商对API支持的不一致导致的,那为什么会存在第一个问题呢?个人认为有以下原因。
第一,对DOM节点的监听会消耗节点遍历的时间,而被监听DOM的分支越深,则所用时间越多。
第二,某些事件如DOMAttributeNameChanged and DOMElementNameChanged.等会储存节点信息,而当这些信息为SVG attribute或一些大的样式,其开销也会相当大。mutation将这些信息封装为一个信息量很大的objects并返回给回调函数,这些操作需要一定的计算消耗和内存损耗。

因为mutation event存在比较大的性能问题,它已经逐渐被淘汰了,而且官方建议最好不再使用。取而代之的是MutationObserver类,其构造函数的格式为:

MutationObserver(
     function callback
);

callback函数有两个参数,第一个参数为MutationRecord类型的对象,第二个为该MutationObserver的实例。每一个MutationObserver包含三个方法。

方法一, observe(Node target, MutationObserverInit options);
observe即为监听函数,传入两个参数:

void observe(
     Node target,     //设置监听的节点
     MutationObserverInit options     //设置监听的选项
);

一个最简单的监听是这样写的:

var observer = new MutationObserver(function(mutations) {
    console.log('MutationObserver:', mutations);
});
observer.observe(document, {
    subtree: true, //表示对target的后代也添加该监听
    childList: true //必选项,表示子节点(包括文字节点)的添加删除操作会被监听
});

当html中外部引入js时

<script src="http://www.xxx.com/xss/out.js"></script>

MutationObserver监听到了document中的静态元素变化。发现有新元素添加就会触发回调。当页面中载入<script src="http://www.xxx.com/xss/out.js"></script>时,会输出mutations对象。
方法二,disconnect()
取消节点的监听事件。
方法三,takeRecords()
清除MutationObserver事例的记录队列并返回该队列。
返回的类型为MutationRecords,也就是MutationObserver构造函数回调的第一个参数。
该object包括addedNodes,removedNodes等节点信息。
虽然运用这些方法虽然比较爽地解决一些问题,但是基于节点的遍历在性能上还是存在一定瓶颈,这在于这种观察者模式的内部实现,虽然MutationObserser已经做了优化,但是个人建议非必要时还是不要滥用。

参考:
1.https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Mutation_events
2.https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

gulp + webpack 构建多页面前端项目

gulp + webpack 构建多页面前端项目


之前在使用gulp和webpack对项目进行构建的时候遇到了一些问题,最终算是搭建了一套比较完整的解决方案,接下来这篇文章以一个实际项目为例子,讲解多页面项目中如何利用gulp和webpack进行工程化构建。本文是自己的实践经验,所以有些解决方案并不是最优的,仍在探索优化中。所以有什么错误疏漏请随时指出。

使用gulp过程中的一些问题,我已经在另外一篇文章讲到了 grunt or gulp

前言

现在为什么又整了一个webpack进来呢?

我们知道webpack近来都比较火,那他火的原因是什么,有什么特别屌的功能吗?带着这些疑问,继续看下去。

在使用gulp进行项目构建的时候,我们一开始的策略是将所有js打包为一个文件,所有css打包为一个文件。然后每个页面都将只加载一个js和一个css,也就是我们通常所说的 ==all in one== 打包模式。这样做的目的就是减少http请求。这个方案对于简单的前端项目来说的是一个万金油。因为通常页面依赖的js,css并不会太大,通过压缩和gzip等方法更加减小了文件的体积。在项目最开始的一段时间内(几个月甚至更长),一个前端团队都能通过这种办法达到以不变应万变的效果。

然而,作为一个有追求(爱折腾)的前端,难道就满足于此吗?

妈妈说我不仅要请求合并,还要按需加载,我要模块化开发,还要自动监听文件更新,支持图片自动合并....

等等!你真的需要这些功能吗?是项目真的遇到了性能问题?不然你整这些干嘛?

对于pc端应用来说,性能往往不是最突出的问题,因为pc端的网速,浏览器性能都有比较好,所以很长一段时间我们要考虑的是开发效率的问题而不是性能问题,得在前端框架的选型上下功夫。至于加载文件的大小或文件个数,都难以形成性能瓶颈。

对于wap端来说,限制于手机的慢网速(仍然有很多用不上4g,wifi的人),对网站的性能要求就比较苛刻了,这时候就不仅仅要考虑开发效率的问题了。(移动网络的性能问题可参考《web性能权威指南》)

在《高性能网站建设进阶指南》中也讲到:不要过早地考虑网站的性能问题。

这点我有不一样的看法。如果我们在项目搭建的时候就能考虑得多一点,把基本能做的先做了。所花的成本绝对比以后去重构代码的成本要低很多,而且我们能够同时保证开发效率和网站性能,何乐而不为呢。

问题

竟然要做,那要做到什么程度呢,往往“度”是最难把握的东西。

以前在做wap网站的时候,遇到的最大的问题按需加载和请求合并的权衡。
通过纯前端的方法不能同时满足请求合并和按需加载,这里面的原理和难点已经有大牛讲得很清楚了 前端工程与模块化框架

实现的方法归纳起来主要有以下步骤:

  1. 通过工具分析出前端静态文件依赖表
  2. 页面通过模块化工具加载入口文件,并将所依赖的所有文件合并为combo请求。
  3. 后端返回combo文件,浏览器将模块缓存起来,跳页面的时候执行步骤2,只请求没有缓存过的文件。

如此通过依赖分析和后端combo实现了按需加载和请求合并。

这种实现方式的缺陷就是需要后端的支持,如果前端团队本身不是自己实现的后端路由层,需要后端同学加以配合,就需要更多沟通成本。

在没有后端支持的情况下,怎么比较好地实现按需加载和请求合并,我们用webpack做了尝试。

webpack的使用

webpack可以说是一个大而全的前端构建工具。它实现了模块化开发和静态文件处理两大问题。

以往我们要在项目中支持模块化开发,需要引入requirejs,seajs等模块加载框架。而webpack天生支持AMD,CommonJS, ES6 module等模块规范。不用思考加载器的选型,可以直接像写nodejs一样写模块。
而webpack这种万物皆模块的**好像就是为React而生的,在React组件中可以直接引入css或图片,而做到这一切只需要一个require语句和loader的配置。

webpack的功能之多和繁杂的配置项会让初学者感到眼花缭乱,网上的很多资料也是只介绍功能不教人实用技巧。这里有一篇文章就讲解了webpack开发的workflow, 虽然该教程是基于React的,但是比较完整地讲了webpack的开发流程。下面我也用一个实例讲解使用中遇到的问题和解决方案。

我们的项目是一个多页面项目,即每个页面为一个html,访问不同的页面需要跳转链接。
项目目录结构大概是这样的,app放html文件,css为样式文件,images存放图片,js下有不同的文件夹,里面的子文件夹为一些核心文件和一些库文件,ui组件。js的根目录为页面入口文件。

├── app
│   ├── header.inc
│   ├── help-charge.inc
│   ├── index.html
│   ├── news-detail.html
│   └── news-list.html
├── css
│   ├── icon.less
│   └── slider.css
├── images
└── js
    ├── core
    ├── lib
    ├── ui
    ├── news-detail.js
    ├── news-list.js
    └── main.js

该项目中我们只用webpack处理js文件的合并压缩。其他任务交给gulp。关于多页面项目和单页面项目中js处理的差异请看这里

配置文件如下:
module.exports = {
    devtool: "source-map",  //生成sourcemap,便于开发调试
    entry: getEntry(),      //获取项目入口js文件
    output: {
        path: path.join(__dirname, "dist/js/"), //文件输出目录
        publicPath: "dist/js/",     //用于配置文件发布路径,如CDN或本地服务器
        filename: "[name].js",      //根据入口文件输出的对应多个文件名
    },
    module: {
        //各种加载器,即让各种文件格式可用require引用
        loaders: [
            // { test: /\.css$/, loader: "style-loader!css-loader"},
            // { test: /\.less$/, loader: "style-loader!csss-loader!less-loader"}
        ]
    },
    resolve: {
        //配置别名,在项目中可缩减引用路径
        alias: {
            jquery: srcDir + "/js/lib/jquery.min.js",
            core: srcDir + "/js/core",
            ui: srcDir + "/js/ui"
        }
    },
    plugins: [
        //提供全局的变量,在模块中使用无需用require引入
        new webpack.ProvidePlugin({
            jQuery: "jquery",
            $: "jquery",
            // nie: "nie"
        }),
        //将公共代码抽离出来合并为一个文件
        new CommonsChunkPlugin('common.js'),
        //js文件的压缩
        new uglifyJsPlugin({
            compress: {
                warnings: false
            }
        })
    ]
};

配置项参考文档

打包思路:

该配置方案的思路是每个页面一个入口文件,文件中可以通过require引入其他模块,而这些模块webpack会自动跟入口文件合并为一个文件。通过getEntry获取入口文件:

function getEntry() {
    var jsPath = path.resolve(srcDir, 'js');
    var dirs = fs.readdirSync(jsPath);
    var matchs = [], files = {};
    dirs.forEach(function (item) {
        matchs = item.match(/(.+)\.js$/);
        if (matchs) {
            files[matchs[1]] = path.resolve(srcDir, 'js', item);
        }
    });
    return files;
}

该方法将生成文件名到文件绝对路径的map, 比如

entry:{
    news-detail: /../Document/project/.../news-detail.js
}

然后output就会在output.path路径下生成[name].js,即news-detail.js,文件名保持相同。

module 的作用是添加loaders, 那loaders有什么作用呢?
如果我们想要在js文件中通过require引入模块,比如css或image,那么就需要在这里配置加载器,这一点对于React来说相当方便,因为可以在组件中使用模块化CSS。而一般的项目中可以不用到这个加载器。

resolve 中的alias可以用于定义别名,用过seajs等模块工具的都知道alias的作用,比如我们在这里定义了ui这个别名,那么我们在模块中想引用ui目录下的文件,就可以直接这样写

require('ui/dialog.js');

不用加上前面的更长的文件路径。

plugin 用于引入一些插件,常见的有 这些
我们这里使用了CommonsChunkPlugin用于生成公用代码,不只可以生成一个,还能根据不同页面的文件关系,自由生成多个,例如:

var CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");
module.exports = {
    entry: {
        p1: "./page1",
        p2: "./page2",
        p3: "./page3",
        ap1: "./admin/page1",
        ap2: "./admin/page2"
    },
    output: {
        filename: "[name].js"
    },
    plugins: [
        new CommonsChunkPlugin("admin-commons.js", ["ap1", "ap2"]),
        new CommonsChunkPlugin("commons.js", ["p1", "p2", "admin-commons.js"])
    ]
};
// 在不同页面用<script>标签引入如下js:
// page1.html: commons.js, p1.js
// page2.html: commons.js, p2.js
// page3.html: p3.js
// admin-page1.html: commons.js, admin-commons.js, ap1.js
// admin-page2.html: commons.js, admin-commons.js, ap2.js

这种用法有点像gulp或grunt中手动将多个js合并为common, 但是在webpack里,这个过程是全自动生成的,不用我们自己分析代码的依赖关系。
另外一个插件是uglifyJsPlugin,用于压缩js代码。

我们还用到一个字段是 devtool, 用于配置开发工具。‘source-map’就是在生成的代码中加入sourceMap的支持。能够直接定位到出错代码的具体位置,对sourcemap的使用和原理还不了解的可以看下这篇文章
另外,devtool的配置参数使用在这里

如何加载第三方库?

在pc开发中我们通常会用到jQuery库。如何很好地处理这类文件呢?这里有两种办法。

方法一 是在html中用script标签引入js文件,如

<script src="https://code.jquery.com/jquery-git2.min.js"></script>

然后再配置文件中添加externals

externals: { jquery: "jQuery" }

该字段的作用是将加jQuery全局变量变为模块可引入。然后在各个模块中,就可以如下使用:

var $ = require("jquery");

然而我个人觉得既然已经将加jQuery通过script引入了,那么就直接使用$标签就行了。不必再将其转化为模块。

方法二 是将jQuery代码保存到本地,在配置文件中添加:

resolve: { alias: { jquery: "/path/to/jquery-git2.min.js" } }

即为jquery添加了别名,然后在模块中也是这样使用:

var $ = require("jquery");

还可以配合使用ProvidePlugin,其作用是提供全局变量给每个模块,这样就不需要在模块中通过require引入,例如:
使用前:

var _ = require("underscore");
_.size(...);

使用后:

plugins: [
  new webpack.ProvidePlugin({
    "_": "underscore"
  })
]

// If you use "_", underscore is automatically required
_.size(...)

总的来说,如果文件来自CDN,那么使用方法一,如果文件在本地,则用方法二。

如何启动服务器?

首先肯定要安装webpack-dev-server,安装方法自行脑补。

接着在webpack.config.js中添加配置

entry: [
    'webpack-dev-server/client?http://0.0.0.0:9090',//资源服务器地址
    'webpack/hot/only-dev-server',
    './static/js/entry.js'
]

output的发布路径改为本地服务器

output: {
    publicPath: "http://127.0.0.1:9090/static/dist/",
    path: './static/dist/',
    filename: "bundle.js"
}

在plugin中添加

new webpack.HotModuleReplacementPlugin()

html中通过资源服务器的绝对路径引入js

<script src="http://127.0.0.1:9090/static/dist/bundle.js"></script>

最后通过命令行启动

$ webpack-dev-server --hot --inline

配置参数的解释在这里

由于webpack服务器配置比较繁琐,所以我们的项目还是采用gulp来启动本地服务器...

gulp足够优秀

目前来说,我们只利用webpack进行了js方面的打包,其他功能用gulp就足够了。gulp主要做了下面几个工作:

  • css转化合并压缩
  • 图片的雪碧图合并和base64
  • 文件md5计算与替换
  • 热启动,浏览器自动刷新

下列是依赖的npm模块:

  "devDependencies": {
    "gulp": "^3.8.10",
    "gulp-clean": "0.3.1",
    "gulp-concat": "2.6.0",
    "gulp-connect": "2.2.0",
    "gulp-css-base64": "^1.3.2",
    "gulp-css-spriter": "^0.3.3",
    "gulp-cssmin": "0.1.7",
    "gulp-file-include": "0.13.7",
    "gulp-less": "3.0.3",
    "gulp-md5-plus": "0.1.8",
    "gulp-open": "1.0.0",
    "gulp-uglify": "1.4.2",
    "gulp-util": "~2.2.9",
    "gulp-watch": "4.1.0",
    "webpack": "~1.0.0-beta6"
  },

支持雪碧图合并和base64
我对gulp-css-spriter和gulp-css-base64的源码做了一点修改,使其支持下面的语法:

.icon_corner_new{
    background-image: url(../images/new-ico.png?__sprite);
}

如果在url的后面加上__sprite后缀,则插件将会把该图片合并到雪碧图里。可以支持一个css文件合并为一个雪碧图,也可以整站合并。

.icon_corner_new{
    background-image: url(../images/new-ico.png?__inline);
}

如果加上后缀__inline,则会将图片转化为base64,直接添加到css文件中,对于几k的小文件可以直接使用inline操作。具体配置代码如下:

gulp.task('sprite', function (done) {
    var timestamp = +new Date();
    gulp.src('dist/css/style.min.css')
        .pipe(spriter({
            spriteSheet: 'dist/images/spritesheet' + timestamp + '.png',
            pathToSpriteSheetFromCSS: '../images/spritesheet' + timestamp + '.png',
            spritesmithOptions: {
                padding: 10
            }
        }))
        .pipe(base64())
        // .pipe(cssmin())
        .pipe(gulp.dest('dist/css'))
        .on('end', done);
});

src为需要处理的css文件,spriteSheet为雪碧图生成的目标文件夹,pathToSpriteSheetFromCSS为css文件中url的替换字符串,spritesmithOptions是生成雪碧图的间隙。

文件加md5, 实现发布更新
发版本的时候为了避免浏览器读取了旧的缓存文件,需要为其添加md5戳。
这里采用了gulp-md5-plus

gulp.task('md5:js', function (done) {
    gulp.src('dist/js/*.js')
        .pipe(md5(10, 'dist/app/*.html'))
        .pipe(gulp.dest('dist/js'))
        .on('end', done);
});

该代码会将dist/js下面所有的js计算md5戳,并将dist/app/下的html中script中的src引用文件名替换为加了md5的文件名,再将md5文件替换到目标目录dist/js。css的md5操作跟js无异。

关于服务器启动和代码转换的功能点,这里就不展开讲了。

总结

该方案总结下来做了下面几件事:

  1. 将css直接合并为一个文件,在head中通过link标签引入,提高网页渲染速度。
  2. 将js打包为不同的入口文件,并自动合并依赖关系。将跨页面的公用代码抽离为独立文件,益于浏览器缓存。
  3. 增加图片雪碧图,base64的支持,开发者可以手动配置__sprite和__inline,灵活性较高。
  4. 静态文件md5打包,并自动更改html引用路径,方便发布。
  5. 提供开发调试所需要的环境,包括热启动,浏览器自动刷新,sourceMap。

该方案之所以针对多页面应用,区别在于对js和css的处理方式。在单页面应用中,通过哈希跳转来实现静态文件的异步加载,打包策略又有所不同。但webpack中已经提供了处理异步加载的接口require.ensure,可以发挥无穷的力量。

Cookie 知多少

问题由来

Web服务器可能会同时与数千个不同的客户端进行对话,这些服务器通常要记录下它们与谁交流,而不会认为所有的请求都来自匿名的客户端,那有哪些技巧可以让服务器识别到不同的客户端呢。

我们知道HTTP是一个无连接,无状态的请求/响应协议。
【无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。HTTP协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就较快。】
Web服务器几乎没有什么信息可以用来判定是哪个用户发送的请求,也无法记录来访用户的请求序列。

解决方案

  • 承载用户身份的HTTP首部。
  • 客户端IP地址跟踪,通过用户的IP地址对其进行识别。
  • 用户登录,用认证方式来识别用户。
  • 胖URL,一种在URL中嵌入识别信息的技术。
  • cookie,一种功能强大且高效的持久身份识别技术。

下面主要就cookie的实现方式展开,如果对上述方法也有兴趣的可以参考《HTTP权威指南》

cookie

1. cookie的分类类
cookie分为会话cookie持久cookie。会话cookie是一种临时的cookie,它记录了用户访问站点时的设置和偏好。用户退出浏览器时,会话cookie就被删除了,持久cookie的生存时间更长一些;它们存储在硬盘上。通常会用持久cookie来维护某个用户周期性访问站点的登录信息。

会话cookie和持久cookie之间唯一的区别就是他们的过期时间。如果设置了Discard参数,或者没有设置Expires或Max-Age参数来说明扩展的过期时间,这个cookie就是一个会话cookie。

2. cookie工作原理
用户首次访问Web站点是,Web服务器对用户一无所知。服务器返回信息给用户时,就包含了一个cookie,用户下次访问站点时,就会携带此cookie,这时候Web服务器就能识别此客户端了。
cookie中包含了一个由name=value信息构成的任意列表,并通过Set-Cookie或Set-Cookie2 HTTP响应首部将其贴到用户身上去。
roadmap.path
cookie中可以包含任意信息,它们通常都只包含一个服务器为了进行跟踪而产生的独特识别码,比如id="34294"。服务器可以用这个数字来查找服务器为其访问者积累的数据库信息(用户购物地址,地址信息等)。

浏览器会记住从服务器返回的Set-Cookie或Set-Cookie2首部中的cookie内容,并将cookie集存储在浏览器的cookie数据库中。并在下次访问时在一个cookie请求首部中将其传出去。

3. 几个重要的cookie属性
cookie的域属性---domain
产生cookie的服务器可以像Set-Cookie响应首部添加一个Domain属性来控制哪些站点可以看到那个cookie。

cookie的路径属性---path
通过Path属性可以讲cookie与部分Web站点关联起来。例如,某个Web服务器可能由两个组织共享的,每个组织都有独立的cookie。比如站点www.airtravel.com可能会将部分Web站点用于汽车租赁--比如,www.airtravel.com/autos/用一个独立的cookie来记录用户喜欢的汽车尺寸,可能会生成一个如下所示的特殊汽车租赁cookie:

Set-cookie:pref=compact;domain="airtravel.com";path=/autos/

如果用户访问www.airtravel.com/specials.html,就只会获得这个cookie:

Cookie:user="mary17"

但如果访问www.airtravel.com/autos/cheapo/index.html,就会获得这两个cookie:

Cookie: user="mary17"
Cookie: pref="compact"

4. Cookie的版本
cookie规范有两个不同的版本,cookie版本0(有时候被称为Netscape cookies)和cookies版本1(RFC 2965)。cookie版本1是对cookies版本0的扩展,应用不如后者广泛。
cookie版本0(Netscape)
最初的cookie规范是由网景公司定义的。这些"版本0"的cookie定义了Set-Cookie响应首部,cookie请求首部以及用于控制cookie的字段。版本0的cookie看起来如下所示:
Set-Cookie:name=value[;expires=date][;path=path][;domain=domain][;secure]
Cookie:name1=value1[;name2=value2]...

cookie版本1(RFC 2965)
RFC 2965定义了一个cookie的扩展版本。这个版本1标准引入了Set-Cookie2首部和Cookie2首部,但它也能与版本0系统进行互操作。

RFC2965Cookie的主要改动包括下列内容:

  • 为每个Cookie关联上解释性文本,对其目的进行解释。
  • 允许在浏览器退出时,不考虑过期时间,将Cookie强制销毁。
  • 用相对秒数,而不是绝对日期来表示Cookie的Max-Age。
  • 通过URL端口号,而不仅仅是域和路径来控制Cookie的能力。
  • 通过Cookie首部回送域,端口和路径过滤器。
  • 为实现互操作性使用的版本号。
  • 在Cookie首部从名字中区分出附加关键字的$前缀。

如果客户端从同一个响应中既获得了Set-Cookie首部,又获得了Set-Cookie2首部,就会忽略老的Set-Cookie首部。如果客户端既支持版本0又支持版本1的cookie,但从服务器获得的是版本0的Set-Cookie首部,就会带着版本0的Set-Cookie首部发送cookie。但客户端还应该发送Cookie2:$Version="1"来告知服务器它是可以升级的。

Cookie的安全性
cookie被用来做的最多的一件事就是保持身份认证的服务端状态。这种保持可能是基于会话的(Session),也有可能是持久性的。所以cookie中包含的服务端信息一旦泄露,那么就有可能造成用户信息被盗的危险。为了安全考虑,通常我们会禁止js直接对Cookie进行操作。通过给Cookie加上HttpOnly标签。浏览器的document就看不到Cookie了。能有效防止简单的页面XSS攻击。

对已Cookie的安全防范包括与Session的配合使用将会在后面再做总结。

JSON.stringify 和 toString 有什么区别?

  1. 字符串,数字,布尔值的JSON.stringify(..)规则和ToString基本相同。
  2. 如果传递给JSON.stringify(..)的对象中定义了toJSON()方法,那么该方法会在字符串化前调用,以便将对象转换为安全的JSON值。

null 和 undefined 值没有toString方法。直接调用会报错,在不知道要转换的值是不是null或undefined的情况下,可以使用转型函数String()。这个函数能够将任何类型的值转换为字符串。String()函数遵循下列转换规则:

  1. 如果值有toString()方法,则调用该方法并返回相应结果;
  2. 如果值时null, 则返回"null";
  3. 如果值时undefined,则返回"undefined"。

在调用 数值的 toString()方法时,可以传递一个参数:输出数值的基数,默认采用的是10进制

var num = 10;
num.toString(); //"10"
num.toString(2); //"1010"
num.toString(8); //"12"
num.toString(10); //"10"
num.toString(16); //"a"

对普通对象来说,除非自行定义,否则toString() (Object.prototype.toString()) 返回内部属性[[Class]]的值,如'[object Object]', '[object Array]'。

所有安全的JSON值(JSON-safe)都可以使用JSON.stringify(..)字符串化。
不安全的JSON值包括undefined,function,symbol和包含循环引用的对象都不符合JSON结构标准,支持JSON的语言无法处理它们。

JSON.stringify(..)对象中 遇到undefined、function和symbol时会自动将其忽略,在数值中出现则会返回null(以保证单元位置不变)。

如果对象中定义了toJSON()方法,JSON字符串化时会首先调用该方法,然后用它的返回值来进行序列化。
toJSON() 应该"返回一个能够被字符串化的安全的JSON值",而不是"返回一个JSON字符串"。

JSON.stringify(..) 提供了replacer,和space参数。用来置顶对象序列化过程中那些属性应该被处理,哪些属性被忽略,并且支持space的缩进格式。

Javascript Scoping and Hoisting

引子

首先大家看一下下面的代码,猜猜会输出什么结果?

var foo = 1;
function bar() {
    if (!foo) {
        var foo = 10;
    }
    alert(foo);
}
bar();

答案是10!
你是否会疑惑条件语句if(!foo)并不会执行,为什么foo会被赋值为10

再来看第二个例子

var a = 1;
function b() {
    a = 10;
    return;
    function a() {}
}
b();
alert(a);

答案还是10吗?显然不是,alert输出了1

如果你仍然对上面两个输出结果摸不着头脑,那么请认真阅读这篇文章

Scoping in Javascript

Javascript的作用域已经是老生常谈的问题了,但是不一定每个人都能准确理解。
我们先来看一下C语言的一个例子:

#include <stdio.h>
int main() {
    int x = 1;
    printf("%d, ", x); // 1
    if (1) {
        int x = 2;
        printf("%d, ", x); // 2
    }
    printf("%d\n", x); // 1
}

程序依次输出了1,2,1
为什么第三个输出了1而不是2呢?因为在C语言中,我们有块级作用域(block-level scope)。在一个代码块的中变量并不会覆盖掉代码块外面的变量。我们不妨试一下Javascript中的表现

var x = 1;
console.log(x); // 1
if (true) {
    var x = 2;
    console.log(x); // 2
}
console.log(x); // 2

输出的结果为1,2,2 if代码块中的变量覆盖了全局变量。那是因为JavaScript是一种函数级作用域(function-level scope)所以if中并没有独立维护一个scope,变量x影响到了全局变量x

C,C++,C#和Java都是块级作用域语言,那么在Javascript中,我们怎么实现一种类似块级作用域的效果呢?答案是闭包

function foo() {
    var x = 1;
    if (x) {
        (function () {
            var x = 2;
            // some other code
        }());
    }
    // x is still 1.
}

上面代码在if条件块中创建了一个闭包,它是一个立即执行函数,所以相当于我们又创建了一个函数作用域,所以内部的x并不会对外部产生影响。

Hoisting in Javascript

在Javascript中,变量进入一个作用域可以通过下面四种方式:

  1. 语言自定义变量:所有的作用域中都存在this和arguments这两个默认变量
  2. 函数形参:函数的形参存在函数作用域中
  3. 函数声明:function foo() {}
  4. 变量定义:var foo

其中,_在代码运行前,函数声明和变量定义通常会被解释器移动到其所在作用域的最顶部_,如何理解这句话呢?

function foo() {
    bar();
    var x = 1;
}

上面这段在吗,被代码解释器编译完后,将变成下面的形式:

function foo() {
    var x;
    bar();
    x = 1;
}

我们注意到,x变量的定义被移动到函数的最顶部。然后在bar()后,再对其进行赋值。
再来看一个例子,下面两段代码其实是等价的:

function foo() {
    if (false) {
        var x = 1;
    }
    return;
    var y = 1;
}
function foo() {
    var x, y;
    if (false) {
        x = 1;
    }
    return;
    y = 1;
}

所以变量的上升(Hoisting)只是其定义上升,而变量的赋值并不会上升。

我们都知道,创建一个函数的方法有两种,一种是通过函数声明function foo(){}
另一种是通过定义一个变量var foo = function(){}那这两种在代码执行上有什么区别呢?

来看下面的例子:

function test() {
    foo(); // TypeError "foo is not a function"
    bar(); // "this will run!"
    var foo = function () { // function expression assigned to local variable 'foo'
        alert("this won't run!");
    }
    function bar() { // function declaration, given the name 'bar'
        alert("this will run!");
    }
}
test();

在这个例子中,foo()调用的时候报错了,而bar能够正常调用
我们前面说过变量会上升,所以var foo首先会上升到函数体顶部,然而此时的fooundefined,所以执行报错。而对于函数bar, 函数本身也是一种变量,所以也存在变量上升的现象,但是它是上升了整个函数,所以bar()才能够顺利执行。

再回到一开始我们提出的两个例子,能理解其输出原理了吗?

var foo = 1;
function bar() {
    if (!foo) {
        var foo = 10;
    }
    alert(foo);
}
bar();

其实就是:

var foo = 1;
function bar() {
    var foo;
    if (!foo) {
        foo = 10;
    }
    alert(foo);
}
bar();
var a = 1;
function b() {
    a = 10;
    return;
    function a() {}
}
b();
alert(a);

其实就是:

var a = 1;
function b() {
    function a() {}
    a = 10;
    return;
}
b();
alert(a);

这就是为什么,我们写代码的时候,变量定义总要写在最前面。

ES6有何区别

在ES6中,存在let关键字,它声明的变量同样存在块级作用域。
而且函数本身的作用域,只存在其所在的块级作用域之内,例如:

function f() { console.log('I am outside!'); }
if(true) {
   // 重复声明一次函数f
   function f() { console.log('I am inside!'); }
}
f();

上面这段代码在ES5中的输出结果为I am inside!因为f被条件语句中的f上升覆盖了。
在ES6中的输出是I am outside!块级中定义的函数不会影响外部。

如果对let的使用,或ES6的其他新特性感兴趣,请自行阅读ES6文档。

JavaScript怎么进行类型判断?

JavaScript有7种内置类型:

roadmap.path

typeof
typeof 运算符用来查看 内置类型,它返回的是类型的字符串值,但是这七中类型和它们的字符串值并不一一对应。

其中
typeof null == "object" 而不是 "null", 这可以简单理解为JavaScript的一个陈年bug。另外还有一个"function"类型,但是实际上function不是一种内置类型,它只是object的一个子类型。可能为了方便而引入的一个feature。实际使用中typeof的功能还是很鸡肋的。

roadmap.path

instanceof
instanceof 用来判断 变量引用类型,引用类型通常是通过new一个构造函数生成的,如new String(),new Array()...

roadmap.path

如果你用typeof判断由上面引用类型new出来的变量,将统一得到"object"。因为这些引用类型的基本类型都是继承自Object。

var arr = new Array();
arr instanceof Array; //true
arr instanceof Object; //true
typeof arr; //"object"
"abc" instanceof String; //false

所以instanceof是用来判断某个变量是否为构造函数的实例。通常我们需要自己封装一个方法来判断我们最终的值是什么类型的,并在业务中做进一步操作。下面是一种封装的方法。

Object.prototype.toString
Object 引用对象内置了toString方法,不管对于基本类型值还是构造函数生成的引用类型值,都能正确地返回其类型,我们可以这么来封装一个对象判断方法。

function isType(type) {
  return function(obj) {
    return Object.prototype.toString.call(obj) == "[object " + type + "]"
  }
}

var isObject = isType("Object")
var isString = isType("String")
var isArray = Array.isArray || isType("Array")
var isFunction = isType("Function")
var isUndefined = isType("Undefined")

0.1 + 0.2 == 0.3 ?

JavaScript采用的是IEEE 754规范的二进制浮点数计算方法,由于精度问题。导致0.1+0.2不等于0.3。

解决办法:设置一个误差范围值,通常称为"机器精度",对JavaScript的数字来说,这个值通常是2^-52

在ES6中,该精度值定义在Number.EPSILON中。

if (!Number.EPSILON) {
	Number.EPSILON = Math.pow(2, -52);
}

function numbersCloseEnoughToEqual(n1, n2) {
	return Math.abs(n1 - n2) < Number.EPSILON;
}

var a = 0.1 + 0.2;
var b = 0.3;

numbersCloseEnoughToEqual(a, b); //true
numbersCloseEnoughToEqual(0.0000001, 0.0000002); //false

从零开始开发一款H5小游戏(四) 撞击吧粒子,炫酷技能的实现

本游戏有五种技能粒子,分别是 "护盾","重力场","时间变慢","使敌人变小","增加生命"。Player粒子吃了技能粒子后就能表现各种特殊效果。

碰撞检测

游戏中Player粒子可能会撞击到Enemy粒子,也可能吃到Skill粒子。我们怎么来判断呢?画布中两个粒子的碰撞检测其实很简单,如果是圆形粒子,只需要判断两个粒子圆心的距离是否小于两个圆半径之和就行了。

//index.js
function collision(enemy, player) {
    const disX = player.x - enemy.x;
    const disY = player.y - enemy.y;

    return Math.hypot(disX, disY) < (player.radius + enemy.radius);
}

撞击敌人

roadmap.path

撞击后Enemy粒子尾巴上的生命点会减一,并且Player身体出现闪烁,接着会有蓝色粒子爆炸的效果。

前面我们已经讲过尾巴上的生命点如何实现,这时候只需要将生命点值livesPoint减一就可以了。

Player的闪烁怎么实现呢?如果将这个过程拆解一下,其实闪烁效果就是在一段时间内,Player的颜色不断随机地做蓝白变化。这里只要控制两个变量,闪烁时间和闪烁颜色。

collision检测到碰撞的时候,会调用一个flash方法。这个方法有两个作用,一是控制闪烁的时间,通过flashing, 判断是否渲染闪烁效果。二是当时间结束后,我们需要重置Player的颜色为默认的蓝色。

//Player.js
flash() {
    let self = this;

    self.flashing = true;
    let timeout = setTimeout(function() {
        self.flashing = false;
        self.color = BODYCOLOR;
        clearTimeout(timeout);
    }, 500);
}

在整个Player的render方法中, 如果flashing标记为true,则控制Player的颜色在两个随机值间切换。这样每次render调用所产生的颜色就有所不同,实现随机闪烁的效果。

render() {
    //闪烁效果
    if (this.flashing) {
        this.color = ["#fff", BODYCOLOR][Math.round(Math.random())];
    }
}

爆炸的实现其实也很简单。同样的方法,我们将这个过程分解一下:多个粒子以撞击点为原点,向随机方向做速度不同的运动,到达某个边界距离时,粒子消失。
这里我们要确定哪些变量呢?粒子的数量和颜色大小、爆炸原点位置、粒子的运动方向和速度,粒子消失的边界值。由于这些属性比较多,所以还是独立出来一个爆炸粒子的类Particle.js

//Particle.js
/**
 * 爆炸粒子
 */

import map from './Map';

const rand = Math.random;

export default class Particle {

    constructor(options) {
        this.x = options.x;
        this.y = options.y;
        this.vx = -2 + 4 * rand();   //速度随机
        this.vy = -2 + 4 * rand();   //速度随机
        this.destroy = false;
        this.speed = 0.04;           //粒子消失的速度
        this.size = options.size || 2;
        this.color = options.color || "rgb(30,136,168)";
        this.width = this.size + rand() * 2; //粒子大小
        this.height = this.size + rand() * 2; //粒子大小
    }

    update() {
        //向x轴和y轴的运动
        this.x += this.vx;
        this.y += this.vy;

        //粒子不断变小
        this.width -= this.speed;
        this.height -= this.speed;

        //粒子消失时,将状态至为destroy,不再渲染
        if (this.width < 0) {
            this.destroy = true;
        }
    }

    render() {
        map.ctx.fillStyle = this.color;
        map.ctx.fillRect(this.x, this.y, this.width, this.height);
    }
}

同样,在检测到碰撞时,会调用boom方法, 该方法初始化所有爆炸粒子,由于爆炸需要一个过渡的过程,所以不能像闪烁一样用简单的时间控制,这样会照成爆炸到一半突然所有粒子消失的情况。

//Player.js
boom(x, y, color, size) {
    let self = this;
    let eachPartical = [];
    for (let i = 0; i < self.particleCount; i++) {
        eachPartical.push(new Particle({x, y, color, size}));
    }
    self.particles.push(eachPartical);
}

在整个大render方法中,调用renderBoom方法,当某个爆炸粒子达到边界值时,就将其从数组中剔除。达到粒子渐渐消失,不断变少的效果。

//Player.js
renderBoom() {
    for (let i = 0; i < this.particles.length; i++) {
        let eachPartical = this.particles[i];
        for (let j = 0; j < eachPartical.length; j++) {
            //爆炸粒子消失时,从数组中排除
            if (eachPartical[j].destroy) {
                eachPartical.splice(j, 1);
            } else {
                eachPartical[j].render();
                eachPartical[j].update();
            }
        }
    }    
}

render() {
    //爆炸
    if (self.particles.length) self.renderBoom();
}

最后还要做一件事,就是将撞击的Enemy粒子从数组中除去,并重新随机生成一个。

护盾

roadmap.path
知道了Enemy撞击效果的实现,护盾效果实现起来就简单很多了。试着分解一下护盾撞击的整个动作,就能清晰地用代码描述出来,这里就不细讲了。
有所不同的就是护盾撞击的判断,他的撞击点变成了外圈,而不是粒子本身。所以需要对collosion做点修改。

function collision(enemy, player) {
    const disX = player.x - enemy.x;
    const disY = player.y - enemy.y;
    if (player.hasShield) {
        return Math.hypot(disX, disY) < (player.shieldRadius + enemy.radius);
    }
    return Math.hypot(disX, disY) < (player.radius + enemy.radius);
}

细心的话会注意到护盾撞击粒子后右上角有分数增加,这些数字会出现并渐隐。他的实现原理跟爆炸粒子相似,我们用一个数组来存储撞击位置,并在render将数组渲染出来,每个粒子达到边界值时将其删除,same thing。

重力场

roadmap.path

重力场这个效果其实是最难的,它需要找到一条公式来完美描述粒子的运动轨迹。尝试了很多种方法还是没能达到很好的效果。这里主要讲一下我的实现思路。

首先重力场的渲染原理跟护盾差不多,都是画圆,不过这里用到了颜色过渡的API createRadialGradient

renderGravity() {
    map.ctx.beginPath();
    map.ctx.globalCompositeOperation="source-over";

    var gradient = map.ctx.createRadialGradient(this.x, this.y, this.radius, this.x, this.y, this.gravityRadius);
    gradient.addColorStop(0, "rgba(30,136,168,0.8)");
    gradient.addColorStop(1, "rgba(30,136,168,0)");

    map.ctx.fillStyle = gradient;
    map.ctx.arc(this.x, this.y, this.gravityRadius, 0, Math.PI*2, false);
    map.ctx.fill();
}

重力技能有别于其他技能的点在于,他会影响Enemy粒子的运动轨迹,所以还要在Enemy中做点手脚。

index.js中,发动机animate方法通过一个循环来渲染Enemy粒子。

//index.js
function animate() {
    for (let i = 0; i < enemys.length; i++) {
        enemys[i].render();
        enemys[i].update();
        if (!player.dead && collision(enemys[i], player)) {
            if (player.hasGravity) {
                enemys[i].escape(player);
            }
        }
    }
}

这里加入了一个判断,当粒子撞击的时候,判断Player是否有重力技能,如果有的话调用Enemy的escape方法,传入player为引用。为什么要传入player?因为Enemy粒子要根据Player的位置实时做出反馈。来看escape方法怎么实现的,这里讲两种思路:

第一种,计算Enemy粒子和Player粒子之间的角度,并通过Player重力场的半径算出在x轴方向和y轴方向的运动速度,主要是想得到两个方向运动速度的比例,从而也就确定运动的方向。再将两个速度乘以某个比率ratio,从而达到想要的速度。这个效果会导致Enemy粒子朝Player相反的方向运动,有种排斥的效果。

//Enemy.js
escape(player) {
    let ratio = 1/30;
    let angle = Math.atan2(this.y - player.y, this.x - player.x);
    let ax = Math.abs(player.gravityRadius * Math.cos(angle));    
    ax = this.x > player.x ? ax : -ax;    

    let ay = Math.abs(player.gravityRadius * Math.sin(angle));    
    ay = this.y > player.y ? ay : -ay;

    this.vx += ax * ratio;
    this.vy += ay * ratio;
    this.x += this.vx * ratio;
    this.y += this.vy * ratio;
}

第二种,同样计算出两个撞击粒子之间的角度,并计算出x轴和y轴的投射距离。当两个粒子碰撞时,粒子还会继续前进,然后Enemy粒子就会进入Player粒子的重力场,这时候马上改变各轴上的位置。使Enemy粒子运动到重力场外,这样达到的效果就是Enemy粒子会沿着重力场的边界运动,直到逃离重力场。

escape(player) {
    let angle = Math.atan(Math.abs(player.y - this.y) / Math.abs(player.x - this.x));
    let addX = (player.gravityRadius) * Math.cos(angle);
    let addY = (player.gravityRadius) * Math.sin(angle);

    if (this.x > player.x && this.x < player.x + addX) {
        this.x += this.speed * 2;
    } else if (this.x < player.x && this.x > player.x - addX) {
        this.x -= this.speed * 2;    
    }

    if (this.y > player.y && this.y < player.y + addY) {
        this.y += this.speed;
    } else if (this.y < player.y && this.y > player.y - addY) {
        this.y -= this.speed;    
    }
}

这两种方法都还不够完美,没法表现出顺滑的逃逸效果。自认功力尚浅,需要继续研究一些物理运动的方法才行。

粒子变小&时间变慢

粒子变小的操作就很简单了。只需改变Enemy粒子的半径就可以了。而时间变慢也仅仅是改变Enemy粒子的运动速度,这两个就不拿出来讲了。
roadmap.path

增加生命

还有一个功能是增加生命,没错,上面提到了减少生命直接改变livesPoint的值,而增加生命我们还需要改变尾巴的长度。尾巴的长度怎么变长?读了上一篇文章你应该知道了吧。
roadmap.path

关于粒子撞击和技能的实现就讲到这了,这部分是游戏的精华,也是游戏能不能吸引人的根本。然而一个游戏要完整,肯定少不了一些游戏的策略还有一些附属场景,下一节要讲的是《从零开始开发一款H5小游戏(五) 必要的包装,游戏规则和场景设计》

从零开始开发一款H5小游戏(五) 必要的包装,游戏规则和场景设计

到这里我们已经讲了游戏的整体设计和实现。一个游戏要完整,还需要给它制定一个评分机制,它是整个游戏的关键所在。就好比一部电影,特效再好看,如果剧情狗血,那也是一部烂片。

相信大家都玩过一些简单但很吸引人的小游戏。比如很久以前微信上的打飞机,围住神经猫,还有前段时间大火的slither.io。他们都简单易玩,但却能让人肾上腺素飙升,百玩不腻。

所以一款好玩的小游戏必须具备了这样的特点,简单易玩,却能给人制造紧张感,有时还能利用一些攀比心理。本游戏也基本具备了这样的特点。

计分实现

游戏以秒数作为计分,随着时间的增加,Enemy粒子的运动速度会越来越快,躲避难度也就越来越大。游戏中的计秒实现比较简单,就是用setTimeout来实现,这里不使用setInterval,原因在第一章已经大致讲过了,就是考虑到准确性的问题。

//index.js
function initTimer() {
    holdingTime = 0;
    holdingLevel = 0;
    clearTimeout(timer);
    let time = function() {
        timer = setTimeout(function() {
            holdingTime = +timeEle.innerText + 1;
            timeEle.innerText = holdingTime;
            //每隔10秒加速一次
            if (holdingTime % 10 === 0) {
                holdingLevel++;
                levelEle.innerText = holdingLevel;
                for (let i = 0; i < enemys.length; i++) {
                    //Enemy粒子速度增加
                    enemys[i].speedUp();
                }
            }
            clearTimeout(timer);
            time();
        }, 1000)
    };
    time();
}

每隔10s, Enemy粒子的速度增加一次,Enemy中封装了speedUp方法。

//Enemy.js
speedUp(speed) {
   this.speed += speed || 0.2;
}

在技能粒子中,有一个护盾粒子。吃了护盾后,撞击Enemy粒子能增加分数。实现起来也很简单,直接修改计分板上的分数就行了。

//Player.js
let score = document.getElementById('time').innerText;
document.getElementById('time').innerText = (+score + REDSCORE);

粒子的初始生命值有三条,每次撞击到Enemy粒子都会减少一条,而如果撞击到视界的边界则会直接狗带。这里我们需要增加一个游戏结束的画面。给出最后的分数。

roadmap.path

开始和结束画面都是通过DOM实现的,这部分比较简单,就不做具体介绍了。

其实在游戏的评分机制上还可以做很多改进,比如增加排行榜,或记录自己的最优成绩,并可分享到朋友圈等。这部分可以极大增加游戏的热度。 读者可以自己展开想象,对玩法进行扩展。

预加载

当我在微信打开游戏的时候,发现开始画面和结束画面的图片加载很慢。导致DOM结构出来了,图片却迟迟没看到,没法给玩家准确的提示。所以需要增加一个图片预加载的功能。当然这也是每一个网页游戏框架必备的功能。

这部分功能直接参考了阿里的一个游戏框架Hilo,并把它抽象到loader.js。读者可自行查阅实现细节。

抽象后在入口处预加载所需的图片:

//index.js
let loader = new Loader();
let source = [
    {src: 'assets/images/number.png'},
    {src: 'assets/images/over.png'},
    {src: 'assets/images/sprites.png'}
];
loader.load(source, function() {
    start(); //开始游戏
});

预加载的时候还需要有个提示画面来告知加载进度。

进度条的实现也独立成一个文件loading.js,并暴露一个外部API给游戏使用。

我们还需要将预加载插件和进度条结合起来,每个图片加载完成后,loader会触发一次load事件,用一个计数器统计加载的图片数,除以总数得到一个进度比例。然后将这个比例barRatio传给进度条。让其渲染出相应的进度。

//根据加载进度渲染进度条
let loaded = 0;
loader.on('load', e => {
    ++loaded;
    barRatio = loaded / source.length;
});

//进度条渲染
(function loading() {
    drawLoading(barRatio);
    if (!loadingFinish) {
        raf(loading);    
    }
})();

需要注意的一点是,进度条是通过canvas画布实现的。所以进度canvas的draw方法是在不停运行的。如果每张图片加载完的时候才改变进度条的位置,就会造成进度跳跃式地前进,无法连续顺滑加载的效果。
这个逻辑在loading中通过一个判断来解决。

//loading.js
let currentBarWidth = bar.total * ratio;
if (bar.width < currentBarWidth) {
    bar.width += 2;
}

保证进度条每次增加只能是2。而不是直接让bar.width = currentBarWidth;

结语

至此整个游戏的开发就介绍到这了,主要还是讲游戏的实现思路。 游戏中还是有挺多细节处理的,这些真的要亲自动手写一下才能了解。

本教程的初衷就是想让读者能对H5游戏开发有个宏观的了解,知道怎么入手。想起几周前自己要写这个游戏的时候还无从下手,如今也完成开发并写了几篇总结,算是有所沉淀。

其实H5游戏开发远比这个复杂,本游戏只是基于画笔实现,还没有涉及到图片的绘制,坐标轴转换等等。还有很多了要学习的东西啊。当然这只是自己一时的兴趣尝试,等什么时候心血来潮了,说不定再写一个系列呢。

从零开始开发一款H5小游戏(一) 重温canvas的基础用法

本系列文章对应游戏代码已开源 escape game

初衷

从萌发写一个小游戏的想法到完成游戏开发用了大概一周的业余时间。这个过程积累了一些经验,也算是参透了一些游戏开发的原理。在这里打算写一个系列教程,讲述怎样从零开始开发一款小游戏。让新者少走弯路,快速入手。也能让自己总结反思,发现问题。

在开始介绍如何写游戏前有必要重温一下canvas。它是本游戏的地基,建房子要快,首先地基要牢固。

Canvas
Canvas 对一个做前端的人来说再熟悉不过,html5中新增的这个功能为网页创造了无限可能,极大促进了网页富应用的开发。
而canvas对于大部分前端来说又是陌生的。
可以说在写这个游戏之前,我只是模糊地记得canvas的一些功能,以及经常在网上看到的酷炫高大上的基于canvas实现的效果,但自己绝对答不出canvas有哪些API,以及它们的具体使用方法。毕竟自己平时没做过类似的活动页,在大厂里这些工作一般都是让UED部门给承包了。

好了,废话不多说,进入主题。

在开始之前建议读一遍MDN的教程,如果有《犀牛书》也可以看第21章关于canvas图形编程一节。

里面几个概念需要说一下。
context上下文:

var canvas = document.getElementById('canvas');
var cxt = canvas.getContext('2d');

我们的图形并不是直接花在canvas上的,而是要通过getContext首先获得这个画板的上下文。传入的2d参数则表示我们创建的是一个2d的画布。后面所有的绘画都是直接操作cxt这个画布对象。

这个画布对象的全称是 CanvasRenderingContext2D,上面实现了很多绘制方法。具体用到可以参考这里

API虽然多,但是道理只有一个,万变不离其宗。

现实中我们画一个东西一般要有以下几个步骤:

  1. 准备画布
  2. 选择画笔
  3. 选择颜料
  4. 画出轮廓
  5. 填充颜色

而实际上CanvasRenderingContext2D API的设计也是大概遵循这样一个步骤,每一步都会最终影响画出来的图案。
我们可以将所有绘制分为两大类,一类是线,一类是面。线使用的API一般以stroke开头,面的API是以fill开头。

画一条线:

var c=document.getElementById("canvas");
var cxt=c.getContext("2d");                     //准备画布
cxt.lineWidth = 5;                              //选择画笔
cxt.strokeStyle = "red";                        //选择颜料
cxt.moveTo(10,10);                              //...
cxt.lineTo(150,50);                             //...
cxt.lineTo(10,50);                              //画出轮廓
cxt.stroke();                                   //填充颜色

效果图:
roadmap.path

画一个三角形面

var c=document.getElementById("canvas");
var cxt=c.getContext("2d");                     //准备画布
cxt.lineWidth = 5;                              //选择画笔
cxt.fillStyle = "red";                          //选择颜料
cxt.moveTo(10,10);                              //...
cxt.lineTo(150,50);                             //...
cxt.lineTo(10,50);                              //画出轮廓
cxt.fill();                                     //填充颜色

效果图:
roadmap.path

只要将stroke的地方换成fill, 就变成图形面的填充。而这里的lineWidth其实是可以省略的,它的默认值是1。

为了方便,CanvasRenderingContext2D为我们提供了一些简单的API,不需要使用moveTo和lineTo一条线段一条线段绘制。最重要的有几个:

arc: 画圆

cxt.arc(x, y, radius, startAngle, endAngle, anticlockwise);

fillRectstrokeRect: 画矩形

cxt.fillRect(x, y, width, height)           //填充图形
cxt.strokeRect(x, y, width, height)         //不填充图形

fillText: 写字

cxt.fillText(text, x, y [, maxWidth])

当然CanvasRenderingContext2D还有更多丰富的API,但是基本都是基于上面5个步骤衍生出来的。基础开发中很少会使用到,可以用时再查阅文档。

为了能在一张画图上绘制多个图形而互不影响,CanvasRenderingContext2D提供了
beginPathclosePath

beginPath 用于在开始绘制一个独立图形的时候声明,在beginPath之后定义的画笔,颜料都不会影响到画图中的其他图形。可以看到下面的两条路径,各自定义了strokeStyle, 但是互不影响。

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

//第一条路径
ctx.beginPath();
ctx.strokeStyle = 'blue';
ctx.moveTo(20,20);
ctx.lineTo(200,20);
ctx.stroke();

//第二条路径
ctx.beginPath();
ctx.strokeStyle = 'green';
ctx.moveTo(20,20);
ctx.lineTo(120,120);
ctx.stroke();

效果图:
roadmap.path

closePath 用于方便地将首尾两个点连接起来,形成一个封闭的图形,而不必手动调用lineTo闭合图形。 例如上方的三角形线段可以这样用:

var c=document.getElementById("canvas");
var cxt=c.getContext("2d");
cxt.moveTo(10,10);
cxt.lineTo(150,50);
cxt.lineTo(10,50);
cxt.closePath();
cxt.stroke();

效果图:
roadmap.path

上面详细介绍的几个简单的API已经足够开发一个简单的游戏了。而如何使游戏界面更丰富炫酷,则需要用到更多的辅助方法。我们将在游戏中用到时再做具体介绍。

关于canvas的基础就温习到这,下一篇文章将进入本游戏的开发。敬请期待
《从零开始开发一款H5小游戏(二) 创造游戏的世界,启动发动机》

如何实现一个HTTP/HTTPS代理客户端

原理

作为一个合格的前端工程师,你一定用过Fiddler或Charles之类的抓包工具。但是在Mac上做开发时,相关的抓包工具很多是收费的。当你费劲心思下载到了破解版,却还是难以忍受其丑陋的win风格界面和令人悲伤的闪退问题。有没有想过自己来实现一个代理客户端呢?其实这个真的可以有。

中间人

一个http代理服务器的原理很简单。有了Nodejs作为武器,创建一个代理服务器就是分分钟的事。具体可参见jerryQu写的两篇文章 《HTTP 代理原理及实现(一)》 《HTTP 代理原理及实现(二)》 文章对HTTP代理的原理和实践讲得比较清楚。

简单来讲就是要实现一个中间人,用户通过设置代理,网络请求就会通过中间人代理,再发往正式服务器。

这种中间人的实现方式有两种。
一种为普通的HTTP代理,通过Node.js开启一个HTTP服务,并将我们的浏览器或手机设置到该服务所在的ip和端口,那么HTTP流量就会经过该代理,从而实现数据的拦截。
pic

对于非HTTP请求,比如HTTPS, 或其他应用层请求。可以通过在Node.js 中开启一个TCP服务,监听CONNECT请求,因为应用层也是基于传输层的,所以数据在到达应用层之前会首先经过传输层,从而我们能实现传输层数据监听。
pic

但是对于CONNECT捕抓到的请求,无法获取到HTTP相关的信息,包括头信息等,这对一般的前端分析作用不大,那么想要真正监听HTTPS,还需要支持证书相关的验证。

证书

假设我们是通过浏览器设置代理进行抓包实验(或全局代理),在这个过程中我们主要关注的是浏览器和代理服务器之间的交互,这个过程大概如下:

  1. 浏览器客户端发出了一个请求,该请求会首先经过代理服务器。
  2. 代理服务器获取到客户端请求,知道了真实服务器的地址,它可能会做一些手脚,比如对请求数据进行修改,再发往真实服务器,获取到数据再返回给浏览器(利用这一点能实现跨域支持等)。或者代理服务器压根就不会请求真实服务器,而是直接伪造一份假数据给浏览器(利用这一点能实现接口mock)。
  3. 浏览器接收到数据,并返回给用户,显示在页面。

上面这三步在HTTP中会无比流畅,然而如果请求是HTTPS,浏览器会验证代理服务器的安全性。这里会涉及到TLS握手的过程,其中也包括了证书的验证。

代理服务器返回HTTPS请求时,需要将对应请求域名的证书发给浏览器,浏览器再向本地的CA根证书验证域名证书的安全性。如果验证通过,则继续后续请求,验证失败浏览器会返回安全警告。
pic

这里提到了两个证书,一个是域名证书,一个是CA根证书。

域名证书 是每个支持HTTPS网站都需要有的一份证书,用于客户端验证该网站的安全性,而该证书通常是通过安全机构申请的,这个机构就是 CA(Certificate Authority,证书颁发机构)。在每台用户计算机的操作系统或浏览器中,都会保存一份CA列表,也就是有多个根证书,不同CA分别包含了不同的域名证书,浏览器在获取到域名证书之后,会向CA根证书进行验证,如果验证通过则能正常收发请求。

对于代理服务器来说,我们并没有合法的域名证书(证书只存在真实目标服务器,无法获取到),怎么让浏览器相信我们是个安全的代理(服务器)呢?答案是————伪造!

没错,我们既要伪造域名证书,也要伪造根证书。其实根证书是可以自己签发的。下面两条命令首先生成了一个私钥,然后利用私钥生成crt证书,我们只要双击crt文件进行安装,并设置为信任,就成功建立了一个本地根证书。

openssl genrsa -out private.pem 2048
openssl req -new -x509 -key private.pem -out public.crt -days 99999

利用根证书,我们能够签发更多的域名证书。证书是链式验证的,验证域名证书的时候,会往上验证CA根证书,由于CA根证书已经被我们本地信任了,所以浏览器也会信任该域名证书,成功返回代理服务器的数据。

pic

具体的操作流程是这样的,首先利用Node.js 生成AnyProxy CA证书,并手动信任。浏览器往cn.vuejs.org发出请求,代理服务器拦截到请求,知道请求是发往cn.vuejs.org,在返回数据之前,利用AnyProxy证书动态签发cn.vuejs.org域名证书,放于本地(用于下次请求,这里省去了中间证书的步骤),同时将该域名证书返回给浏览器。那么浏览器在接受到cn.vuejs.org域名证书后,往证书链上寻找到根证书AnyProxy,并通过AnyProxy证书验证域名证书是否受信任,同时还要检查域名证书的有效性,包括过期时间等。由于域名证书是我们通过AnyProxy动态创建的,所以保证了其受信任和有效性。最后浏览器返回代理服务器结果。从而实现了HTTPS请求的抓取。

具体的证书签发实现可参考 forge 库,现在广泛使用的证书是X.509格式。

Electron

解决了证书问题,可以说已经完成了一大半的工作,那如何快速实现一个代理客户端呢?对于一个JSer来说,能利用Node.js来写是最好不过了。

简介

Electron大家应该不陌生了,它提供了一种解决方案,让我们能够利用Node.js 和 前端三宝 HTML + JS + CSS 来实现客户端软件。咋一听感觉像NW.js。经过一番了解,才知道其实NW.js可以算是Electron的前身了,都是出自同个作者之手,只不过该作者现在维护Electron去了,这其中涉及到一些产权的问题,感兴趣的可以围观一下知乎上原作者的回答。关于Electron和NW.js的区别官网上是这么说的。简单讲就是Electron优化了NW.js中的一些不足。 秉着与时俱进的态度,我们当然要使用Electron。

有了Electron作为容器,我们小前端就可以用HTML+JS+CSS来开发客户端了。就像开发前端页面一样柔顺。Electron的使用比较简单,提供的API也比较清晰。核心概念就是Main Process 和 Render Process。

顾名思义Main Process是主进程,用于运行Electron的基本操作,如创建窗口,创建菜单等。Render Process是渲染进程,我们需要在渲染进程中创建软件界面,每个渲染进程对应的是一个窗口,主进程开启了多个窗口就会有多个渲染进程。

Electron提供了IPC用于进程间通信。分别是ipcMain和ipcRender。该通信机制允许ipcRender向ipcMain发送信号请求,并通过ipcMain返回数据。反回来ipcMain无法向特定的ipcRender发起请求。而且通信间传递的消息会被格式化为JSON字符串,所以并不支持在两个进程间传递句柄方法等,也就是不支持上下文传递。

Arguments will be serialized in JSON internally and hence no functions or prototype chain will be included.

假如要实现在渲染进程中点击一个按钮,则关闭客户端窗口,可以通过ipcRender发送一个信号给ipcMain, ipcMain接收到该信号后调用Electron的API关闭窗口。对于类似这种比较简单的指令操作,运用IPC实现就可以了,但是如果操作比较复杂,并且需要传递复杂数据类型,则用IPC就行不通了。

Electron提供了另一个API remote,用于在Render Process中直接操作主进程的方法。这样就不需要移交Main Process处理,直接在前端页面中调用Electron的API。

打包

由于Electron本身包含了chromium和Node.js的代码, 所以不考虑项目本身体积,打包后的软件最小仍然有100M+, 这也是Electron最为显著的缺点之一。所以基本体积是无法避免的,我们只能尽量减小其他开发文件的大小,避免将一些无关包文件也打包进去。

为什么要强调这点呢?因为基于Node.js开发的项目往往会有一个庞大的node_modules文件夹,里面包含了一些开发和生产所用的包,也即对应package.json中的dependencies和devDependencies。而devDependencies中的包是不需要打包到软件的。这里推荐使用 electron-packager, 能自动排除dev依赖包,并支持自定义排除包文件夹。也可以打包出支持不同系统格式的软件。

界面开发

界面开发采用传统前端页面开发方式,意味着你可以使用任何前端框架,利用Angular,Vue,React等框架来提升开发效率。

这些框架都支持模块化,利用webpack等打包工具,webpack本身会提供require等模块加载的方法,在前端开发的时候能实现类似后端的模块动态加载。

但是,当我们在Render Process中使用webpack进行开发,用require引入模块的时候就会出现冲突。因为require此时是webpack提供的一个引用本地文件的API,而不是Node.js的require, 导致我们无法通过require来引用Node.js的API,或者Electron的API。这有什么解决方案吗?

这里提供一个简单的方法,我们将需要用到Node.js API和Electron API 的方法抽象到renderer.js, 从HTML中单独引入,也就避免了webpack对renderer.js进行处理。然后通过插件的方式引入到前端框架中,以Vue为例:
html中

<script src="renderer.js"></script><!--提供Render Process 方法 -->
<script src="./dist/build.js"></script><!--webpack 打包文件-->

renderer.js

const electron = require('electron');
const remote = electron.remote;
const remoteApi = remote.require('./api.js');

global.remoteApi = remoteApi;

Vue入口文件main.js

Vue.use({
    install (Vue, options) {
        //添加实例方法
        Vue.prototype.$remoteApi = global.remoteApi;
    }
});

在Vue组件中就可以直接通过this.$remoteApi调用基于Nodejs或Electron的接口了。这样就有效地分离了前端界面和客户端的代码,只要剥离了$remoteApi, 前端界面也可作为一个独立的项目进行开发。

方案优缺点

Electron的这种实现方式也不是什么新鲜套路了,对于NW.js 有的大多数缺点Electron也有。

其中一个通病就是性能问题,主要是渲染性能方面。基于webkit引擎来渲染UI界面,跟原生的系统UI还是有一定的差距。毕竟是基于DOM节点的渲染,每次节点的重排都是一次大的开销。这点只能通过在前端框架中来优化,比如利用Virtual DOM等相关技术。而视觉上的缺点则可以通过CSS做到竟可能接近原生控件。

而对于JS的执行性能,v8表示hold得住。

优点当然也比较明显,对比于Cocoa,Qt等传统桌面客户端技术,基于前端技术的实现成本较低(C++牛请忽略)跨平台支持更好(框架都帮你做好了),且天然支持热更新。

更重要的是,有这么多优秀软件帮你背书啊.....以下都是基于Electron开发。
pic

当然,我并不是在安利Electron。毕竟别人能开发得这么原生态,你不一定行...

关键还是看技术,Electron是完全能够开发出中大型产品级的软件的。

说了这么多,代码呢?

能读到这里,感谢你的坚持!

下面基于以上理论实现的代理客户端。目前支持以下功能:

  1. 支持HTTP/HTTPS请求抓取。
  2. 支持网速模拟。
  3. 支持请求拦截修改,实现跨域等功能。
  4. 实现接口Mock,用于本地开发调试。

源码地址 欢迎试玩。

由秒杀活动想到的

由秒杀活动想到的


我们经常会在网页中看到各种倒计时,如彩票开奖倒计时,秒杀活动倒计时等。
对于一般的倒计时如开奖倒计时等对于时间的精准性要求并不高,我们只需要给用户提供一个可以看得到的倒计时,至于相差那么一两秒也是无关紧要的。因为对用户来说这并不会对他照成任何损失。
而对于秒杀活动,那么倒计时的精准性要求就很高了。假设有这样一个场景,用户打开个某个秒杀活动页,一直不刷新页面,等待了一天,这个时候用户看到的时间是准确的吗?
对前端有所了解的开发人员都知道倒计时一般是基于setInterval来实现的,通过类似的代码来实现:

setInterval(showTime, 1000);

而setInterval本身存在着计算误差,假设某个时候有一个CPU密集型的计算在执行。
showTime执行的间隔时间就会大于1秒.随着时间的累加,这个值是不断增大的。所以时间会越来越不准确。具体分析可看这里。我这里要讲一下怎么在前端实现准确倒计时。
由于客户端的时间千差网别,所以网页的倒计时一般都要首先获取服务端的时间。假设为serverTimeLong。
当前端获取到服务器时间后,转化为时间格式并对其进行倒计时

var serverTime1 = new Date(serverTimeLong);
var clientTime1 = new Date();
countDown(serverTime1);

当一段时间后,前端计时已经不准确,这时候要重新倒计时,我们通过将第一次获取的服务器时间和客户端时间进行差值运算

var clientTime2 = new Date();
var serverTime2 = new Date(serverTime1.getTime() + (clientTime2.getTime() - clientTime1.getTime()));

由客户端的时间差重新计算得到服务端的时间,我们可以每个一段时间进行这样一次校验,使得倒计时重新进行。之所以能这样做是因为即时代码中的异步计时是不准确的,但客户端的时钟是准确,所以其时间差也是准确的。
这个做法有两个优势,一个是不需再对服务端接口请求时间,减少负载,第二是能准确地进行倒计时重置。

问题到这,可能会想到,秒杀活动的时候肯定是不断刷新页面的,这时候带来的并发访问是平时的成百上千倍。网站想要hold得住,就必需用到比平时多更多的服务器。而这些服务器大部分时候是用不着的,会照成资源的浪费。
其实网站的秒杀业务并不能使用正常的网站业务流程,也不能和正常的网站交易共用服务器,必须设计部属专门的秒杀系统,进行专门应对。否则不仅无法完成这样大并发的请求,而且很有可能对常规业务造成影响,服务器资源消耗殆尽。

在这样的高并发下,会对应用服务器和数据库造成极大的负载压力,同时会突然增加网络及服务器带宽。在这些情况下,我们是否有可行的应对措施呢?通过查阅资料,了解到了一些解决办法。

  1. 秒杀系统独立部属
    为了避免因为秒杀活动的高并发访问拖垮整个网站,使整个网站不必面对蜂拥而至的用户访问,可将秒杀系统独立部属;如果需要,还可以使用独立的域名,使其与网站完全隔离,即时秒杀系统崩溃了,也不会对网站造成任何影响。
  2. 秒杀商品页面静态化
    将秒杀页面作为静态页,一方面能减少应用服务器的请求,也不用访问数据库。同时还能将静态页面缓存在CDN中,所以秒杀商品服务不需要部属动态的web服务器和数据库服务器。
  3. 租借秒杀活动网络带宽
    因为秒杀新增的网络带宽,必须和运营商重新购买或租借,为了减轻网站服务器的压力,需要将描述商品页面缓存在CDN,同样需要和CDN服务商临时租借新增的出口带宽。

下图为业界秒杀下单的基本流程和秒杀系统的整体架构。
秒杀下单流程图
roadmap.path
秒杀系统整体架构图
roadmap.path

跨域CORS小记

浏览器对XMLHttpRepeat或Fetch发起的请求都有同域的限制。

在调用跨域API的时候,用的最多的是jsonp的方式。

jsonp
jsonp的原理就是往页面添加一个<script>标签,通过在src连接后面添加callback=callbackName参数,并在页面定义callbackName方法。后端获取参数名后往页面输出callbackName(data); 即执行了页面定义好的回调函数,将数据返回给前端页面。
本质上这也是一种跨域请求,但是浏览器对这种直接引入链接的方式并没有限制。就像直接在页面上用<script>引入第三方js,css文件。

这种方式的缺点是,如果原先接口不是采用jsonp的方式,改动影响面较大。

CORS
CORS也可以解决跨域请求的问题,然而思路就不一样了。

CORS也需要后端配合,浏览器发起ajax请求时,会在HTTP请求头中携带Origin头,表示当前发起请求的域名。通过对比Origin和Request URL,即请求接口域名,浏览器自动判断该请求是否为跨域请求。

跨域ajax请求可以分为两种情况:简单请求和复杂请求。
简单请求包括:GET,HEAD请求,和Content-Type为application/x-www-form-urlencoded, multipart/form-data 或 text/plain 的POST请求。
复杂请求为:除了GET,HEAD,POST之外的其他请求,也包括Content-type为application-xml或者 text/xml的POST请求

当浏览器发起的请求为简单ajax请求时。服务端接收到请求后,会返回数据给浏览器。这时候浏览器会检查接口的返回头,判断是否有Access-Control-Allow-xxx相关的头部。如果Access-Control-Allow-Origin为*,或者白名单有发起请求的Origin。那么浏览器就会将数据返回给前端,否则的话请求将不会返回,浏览器直接在控制台报出跨域异常信息。

当浏览器发起的请求为复杂ajax请求时,浏览器首先会往后端发起一个OPTIONS请求,并提交字段Access-Control-Request-Method,询问服务器是否支持这种Type请求方式,比如Access-Control-Request-Method: POST。

服务端接收到请求后,会返回数据给浏览器。这时候浏览器会检测接口的返回头,判断Access-Control-Allow-Origin是否设置允许跨域,同时检查Access-Control-Request-Method是否包括请求的方式。通常还会返回Access-Control-Max-Age用于告知浏览器多长时间内对同样的请求免除发起OPTIONS预请求。剩下的处理方式跟简单请求一致。

CORS携带cookie
有些接口需要判断用户状态,在发起请求的时候要携带cookie。我们知道同域的请求都会自动带上cookie。对于跨域的ajax, XMLHttpRequest 方法中有个参数withCredentials, 通过设置参数为xhr.withCredentials = true,接口请求中也会自动带上cookie。当然这里面后端也要做点工作。

通过Set-Cookie头部,服务器能为浏览器设置cookie,可以同时支持发送多个Set-Cookie头设置多个cookie,并且通过path字段设置cookie的有效存放路径,浏览器发起的请求只有命中了path, 才能携带该cookie到服务器。 还有必须设置另外一个头部Access-Control-Allow-Credentials,当该值为true的时候,才允许往接口所在域名下发cookie。当ajax请求中的withCredentials为true时,浏览器会将cookie保存到域名下。而当前端请求中并没有设置withCredentials:true,即使后端返回了Access-Control-Allow-Credentials:true,浏览器也不会保存cookie。

所以在跨域请求的时候,往往要在第一次请求的时候保存cookie,后面请求的时候才能将此cookie携带给后端。

为了安全考虑,后端在设置Access-Control-Allow-Credentials:true的时候,不允许将Access-Control-Allow-Origin设置为*,只能够用添加白名单的方式

调试跨域问题的时候通常还要检查一下前端的提交方式正不正确,后端在获取值的时候怎么获取的。在没有明确的接口文档的时候,双方要确认好数据的提交方式,哪些数据是在Request Url里的,哪些数据是在data里的。才能避免跨域问题带来的调试陷阱。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.