博客

基于文件夹的个性化 git 配置方法

在一些时候,我们希望 git 有不同的配置。 比如自己的开源或者私人项目用一套 git 配置,其中 user.name 是 xyz, user.email 是 [email protected] 公司项目用另外一套 git 配置,user.name 是 Real Name、 user.email 是 [email protected]

git config 支持系统层级 --system、 用户层级 --global 与仓库层级(无选项)的配置。但是, 对于大量项目,手动地通过 git config 指定未免过于繁琐。 本文介绍了一种通过修改 git 的配置文件 .gitconfig,使用 [includeIf] 对某个文件夹下的所有 git 项目指定 git 配置的方法。

git 配置文件层级

在 git 中,有三个层级的配置文件:

  1. 系统层级: /etc/gitconfig,作用于系统中所有用户的 git 配置;
  2. 用户层级: $HOME/.gitconfig,作用于用户的 git 配置;
  3. 项目层级: .git/config,作用于项目中。

如果有相同的配置,按照 项目 > 用户 > 系统 的优先级获取配置。

[includeIf]

从 git 2.13.0 开始,git 配置文件开始支持 Conditional Includes 的配置。通过设置 includeIf.<condition>.path,可以向命中 condition 的 git 仓库引入 path 指向的一个 git 配置文件中配置。

[includeIf] 的语法如下,<keyword> 为关键词,<data> 是与关键词关联的数据, 具体意义由关键词决定。

1
2
[includeIf “<keyword>:<data>”]
path = path/to/gitconfig

其中支持的 keyword 有:

  1. gitdir: 其中 <data> 是一个 glob pattern 如果代码仓库的.git目录匹配 <data> 指定的 glob pattern,那么条件命中;
  2. gitdir/igitdir的大小写不敏感版本。
  3. onbranch:其中 <data> 是匹配分支名的一个glob pattern。 假如代码仓库中分支名匹配 <data>,那么条件命中。

就我们的需求,使用 gitdir 完全可以。

例子

假设在家用工作电脑上,我们默认开发的是个人项目。有时为了应对紧急需求, 会将公司项目 clone 到电脑中,统一放置放到 ~/corp-projects/ 目录下面。 个人项目与公司项目的差异点在:第一、使用的邮箱名不同, 个人项目会使用个人邮箱,公司项目使用公司邮箱;第二, 公司项目可能需要 VPN 接入才能够存取代码库。 我们首选使用,用户层级的 git 配置文件。

1vim ~/.gitconfig

在最后添加一个 conditional include:

1
2
3
# ~/corp-projects/ 下面的所有仓库引入 `~/crop-projects/.gitconfig` 中的配置
[includeIf “gitdir:~/corp-projects/”]
path = ~/corp-projects/.gitconfig

最后创建公司项目统一的配置文件:

1vim ~/corp-projects/.gitconfig
1
2
3
4
5
6
7
[user]
name = <Your Name>
email = <Your Email>

[http]
# 代理地址,如果公司项目需要代理才能够存取,填写此项;如果不需要,则不用这一行
proxy = <Proxy URL>

JS解析TTF字体文件

在这篇文章,我们计划操作如下:

  1. 将字体文件拖入网页,并读取之
  2. 尽管ttf文件是为C语言读取设计的,但我们仍试图解析之
  3. 读取文件的字形数目,并定位各个字形轮廓的位置
  4. 解析每个字形轮廓
  5. 最后,把这些字形轮廓呈现到网页上

本文由原始文档从零开始解析ttf文件,并获取字形轮廓坐标。如果需要完整解析ttf文件,并获取字体文件的各个属性,以下第三方库可能是更优选择:

JavascriptPythonCPhp
opentype.jsfonttoolsotfccphp-font-lib

用Javascript读取文件

这… 好像很危险。 不过,放心吧,只有把文件拖动到网页上,才能用javascript读取它。通过处理dragover(拖入方框)和drop(释放鼠标)事件,我们可以读取拖进方框的文件。

在页面接听到drop事件的时候,可以获取该文件的引用(指针),进而读取该文件。这个操作无需与服务器进行交互。 我们还得处理dragover事件,不然它将不能工作。

var dropTarget = document.getElementById("dropTarget");
dropTarget.ondragover = function(e) {
    e.preventDefault();
};
dropTarget.ondrop = function(e) {
    e.preventDefault();

    if (!e.dataTransfer || !e.dataTransfer.files) {
        alert("没有读取到文件");
        return;
    }

    var reader = new FileReader();
    reader.readAsArrayBuffer(e.dataTransfer.files[0]);
    reader.onload = function(e) {
        ShowTtfFile(reader.result);
    };

};

HTML5文件对象不太方便后续的操作。要想获取文件的原始数据,只能用FileReader异步读取它。我们可以读取为base64编码的字符串或ArrayBuffer。在这里,我们读取ttf文件为ArrayBuffer类型。

解析C结构体

TrueType文件设计的时候,计算机内存还很小。它的设计思路是,先把硬盘上的字体文件拷贝到运行内存,然后在适当的位置读取。字体文件中甚至直接存入了C结构体。要读取TrueType文件,只要把它加载到内存就可以了。我们将做类似的事情。不过,首先需要一些功能函数,以便在文件适当的位置查找并读取各种数据类型。 这个类可以实现以上目的。

function BinaryReader(arrayBuffer)
{
    assert(arrayBuffer instanceof ArrayBuffer);
    this.pos = 0;
    this.data = new Uint8Array(arrayBuffer);
}

BinaryReader.prototype = {
    seek: function(pos) {
        assert(pos >=0 && pos <= this.data.length);
        var oldPos = this.pos;
        this.pos = pos;
        return oldPos;
    },

    tell: function() {
        return this.pos;
    },

    getUint8: function() {//读取单字节无符号整型
        assert(this.pos < this.data.length);
        return this.data[this.pos++];
    },

    getUint16: function() {//读取双字节无符号整型
        return ((this.getUint8() << 8) | this.getUint8()) >>> 0;
    },

    getUint32: function() {//读取四字节无符号整型
       return this.getInt32() >>> 0;
    },

    getInt16: function() {//读取双字节有符号整型
        var result = this.getUint16();
        if (result & 0x8000) {
            result -= (1 << 16);
        }
        return result;
    }, 

    getInt32: function() {//读取四字节有符号整型
        return ((this.getUint8() << 24) | 
                (this.getUint8() << 16) |
                (this.getUint8() <<  8) |
                (this.getUint8()      ));
    }, 

    getFword: function() {
        return this.getInt16();
    },

    get2Dot14: function() {//读取定点数,00.00000000000000
        return this.getInt16() / (1 << 14);
    },

    getFixed: function() {//读取定点数,00.00
        return this.getInt32() / (1 << 16);
    },

    getString: function(length) {//由arraybuffer转字符串(ascii编码)
        var result = "";
        for(var i = 0; i < length; i++) {
            result += String.fromCharCode(this.getUint8());
        }
        return result;
    },

    getDate: function() {//读取日期
        var macTime = this.getUint32() * 0x100000000 + this.getUint32();
        var utcTime = macTime * 1000 + Date.UTC(1904, 0, 1);
        return new Date(utcTime);
    }
};

定点数

除了无符号及有符号8位、16位和32位整型,字体文件中还需要一些其他数据类型。某些特定位数的小数可以用定点数来表示。类似于定点算术,我们只使用二进制而非十进制。假设我们打算写入十进制数字1.53,由于1.53转换成二进制是循环小数,因此不能精确写入文件,不过我们将其改写为153再存入文件。只要把它再除以100,就可以欲获得原始数据1.53。

有关Javascript中的数据类型

Javascript中的数据类型是变化无常的,它通常是32位整型。只要它认为是必要的,就会从有符号类型自动转换为无符号类型。即使不需要,js也可能把数据转换成64位双精度浮点数(double float)。

不过,可以用无符号右移位运算符(»>)将数据类型强制转换为无符号数。将一个数右移0位,其内部类型就转为无符号整型了。

寻找宝藏

TrueType字体格式的详细说明在苹果公司网站。Truetype文件头是偏移表,记录了其余表在文件中的位置。我们将深入一些表来获取字形轮廓。

每个表有一个校验和,以此保证其正确性。校验和可以通过将该表的所有4字节整数相加模2^32得到。 这段代码用来读取每个表的相对于整个文件的偏移量。

