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;
    }

源码

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);

在线代码

移动端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节点,从而提高渲染效率。

js中this指向

首先必须要说的是,this的指向在函数定义的时候是确定不了的,只有函数执行的时候才能确定this到底指向谁实际上this的最终指向的是那个调用它的对象(这句话有些问题,后面会解释为什么会有问题,虽然网上大部分的文章都是这样说的,虽然在很多情况下那样去理解不会出什么问题,但是实际上那样理解是不准确的,所以在你理解this的时候会有种琢磨不透的感觉,那么接下来我会深入的探讨这个问题。

为什么要学习this?如果你学过面向对象编程,那你肯定知道干什么用的,如果你没有学过,那么暂时可以不用看这篇文章,当然如果你有兴趣也可以看看,毕竟这是js中必须要掌握的东西。

例子1:

function a(){
    var user = "peng";
    console.log(this.user); //undefined
    console.log(this); //Window
}
a();

按照我们上面说的this最终指向的是调用它的对象,这里的函数a实际是被Window对象所点出来的,下面的代码就可以证明。

function a(){
    var user = "peng";
    console.log(this.user); //undefined
    console.log(this);  //Window
}
window.a();

和上面代码一样吧,其实alert也是window的一个属性,也是window点出来的。

例子2:

var o = {
    user:"peng",
    fn:function(){
        console.log(this.user);  //peng
    }
}
o.fn();

这里的this指向的是对象o,因为你调用这个fn是通过o.fn()执行的,那自然指向就是对象o,这里再次强调一点,this的指向在函数创建的时候是决定不了的,在调用的时候才能决定,谁调用的就指向谁,一定要搞清楚这个。

 

其实例子1和例子2说的并不够准确,下面这个例子就可以推翻上面的理论。

如果要彻底的搞懂this必须看接下来的几个例子

例子3:

var o = {
    user:"peng",
    fn:function(){
        console.log(this.user); //peng
    }
}
window.o.fn();

这段代码和上面的那段代码几乎是一样的,但是这里的this为什么不是指向window,如果按照上面的理论,最终this指向的是调用它的对象,这里先说个而外话,window是js中的全局对象,我们创建的变量实际上是给window添加属性,所以这里可以用window点o对象。

这里先不解释为什么上面的那段代码this为什么没有指向window,我们再来看一段代码。

例子4:

var o = {
    a:10,
    b:{
        a:12,
        fn:function(){
            console.log(this.a); //12
        }
    }
}
o.b.fn();

这里同样也是对象o点出来的,但是同样this并没有执行它,那你肯定会说我一开始说的那些不就都是错误的吗?其实也不是,只是一开始说的不准确,接下来我将补充一句话,我相信你就可以彻底的理解this的指向的问题。

情况1:如果一个函数中有this,但是它没有被上一级的对象所调用,那么this指向的就是window,这里需要说明的是在js的严格版中this指向的不是window,但是我们这里不探讨严格版的问题,你想了解可以自行上网查找。

情况2:如果一个函数中有this,这个函数有被上一级的对象所调用,那么this指向的就是上一级的对象。

情况3:如果一个函数中有this,这个函数中包含多个对象,尽管这个函数是被最外层的对象所调用,this指向的也只是它上一级的对象,例子4可以证明,如果不相信,那么接下来我们继续看几个例子。

var o = {
    a:10,
    b:{
        // a:12,
        fn:function(){
            console.log(this.a); //undefined
        }
    }
}
o.b.fn();

尽管对象b中没有属性a,这个this指向的也是对象b,因为this只会指向它的上一级对象,不管这个对象中有没有this要的东西。

还有一种比较特殊的情况,例子5:

var o = {
    a:10,
    b:{
        a:12,
        fn:function(){
            console.log(this.a); //undefined
            console.log(this); //window
        }
    }
}
var j = o.b.fn;
j();

这里this指向的是window,是不是有些蒙了?其实是因为你没有理解一句话,这句话同样至关重要。

this永远指向的是最后调用它的对象,也就是看它执行的时候是谁调用的,例子4中虽然函数fn是被对象b所引用,但是在将fn赋值给变量j的时候并没有执行所以最终指向的是window,这和例子3是不一样的,例子3是直接执行了fn。

this讲来讲去其实就是那么一回事,只不过在不同的情况下指向的会有些不同,上面的总结每个地方都有些小错误,也不能说是错误,而是在不同环境下情况就会有不同,所以我也没有办法一次解释清楚,只能你慢慢地的去体会。

构造函数版this:

function Fn(){
    this.user = "peng";
}
var a = new Fn();
console.log(a.user); //peng

这里之所以对象a可以点出函数Fn里面的user是因为new关键字可以改变this的指向,将这个this指向对象a,为什么我说a是对象,因为用了new关键字就是创建一个对象实例,理解这句话可以想想我们的例子3,我们这里用变量a创建了一个Fn的实例(相当于复制了一份Fn到对象a里面),此时仅仅只是创建,并没有执行,而调用这个函数Fn的是对象a,那么this指向的自然是对象a,那么为什么对象a中会有user,因为你已经复制了一份Fn函数到对象a中,用了new关键字就等同于复制了一份。

除了上面的这些以外,我们还可以自行改变this的指向。

 

更新一个小问题当this碰到return时

function fn()  
{  
    this.user = 'peng';  
    return {};  
}
var a = new fn;  
console.log(a.user); //undefined

再看一个

function fn()  
{  
    this.user = 'peng';  
    return function(){};
}
var a = new fn;  
console.log(a.user); //undefined

再来

function fn()  
{  
    this.user = 'peng';  
    return 1;
}
var a = new fn;  
console.log(a.user); //peng
function fn()  
{  
    this.user = 'peng';  
    return undefined;
}
var a = new fn;  
console.log(a.user); //peng

什么意思呢?

  如果返回值是一个对象,那么this指向的就是那个返回的对象,如果返回值不是一个对象那么this还是指向函数的实例。

function fn()  
{  
    this.user = 'peng';  
    return undefined;
}
var a = new fn;  
console.log(a); //fn {user: "peng"}

还有一点就是虽然null也是对象,但是在这里this还是指向那个函数的实例,因为null比较特殊。

function fn()  
{  
    this.user = 'peng';  
    return null;
}
var a = new fn;  
console.log(a.user); //peng

知识点补充:

  1.在严格版中的默认的this不再是window,而是undefined。

2.new操作符会改变函数this的指向问题,虽然我们上面讲解过了,但是并没有深入的讨论这个问题,网上也很少说,所以在这里有必要说一下。

function fn(){
    this.num = 1;
}
var a = new fn();
console.log(a.num); //1

为什么this会指向a?首先new关键字会创建一个空的对象,然后会自动调用一个函数apply方法,将this指向这个空对象,这样的话函数内部的this就会被这个空的对象替代。

注意: 当你new一个空对象的时候,js内部的实现并不一定是用的apply方法来改变this指向的,这里我只是打个比方而已.

if (this === 动态的\可改变的) return true;

JS 中 new 理解

按照javascript语言精粹中所说,如果在一个函数前面带上new来调用该函数,那么将创建一个隐藏连接到该函数的prototype成员的新对象,同时this将被绑定到那个新对象上。这个话很抽象,我想用实例来让自己加深理解。

1.如果就一个函数,没有返回值,没有prototype成员,然后使用new,会是什么结果呢?如果一个函数没有返回值,那么如果不使用new来创建变量,那么该变量的值为undefined.如果用了new,那么就是Object.说明一个函数的默认的Prototype是Object.
复制代码

function Test1(str) {
this.a = str;
}
var myTest = new Test1(“test1”);
alert(myTest); //[object Object]
function Test1WithoutNew(str) {
this.a = str;
}
var myTestWithoutNew = Test1WithoutNew(“test1”);
alert(myTestWithoutNew); //undefined;

复制代码

2.如果函数有返回值,但是返回值是基本类型。那么new出来的myTest还是object.因为基本类型的prototype还是Object. 而如果不使用new,那么返回值就是string的值。
复制代码

function Test1(str) {
this.a = str;
return this.a;
}
var myTest = new Test1(“test1”);
alert(myTest); //Object

function Test1WithoutNew(str) {
this.a = str;
return this.a;
}
var myTestWithoutNew = Test1WithoutNew(“test1″);
alert(myTestWithoutNew); //”test1”

复制代码

3。如果函数的返回值为new出来的对象,那么myTest的值根据new出来的对象的prototype而定。

function Test1(str) {
this.a = str;
return new String(this.a);
}
var myTest = new Test1(“test1”);
alert(myTest); //String “test1”

4。接下来我们开始讨论new中的this。如果我们给Test1的prototype中加入一个方法叫get_string(),那么get_string()中的this指的就是这个新对象。能够得到在new时候赋予该对象的属性值。
复制代码

var Test2 = function(str) {
this.a = str;
}

Test2.prototype.get_string = function () {
return this.a;
};

var myTest2 = new Test2(“test2”);
alert(myTest2.get_string()); //“test2”

var Test2 = function(str) {
this.a = str;
}

Test2.prototype.get_string = function () {
return this.a;
};

var myTest2 = Test2(“test2”);
alert(myTest2)//undefined

复制代码

5。如果我们修改了函数的prototype,又会发生什么样的情况呢? 那么就会发生类似继承的功能,其实就是js的伪类实现。
复制代码

function Test1(str) {
this.b = str;
}
Test1.prototype.Get_Test1String = function () {
return this.b;
};

var Test2 = function(str) {
this.a = str;
}
Test2.prototype = new Test1(“test1”);
Test2.prototype.get_string = function () {
return this.a;
};

var myTest2 = new Test2(“test2″);
alert(myTest2); //Object
alert(myTest2.get_string()); //”test2″
alert(myTest2.Get_Test1String()); //”test1”

关于setInterval和setTImeout中的this指向问题

若想要让setTimeout中的this指向正确的值,可以使用以下三种比较常用的方法来使this指向正确的值:

1.将当前对象的this存为一个变量,定时器内的函数利用闭包来访问这个变量,如下:

var num = 0;
function Obj (){
    var that = this;    //将this存为一个变量,此时的this指向obj
    this.num = 1,
    this.getNum = function(){
        console.log(this.num);
    },
    this.getNumLater = function(){
        setTimeout(function(){
            console.log(that.num);    //利用闭包访问that,that是一个指向obj的指针
        }, 1000)
    }
}
var obj = new Obj; 
obj.getNum();//1  打印的是obj.num,值为1
obj.getNumLater()//1  打印的是obj.num,值为1

 

这种方法是将当前对象的引用放在一个变量里,定时器内部的函数来访问到这个变量,自然就可以得到当前的对象。

2.利用bind()方法

var num = 0;
function Obj (){
    this.num = 1,
    this.getNum = function(){
        console.log(this.num);
    },
    this.getNumLater = function(){
        setTimeout(function(){
            console.log(this.num);
        }.bind(this), 1000)    //利用bind()将this绑定到这个函数上
    }
}
var obj = new Obj; 
obj.getNum();//1  打印的为obj.num,值为1
obj.getNumLater()//1  打印的为obj.num,值为1

 

bind()方法是在Function.prototype上的一个方法,当被绑定函数执行时,bind方法会创建一个新函数,并将第一个参数作为新函数运行时的this。在这个例子中,在调用setTimeout中的函数时,bind方法创建了一个新的函数,并将this传进新的函数,执行的结果也就是正确的了。关于bind方法可参考 MDN bind

3. 箭头函数

var num = 0;
function Obj (){
    this.num = 1,
    this.getNum = function(){
        console.log(this.num);
    },
    this.getNumLater = function(){
        setTimeout(() => {
            console.log(this.num);
        }, 1000)    //箭头函数中的this总是指向外层调用者,也就是Obj
    }
}
var obj = new Obj; 
obj.getNum();//1  打印的是obj.num,值为1
obj.getNumLater()//1  打印的是obj.num,值为1

ES6中的箭头函数完全修复了this的指向,this总是指向词法作用域,也就是外层调用者obj,因此利用箭头函数就可以轻松解决这个问题。

以上三种方法都是比较常用的,当然如果使用call或apply方法来代替bind方法,得到的结果也是正确的,但是call方法会在调用之后立即执行,那样也就没有了延时的效果,定时器也就没有用了,所以推荐使用上述方法来将this传进setTimeout和setInterval中。

4类 JavaScript 内存泄漏及如何避免

本文将探索常见的客户端 JavaScript 内存泄漏,以及如何使用 Chrome 开发工具发现问题。

简介

内存泄漏是每个开发者最终都要面对的问题,它是许多问题的根源:反应迟缓,崩溃,高延迟,以及其他应用问题。

什么是内存泄漏?

本质上,内存泄漏可以定义为:应用程序不再需要占用内存的时候,由于某些原因,内存没有被操作系统或可用内存池回收。编程语言管理内存的方式各不相同。只有开发者最清楚哪些内存不需要了,操作系统可以回收。一些编程语言提供了语言特性,可以帮助开发者做此类事情。另一些则寄希望于开发者对内存是否需要清晰明了。

JavaScript 内存管理

JavaScript 是一种垃圾回收语言。垃圾回收语言通过周期性地检查先前分配的内存是否可达,帮助开发者管理内存。换言之,垃圾回收语言减轻了“内存仍可用”及“内存仍可达”的问题。两者的区别是微妙而重要的:仅有开发者了解哪些内存在将来仍会使用,而不可达内存通过算法确定和标记,适时被操作系统回收。

JavaScript 内存泄漏

垃圾回收语言的内存泄漏主因是不需要的引用。理解它之前,还需了解垃圾回收语言如何辨别内存的可达与不可达。

Mark-and-sweep

大部分垃圾回收语言用的算法称之为 Mark-and-sweep 。算法由以下几步组成:

  1. 垃圾回收器创建了一个“roots”列表。Roots 通常是代码中全局变量的引用。JavaScript 中,“window” 对象是一个全局变量,被当作 root 。window 对象总是存在,因此垃圾回收器可以检查它和它的所有子对象是否存在(即不是垃圾);
  2. 所有的 roots 被检查和标记为激活(即不是垃圾)。所有的子对象也被递归地检查。从 root 开始的所有对象如果是可达的,它就不被当作垃圾。
  3. 所有未被标记的内存会被当做垃圾,收集器现在可以释放内存,归还给操作系统了。

现代的垃圾回收器改良了算法,但是本质是相同的:可达内存被标记,其余的被当作垃圾回收。

不需要的引用是指开发者明知内存引用不再需要,却由于某些原因,它仍被留在激活的 root 树中。在 JavaScript 中,不需要的引用是保留在代码中的变量,它不再需要,却指向一块本该被释放的内存。有些人认为这是开发者的错误。

为了理解 JavaScript 中最常见的内存泄漏,我们需要了解哪种方式的引用容易被遗忘。

三种类型的常见 JavaScript 内存泄漏

1:意外的全局变量

JavaScript 处理未定义变量的方式比较宽松:未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是 window 。

1
2
3
function foo(arg) {
bar = “this is a hidden global variable”;
}

真相是:

1
2
3
function foo(arg) {
window.bar = “this is an explicit global variable”;
}

函数 foo 内部忘记使用 var ,意外创建了一个全局变量。此例泄漏了一个简单的字符串,无伤大雅,但是有更糟的情况。

另一种意外的全局变量可能由 this 创建:

1
2
3
4
5
6
7
function foo() {
this.variable = “potential accidental global”;
}
// Foo 调用自己,this 指向了全局对象(window)
// 而不是 undefined
foo();

在 JavaScript 文件头部加上 'use strict',可以避免此类错误发生。启用严格模式解析 JavaScript ,避免意外的全局变量。

全局变量注意事项

尽管我们讨论了一些意外的全局变量,但是仍有一些明确的全局变量产生的垃圾。它们被定义为不可回收(除非定义为空或重新分配)。尤其当全局变量用于临时存储和处理大量信息时,需要多加小心。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。与全局变量相关的增加内存消耗的一个主因是缓存。缓存数据是为了重用,缓存必须有一个大小上限才有用。高内存消耗导致缓存突破上限,因为缓存内容无法被回收。

2:被遗忘的计时器或回调函数

在 JavaScript 中使用 setInterval 非常平常。一段常见的代码:

1
2
3
4
5
6
7
8
var someResource = getData();
setInterval(function() {
var node = document.getElementById(‘Node’);
if(node) {
// 处理 node 和 someResource
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);

此例说明了什么:与节点或数据关联的计时器不再需要,node 对象可以删除,整个回调函数也不需要了。可是,计时器回调函数仍然没被回收(计时器停止才会被回收)。同时,someResource 如果存储了大量的数据,也是无法被回收的。

对于观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。如今,即使没有明确移除它们,一旦观察者对象变成不可达,大部分浏览器是可以回收观察者处理函数的。

观察者代码示例:

1
2
3
4
5
6
var element = document.getElementById(‘button’);
function onClick(event) {
element.innerHTML = ‘text’;
}
element.addEventListener(‘click’, onClick);

对象观察者和循环引用注意事项

老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。如今,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法,已经可以正确检测和处理循环引用了。换言之,回收节点内存时,不必非要调用 removeEventListener 了。

3:脱离 DOM 的引用

有时,保存 DOM 节点内部数据结构很有用。假如你想快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或者数组很有意义。此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。将来你决定删除这些行时,需要把两个引用都清除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var elements = {
button: document.getElementById(‘button’),
image: document.getElementById(‘image’),
text: document.getElementById(‘text’)
};
function doStuff() {
image.src = ‘http://some.url/image’;
button.click();
console.log(text.innerHTML);
// 更多逻辑
}
function removeButton() {
// 按钮是 body 的后代元素
document.body.removeChild(document.getElementById(‘button’));
// 此时,仍旧存在一个全局的 #button 的引用
// elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}

此外还要考虑 DOM 树内部或子节点的引用问题。假如你的 JavaScript 代码中保存了表格某一个 <td> 的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td> 以外的其它节点。实际情况并非如此:此 <td> 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td> 的引用,导致整个表格仍待在内存中。保存 DOM 元素引用的时候,要小心谨慎。

4:闭包

闭包是 JavaScript 开发的一个关键方面:匿名函数可以访问父级作用域的变量。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log(“hi”);
};
theThing = {
longStr: new Array(1000000).join(‘*’),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);

代码片段做了一件事情:每次调用 replaceThing ,theThing 得到一个包含一个大数组和一个新闭包(someMethod)的新对象。同时,变量 unused 是一个引用 originalThing 的闭包(先前的 replaceThing又调用了 theThing )。思绪混乱了吗?最重要的事情是,闭包的作用域一旦创建,它们有同样的父级作用域,作用域是共享的。someMethod 可以通过 theThing 使用,someMethod 与 unused 分享闭包作用域,尽管 unused 从未使用,它引用的 originalThing 迫使它保留在内存中(防止被回收)。当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄漏。

Meteor 的博文 解释了如何修复此种问题。在 replaceThing 的最后添加 originalThing = null 。

Chrome 内存剖析工具概览

Chrome 提供了一套很棒的检测 JavaScript 内存占用的工具。与内存相关的两个重要的工具:timeline 和 profiles

Timeline

附图1

timeline 可以检测代码中不需要的内存。在此截图中,我们可以看到潜在的泄漏对象稳定的增长,数据采集快结束时,内存占用明显高于采集初期,Node(节点)的总量也很高。种种迹象表明,代码中存在 DOM 节点泄漏的情况。

Profiles

附图2

Profiles 是你可以花费大量时间关注的工具,它可以保存快照,对比 JavaScript 代码内存使用的不同快照,也可以记录时间分配。每一次结果包含不同类型的列表,与内存泄漏相关的有 summary(概要) 列表和 comparison(对照) 列表。

summary(概要) 列表展示了不同类型对象的分配及合计大小:shallow size(特定类型的所有对象的总大小),retained size(shallow size 加上其它与此关联的对象大小)。它还提供了一个概念,一个对象与关联的 GC root 的距离。

对比不同的快照的 comparison list 可以发现内存泄漏。

实例:使用 Chrome 发现内存泄漏

实质上有两种类型的泄漏:周期性的内存增长导致的泄漏,以及偶现的内存泄漏。显而易见,周期性的内存泄漏很容易发现;偶现的泄漏比较棘手,一般容易被忽视,偶尔发生一次可能被认为是优化问题,周期性发生的则被认为是必须解决的 bug。

以 Chrome 文档中的代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var x = [];
function createSomeNodes() {
var div,
i = 100,
frag = document.createDocumentFragment();
for (;i > 0; i–) {
div = document.createElement(“div”);
div.appendChild(document.createTextNode(i + ” – “+ new Date().toTimeString()));
frag.appendChild(div);
}
document.getElementById(“nodes”).appendChild(frag);
}
function grow() {
x.push(new Array(1000000).join(‘x’));
createSomeNodes();
setTimeout(grow,1000);
}

当 grow 执行的时候,开始创建 div 节点并插入到 DOM 中,并且给全局变量分配一个巨大的数组。通过以上提到的工具可以检测到内存稳定上升。

找出周期性增长的内存

timeline 标签擅长做这些。在 Chrome 中打开例子,打开 Dev Tools ,切换到 timeline,勾选 memory 并点击记录按钮,然后点击页面上的 The Button 按钮。过一阵停止记录看结果:

附图3

两种迹象显示出现了内存泄漏,图中的 Nodes(绿线)和 JS heap(蓝线)。Nodes 稳定增长,并未下降,这是个显著的信号。

JS heap 的内存占用也是稳定增长。由于垃圾收集器的影响,并不那么容易发现。图中显示内存占用忽涨忽跌,实际上每一次下跌之后,JS heap 的大小都比原先大了。换言之,尽管垃圾收集器不断的收集内存,内存还是周期性的泄漏了。

确定存在内存泄漏之后,我们找找根源所在。

保存两个快照

切换到 Chrome Dev Tools 的 profiles 标签,刷新页面,等页面刷新完成之后,点击 Take Heap Snapshot 保存快照作为基准。而后再次点击 The Button 按钮,等数秒以后,保存第二个快照。

附图4

筛选菜单选择 Summary,右侧选择 Objects allocated between Snapshot 1 and Snapshot 2,或者筛选菜单选择 Comparison ,然后可以看到一个对比列表。

此例很容易找到内存泄漏,看下 (string) 的 Size Delta Constructor,8MB,58个新对象。新对象被分配,但是没有释放,占用了8MB。

如果展开 (string) Constructor,会看到许多单独的内存分配。选择某一个单独的分配,下面的 retainers 会吸引我们的注意。

附图5

我们已选择的分配是数组的一部分,数组关联到 window 对象的 x 变量。这里展示了从巨大对象到无法回收的 root(window)的完整路径。我们已经找到了潜在的泄漏以及它的出处。

我们的例子还算简单,只泄漏了少量的 DOM 节点,利用以上提到的快照很容易发现。对于更大型的网站,Chrome 还提供了 Record Heap Allocations 功能。

Record heap allocations 找内存泄漏

回到 Chrome Dev Tools 的 profiles 标签,点击 Record Heap Allocations。工具运行的时候,注意顶部的蓝条,代表了内存分配,每一秒有大量的内存分配。运行几秒以后停止。

附图6

上图中可以看到工具的杀手锏:选择某一条时间线,可以看到这个时间段的内存分配情况。尽可能选择接近峰值的时间线,下面的列表仅显示了三种 constructor:其一是泄漏最严重的(string),下一个是关联的 DOM 分配,最后一个是 Text constructor(DOM 叶子节点包含的文本)。

从列表中选择一个 HTMLDivElement constructor,然后选择 Allocation stack

附图7

现在知道元素被分配到哪里了吧(grow -> createSomeNodes),仔细观察一下图中的时间线,发现 HTMLDivElement constructor 调用了许多次,意味着内存一直被占用,无法被 GC 回收,我们知道了这些对象被分配的确切位置(createSomeNodes)。回到代码本身,探讨下如何修复内存泄漏吧。

另一个有用的特性

在 heap allocations 的结果区域,选择 Allocation。

附图8

这个视图呈现了内存分配相关的功能列表,我们立刻看到了 grow 和 createSomeNodes。当选择 grow 时,看看相关的 object constructor,清楚地看到 (string)HTMLDivElement 和 Text 泄漏了。

结合以上提到的工具,可以轻松找到内存泄漏。