function TrueTypeFont(arrayBuffer)
{
    this.file = new BinaryReader(arrayBuffer);
    this.tables = this.readOffsetTables(this.file);
    this.readHeadTable(this.file);
    this.length = this.glyphCount();
}

TrueTypeFont.prototype = {
    readOffsetTables: function(file) {
        var tables = {};
        this.scalarType = file.getUint32();
        var numTables = file.getUint16();
        this.searchRange = file.getUint16();
        this.entrySelector = file.getUint16();
        this.rangeShift = file.getUint16();

        for( var i = 0 ; i < numTables; i++ ) {
            var tag = file.getString(4);
            tables[tag] = {
                checksum: file.getUint32(),
                offset: file.getUint32(),
                length: file.getUint32()
            };

            if (tag !== 'head') {
                assert(this.calculateTableChecksum(file, tables[tag].offset,
                            tables[tag].length) === tables[tag].checksum);
            }
        }

        return tables;
    },

    calculateTableChecksum: function(file, offset, length)
    {
        var old = file.seek(offset);
        var sum = 0;
        var nlongs = ((length + 3) / 4) | 0;
        while( nlongs-- ) {
            sum = (sum + file.getUint32() & 0xffffffff) >>> 0;
        }

        file.seek(old);
        return sum;
    },

好了,现在我们定位了各个表的位置。不过,接下来我们需要读取“head”表。除了记录字体尺寸,更重要的是它定义了字形索引的格式。

    readHeadTable: function(file) {
        assert("head" in this.tables);
        file.seek(this.tables["head"].offset);

        this.version = file.getFixed();
        this.fontRevision = file.getFixed();
        this.checksumAdjustment = file.getUint32();
        this.magicNumber = file.getUint32();
        assert(this.magicNumber === 0x5f0f3cf5);
        this.flags = file.getUint16();
        this.unitsPerEm = file.getUint16();
        this.created = file.getDate();
        this.modified = file.getDate();
        this.xMin = file.getFword();
        this.yMin = file.getFword();
        this.xMax = file.getFword();
        this.yMax = file.getFword();
        this.macStyle = file.getUint16();
        this.lowestRecPPEM = file.getUint16();
        this.fontDirectionHint = file.getInt16();
        this.indexToLocFormat = file.getInt16();
        this.glyphDataFormat = file.getInt16();
    },

诸如字形之间的水平距离,建议的最小高度,创建日期等属性,可以从许多表得到。不过我们要专注于埋藏的宝藏 – 字形轮廓。 字形轮廓在“glyf”表中。字形是高度压缩的,每个字形的长度也不同。要快速找到某个字形的位置,我们必须先读取“loca”表—字形索引表。

head表的“indexToLocFormat”值决定了“loca”表是一个2字节还是一个4字节值的数组。如果indexToLocFormat为1,那么loca表每个元素占用4个字节,记录了字形在glyf表的位置序号;否则,loca表每个元素占用2个字节,这个元素乘以2就是是字形在glyf表的位置序号。这样的设计不会导致数据的错乱。

    getGlyphOffset: function(index) {
        assert("loca" in this.tables);
        var table = this.tables["loca"];
        var file = this.file;
        var offset, old;

        if (this.indexToLocFormat === 1) {
            old = file.seek(table.offset + index * 4);
            offset = file.getUint32();
        } else {
            old = file.seek(table.offset + index * 2);
            offset = file.getUint16() * 2;
        }

        file.seek(old);

        return offset + this.tables["glyf"].offset;
    },

现在,给定任何字形的索引,就可以定位该字形的位置。不过接下来,有点小麻烦。

如果两个图形彼此重叠,且路径方向不同(一个逆时针,一个顺时针),那么第二个将切掉第一个形状。字体依照这个约定来从轮廓构建形状。例如,字母O需要有两个轮廓 – 一个用于外圆,一个用于内圆。

不过有两种字形。一种是简单字形,由轮廓构成,如上所述;另一种是复合字形,由简单字形复合而成。要绘制复合字形,我们必须把每个简单字形部件放到到正确的位置。这样,复合字形就能处理带重音的字符(如汉语拼音)。正因为此,字母的重音版本占用的空间非常小。

为了专注于获取字体的精华,我们将暂不考虑复合字形。在这里只是提取那些简单字形。

解析轮廓

此函数将解析字形头,然后调用正确的函数来读取字形。

    readGlyph: function(index) {
        var offset = this.getGlyphOffset(index);
        var file = this.file;

        if (offset >= this.tables["glyf"].offset + this.tables["glyf"].length)
        {
            return null;
        }

        assert(offset >= this.tables["glyf"].offset);
        assert(offset < this.tables["glyf"].offset + this.tables["glyf"].length);

        file.seek(offset);

        var glyph = {
            numberOfContours: file.getInt16(),
            xMin: file.getFword(),
            yMin: file.getFword(),
            xMax: file.getFword(),
            yMax: file.getFword()
        };

        assert(glyph.numberOfContours >= -1);

        if (glyph.numberOfContours === -1) {
            this.readCompoundGlyph(file, glyph);
        } else {
            this.readSimpleGlyph(file, glyph);
        }

        return glyph;
    },

简单字形以压缩格式存储。通过使用一系列单字节标识,可以很好地处理重复点以及邻点之间的变动情况。对每一个XY坐标,每个标识字节指示对应点是存储在一个字节还是两个字节中。标志数组之后是X坐标,最后是Y坐标数组。这样设计的好处是,如果X或Y坐标没改变,那么只需要一个字节就可以存储这个点。

我们读取每一个字形,并把这些点拼成(x,y)坐标数组,并记录对渲染非常重要的标识。

 readSimpleGlyph: function(file, glyph) {

        var ON_CURVE        =  1,
            X_IS_BYTE       =  2,
            Y_IS_BYTE       =  4,
            REPEAT          =  8,
            X_DELTA         = 16,
            Y_DELTA         = 32;

        glyph.type = "simple";
        glyph.contourEnds = [];
        var points = glyph.points = [];

        for( var i = 0; i < glyph.numberOfContours; i++ ) {
            glyph.contourEnds.push(file.getUint16());
        }

        // skip over intructions
        file.seek(file.getUint16() + file.tell());

        if (glyph.numberOfContours === 0) {
            return;
        }

        var numPoints = Math.max.apply(null, glyph.contourEnds) + 1;

        var flags = [];

        for( i = 0; i < numPoints; i++ ) {
            var flag = file.getUint8();
            flags.push(flag);
            points.push({
                onCurve: (flag & ON_CURVE) > 0
            });

            if ( flag & REPEAT ) {
                var repeatCount = file.getUint8();
                assert(repeatCount > 0);
                i += repeatCount;
                while( repeatCount-- ) {
                    flags.push(flag);
                    points.push({
                        onCurve: (flag & ON_CURVE) > 0
                    });
                }
            }
        }

        function readCoords(name, byteFlag, deltaFlag, min, max) {
            var value = 0;

            for( var i = 0; i < numPoints; i++ ) {
                var flag = flags[i];
                if ( flag & byteFlag ) {
                    if ( flag & deltaFlag ) {
                        value += file.getUint8();
                    } else {
                        value -= file.getUint8();
                    }
                } else if ( ~flag & deltaFlag ) {
                    value += file.getInt16();
                } else {
                    // value is unchanged.
                }

                points[i][name] = value;
            }
        }

        readCoords("x", X_IS_BYTE, X_DELTA, glyph.xMin, glyph.xMax);
        readCoords("y", Y_IS_BYTE, Y_DELTA, glyph.yMin, glyph.yMax);
    }

在网页中绘制字形

最后,我们应该为所有的努力展示一些东西 – 绘制字形。我们可以用HTML5画布API绘制。

这个函数用来控制整个流程。首先,从拖放事件中读取数组,并创建TrueType对象;接下来,删除之前绘制的字形;然后,针对每个字形,创建一个canvas元素并缩放字形,使其高度为字母’M’的高度–64像素;最后,由于字体坐标原点在屏幕左下角,但canvas坐标原点在左上角,所以需要垂直翻转一下。

function ShowTtfFile(arrayBuffer)
{
    var font = new TrueTypeFont(arrayBuffer);

    var width = font.xMax - font.xMin;
    var height = font.yMax - font.yMin;
    var scale = 64 / font.unitsPerEm;

    var container = document.getElementById("font-container");

    while(container.firstChild) {
        container.removeChild(container.firstChild);
    }

    for( var i = 0; i < font.length; i++ ) {
        var canvas = document.createElement("canvas");
        canvas.style.border = "1px solid gray";
        canvas.width = width * scale;
        canvas.height = height * scale;
        var ctx = canvas.getContext("2d");
        ctx.scale(scale, -scale);
        ctx.translate(-font.xMin, -font.yMin - height);
        ctx.fillStyle = "#000000";
        ctx.beginPath();
        if (font.drawGlyph(i, ctx)) {
            ctx.fill();
            container.appendChild(canvas);
        }
    }

}

这里展示了它们是如何绘制的。在此函数中,我们忽略了曲线上的控制点,简单连接了轮廓中的每个点。

 drawGlyph: function(index, ctx) {

        var glyph = this.readGlyph(index);

        if ( glyph === null || glyph.type !== "simple" ) {
            return false;
        }

        var p = 0,
            c = 0,
            first = 1;

        while (p < glyph.points.length) {
            var point = glyph.points[p];
            if ( first === 1 ) {
                ctx.moveTo(point.x, point.y);
                first = 0;
            } else {
                ctx.lineTo(point.x, point.y);
            }

            if ( p === glyph.contourEnds[c] ) {
                c += 1;
                first = 1;
            }

            p += 1;
        }

        return true;
    }

源码

node cli 开发 (command line interface)

开发 node cli 的核心是依赖 node process 来获取所输入进命令行的参数,根据参数来执行任务。
例子

// 新建一个cli.js 文件写入如下代码
// 写的文件头部加`#!/usr/bin/env node`,用于动态检测出不同用户各自的 node 路径并执行
#!/usr/bin/env node
console.log(process.argv)

在terminal中执行如下命令

node cli.js name age place
// 输出显示如下结果
[ '/usr/local/bin/node',
  '/your_programs/cli.js',
  'name',
  'age',
  'place' ]

从上面结果可以看出来返回一个数组。
数组第一个字符串含义为 node 命令所在的目录;
数组第二个字符串含义为执行文件所在的目录;
之后的字符串为在命令行输入的参数。

以上为获取参数方法,下面让我们看看如何封装自定义命令
很简单,在package.json文件中增加 key 为 bin 的值。
例子

{
  "name": "cli-exercise",
  "bin": {
    "cli": "dist/index.js"
  }
}

bin 的值可以为key-value形式,也可以是字符串。
当为key-value形式的时候,key为自定义的命令,value为输入命令后执行的文件。
当为字符串的时候,则只代表执行的文件,自定义命令则为package.jsonkeyname的值。

做完这些我们如何在本地测试呢?
在项目目录下执行
npm link
执行cli name age place
输出如下结果

[ '/usr/local/bin/node',
  '/your_programs/cli.js',
  'name',
  'age',
  'place' ]

最后一步:
上传到npm上我们就可以全局安装使用了。

以上我们就完成了 node cli 简单的工具开发。

示例项目:
cli-exercise
GitHub npm

javascript URL 编码与解码

编码与解码函数

编码函数

encodeURI
encodeURIComponent

解码函数

decodeURI
decodeURIComponent

编码规则相同点

会替换所有的字符,但不包括以下字符
非转义的字符:字母 数字 – _ . ! ~ * ‘ ( )

编码规则不同点

encodeURI 还会有一些不编码的字符,如下
保留字符:; , / ? : @ & = + $
数字符号:#

解码规则

解码只会解码对应规则编码出来的字符串,若不会解码出乱码。
encodeURI ->(对应) decodeURI
encodeURIComponent -> (对应) decodeURIComponent
这里的编码你可以用JavaScript自带的编码函数,当然你也可以按照规则和标准自行开发编码函数。

实战应用举例

说了这么多可能很多人就问了,JavaScript自带的编码与解码函数在实际中是如何应用的,为什么需要两个不同的编码与解码函数。
const url = "https://auto.3g.163.com/";

// 字符串编码
const params = {
  title: "今天去干什么?& 今天去哪里浪?",
  desc: "去哪里都行 & 哪里都不去!!!"
};

function compURL(url, params) {
  let result = encodeURI(url) + "?";
  for (let key in params) {
    result +=
      encodeURIComponent(key) + "=" + encodeURIComponent(params[key]) + "&";
  } //这样即使带有一些符号 ? & 等也可以方便的从url 中获取参数
  return result.slice(0, -1);
}

const urlCompd = compURL(url, params);

console.log(urlCompd);

// 字符窜解码获取数据

function parseURL(url) {
  const result = {
    sourceUrl: "",
    params: {}
  };
  result.sourceUrl = decodeURI(url.split("?")[0]);
  const paramStr = url.split("?")[1];
  const paramArr = paramStr.split("&");
  paramArr.forEach(item => {
    const itemArr = item.split("=");
    const key = decodeURIComponent(itemArr[0]),
      value = decodeURIComponent(itemArr[1]);
    result.params[key] = value;
  });
  return result;
}

const paramsData = parseURL(urlCompd);
console.log(paramsData);

在线代码

前端异常监控解决方案研究

前端监控包括行为监控、异常监控、性能监控等,本文主要讨论异常监控。对于前端而言,和后端处于同一个监控系统中,前端有自己的监控方案,后端也有自己等监控方案,但两者并不分离,因为一个用户在操作应用过程中如果出现异常,有可能是前端引起,也有可能是后端引起,需要有一个机制,将前后端串联起来,使监控本身统一于监控系统。因此,即使只讨论前端异常监控,其实也不能严格区分前后端界限,而要根据实际系统的设计,在最终的报表中体现出监控对开发和业务的帮助。

一般而言,一个监控系统,大致可以分为四个阶段:日志采集、日志存储、统计与分析、报告和警告。

采集阶段:收集异常日志,先在本地做一定的处理,采取一定的方案上报到服务器。

存储阶段:后端接收前端上报的异常日志,经过一定处理,按照一定的存储方案存储。

分析阶段:分为机器自动分析和人工分析。机器自动分析,通过预设的条件和算法,对存储的日志信息进行统计和筛选,发现问题,触发报警。人工分析,通过提供一个可视化的数据面板,让系统用户可以看到具体的日志数据,根据信息,发现异常问题根源。

报警阶段:分为告警和预警。告警按照一定的级别自动报警,通过设定的渠道,按照一定的触发规则进行。预警则在异常发生前,提前预判,给出警告。

1 前端异常

前端异常是指在用户使用Web应用时无法快速得到符合预期结果的情况,不同的异常带来的后果程度不同,轻则引起用户使用不悦,重则导致产品无法使用,使用户丧失对产品的认可。

1.1 前端异常分类

根据异常代码的后果的程度,对前端异常的表现分为如下几类

a. 出错

界面呈现的内容与用户预期的内容不符,例如点击进入非目标界面,数据不准确,出现的错误提示不可理解,界面错位,提交后跳转到错误界面等情况。这类异常出现时,虽然产品本身功能还能正常使用,但用户无法达成自己目标。

b. 呆滞

界面出现操作后没有反应的现象,例如点击按钮无法提交,提示成功后无法继续操作。这类异常出现时,产品已经存在界面级局部不可用现象。

c. 损坏

界面出现无法实现操作目的的现象,例如点击无法进入目标界面,点击无法查看详情内容等。这类异常出现时,应用部分功能无法被正常使用。

d. 假死

界面出现卡顿,无法对任何功能进行使用的现象。例如用户无法登陆导致无法使用应用内功能,由于某个遮罩层阻挡且不可关闭导致无法进行任何后续操作。这类异常出现时,用户很可能杀死应用。

e. 崩溃

应用出现经常性自动退出或无法操作的现象。例如间歇性crash,网页无法正常加载或加载后无法进行任何操作。这类异常持续出现,将直接导致用户流失,影响产品生命力。

1.2 异常错误原因分类

前端产生异常的原因主要分5类:

原因案例频率
逻辑错误1)    业务逻辑判断条件错误
2)    事件绑定顺序错误
3)    调用栈时序错误
4)    错误的操作js对象
经常
数据类型错误1)    将null视作对象读取property
2)    将undefined视作数组进行遍历
3)    将字符串形式的数字直接用于加运算
4)    函数参数未传
经常
语法句法错误较少
网络错误1)    慢
2)    服务端未返回数据但仍200,前端按正常进行数据遍历
3)    提交数据时网络中断
4)    服务端500错误时前端未做任何错误处理
偶尔
系统错误1)    内存不够用
2)    磁盘塞满
3)    壳不支持API
4)    不兼容
较少

2 异常采集

2.1 采集内容

当异常出现的时候,我们需要知道异常的具体信息,根据异常的具体信息来决定采用什么样的解决方案。在采集异常信息时,可以遵循4W原则:

WHO did WHAT and get WHICH exception in WHICH environment?

a. 用户信息

出现异常时该用户的信息,例如该用户在当前时刻的状态、权限等,以及需要区分用户可多终端登录时,异常对应的是哪一个终端。

b. 行为信息

用户进行什么操作时产生了异常:所在的界面路径;执行了什么操作;操作时使用了哪些数据;当时的API吐了什么数据给客户端;如果是提交操作,提交了什么数据;上一个路径;上一个行为日志记录ID等。

c. 异常信息

产生异常的代码信息:用户操作的DOM元素节点;异常级别;异常类型;异常描述;代码stack信息等。

d. 环境信息

网络环境;设备型号和标识码;操作系统版本;客户端版本;API接口版本等。

字段类型解释
requestIdString一个界面产生一个requestId
traceIdString一个阶段产生一个traceId,用于追踪和一个异常相关的所有日志记录
hashString这条log的唯一标识码,相当于logId,但它是根据当前日志记录的具体内容而生成的
timeNumber当前日志产生的时间(保存时刻)
userIdString
userStatusNumber当时,用户状态信息(是否可用/禁用)
userRolesArray当时,前用户的角色列表
userGroupsArray当时,用户当前所在组,组别权限可能影响结果
userLicensesArray当时,许可证,可能过期
pathString所在路径,URL
actionString进行了什么操作
refererString上一个路径,来源URL
prevActionString上一个操作
dataObject当前界面的state、data
dataSourcesArray<Object>上游api给了什么数据
dataSendObject提交了什么数据
targetElementHTMLElement用户操作的DOM元素
targetDOMPathArray<HTMLElement>该DOM元素的节点路径
targetCSSObject该元素的自定义样式表
targetAttrsObject该元素当前的属性及值
errorTypeString错误类型
errorLevelString异常级别
errorStackString错误stack信息
errorFilenameString出错文件
errorLineNoNumber出错行
errorColNoNumber出错列位置
errorMessageString错误描述(开发者定义)
errorTimeStampNumber时间戳
eventTypeString事件类型
pageXNumber事件x轴坐标
pageYNumber事件y轴坐标
screenXNumber事件x轴坐标
screenYNumber事件y轴坐标
pageWNumber页面宽度
pageHNumber页面高度
screenWNumber屏幕宽度
screenHNumber屏幕高度
eventKeyString触发事件的键
networkString网络环境描述
userAgentString客户端描述
deviceString设备描述
systemString操作系统描述
appVersionString应用版本
apiVersionString接口版本

这是一份非常庞大的日志字段表,它几乎囊括了一个异常发生时,能够对异常周遭环境进行详细描述的所有信息。不同情况下,这些字段并不一定都会收集,由于我们会采用文档数据库存储日志,因此,并不影响它的实际存储结果。

2.2 异常捕获

前端捕获异常分为全局捕获和单点捕获。全局捕获代码集中,易于管理;单点捕获作为补充,对某些特殊情况进行捕获,但分散,不利于管理。

a、全局捕获

通过全局的接口,将捕获代码集中写在一个地方,可以利用的接口有:

  • window.addEventListener(‘error’) / window.addEventListener(“unhandledrejection”) / document.addEventListener(‘click’) 等
  • 框架级别的全局监听,例如aixos中使用interceptor进行拦截,vue、react都有自己的错误采集接口
  • 通过对全局函数进行封装包裹,实现在在调用该函数时自动捕获异常
  • 对实例方法重写(Patch),在原有功能基础上包裹一层,例如对console.error进行重写,在使用方法不变的情况下也可以异常捕获

b、单点捕获

在业务代码中对单个代码块进行包裹,或在逻辑流程中打点,实现有针对性的异常捕获:

  • try…catch
  • 专门写一个函数来收集异常信息,在异常发生时,调用该函数
  • 专门写一个函数来包裹其他函数,得到一个新函数,该新函数运行结果和原函数一模一样,只是在发生异常时可以捕获异常

2.3 跨域脚本异常

由于浏览器安全策略限制,跨域脚本报错时,无法直接获取错误的详细信息,只能得到一个Script Error。例如,我们会引入第三方依赖,或者将自己的脚本放在CDN时。

解决Script Error的方法:

方案一:

  • 将js内联到HTML中
  • 将js文件与HTML放在同域下

方案二:

  1. 为页面上script标签添加crossorigin属性
  2. 被引入脚本所在服务端响应头中,增加 Access-Control-Allow-Origin 来支持跨域资源共享

2.4 异常录制

对于一个异常,仅仅拥有该异常的信息还不足以完全抓住问题的本质,因为异常发生的位置,并不一定是异常根源所在的位置。我们需要对异常现场进行还原,才能复原问题全貌,甚至避免类似问题在其他界面中发生。这里需要引进一个概念,就是“异常录制”。录制通过“时间”“空间”两个维度记录异常发生前到发生的整个过程,对于找到异常根源更有帮助。

上图表示,当异常发生时,异常的根源可能离我们很远,我们需要回到异常发生的现场,找到异常根源。就像现实生活中破案一样,如果有监控摄影机对案发过程的录影,对破案来说更加容易。如果仅仅关注异常本身,要找到异常的根源,需要凭借运气,但有了异常录制的帮助,找到根源就更加容易。

所谓的“异常录制”,实际上就是通过技术手段,收集用户的操作过程,对用户的每一个操作都进行记录,在发生异常时,把一定时间区间内的记录重新运行,形成影像进行播放,让调试者无需向用户询问,就能看到用户当时的操作过程。

上图是来自阿里的一套异常录制还原方案示意图,用户在界面上的操作产生的events和mutation被产品收集起来,上传到服务器,经过队列处理按顺序存放到数据库中。当需要进行异常重现的时候,将这些记录从数据库中取出,采用一定的技术方案,顺序播放这些记录,即可实现异常还原。

2.5 异常级别

一般而言,我们会将收集信息的级别分为info,warn,error等,并在此基础上进行扩展。

当我们监控到异常发生时,可以将该异常划分到“重要——紧急”模型中分为A、B、C、D四个等级。有些异常,虽然发生了,但是并不影响用户的正常使用,用户其实并没有感知到,虽然理论上应该修复,但是实际上相对于其他异常而言,可以放在后面进行处理。

下文会讨论告警策略,一般而言,越靠近右上角的异常会越快通知,保证相关人员能最快接收到信息,并进行处理。A级异常需要快速响应,甚至需要相关负责人知悉。

在收集异常阶段,可根据第一节划分的异常后果来判断异常的严重程度,在发生异常时选择对应的上报方案进行上报。

3 整理与上报方案

前文已经提到,除了异常报错信息本身,我们还需要记录用户操作日志,以实现场景复原。这就涉及到上报的量和频率问题。如果任何日志都立即上报,这无异于自造的DDOS攻击。因此,我们需要合理的上报方案。下文会介绍4种上报方案,但实际我们不会仅限于其中一种,而是经常同时使用,对不同级别的日志选择不同的上报方案。

3.1 前端存储日志

我们前面提到,我们并不单单采集异常本身日志,而且还会采集与异常相关的用户行为日志。单纯一条异常日志并不能帮助我们快速定位问题根源,找到解决方案。但如果要收集用户的行为日志,又要采取一定的技巧,而不能用户每一个操作后,就立即将该行为日志传到服务器,对于具有大量用户同时在线的应用,如果用户一操作就立即上传日志,无异于对日志服务器进行DDOS攻击。因此,我们先将这些日志存储在用户客户端本地,达到一定条件之后,再同时打包上传一组日志。

那么,如何进行前端日志存储呢?我们不可能直接将这些日志用一个变量保存起来,这样会挤爆内存,而且一旦用户进行刷新操作,这些日志就丢失了,因此,我们自然而然想到前端数据持久化方案。

目前,可用的持久化方案可选项也比较多了,主要有:Cookie、localStorage、sessionStorage、IndexedDB、webSQL 、FileSystem 等等。那么该如何选择呢?我们通过一个表来进行对比:

存储方式cookielocalStoragesessionStorageIndexedDBwebSQLFileSystem
类型key-valuekey-valueNoSQLSQL
数据格式stringstringstringobject
容量4k5M5M500M60M
进程同步同步同步异步异步
检索keykeykey, indexfield
性能读快写慢读慢写快

综合之后,IndexedDB是最好的选择,它具有容量大、异步的优势,异步的特性保证它不会对界面的渲染产生阻塞。而且IndexedDB是分库的,每个库又分store,还能按照索引进行查询,具有完整的数据库管理思维,比localStorage更适合做结构化数据管理。但是它有一个缺点,就是api非常复杂,不像localStorage那么简单直接。针对这一点,我们可以使用hello-indexeddb这个工具,它用Promise对复杂api进行来封装,简化操作,使IndexedDB的使用也能做到localStorage一样便捷。另外,IndexedDB是被广泛支持的HTML5标准,兼容大部分浏览器,因此不用担心它的发展前景。

接下来,我们究竟应该怎么合理使用IndexedDB,保证我们前端存储的合理性呢?

上图展示了前端存储日志的流程和数据库布局。当一个事件、变动、异常被捕获之后,形成一条初始日志,被立即放入暂存区(indexedDB的一个store),之后主程序就结束了收集过程,后续的事只在webworker中发生。在一个webworker中,一个循环任务不断从暂存区中取出日志,对日志进行分类,将分类结果存储到索引区中,并对日志记录的信息进行丰富,将最终将会上报到服务端的日志记录转存到归档区。而当一条日志在归档区中存在的时间超过一定天数之后,它就已经没有价值了,但是为了防止特殊情况,它被转存到回收区,再经历一段时间后,就会被从回收区中清除。

3.2 前端整理日志

上文讲到,在一个webworker中对日志进行整理后存到索引区和归档区,那么这个整理过程是怎样的呢?

由于我们下文要讲的上报,是按照索引进行的,因此,我们在前端的日志整理工作,主要就是根据日志特征,整理出不同的索引。我们在收集日志时,会给每一条日志打上一个type,以此进行分类,并创建索引,同时通过object-hashcode计算每个log对象的hash值,作为这个log的唯一标志。

  • 将所有日志记录按时序存放在归档区,并将新入库的日志加入索引
  • BatchIndexes:批量上报索引(包含性能等其他日志),可一次批量上报100条
  • MomentIndexes:即时上报索引,一次全部上报
  • FeedbackIndexes:用户反馈索引,一次上报一条
  • BlockIndexes:区块上报索引,按异常/错误(traceId,requestId)分块,一次上报一块
  • 上报完成后,被上报过的日志对应的索引删除
  • 3天以上日志进入回收区
  • 7天以上的日志从回收区清除

rquestId:同时追踪前后端日志。由于后端也会记录自己的日志,因此,在前端请求api的时候,默认带上requestId,后端记录的日志就可以和前端日志对应起来。

traceId:追踪一个异常发生前后的相关日志。当应用启动时,创建一个traceId,直到一个异常发生时,刷新traceId。把一个traceId相关的requestId收集起来,把这些requestId相关的日志组合起来,就是最终这个异常相关的所有日志,用来对异常进行复盘。

上图举例展示了如何利用traceId和requestId找出和一个异常相关的所有日志。在上图中,hash4是一条异常日志,我们找到hash4对应的traceId为traceId2,在日志列表中,有两条记录具有该traceId,但是hash3这条记录并不是一个动作的开始,因为hash3对应的requestId为reqId2,而reqId2开始于hash2,因此,我们实际上要把hash2也加入到该异常发生的整个复盘备选记录中。总结起来就是,我们要找出同一个traceId对应的所有requestId对应的日志记录,虽然有点绕,但稍理解就可以明白其中的道理。

我们把这些和一个异常相关的所有日志集合起来,称为一个block,再利用日志的hash集合,得出这个block的hash,并在索引区中建立索引,等待上报。

3.3 上报日志

上报日志也在webworker中进行,为了和整理区分,可以分两个worker。上报的流程大致为:在每一个循环中,从索引区取出对应条数的索引,通过索引中的hash,到归档区取出完整的日志记录,再上传到服务器。

按照上报的频率(重要紧急度)可将上报分为四种:

a. 即时上报

收集到日志后,立即触发上报函数。仅用于A类异常。而且由于受到网络不确定因素影响,A类日志上报需要有一个确认机制,只有确认服务端已经成功接收到该上报信息之后,才算完成。否则需要有一个循环机制,确保上报成功。

b. 批量上报

将收集到的日志存储在本地,当收集到一定数量之后再打包一次性上报,或者按照一定的频率(时间间隔)打包上传。这相当于把多次合并为一次上报,以降低对服务器的压力。

c. 区块上报

将一次异常的场景打包为一个区块后进行上报。它和批量上报不同,批量上报保证了日志的完整性,全面性,但会有无用信息。而区块上报则是针对异常本身的,确保单个异常相关的日志被全部上报。

d. 用户主动提交

在界面上提供一个按钮,用户主动反馈bug。这有利于加强与用户的互动。

或者当异常发生时,虽然对用户没有任何影响,但是应用监控到了,弹出一个提示框,让用户选择是否愿意上传日志。这种方案适合涉及用户隐私数据时。

即时上报批量上报区块上报用户反馈
时效立即定时稍延时延时
条数一次全部上报一次100条单次上报相关条目一次1条
容量
紧急紧急重要不紧急不紧急但重要不紧急

即时上报虽然叫即时,但是其实也是通过类似队列的循环任务去完成的,它主要是尽快把一些重要的异常提交给监控系统,好让运维人员发现问题,因此,它对应的紧急程度比较高。

批量上报和区块上报的区别:批量上报是一次上报一定条数,比如每2分钟上报1000条,直到上报完成。而区块上报是在异常发生之后,马上收集和异常相关的所有日志,查询出哪些日志已经由批量上报上报过了,剔除掉,把其他相关日志上传,和异常相关的这些日志相对而言更重要一些,它们可以帮助尽快复原异常现场,找出发生异常的根源。

用户提交的反馈信息,则可以慢悠悠上报上去。

为了确保上报是成功的,在上报时需要有一个确认机制,由于在服务端接收到上报日志之后,并不会立即存入数据库,而是放到一个队列中,因此,前后端在确保日志确实已经记录进数据库这一点上需要再做一些处理。

上图展示了上报的一个大致流程,在上报时,先通过hash查询,让客户端知道准备要上报的日志集合中,是否存在已经被服务端保存好的日志,如果已经存在,就将这些日志去除,避免重复上报,浪费流量。

3.4 压缩上报数据

一次性上传批量数据时,必然遇到数据量大,浪费流量,或者传输慢等情况,网络不好的状态下,可能导致上报失败。因此,在上报之前进行数据压缩也是一种方案。

对于合并上报这种情况,一次的数据量可能要十几k,对于日 pv 大的站点来说,产生的流量还是很可观的。所以有必要对数据进行压缩上报。lz-string是一个非常优秀的字符串压缩类库,兼容性好,代码量少,压缩比高,压缩时间短,压缩率达到惊人的60%。但它基于LZ78压缩,如果后端不支持解压,可选择gzip压缩,一般而言后端会默认预装gzip,因此,选择gzip压缩数据也可以,工具包pako中自带了gzip压缩,可以尝试使用。

4 日志接收与存储

4.1 接入层与消息队列

一般通过提供独立的日志服务器接收客户端日志,接收过程中,要对客户端日志内容的合法性、安全性等进行甄别,防止被人攻击。而且由于日志提交一般都比较频繁,多客户端同时并发的情况也常见。通过消息队列将日志信息逐一处理后写入到数据库进行保存也是比较常用的方案。

上图为腾讯BetterJS的架构图,其中“接入层”和“推送中心”就是这里提到的接入层和消息队列。BetterJS将整个前端监控的各个模块进行拆分,推送中心承担了将日志推送到存储中心进行存储和推送给其他系统(例如告警系统)的角色,但我们可以把接收日志阶段的队列独立出来看,在接入层和存储层之间做一个过渡。

4.2 日志存储系统

存储日志是一个脏活累活,但是不得不做。对于小应用,单库单表加优化就可以应付。一个成规模的应用,如果要提供更标准高效的日志监控服务,常常需要在日志存储架构上下一些功夫。目前业界已经有比较完备的日志存储方案,主要有:Hbase系,Dremel系,Lucene系等。总体而言,日志存储系统主要面对的问题是数据量大,数据结构不规律,写入并发高,查询需求大等。一般一套日志存储系统,要解决上面这些问题,就要解决写入的缓冲,存储介质按日志时间选择,为方便快速读取而设计合理的索引系统等等。

由于日志存储系统方案比较成熟,这里就不再做更多讨论。

4.3 搜索

日志的最终目的是要使用,由于一般日志的体量都非常大,因此,要在庞大的数据中找到需要的日志记录,需要依赖比较好的搜索引擎。Splunk是一套成熟的日志存储系统,但它是付费使用的。按照Splunk的框架,Elk是Splunk的开源实现,Elk是ElasticSearch、Logstash、Kibana的结合,ES基于Lucene的存储、索引的搜索引擎;logstash是提供输入输出及转化处理插件的日志标准化管道;Kibana提供可视化和查询统计的用户界面。

5 日志统计与分析

一个完善的日志统计分析工具需要提供各方面方便的面板,以可视化的方式给日志管理员和开发者反馈信息。

5.1 用户纬度

同一个用户的不同请求实际上会形成不同的story线,因此,针对用户的一系列操作设计唯一的request id是有必要的。同一个用户在不同终端进行操作时,也能进行区分。用户在进行某个操作时的状态、权限等信息,也需要在日志系统中予以反应。

5.2 时间维度

一个异常是怎么发生的,需要将异常操作的前后story线串联起来观察。它不单单涉及一个用户的一次操作,甚至不限于某一个页面,而是一连串事件的最终结果。

5.3 性能维度

应用运行过程中的性能情况,例如,界面加载时间,api请求时长统计,单元计算的消耗,用户呆滞时间。

5.4 运行环境维度

应用及服务所运行的环境情况,例如应用所在的网络环境,操作系统,设备硬件信息等,服务器cpu、内存状况,网络、宽带使用情况等。

5.4 细粒度代码追踪

异常的代码stack信息,定位到发生异常的代码位置和异常堆栈。

5.6 场景回溯

通过将异常相关的用户日志连接起来,以动态的效果输出发生异常的过程。

6 监控与通知

对异常进行统计和分析只是基础,而在发现异常时可以推送和告警,甚至做到自动处理,才是一个异常监控系统应该具备的能力。

6.1 自定义触发条件的告警

a. 监控实现

当日志信息进入接入层时,就可以触发监控逻辑。当日志信息中存在较为高级别的异常时,也可以立即出发告警。告警消息队列和日志入库队列可以分开来管理,实现并行。

对入库日志信息进行统计,对异常信息进行告警。对监控异常进行响应。所谓监控异常,是指:有规律的异常一般而言都比较让人放心,比较麻烦的是突然之间的异常。例如在某一时段突然频繁接收到D级异常,虽然D级异常是不紧急一般重要,但是当监控本身发生异常时,就要提高警惕。

b. 自定义触发条件

除了系统开发时配置的默认告警条件,还应该提供给日志管理员可配置的自定义触发条件。

  • 日志内含有什么内容时
  • 日志统计达到什么度、量时
  • 向符合什么条件的用户告警

6.2 推送渠道

可选择的途径有很多,例如邮件、短信、微信、电话。

6.3 推送频率

针对不同级别的告警,推送的频率也可以进行设定。低风险告警可以以报告的形式一天推送一次,高风险告警10分钟循环推送,直到处理人手动关闭告警开关。

6.4 自动报表

对于日志统计信息的推送,可以做到自动生成日报、周报、月报、年报并邮件发送给相关群组。

6.5 自动产生bug工单

当异常发生时,系统可以调用工单系统API实现自动生成bug单,工单关闭后反馈给监控系统,形成对异常处理的追踪信息进行记录,在报告中予以展示。

7 修复异常

7.1 sourcemap

前端代码大部分情况都是经过压缩后发布的,上报的stack信息需要还原为源码信息,才能快速定位源码进行修改。

发布时,只部署js脚本到服务器上,将sourcemap文件上传到监控系统,在监控系统中展示stack信息时,利用sourcemap文件对stack信息进行解码,得到源码中的具体信息。

但是这里有一个问题,就是sourcemap必须和正式环境的版本对应,还必须和git中的某个commit节点对应,这样才能保证在查异常的时候可以正确利用stack信息,找到出问题所在版本的代码。这些可以通过建立CI任务,在集成化部署中增加一个部署流程,以实现这一环节。

7.2 从告警到预警

预警的本质是,预设可能出现异常的条件,当触发该条件时异常并没有真实发生,因此,可以赶在异常发生之前对用户行为进行检查,及时修复,避免异常或异常扩大。

怎么做呢?其实就是一个统计聚类的过程。将历史中发生异常的情况进行统计,从时间、地域、用户等不同维度加以统计,找出规律,并将这些规律通过算法自动加入到预警条件中,当下次触发时,及时预警。

7.3 智能修复

自动修复错误。例如,前端要求接口返回数值,但接口返回了数值型的字符串,那么可以有一种机制,监控系统发送正确数据类型模型给后端,后端在返回数据时,根据该模型控制每个字段的类型。

8 异常测试

8.1 主动异常测试

撰写异常用例,在自动化测试系统中,加入异常测试用户。在测试或运行过程中,每发现一个异常,就将它加入到原有的异常用例列表中。

8.2 随机异常测试

模拟真实环境,在模拟器中模拟真实用户的随机操作,利用自动化脚本产生随机操作动作代码,并执行。

定义异常,例如弹出某个弹出框,包含特定内容时,就是异常。将这些测试结果记录下来,再聚类统计分析,对防御异常也很有帮助。

9 部署

9.1 多客户端

一个用户在不同终端上登录,或者一个用户在登录前和登录后的状态。通过特定算法生成requestID,通过该requestId可以确定某个用户在独立客户端上的一系列操作,根据日志时序,可以梳理出用户产生异常的具体路径。

9.2 集成便捷性

前端写成包,全局引用即可完成大部分日志记录、存储和上报。在特殊逻辑里面,可以调用特定方法记录日志。

后端与应用本身的业务代码解耦,可以做成独立的服务,通过接口和第三方应用交互。利用集成部署,可以将系统随时进行扩容、移植等操作。

9.3 管理系统的可扩展

整套系统可扩展,不仅服务单应用,可支持多个应用同时运行。同一个团队下的所有应用都可以利用同一个平台进行管理。

9.4 日志系统权限

不同的人在访问日志系统时权限不同,一个访问者只能查看自己相关的应用,有些统计数据如果比较敏感,可以单独设置权限,敏感数据可脱敏。

10 其他

10.1 性能监控

异常监控主要针对代码级别的报错,但也应该关注性能异常。性能监控主要包括:

  • 运行时性能:文件级、模块级、函数级、算法级
  • 网络请求速率
  • 系统性能

10.2 API Monitor

后端API对前端的影响也非常大,虽然前端代码也控制逻辑,但是后端返回的数据是基础,因此对API的监控可以分为:

  • 稳定性监控
  • 数据格式和类型
  • 报错监控
  • 数据准确性监控

10.3 数据脱敏

敏感数据不被日志系统采集。由于日志系统的保存是比较开放的,虽然里面的数据很重要,但是在存储上大部分日志系统都不是保密级,因此,如果应用涉及了敏感数据,最好做到:

  • 独立部署,不和其他应用共享监控系统
  • 不采集具体数据,只采集用户操作数据,在重现时,通过日志信息可以取出数据api结果来展示
  • 日志加密,做到软硬件层面的加密防护
  • 必要时,可采集具体数据的ID用于调试,场景重现时,用mock数据替代,mock数据可由后端采用假的数据源生成
  • 对敏感数据进行混淆

结语

本文主要是对前端异常监控的整体框架进行了研究,没有涉及到具体的技术实现,涉及前端部分和后台部分以及与整个问题相关的一些知识点,主要关注前端部分,它和后端的监控有重叠部分也有分支部分,需要在一个项目中不断实践,总结出项目本身的监控需求和策略。

移动端android webview video标签的问题

移动端浏览器的坑主要集中在android 区域,ios 只要你在apple store 上架必须用原生的webview 不能自己开发所以基本没什么天坑,比较统一好处理 详见  网友讨论

android 端问题主要出现在UC浏览器 , QQ浏览器 ,微信webview …… 目前我测试出来的。主要表现为video 标签高度不可控制。不按国际标准执行。非常恶心!!!想用canvas 播放视频也无法实现,canvas 标签无法截取到视频画面,或者截取的是花屏的画面。所以目前大家就不要花时间测试了😇

 

60行JavaScript代码俄罗斯方块游戏解析

总结起来主要是以下三点

1.使用eval来产生JavaScript代码,减小了代码体积
2.以字符串作为游戏场景数据,使用正则表达式做查找和匹配,省去了通常应当手动编写的查找验证代码
3.以二进制方式管理俄罗斯方块数据和场景数据,通过位运算简化比较和验证
另外,原作者代码换行很少,代码写的比较紧凑,这也是导致这个程序仅仅只有60行的一个原因。

下面给出经过我排版注释后的代码。

 

  1 <!doctype html>  
  2 <html>
  3     <head>
  4         <title>俄罗斯方块</title>
  5     </head>
  6     
  7     <body>  
  8         <div id = "box"
  9              style = "margin : 20px auto;
 10                       text-align : center;
 11                       width : 252px;
 12                       font : 25px / 25px 宋体;
 13                       background : #000;
 14                       color : #9f9;
 15                       border : #999 20px ridge;
 16                       text-shadow : 2px 3px 1px #0f0;">
 17         </div>
 18         
 19         <script>  
 20             //eval的功能是把字符串变成实际运行时的JavaScript代码
 21             //这里代码变换之后相当于 var map = [0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0x801, 0xfff];
 22             //其二进制形式如下
 23             //100000000001 十六进制对照 0x801
 24             //100000000001              0x801
 25             //100000000001              0x801
 26             //100000000001              0x801
 27             //100000000001              0x801
 28             //100000000001              0x801
 29             //100000000001              0x801
 30             //100000000001              0x801
 31             //100000000001              0x801
 32             //100000000001              0x801
 33             //100000000001              0x801
 34             //100000000001              0x801
 35             //100000000001              0x801
 36             //100000000001              0x801
 37             //100000000001              0x801
 38             //100000000001              0x801
 39             //100000000001              0x801            
 40             //100000000001              0x801
 41             //100000000001              0x801
 42             //100000000001              0x801
 43             //100000000001              0x801
 44             //100000000001              0x801
 45             //111111111111              0xfff
 46             //数据呈U形分布,没错,这就是俄罗斯方块的地图(或者游戏场地更为合适?)的存储区
 47             var map = eval("[" + Array(23).join("0x801,") + "0xfff]"); 
 48             
 49             //这个锯齿数组存储的是7种俄罗斯方块的图案信息
 50             //俄罗斯方块在不同的旋转角度下会产生不同的图案,当然可以通过算法实现旋转图案生成,这里为了减少代码复杂性直接给出了不同旋转状态下的图案数据
 51             //很明显,第一个0x6600就是7种俄罗斯方块之中的正方形方块
 52             //0x6600二进制分四行表示如下
 53             //0110
 54             //0110
 55             //0000
 56             //0000
 57             //这就是正方形图案的表示,可以看出,这里统一采用一个16位数来存储4 * 4的俄罗斯方块图案
 58             //因为正方形图案旋转不会有形状的改变,所以此行只存储了一个图案数据
 59             var tatris = [[0x6600],
 60                           [0x2222, 0x0f00],
 61                           [0xc600, 0x2640],
 62                           [0x6c00, 0x4620],
 63                           [0x4460, 0x2e0, 0x6220, 0x740],
 64                           [0x2260, 0x0e20, 0x6440, 0x4700], 
 65                           [0x2620, 0x720, 0x2320, 0x2700]];  
 66 
 67             //此对象之中存储的是按键键值(上,下,左,右)和函数之间的调用映射关系,之后通过eval可以做好调用映射
 68             var keycom = {"38" : "rotate(1)",
 69                           "40" : "down()",
 70                           "37" : "move(2, 1)",
 71                           "39" : "move(0.5, -1)"};
 72             
 73             //dia存储选取的俄罗斯方块类型(一共七种俄罗斯方块类型)
 74             //pos是前台正在下落的俄罗斯方块图案(每一种俄罗斯方块类型有属于自己的图案,如果不清楚可以查看上文的锯齿数组)对象
 75             //bak里存储关于pos图案对象的备份,在需要的时候可以实现对于pos运动的撤销
 76             var dia, pos, bak, run;
 77             
 78             //在游戏场景上方产生一个新的俄罗斯方块
 79             function start(){ 
 80             
 81                 //产生0~6的随机数,~运算符在JavaScript依然是位取反运算,隐式实现了浮点数到整形的转换,这是一个很丑陋的取整实现方式
 82                 //其作用是在七种基本俄罗斯方块类型之中随机选择一个
 83                 dia = tatris[~~(Math.random() * 7)];
 84                 
 85                 //pos和bak两个对象分别为前后台,实现俄罗斯方块运动的备份和撤销
 86                 bak = pos = {fk : [],                                    //这是一个数组存储的是图案转化之后的二进制数据
 87                              y : 0,                                        //初生俄罗斯方块的y坐标 
 88                              x : 4,                                     //初生俄罗斯方块的x坐标,相对于右侧
 89                              s : ~~(Math.random() * dia.length)};        //在特定的俄罗斯方块类型之中随机选择一个具体的图案
 90                 
 91                 //新生的俄罗斯方块不旋转,所以这里参数为0
 92                 rotate(0);
 93             } 
 94             
 95             //旋转,实际上这里做的处理只不过是旋转旋转之后的俄罗斯方块具体图案,之后进行移位,根据X坐标把位置移动到场景里对应的地点
 96             function rotate(r){ 
 97             
 98                 //这里是根据旋转参数 r 选择具体的俄罗斯方块图案,这里的 f ,就是上文之中的十六进制数
 99                 //这里把当前pos.s的值和r(也就是旋转角度)相加,最后和dia.length求余,实现了旋转循环
100                 var f = dia[pos.s = (pos.s + r) % dia.length];
101                 
102                 //根据f(也就是上文之中提供的 16 位数据)每4位一行填写到fk数组之中
103                 for(var i = 0; i < 4; i++) {
104                     
105                     //初生的俄罗斯方块pos.x的值为4,因为是4 * 4的团所以在宽度为12的场景里左移4位之后就位于中间四列范围内
106                     pos.fk[i] = (f >> (12 - i * 4) & 0x000f) << pos.x;
107                 }
108                 
109                 //更新场景
110                 update(is());  
111             }      
112 
113             //这是什么意思,这是一个判断,判断有没有重叠
114             function is(){  
115             
116                 //对于当前俄罗斯方块图案进行逐行分析
117                 for(var i = 0; i < 4; i++) {
118                 
119                     //把俄罗斯方块图案每一行的二进制位与场景内的二进制位进行位与,如果结果非0的话,那么这就证明图案和场景之中的实体(比如墙或者是已经落底的俄罗斯方块)重合了
120                     //既然重合了,那么之前的运动就是非法的,所以在这个if语句里面调用之前备份的bak实现对于pos的恢复
121                     if((pos.fk[i] & map[pos.y + i]) != 0) {
122                         
123                         return pos = bak;
124                     }                            
125                 }    
126 
127                 //如果没有重合,那么这里默认返回空
128             }      
129             
130             //此函数产生用于绘制场景的字符串并且写入到div之中完成游戏场景的更新
131             function update(t){  
132             
133                 //把pos备份到bak之中,slice(0)意为从0号开始到结束的数组,也就是全数组,这里不能直接赋值,否则只是建立引用关系,起不到数据备份的效果
134                 bak = {fk : pos.fk.slice(0), y : pos.y, x : pos.x, s : pos.s};  
135                 
136                 //如果俄罗斯方块和场景实体重合了的话,就直接return返回,不需要重绘场景
137                 if (t) {
138                 
139                     return;
140                 }
141                 
142                 //这里是根据map进行转换,转化得到的是01加上换行的原始串
143                 for(var i = 0, a2 = ""; i < 22; i++) {
144                 
145                     //br就是换行,在这个循环里,把地图之中所有数据以二进制数字的形式写入a2字符串
146                     //这里2是参数,指定基底,2的话就是返回二进制串的形式
147                     //slice(1, -1)这里的参数1,-1作用是取除了墙(收尾位)之外中间场景数据(10位)
148                     a2 += map[i].toString(2).slice(1, -1) + "<br/>";
149                 }
150                 
151                 //这里实现的是对于字符串的替换处理,就是把原始的01字符串转换成为方块汉字串
152                 for(var i = 0, n; i < 4; i++) {
153                 
154                     //这个循环处理的是正在下落的俄罗斯方块的绘制
155                     ////\u25a1是空格方块,这里也是隐式使用正则表达式
156                     if(/([^0]+)/.test(bak.fk[i].toString(2).replace(/1/g, "\u25a1"))) { 
157                     
158                         a2 = a2.substr(0, n = (bak.y + i + 1) * 15 - RegExp.$_.length - 4) + RegExp.$1 + a2.slice(n + RegExp.$1.length);
159                     }
160                 }
161                 
162                 //对于a2字符串进行替换,并且显示在div之中,这里是应用
163                 ////\u25a0是黑色方块 \u3000是空,这里实现的是替换div之中的文本,由数字替换成为两种方块或者空白
164                 document.getElementById("box").innerHTML = a2.replace(/1/g, "\u25a0").replace(/0/g, "\u3000");
165             }  
166         
167             //游戏结束
168             function over(){  
169             
170                 //撤销onkeydown的事件关联
171                 document.onkeydown = null;
172                 
173                 //清理之前设置的俄罗斯方块下落定时器
174                 clearInterval(run);
175                 
176                 //弹出游戏结束对话框
177                 alert("游戏结束");  
178             }  
179 
180             //俄罗斯方块下落
181             function down(){ 
182             
183                 //pos就是当前的(前台)俄罗斯方块,这里y坐标++,就相当于下落
184                 ++pos.y; 
185                 
186                 //如果俄罗斯方块和场景实体重合了的话
187                 if(is()){ 
188                 
189                     //这里的作用是消行
190                     for(var i = 0; i < 4 && pos.y + i < 22; i++) { 
191                     
192                         //和实体场景进行位或并且赋值,如果最后赋值结果为0xfff,也就说明当前行被完全填充了,可以消行
193                         if((map[pos.y + i] |= pos.fk[i]) == 0xfff) {
194                         
195                             //行删除
196                             map.splice(pos.y + i, 1);
197                             //首行添加,unshift的作用是在数组第0号元素之前添加新元素,新的元素作为数组首元素
198                             map.unshift(0x801);
199                         }
200                     }                                
201                     
202                     //如果最上面一行不是空了,俄罗斯方块垒满了,则游戏结束
203                     if(map[1] != 0x801) {
204                         
205                         return over();
206                     }
207                     
208                     //这里重新产生下一个俄罗斯方块
209                     start();  
210                 } 
211                 
212                 //否则的话更新,因为这里不是局部更新,是全局更新,所以重新绘制一下map就可以了
213                 update();  
214             }  
215 
216             //左右移动,t参数只能为2或者是0.5
217             //这样实现左移右移(相当于移位运算)这种方法也很丑陋,但是为了简短只能这样了
218             //这样做很丑陋,但是可以让代码简短一些
219             function move(t, k){  
220             
221                 pos.x += k;  
222                 
223                 for(var i = 0; i < 4; i++) { 
224                     
225                     //*=t在这里实现了左右移1位赋值的功能
226                     pos.fk[i] *= t;  
227                 }
228                 
229                 //左右移之后的更新,这里同样进行了重合判断,如果和左右墙重合的话,那么一样会撤销操作并且不更新场景
230                 update(is());  
231             }  
232 
233             //设置按键事件映射,这样按下键的时候就会触发对应的事件,具体来说就是触发对应的move,只有2和0.5
234             document.onkeydown = function(e) {  
235             
236                 //eval生成的JavaScript代码,在这里就被执行了
237                 eval(keycom[(e ? e : event).keyCode]);  
238             };
239               
240             //这样看来的话,这几乎是一个递归。。。
241             start();
242 
243             //设置俄罗斯方块下落定时器,500毫秒触发一次,调节这里的数字可以调整游戏之中俄罗斯方块下落的快慢
244             run = setInterval("down()", 500);
245         </script>
246     </body>
247 </html>

 

下面给出原作者代码,60行

 1 <!doctype html><html><head></head><body>
 2 <div id="box" style="width:252px;font:25px/25px 宋体;background:#000;color:#9f9;border:#999 20px ridge;text-shadow:2px 3px 1px #0f0;"></div>
 3 <script>
 4 var map=eval("["+Array(23).join("0x801,")+"0xfff]");
 5 var tatris=[[0x6600],[0x2222,0xf00],[0xc600,0x2640],[0x6c00,0x4620],[0x4460,0x2e0,0x6220,0x740],[0x2260,0xe20,0x6440,0x4700],[0x2620,0x720,0x2320,0x2700]];
 6 var keycom={"38":"rotate(1)","40":"down()","37":"move(2,1)","39":"move(0.5,-1)"};
 7 var dia, pos, bak, run;
 8 function start(){
 9     dia=tatris[~~(Math.random()*7)];
10     bak=pos={fk:[],y:0,x:4,s:~~(Math.random()*4)};
11     rotate(0);
12 }
13 function over(){
14     document.onkeydown=null;
15     clearInterval(run);
16     alert("GAME OVER");
17 }
18 function update(t){
19     bak={fk:pos.fk.slice(0),y:pos.y,x:pos.x,s:pos.s};
20     if(t) return;
21     for(var i=0,a2=""; i<22; i++)
22         a2+=map[i].toString(2).slice(1,-1)+"<br/>";
23     for(var i=0,n; i<4; i++)
24         if(/([^0]+)/.test(bak.fk[i].toString(2).replace(/1/g,"\u25a1")))
25             a2=a2.substr(0,n=(bak.y+i+1)*15-RegExp.$_.length-4)+RegExp.$1+a2.slice(n+RegExp.$1.length);
26     document.getElementById("box").innerHTML=a2.replace(/1/g,"\u25a0").replace(/0/g,"\u3000");
27 }
28 function is(){
29     for(var i=0; i<4; i++)
30         if((pos.fk[i]&map[pos.y+i])!=0) return pos=bak;
31 }
32 function rotate(r){
33     var f=dia[pos.s=(pos.s+r)%dia.length];
34     for(var i=0; i<4; i++)
35         pos.fk[i]=(f>>(12-i*4)&15)<<pos.x;
36     update(is());
37 }
38 function down(){
39     ++pos.y;
40     if(is()){
41         for(var i=0; i<4 && pos.y+i<22; i++)
42             if((map[pos.y+i]|=pos.fk[i])==0xfff)
43                 map.splice(pos.y+i,1), map.unshift(0x801);
44         if(map[1]!=0x801) return over();
45         start();
46     }
47     update();
48 }
49 function move(t,k){
50     pos.x+=k;
51     for(var i=0; i<4; i++)
52         pos.fk[i]*=t;
53     update(is());
54 }
55 document.onkeydown=function(e){
56     eval(keycom[(e?e:event).keyCode]);
57 };
58 start();
59 run=setInterval("down()",400);
60 </script></body></html>

事件回调函数传参

<!DOCTYPE html>
<html lang=”en”>
<head>
  <meta charset=”UTF-8″>
  <title>Document</title>
  <style>
    html , body {
      width: 100%;
      height: 100%;
      background: green;
    }
  </style>
</head>
<body>
</body>
<script>
//在开发一个应用的时候用到事件的回调函数传参,并且需要接收到默认event事件,记录如下笔记
//使用bind将要传入的参数传入,注意第一个参数设置成undefined,要传入的参数一次往后填入
//执行的函数的最后一个参数为默认event事件
  var oBody = document.getElementsByTagName(‘body’)[0];
  //事件的回调函数传参
  //oBody.addEventListener(“click”,start(undefined,2));
  function start(num1, num2, ev) {
    console.log(ev);
    console.log(num1);
    console.log(num2);
  }
  var start = start.bind(undefined,1, 2)
  // 使用bind传参;
  oBody.onclick = start;
</script>
</html>

虚拟DOM

(1)什么是虚拟DOM?

vdom可以看作是一个使用javascript模拟了DOM结构的树形结构,这个树结构包含整个DOM结构的信息,如下图:

 

 

可见左边的DOM结构,不论是标签名称还是标签的属性或标签的子集,都会对应在右边的树结构里。

(2)为什么要使用虚拟DOM?

之前使用原生js或者jquery写页面的时候会发现操作DOM是一件非常麻烦的一件事情,往往是DOM标签和js逻辑同时写在js文件里,数据交互时不时还要写很多的input隐藏域,如果没有好的代码规范的话会显得代码非常冗余混乱,耦合性高并且难以维护。

另外一方面在浏览器里一遍又一遍的渲染DOM是非常非常消耗性能的,常常会出现页面卡死的情况;所以尽量减少对DOM的操作成为了优化前端性能的必要手段,vdom就是将DOM的对比放在了js层,通过对比不同之处来选择新渲染DOM节点,从而提高渲染效率。