yuanzm

yuanzm 查看完整档案

广州编辑中山大学  |  软件工程 编辑国服  |  页面仔 编辑 github.com/yuanzm 编辑
编辑

Github,欢迎Follow~
https://github.com/yuanzm

个人动态

yuanzm 回答了问题 · 2019-12-24

iframe 子页面获取父页面的url 有时候获取不到完整的,有时候只有第一次能获取到

这篇文章可能能帮到你:https://humanwhocodes.com/blog/2013/04/16/getting-the-url-of-an-iframes-parent/

多看看几个评论感觉有可以帮到你的。

document.referrer is empty, when the window and its parent differs in protocols (http/https)

document.referrer only reflects the hostname of the window.parent, in multiple embedded iframes,
wherewindow.top!= window.parent you can't query the top hostname

Webkit implemented the location.ancestorOrigins property, which is just an array of hostnames going upwards through the embedding frames, but ideal for blacklisting or whitelisting your content

关注 3 回答 2

yuanzm 收藏了文章 · 2019-12-18

手把手教你打造一款轻量级canvas渲染引擎

背景

当我们开发一个canvas应用的时候,出于效率的考量,免不了要选择一个渲染引擎(比如PixiJS)或者更强大一点的游戏引擎(比如Cocos Creator、Layabox)。

渲染引擎通常会有Sprite的概念,一个完整的界面会由很多的Sprite组成,如果编写复杂一点的界面,代码里面会充斥创建精灵设置精灵位置和样式的“重复代码”,最终我们得到了极致的渲染性能却牺牲了代码的可读性。

游戏引擎通常会有配套的IDE,界面通过拖拽即可生成,最终导出场景配置文件,这大大方便了UI开发,但是游戏引擎一般都很庞大,有时候我们仅仅想开发个好友排行榜。

基于以上分析,如果有一款渲染引擎,既能用配置文件的方式来表达界面,又可以做到轻量级,将会大大满足我们开发轻量级canvas应用的场景。

本文会详细介绍开发一款可配置化轻量级渲染引擎需要哪些事情,代码开源至Github:https://github.com/wechat-miniprogram/minigame-canvas-engine

配置化分析

我们首先期望页面可配置化,来参考下Cocos Creator的实现:对于一个场景,在IDE里面一顿操作,最后场景配置文件大致长下面的样子:

  // 此处省略n个节点
  {
    "__type__": "cc.Scene",
    "_opacity": 255,
    "_color": {
      "__type__": "cc.Color",
      "r": 255,
      "g": 255,
      "b": 255,
      "a": 255
    },
    "_parent": null,
    "_children": [
      {
        "__id__": 2
      }
    ],
  },

在一个JSON配置文件里面,同时包含了节点的层级结构样式,引擎拿到配置文件后递归生成节点树然后渲染即可。PixiJS虽然只是个渲染引擎,但同样可以和cocos2d一样做一个IDE去拖拽生成UI,然后写一个解析器,声称自己是PixiJS Creator😬。

这个方案很好,但缺点是每个引擎有一套自己的配置规则,没法做到通用化,而且在没有IDE的情况下,手写配置文件也会显得反人类,我们还需要更加通用一点的配置。

寻找更优方案

游戏引擎的配置方案如果要用起来主要有两个问题:

  1. 手写可读性差,特别是对于层级深的节点树;
  2. 样式和节点树没有分离,配置文件冗余;
  3. 配置不通用;

对于高可读性样式分离,我们惊讶的发现,这不就是Web开发的套路么,编写HTML、CSS丢给浏览器,界面就出来了,省时省力。

new.jpeg

如此优秀的使用姿势,我们要寻求方案在canvas里面实现一次!

实现分析

结果预览

在逐步分析实现方案之前,我们先抛个最终实现,编写XML和样式,就可以得到结果:

let template = `
<view id="container">
  <text id="testText" class="redText" value="hello canvas"> </text>
</view>
`;

let style = {
    container: {
         width: 200,
         height: 100,
         backgroundColor: '#ffffff',
         justContent: 'center',
         alignItems: 'center',
     },
     testText: {
         color: '#ff0000',
         width: 200,
         height: 100,
         lineHeight: 100,
         fontSize: 30,
         textAlign: 'center',
     }
}
// 初始化渲染引擎
Layout.init(template, style);
// 执行真正的渲染
Layout.layout(canvasContext);

方案总览

既然要参考浏览器的实现,我们不妨先看看浏览器是怎么做的:
render-tree-construction.png
如上图所示,浏览器从构建到渲染界面大致要经过下面几步:

  • HTML 标记转换成文档对象模型 (DOM);CSS 标记转换成 CSS 对象模型 (CSSOM)
  • DOM 树与 CSSOM 树合并后形成渲染树。
  • 渲染树只包含渲染网页所需的节点。
  • 布局计算每个对象的精确位置和大小。
  • 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上。

在canvas里面要实现将HTML+CSS绘制到canvas上面,上面的步骤缺一不可。

构建布局树和渲染树

上面的方案总览又分两大块,第一是渲染之前的各种解析计算,第二是渲染本身以及渲染之后的后续工作,先看看渲染之前需要做的事情。

解析XML和构建CSSOM

首先是将HTML(这里我们采用XML)字符串解析成节点树,等价于浏览器里面的“HTML 标记转换成文档对象模型 (DOM)”,在npm搜索xml parser),可以得到很多优秀的实现,这里我们只追求两点:

  1. 轻量:大部分库为了功能强大动辄几百k,而我们只需要最核心的xml解析成JSON对象;
  2. 高性能:在游戏里面不可避免有长列表滚动的场景,这时候XML会很大,要尽量控制XML解析时间;

综合以上考量,选择了fast-xml-parser,但是仍然做了一些阉割和改造,最终模板经过解析会得到下面的JSON对象

{
    "name":"view",
    "attr":{
        "id":"container"
    },
    "children":[
        {
            "name":"text",
            "attr":{
                "id":"testText",
                "class":"redText",
                "value":"hello canvas"
            },
            "children":[

            ]
        }
    ]
}

接下来是构建CSSOM,为了减少解析步骤,我们手工构建一个JSON对象,key的名字为节点的id或者class,以此和XML节点形成绑定关系:

let style = {
    container: {
         width: 200,
         height: 100
     },
}

DOM 树与 CSSOM 树合并后形成渲染树

DOM树和CSSOM构建完成后,他们仍是独立的两部分,需要将他们构建成renderTree,由于style的key和XML的节点有关联,这里简单写个递归处理函数就可以实现:该函数接收两个参数,第一个参数为经过XML解析器解析吼的节点树,第二个参数为style对象,等价于DOM和CSSOM。

// 记录每一个标签应该用什么类来处理
const constructorMap = {
    view      : View,
    text      : Text,
    image     : Image,
    scrollview: ScrollView,
}
const create = function (node, style) {
    const _constructor = constructorMap[node.name];

    let children = node.children || [];

    let attr = node.attr || {};
    const id = attr.id || '';
    // 实例化标签需要的参数,主要为收集样式和属性
    const args = Object.keys(attr)
        .reduce((obj, key) => {
            const value = attr[key]
            const attribute = key;

            if (key === 'id' ) {
                obj.style = Object.assign(obj.style || {}, style[id] || {})
                return obj
            }

            if (key === 'class') {
                obj.style = value.split(/\s+/).reduce((res, oneClass) => {
                return Object.assign(res, style[oneClass])
                }, obj.style || {})

                return obj
            }
            
            if (value === 'true') {
                obj[attribute] = true
            } else if (value === 'false') {
                obj[attribute] = false
            } else {
                obj[attribute] = value
            }

            return obj;
        }, {})

    // 用于后续元素查询
    args.idName    = id;
    args.className = attr.class || '';

    const element  = new _constructor(args)
    element.root = this;
    
    // 递归处理
    children.forEach(childNode => {
        const childElement = create.call(this, childNode, style);

        element.add(childElement);
    });

    return element;
}

经过递归解析,构成了一颗节点带有样式的renderTree。

计算布局树

渲染树搞定之后,要着手构建布局树了,每个节点在相互影响之后的位置和大小如何计算是一个很头疼的问题。但仍然不慌,因为我们发现近几年非常火的React Native、weex之类的框架必然会面临同样的问题:

Weex 是使用流行的 Web 开发体验来开发高性能原生应用的框架。
React Native 使用JavaScript和React编写原生移动应用

这些框架也需要将html和css编译成客户端可读的布局树,能否避免重复造轮子将它们的相关模块抽象出来使用呢?起初我以为这部分会很庞大或者和框架强耦合,可喜的是这部分抽象出来仅仅只有1000来行,他就是week和react native早起的布局引擎css-layout。这里有一篇文章分析得非常好,直接引用至,不再赘述:《由 FlexBox 算法强力驱动的 Weex 布局引擎》

npm上面可以搜到css-layout,它对外暴露了computeLayout方法,只需要将上面得到的布局树传给它,经过计算之后,布局树的每个节点都会带上layout属性,它包含了这个节点的位置和尺寸信息!

// create an initial tree of nodes
var nodeTree = {
    "style": {
      "padding": 50
    },
    "children": [
      {
        "style": {
          "padding": 10,
          "alignSelf": "stretch"
        }
      }
    ]
  };
 
// compute the layout
computeLayout(nodeTree);
 
// the layout information is written back to the node tree, with
// each node now having a layout property: 
 
// JSON.stringify(nodeTree, null, 2);
{
  "style": {
    "padding": 50
  },
  "children": [
    {
      "style": {
        "padding": 10,
        "alignSelf": "stretch"
      },
      "layout": {
        "width": 20,
        "height": 20,
        "top": 50,
        "left": 50,
        "right": 50,
        "bottom": 50,
        "direction": "ltr"
      },
      "children": [],
      "lineIndex": 0
    }
  ],
  "layout": {
    "width": 120,
    "height": 120,
    "top": 0,
    "left": 0,
    "right": 0,
    "bottom": 0,
    "direction": "ltr"
  }
}

这里需要注意的是,css-layout实现的是标准的Flex布局,如果对于CSS或者Flex布局不是很熟悉的同学,可以参照这篇文章进行快速的入门:《Flex 布局教程:语法篇》。再值得一提的是,作为css-layout的使用者,好的习惯是给每个节点都赋予width和height属性😀。

渲染

基础样式渲染

在处理渲染之前,我们先分析下在Web开发中我们重度使用的标签:

标签功能
div通常作为容器使用,容器也可以有一些样式,比如border和背景颜色之类的
img图片标签,向网页中嵌入一幅图像,通常我们会对图片添加borderRadius实现圆形头像
p/span文本标签,用于展示段落或者行内文字

在构建节点树的过程中,对于不同类型的节点会有不同的类去处理,上述三个标签对应了ViewImageText类,每个类都有自己的render函数。

render函数只需要做好一件事情:根据css-layout计算得到的layout属性和节点本身样式相关的style属性,通过canvas API的形式绘制到canvas上;

这件事情听起来工作量很大,但其实也没有这么难,比如下面演示如何处理文本的绘制,实现文本的字体、字号、左对齐右对齐等。

 function renderText() {  
    let style = this.style || {};

    this.fontSize = style.fontSize || 12;
    this.textBaseline = 'top';
    this.font = `${style.fontWeight  || ''} ${style.fontSize || 12}px ${DEFAULT_FONT_FAMILY}`;
    this.textAlign = style.textAlign || 'left';
    this.fillStyle = style.color     || '#000';
    
    if ( style.backgroundColor ) {
        ctx.fillStyle = style.backgroundColor;
        ctx.fillRect(drawX, drawY, box.width, box.height)
    }

    ctx.fillStyle = this.fillStyle;

    if ( this.textAlign === 'center' ) {
        drawX += box.width / 2;
    } else if ( this.textAlign === 'right' ) {
        drawX += box.width;
    }

    if ( style.lineHeight ) {
        ctx.textBaseline = 'middle';
        drawY += style.lineHeight / 2;
    }
}

但这件事情又没有这么简单,因为有些效果你必须层层组合计算才能得出效果,比如borderRadius的实现、文本的textOverflow实现,有兴趣的同学可以看看源码

再者还有更深的兴趣,可以翻翻游戏引擎是怎么处理的,结果功能过于强大之后,一个Text类就有1000多行:LayaAir的Text实现😯。

重排和重绘

当界面渲染完成,我们总不希望界面只是静态的,而是可以处理一些点击事件,比如点击按钮隐藏一部分元素,亦或是改变按钮的颜色之类的。

在浏览器里面,有对应的概念叫重排和重绘:

引自文章:《网页性能管理详解》

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。重新渲染,就需要重新生成布局和重新绘制。前者叫做"重排"(reflow),后者叫做"重绘"(repaint)。

那么哪些操作会触发重排,哪些操作会触发重绘呢?这里有个很简单粗暴的规则:只要涉及位置和尺寸修改的,必定要触发重排,比如修改width和height属性,在一个容器内做和尺寸位置无关的修改,只需要触发局部重绘,比如修改图片的链接、更改文字的内容(文字的尺寸位置固定),更具体的可以查看这个网站csstriggers.com

在我们这个渲染引擎里,如果执行触发重排的操作,需要将解析和渲染完整执行一遍,具体来讲是修改了xml节点或者与重排相关的样式之后,重复执行初始化和渲染的操作,重排的时间依赖节点的复杂度,主要是XML节点的复杂度。

// 该操作需要重排以实现界面刷新
style.container.width = 300;
// 重排前的清理逻辑
Layout.clear();
// 完整的初始化和渲染流程
Layout.init(template, style);
Layout.layout(canvasContext);

对于重绘的操作,暂时提供了动态修改图片链接和文字的功能,原理也很简单:通过Object.defineProperty,当修改布局树节点的属性时,抛出repaint事件,重绘函数就会局部刷新界面。

Object.defineProperty(this, "value", {
    get : function() {
        return this.valuesrc;
    },
    set : function(newValue){
        if ( newValue !== this.valuesrc) {
            this.valuesrc = newValue;
            // 抛出重绘事件,在回调函数里面在canvas的局部擦除layoutBox区域然后重新绘制文案
            this.emit('repaint');
        }
    },
    enumerable   : true,
    configurable : true
});

那怎么调用重绘操作呢?引擎只接收XML和style就绘制出了页面,要想针对单个元素执行操作还需要提供查询接口,这时候布局树再次排上用场。在生成renderTree的过程中,为了匹配样式,需要通过id或者class来形成映射关系,节点也顺带保留了id和class属性,通过遍历节点,就可以实现查询API:

function _getElementsById(tree, list = [], id) {
    Object.keys(tree.children).forEach(key => {
        const child = tree.children[key];

        if ( child.idName === id ) {
            list.push(child);
        }

        if ( Object.keys(child.children).length ) {
            _getElementsById(child, list, id);
        }
    });

    return list;
}

此时,可以通过查询API来实现实现重绘逻辑,该操作的耗时可以忽略不计。

let img = Layout.getElementsById('testimgid')[0];
img.src = 'newimgsrc';

事件实现

查询到节点之后,自然是希望可以绑定事件,事件的需求很简单,可以监听元素的触摸和点击事件以执行一些回调逻辑,比如点击按钮换颜色之类的。

我们先来看看浏览器里面的事件捕获和事件冒泡机制:

引自文章《JS中的事件捕获和事件冒泡》
捕获型事件(event capturing):事件从最不精确的对象(document 对象)开始触发,然后到最精确(也可以在窗口级别捕获事件,不过必须由开发人员特别指定)。
冒泡型事件:事件按照从最特定的事件目标到最不特定的事件目标(document对象)的顺序触发。

1576225959480.jpg

前提:每个节点都存在事件监听器on和发射器emit;每个节点都有个属性layoutBox,它表明了元素的在canvas上的盒子模型:

layoutBox: {
    x: 0,
    y: 0,
    width: 100,
    height: 100
}

canvas要实现事件处理与浏览器并无不同,核心在于:给定坐标点,遍历节点树的盒子模型,找到层级最深的包围该坐标的节点。
image.png

当点击事件发生在canvas上,可以拿到触摸点的x坐标和y坐标,该坐标位于根节点的layoutBox内,当根节点仍然有子节点,对子节点进行遍历,如果某个子节点的layoutBox仍然包含了该坐标,再次重复执行以上步骤,直到包含该坐标的节点再无子节点,这个过程称之为事件捕获

// 给定根节点树和触摸点的位置通过递归即可实现事件捕获
function getChildByPos(tree, x, y) {
    let list = Object.keys(tree.children);

    for ( let i = 0; i < list.length;i++ ) {
        const child = tree.children[list[i]];
        const box   = child.realLayoutBox;

        if (   ( box.realX <= x && x <= box.realX + box.width  )
            && ( box.realY <= y && y <= box.realY + box.height ) ) {
            if ( Object.keys(child.children).length ) {
                return getChildByPos(child, x, y);
            } else {
                return child;
            }
        }
    }

    return tree;
}

层级最深的节点被找到之后,调用emit接口触发该节点的ontouchstart事件,如果事先有对ontouchstart进行监听,事件回调得以触发。那么怎么实现事件冒泡呢?在事件捕获阶段我们并没有记录捕获的链条。这时候布局树的优势又体现出来了,每个节点都保存了自己的父节点和子节点信息,子节点emit事件之后,同时调用父节点的emit接口抛出ontouchstart事件,而父节点又继续对它自己的父节点执行同样的操作,直至根节点,这个过程称之为事件冒泡

// 事件冒泡逻辑
['touchstart', 'touchmove', 'touchcancel', 'touchend', 'click'].forEach((eventName) => {
    this.on(eventName, (e, touchMsg) => {
        this.parent && this.parent.emit(eventName, e, touchMsg);
    });
});

滚动列表实现

屏幕区域内,展示的内容是有限的,而浏览器的页面通常都很长,可以滚动。这里我们实现scrollview,如果标签内子节点的总高度大于scrollview的高度,就可以实现滚动。

1.对于在容器scrollview内的所有一级子元素,计算高度之合;

function getScrollHeight() {
    let ids  = Object.keys(this.children);
    let last = this.children[ids[ids.length - 1]];

    return last.layoutBox.top + last.layoutBox.height;
}

2.设定分页大小,假设每页的高度为2000,根据上面计算得到的ScrollHeight,就可以当前滚动列表总共需要几页,为他们分别创建用于展示分页数据的canvas:

this.pageCount = Math.ceil((this.scrollHeight + this.layoutBox.absoluteY) / this.pageHeight);

3.递归遍历scrollview的节点树,通过每个元素的absoluteY值判断应该坐落在哪个分页上,这里需要注意的是,有些子节点会同时坐落在两个分页上面,在两个分页都需要绘制一遍,特别是图片类这种异步加载然后渲染的节点

function renderChildren(tree) {
    const children = tree.children;
    const height   = this.pageHeight;

    Object.keys(children).forEach( id => {
        const child   = children[id];
        let originY   = child.layoutBox.originalAbsoluteY;
        let pageIndex = Math.floor(originY / height);
        let nextPage  = pageIndex + 1;

        child.layoutBox.absoluteY -= this.pageHeight * (pageIndex);

        // 对于跨界的元素,两边都绘制下
        if ( originY + child.layoutBox.height > height * nextPage ) {
            let tmpBox = Object.assign({}, child.layoutBox);
            tmpBox.absoluteY = originY - this.pageHeight * nextPage;

            if ( child.checkNeedRender() ) {
                this.canvasMap[nextPage].elements.push({
                    element: child, box: tmpBox
                });
            }
        }

        this.renderChildren(child);
        });
    }

4.将scrollview理解成游戏里面的Camera,只把能拍摄到的区域展示出来,那么所有的分页数据从上而下拼接起来就是游戏场景,在列表滚动过程中,只“拍摄”尺寸为scrollviewWidth*scrollViewHeight的区域,就实现了滚动效果。拍摄听起来很高级,在这里其实就是通过drawImage实现就好了:

// ctx为scrollview所在的canvas,canvas为分页canvas
this.ctx.drawImage(
    canvas,
    box.absoluteX, clipY, box.width, clipH,
    box.absoluteX, renderY, box.width, clipH,
);

5.当scrollview上触发了触摸事件,会改变scrollview的top属性值,按照步骤4不断根据top去裁剪可视区域,就实现了滚动。

上述方案为空间换时间方案,也就是在每次重绘过程中,因为内容已经绘制到分页canvas上了(这里可能会比较占空间),每次重绘,渲染时间得到了最大优化。

其他

至此,一个类浏览器的轻量级canvas渲染引擎出具模型:

  1. 给定XML+style对象可以渲染界面;
  2. 支持一些特定的标签:view、text、image和scrollview;
  3. 支持查询节点反向修改节点属性和样式;
  4. 支持事件绑定;

文章篇幅有限,很多细节和难点仍然没法详细描述,比如内存管理(内存管理不当很容易内存持续增涨导致应用crash)、scrollview的滚动事件实现细节、对象池使用等。有兴趣的可以看看源码:https://github.com/wechat-miniprogram/minigame-canvas-engine/tree/master/src
下图再补一个滚动好友排行列表demo:
screenshot.gif

调试及应用场景

作为一个完整的引擎,没有IDE怎么行?这里为了提高UI调试的效率(实际上很多时候游戏引擎的工作流很长,调试UI,改个文案之类的是个很麻烦的事情),提供一个简版的在线调试器,调UI是完全够用了:https://wechat-miniprogram.github.io/minigame-canvas-engine/
image.png

最后要问,费了这么大劲搞了个渲染引擎有什么应用场景呢?当然是有的:

  1. 跨游戏引擎的游戏周边插件:很有游戏周边功能比如签到礼包、公告页面等都是偏H5页面的周边系统,如果通过本渲染引擎渲染到离屏canvas,每个游戏引擎都将离屏canvas当成普通精灵渲染即可实现跨游戏引擎插件;
  2. 极致的代码包追求:如果你对微信小游戏有所了解,就会发现现阶段在开放数据域要绘制UI,如果不想裸写UI,就得再引入一份游戏引擎,这对代码包体积影响是很大的,而大部分时候仅仅是想绘制个好友排行榜;
  3. 屏幕截图:这点在普通和H5和游戏里面都比较常见,将一些用户昵称和文案之类的与背景图合并成为截图,这里可以轻松实现。
  4. 等等等......

参考资料

1.由 FlexBox 算法强力驱动的 Weex 布局引擎:https://www.jianshu.com/p/d085032d4788
2.网页性能管理详解:https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html
3.渲染性能:https://developers.google.cn/web/fundamentals/performance/rendering
4.简化绘制的复杂度、减小绘制区域:https://developers.google.com/web/fundamentals/performance/rendering/simplify-paint-complexity-and-reduce-paint-areas?hl=zh-CN

查看原文

yuanzm 收藏了文章 · 2019-12-18

在面试季致那些前端想入门的朋友们

前言

之前在博客上写了一篇装逼的博客《我是如何同时拿到阿里和腾讯offer的》,在文章上面本着学习和共享的精神分享了自己的简历,无意中暴露了不少个人隐私,结果这几天不少认识的和不认识的朋友都加我QQ或微信,请叫我怎么学习Web。在只有一两个人问的时候,我都是耐心回答的,但从长远的角度来看,还是写一篇博客来压压惊好一点。需要声明的是,此文章中的观点仅代表个人意见,不代表大众的观点,如您有异议,欢迎在评论中指出。

从哪里来

Web现在是一个非常热门的话题,不管是计算机专业的还是非计算机方向的,都嚷嚷着要学Web。从我个人的角度来看,如果您真的想学Web,起码保证您是理工方向的。这完全不是看不起文科生,只是大部分理工专业都会涉及计算机方向的课程,比如操作系统编译原理计算机网络计算机组成原理等。这些课程看似没有实际作用,但深刻影响着你的编程思想。换一个角度说,你知道越多越底层的东西,越有利于你以后的发展。
Web是一个自定向下的方向,一开始你就写HTMLCSSJavascript,看到了炫酷的效果,但是对于背后的原理一无所知。只有一直往下学,才会越精通。这和我们的课程学习不同,学校一开始就安排计算机组成原理C++这类课程,给了你一个自底向上的学习过程,所以一开始总是枯燥乏味的,只有学到后面才能有乐趣,但是很多人没等体验到乐趣就已经放弃了。
所以,我希望所有想学习Web的同学,一开始就要想明白,自己是不是真的对Web感兴趣。如果没有兴趣,这个自顶向下的过程会让你越来越枯燥,直到放弃。

该从何开始

万事开头难,如何快速步入正轨可能比掌握一些高级技巧更加重要。
前端很大的一个特点是入门容易精通难,所谓容易也不是分分钟就能学会的,我推荐按照下面的步骤去试试水。

好好把w3school上面有关HTML、CSS、JavaScript部分看两到三遍

相信W3School是很多Web开发者的启蒙老师,这个网站有一个好处就是全面,并且支持在线编辑,这样能够很好巩固每一个知识点。当然,我还是非常推荐大家一开始就去看这个网站的英文版(不幸的是英文的网站http://www.w3schools.com/貌似要翻墙才能访问,用Chrome的朋友推荐大家用红杏这个翻墙插件http://honx.in/i/VKquOX8mTlxxAR1U。)看英文好处还是非常大的,因为等你技术成熟,想找到高级问题的解答的时候,通常都是英文问答才能满足你的需求。

更新于2015/04/04

评论里面有朋友说w3c不值得推荐,而我个人保持中立态度,不一定有缺陷的就是不值得看的,还是看个人。不过很对的一点是,Web学习不得不推荐MDN:https://developer.mozilla.org/en-US/Learn,上面的内容足够专业,极其有价值!

一开始就应该多看书

很多初学者有一个很不好的习惯就是为了实现某一个效果,网上查了一些demo代码,用上了觉得很开心。这样做很不好的一点是,即便你弄懂了demo的原理,你的知识还是支离破碎的,没有系统性可言。所以,一开始多看书潜意思里面培养自己的编程思维很重要。在这里我想推荐一些我看过的觉得好的书。

  • 《JavaScript DOM编程艺术》
    很经典的一门入门书籍,对于初学JavaScript的同学来说还是很重要的。建议把书本上的代码都老老实实敲一遍,特别是最后面那个静态网站Demo。
  • 《JavaScript高级程序设计》
    一本经典到不能再经典的书。这本书有700多页,可是我我前前后后大概看了三遍。看这么多遍是有原因的,第一这本书的知识点广而浅,看完第一遍你就可以知道JavaScript到底能干啥;第二,这本书耐读,随着你对于JavaScript理解的深入,对于这本书的理解也将深入,你会发现这本书第四、五、六、七章大大道出了JavaScript的精华,实在是写得好;第三,这本书对于面试还是非常管用的,本人也是在面试前花了几天把这本书再次过了一遍,效果很好。所以学前端的同学都买一本实体书,好好琢磨。
  • 《JavaScript权威指南》
    这本书的赞誉很高,我也有这本书的实体书,是一个朋友送的,老实说这本书我并没有看很多,但是仍然建议大家好好看看,因为凡是书名中加入了指南二字的,几乎都为佳作。
  • 《CSS实战手册》
    写前端最痛苦的莫过于调CSS样式了,本人对于CSS并没有多少好感,只推荐不说话。

这远远不够

老实说,即便你把我上面说的都学习了一遍,也未必能够成为一个前端工程师,因为前端实在是太广了,而且经验非常重要。所以实战比什么都重要。
说到实战,很多人第一感觉就是,我实战什么?让我做一个项目,我一开始那个水平远远不够;让我写一个个人主页?我好像还没有优秀的设计才华让我觉得我做出来的主页足够惊艳。于是,资源成为了非常重要的一点。我们学校有一门课叫做Web2.0程序设计,我还有幸做了一个学期助教,感觉这门课的作业还是相当有用的,如果能好好把十多次的作业认真做一次,前端怎么也入门了。可惜的是,校内资源还是不要随意公开好,以免生事。
如果我没有好的资源,在这儿说这么多也是扯淡了。我本身在我们学校的一个Web俱乐部,俱乐部有很多优秀资源,当然也包括培训作业什么的,如果你需要,可以在评论注明你的邮箱,我会提供一些资源。

项目项目项目

项目对于任何一个工程师来讲都是非常重要的,书看再多也是理论,只是实战操作才是王道。我刚开始做项目的时候,是跟着一个团队写写简单的页面,尽量跟着别人学点东西。跟着师兄师姐学东西还是很有好处的,之前说过,Web是偏经验编程的,别人做得多了,自然懂得也多。
当然,很大一个问题是,项目哪里来?这涉及到另外一个话题,就是怎么做一个不止会写代码的程序员。我个人认为程序员总归不是一条长远的路,等到年龄增长,思维跟不上年轻人,终究是需要转型的,所以在转型之前培养人际关系什么的尤为重要。于是,项目一开始都要自己主动去找,如果你不能很好把自己推销出去,面试的时候也很难表现良好,所以,多接触懂技术和搞技术的人还是很重要滴。等你技术成熟了,不需要自己去找项目,通常都是项目找上门。本人建议,在你找工作之前,怎么都得做上五六个项目。

不只是JavaScript

很多人误以为前端就是写写CSS和JavaScript,很简单的,其实不然,一个合格的前端工程师需要掌握的比这个多得多。在这里列举一下本人简历上的技术栈,告诉大家前端有哪些值得掌握技术。当然这些技术绝对是皮毛。学到后面怎么挖掘自己需要的技术才是更重要的。

  • JavaScript类库:jQuery、Prototype
  • JavaScript框架:BackBone、Vuejs、Angular.js、React.js
  • CSS预编译器:LessCss、Sass
  • JavaScript模块加载器:RequireJS、Browserify
  • 项目构建工具:Grunt、Gulp
  • 项目管理和协同工具的使用:SVN、Git
  • 前端测试框架以及工具:Jasmine、Mocha
  • 基本后端开发:NodeJS、Python
  • web相关技术:MongoDB、CoffeeScript、SEO、Socket.io、Express、Tornado等

不要停止看书

上面推荐的几本书很好,为什么不一口气推荐完呢?理由很简单,有些书适合初学者,有些书就只适合中高级开发者。我接下来推荐的几本书入门者去看也看不懂,没有足够的编程积累,看起来也没有那么深的体会。

  • 《JavaScript语言精粹》
    好书!如果你编写的JavaScript足够多,就会发现里面字字珠玑,耐人寻味。
  • 《基于MVC的JavaScript Web富应用开发》
    Web前端架构级别的书,每一项技术都是目前的热门,看上两边不为过!
  • 《JavaScript设计模式》
    当你做富交互的应用的时候,就会知道这本书有多管用!
  • 《HTTP权威指南》
    虽然是学前端,但是如果一个前端工程师只会前端那也不叫前端工程师了。深入理解HTTP协议对于Web性能优化非常重要,对于前后端交互也能有更好的体会。一本值得啃的书籍。

好书太多,等到了一定水平,自己发掘去吧~

总结

其实说了这么多,也暴露了Web前端的缺点,作为一个国内的新兴职业和朝阳职业,不可能有系统级的教程,一切真心需要自己自己琢磨,我只能帮你到这儿了。如果您觉得对您有用,欢迎点个赞~

查看原文

yuanzm 发布了文章 · 2019-12-16

手把手教你打造一款轻量级canvas渲染引擎

背景

当我们开发一个canvas应用的时候,出于效率的考量,免不了要选择一个渲染引擎(比如PixiJS)或者更强大一点的游戏引擎(比如Cocos Creator、Layabox)。

渲染引擎通常会有Sprite的概念,一个完整的界面会由很多的Sprite组成,如果编写复杂一点的界面,代码里面会充斥创建精灵设置精灵位置和样式的“重复代码”,最终我们得到了极致的渲染性能却牺牲了代码的可读性。

游戏引擎通常会有配套的IDE,界面通过拖拽即可生成,最终导出场景配置文件,这大大方便了UI开发,但是游戏引擎一般都很庞大,有时候我们仅仅想开发个好友排行榜。

基于以上分析,如果有一款渲染引擎,既能用配置文件的方式来表达界面,又可以做到轻量级,将会大大满足我们开发轻量级canvas应用的场景。

本文会详细介绍开发一款可配置化轻量级渲染引擎需要哪些事情,代码开源至Github:https://github.com/wechat-miniprogram/minigame-canvas-engine

配置化分析

我们首先期望页面可配置化,来参考下Cocos Creator的实现:对于一个场景,在IDE里面一顿操作,最后场景配置文件大致长下面的样子:

  // 此处省略n个节点
  {
    "__type__": "cc.Scene",
    "_opacity": 255,
    "_color": {
      "__type__": "cc.Color",
      "r": 255,
      "g": 255,
      "b": 255,
      "a": 255
    },
    "_parent": null,
    "_children": [
      {
        "__id__": 2
      }
    ],
  },

在一个JSON配置文件里面,同时包含了节点的层级结构样式,引擎拿到配置文件后递归生成节点树然后渲染即可。PixiJS虽然只是个渲染引擎,但同样可以和cocos2d一样做一个IDE去拖拽生成UI,然后写一个解析器,声称自己是PixiJS Creator😬。

这个方案很好,但缺点是每个引擎有一套自己的配置规则,没法做到通用化,而且在没有IDE的情况下,手写配置文件也会显得反人类,我们还需要更加通用一点的配置。

寻找更优方案

游戏引擎的配置方案如果要用起来主要有两个问题:

  1. 手写可读性差,特别是对于层级深的节点树;
  2. 样式和节点树没有分离,配置文件冗余;
  3. 配置不通用;

对于高可读性样式分离,我们惊讶的发现,这不就是Web开发的套路么,编写HTML、CSS丢给浏览器,界面就出来了,省时省力。

new.jpeg

如此优秀的使用姿势,我们要寻求方案在canvas里面实现一次!

实现分析

结果预览

在逐步分析实现方案之前,我们先抛个最终实现,编写XML和样式,就可以得到结果:

let template = `
<view id="container">
  <text id="testText" class="redText" value="hello canvas"> </text>
</view>
`;

let style = {
    container: {
         width: 200,
         height: 100,
         backgroundColor: '#ffffff',
         justContent: 'center',
         alignItems: 'center',
     },
     testText: {
         color: '#ff0000',
         width: 200,
         height: 100,
         lineHeight: 100,
         fontSize: 30,
         textAlign: 'center',
     }
}
// 初始化渲染引擎
Layout.init(template, style);
// 执行真正的渲染
Layout.layout(canvasContext);

方案总览

既然要参考浏览器的实现,我们不妨先看看浏览器是怎么做的:
render-tree-construction.png
如上图所示,浏览器从构建到渲染界面大致要经过下面几步:

  • HTML 标记转换成文档对象模型 (DOM);CSS 标记转换成 CSS 对象模型 (CSSOM)
  • DOM 树与 CSSOM 树合并后形成渲染树。
  • 渲染树只包含渲染网页所需的节点。
  • 布局计算每个对象的精确位置和大小。
  • 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上。

在canvas里面要实现将HTML+CSS绘制到canvas上面,上面的步骤缺一不可。

构建布局树和渲染树

上面的方案总览又分两大块,第一是渲染之前的各种解析计算,第二是渲染本身以及渲染之后的后续工作,先看看渲染之前需要做的事情。

解析XML和构建CSSOM

首先是将HTML(这里我们采用XML)字符串解析成节点树,等价于浏览器里面的“HTML 标记转换成文档对象模型 (DOM)”,在npm搜索xml parser),可以得到很多优秀的实现,这里我们只追求两点:

  1. 轻量:大部分库为了功能强大动辄几百k,而我们只需要最核心的xml解析成JSON对象;
  2. 高性能:在游戏里面不可避免有长列表滚动的场景,这时候XML会很大,要尽量控制XML解析时间;

综合以上考量,选择了fast-xml-parser,但是仍然做了一些阉割和改造,最终模板经过解析会得到下面的JSON对象

{
    "name":"view",
    "attr":{
        "id":"container"
    },
    "children":[
        {
            "name":"text",
            "attr":{
                "id":"testText",
                "class":"redText",
                "value":"hello canvas"
            },
            "children":[

            ]
        }
    ]
}

接下来是构建CSSOM,为了减少解析步骤,我们手工构建一个JSON对象,key的名字为节点的id或者class,以此和XML节点形成绑定关系:

let style = {
    container: {
         width: 200,
         height: 100
     },
}

DOM 树与 CSSOM 树合并后形成渲染树

DOM树和CSSOM构建完成后,他们仍是独立的两部分,需要将他们构建成renderTree,由于style的key和XML的节点有关联,这里简单写个递归处理函数就可以实现:该函数接收两个参数,第一个参数为经过XML解析器解析吼的节点树,第二个参数为style对象,等价于DOM和CSSOM。

// 记录每一个标签应该用什么类来处理
const constructorMap = {
    view      : View,
    text      : Text,
    image     : Image,
    scrollview: ScrollView,
}
const create = function (node, style) {
    const _constructor = constructorMap[node.name];

    let children = node.children || [];

    let attr = node.attr || {};
    const id = attr.id || '';
    // 实例化标签需要的参数,主要为收集样式和属性
    const args = Object.keys(attr)
        .reduce((obj, key) => {
            const value = attr[key]
            const attribute = key;

            if (key === 'id' ) {
                obj.style = Object.assign(obj.style || {}, style[id] || {})
                return obj
            }

            if (key === 'class') {
                obj.style = value.split(/\s+/).reduce((res, oneClass) => {
                return Object.assign(res, style[oneClass])
                }, obj.style || {})

                return obj
            }
            
            if (value === 'true') {
                obj[attribute] = true
            } else if (value === 'false') {
                obj[attribute] = false
            } else {
                obj[attribute] = value
            }

            return obj;
        }, {})

    // 用于后续元素查询
    args.idName    = id;
    args.className = attr.class || '';

    const element  = new _constructor(args)
    element.root = this;
    
    // 递归处理
    children.forEach(childNode => {
        const childElement = create.call(this, childNode, style);

        element.add(childElement);
    });

    return element;
}

经过递归解析,构成了一颗节点带有样式的renderTree。

计算布局树

渲染树搞定之后,要着手构建布局树了,每个节点在相互影响之后的位置和大小如何计算是一个很头疼的问题。但仍然不慌,因为我们发现近几年非常火的React Native、weex之类的框架必然会面临同样的问题:

Weex 是使用流行的 Web 开发体验来开发高性能原生应用的框架。
React Native 使用JavaScript和React编写原生移动应用

这些框架也需要将html和css编译成客户端可读的布局树,能否避免重复造轮子将它们的相关模块抽象出来使用呢?起初我以为这部分会很庞大或者和框架强耦合,可喜的是这部分抽象出来仅仅只有1000来行,他就是week和react native早起的布局引擎css-layout。这里有一篇文章分析得非常好,直接引用至,不再赘述:《由 FlexBox 算法强力驱动的 Weex 布局引擎》

npm上面可以搜到css-layout,它对外暴露了computeLayout方法,只需要将上面得到的布局树传给它,经过计算之后,布局树的每个节点都会带上layout属性,它包含了这个节点的位置和尺寸信息!

// create an initial tree of nodes
var nodeTree = {
    "style": {
      "padding": 50
    },
    "children": [
      {
        "style": {
          "padding": 10,
          "alignSelf": "stretch"
        }
      }
    ]
  };
 
// compute the layout
computeLayout(nodeTree);
 
// the layout information is written back to the node tree, with
// each node now having a layout property: 
 
// JSON.stringify(nodeTree, null, 2);
{
  "style": {
    "padding": 50
  },
  "children": [
    {
      "style": {
        "padding": 10,
        "alignSelf": "stretch"
      },
      "layout": {
        "width": 20,
        "height": 20,
        "top": 50,
        "left": 50,
        "right": 50,
        "bottom": 50,
        "direction": "ltr"
      },
      "children": [],
      "lineIndex": 0
    }
  ],
  "layout": {
    "width": 120,
    "height": 120,
    "top": 0,
    "left": 0,
    "right": 0,
    "bottom": 0,
    "direction": "ltr"
  }
}

这里需要注意的是,css-layout实现的是标准的Flex布局,如果对于CSS或者Flex布局不是很熟悉的同学,可以参照这篇文章进行快速的入门:《Flex 布局教程:语法篇》。再值得一提的是,作为css-layout的使用者,好的习惯是给每个节点都赋予width和height属性😀。

渲染

基础样式渲染

在处理渲染之前,我们先分析下在Web开发中我们重度使用的标签:

标签功能
div通常作为容器使用,容器也可以有一些样式,比如border和背景颜色之类的
img图片标签,向网页中嵌入一幅图像,通常我们会对图片添加borderRadius实现圆形头像
p/span文本标签,用于展示段落或者行内文字

在构建节点树的过程中,对于不同类型的节点会有不同的类去处理,上述三个标签对应了ViewImageText类,每个类都有自己的render函数。

render函数只需要做好一件事情:根据css-layout计算得到的layout属性和节点本身样式相关的style属性,通过canvas API的形式绘制到canvas上;

这件事情听起来工作量很大,但其实也没有这么难,比如下面演示如何处理文本的绘制,实现文本的字体、字号、左对齐右对齐等。

 function renderText() {  
    let style = this.style || {};

    this.fontSize = style.fontSize || 12;
    this.textBaseline = 'top';
    this.font = `${style.fontWeight  || ''} ${style.fontSize || 12}px ${DEFAULT_FONT_FAMILY}`;
    this.textAlign = style.textAlign || 'left';
    this.fillStyle = style.color     || '#000';
    
    if ( style.backgroundColor ) {
        ctx.fillStyle = style.backgroundColor;
        ctx.fillRect(drawX, drawY, box.width, box.height)
    }

    ctx.fillStyle = this.fillStyle;

    if ( this.textAlign === 'center' ) {
        drawX += box.width / 2;
    } else if ( this.textAlign === 'right' ) {
        drawX += box.width;
    }

    if ( style.lineHeight ) {
        ctx.textBaseline = 'middle';
        drawY += style.lineHeight / 2;
    }
}

但这件事情又没有这么简单,因为有些效果你必须层层组合计算才能得出效果,比如borderRadius的实现、文本的textOverflow实现,有兴趣的同学可以看看源码

再者还有更深的兴趣,可以翻翻游戏引擎是怎么处理的,结果功能过于强大之后,一个Text类就有1000多行:LayaAir的Text实现😯。

重排和重绘

当界面渲染完成,我们总不希望界面只是静态的,而是可以处理一些点击事件,比如点击按钮隐藏一部分元素,亦或是改变按钮的颜色之类的。

在浏览器里面,有对应的概念叫重排和重绘:

引自文章:《网页性能管理详解》

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。重新渲染,就需要重新生成布局和重新绘制。前者叫做"重排"(reflow),后者叫做"重绘"(repaint)。

那么哪些操作会触发重排,哪些操作会触发重绘呢?这里有个很简单粗暴的规则:只要涉及位置和尺寸修改的,必定要触发重排,比如修改width和height属性,在一个容器内做和尺寸位置无关的修改,只需要触发局部重绘,比如修改图片的链接、更改文字的内容(文字的尺寸位置固定),更具体的可以查看这个网站csstriggers.com

在我们这个渲染引擎里,如果执行触发重排的操作,需要将解析和渲染完整执行一遍,具体来讲是修改了xml节点或者与重排相关的样式之后,重复执行初始化和渲染的操作,重排的时间依赖节点的复杂度,主要是XML节点的复杂度。

// 该操作需要重排以实现界面刷新
style.container.width = 300;
// 重排前的清理逻辑
Layout.clear();
// 完整的初始化和渲染流程
Layout.init(template, style);
Layout.layout(canvasContext);

对于重绘的操作,暂时提供了动态修改图片链接和文字的功能,原理也很简单:通过Object.defineProperty,当修改布局树节点的属性时,抛出repaint事件,重绘函数就会局部刷新界面。

Object.defineProperty(this, "value", {
    get : function() {
        return this.valuesrc;
    },
    set : function(newValue){
        if ( newValue !== this.valuesrc) {
            this.valuesrc = newValue;
            // 抛出重绘事件,在回调函数里面在canvas的局部擦除layoutBox区域然后重新绘制文案
            this.emit('repaint');
        }
    },
    enumerable   : true,
    configurable : true
});

那怎么调用重绘操作呢?引擎只接收XML和style就绘制出了页面,要想针对单个元素执行操作还需要提供查询接口,这时候布局树再次排上用场。在生成renderTree的过程中,为了匹配样式,需要通过id或者class来形成映射关系,节点也顺带保留了id和class属性,通过遍历节点,就可以实现查询API:

function _getElementsById(tree, list = [], id) {
    Object.keys(tree.children).forEach(key => {
        const child = tree.children[key];

        if ( child.idName === id ) {
            list.push(child);
        }

        if ( Object.keys(child.children).length ) {
            _getElementsById(child, list, id);
        }
    });

    return list;
}

此时,可以通过查询API来实现实现重绘逻辑,该操作的耗时可以忽略不计。

let img = Layout.getElementsById('testimgid')[0];
img.src = 'newimgsrc';

事件实现

查询到节点之后,自然是希望可以绑定事件,事件的需求很简单,可以监听元素的触摸和点击事件以执行一些回调逻辑,比如点击按钮换颜色之类的。

我们先来看看浏览器里面的事件捕获和事件冒泡机制:

引自文章《JS中的事件捕获和事件冒泡》
捕获型事件(event capturing):事件从最不精确的对象(document 对象)开始触发,然后到最精确(也可以在窗口级别捕获事件,不过必须由开发人员特别指定)。
冒泡型事件:事件按照从最特定的事件目标到最不特定的事件目标(document对象)的顺序触发。

1576225959480.jpg

前提:每个节点都存在事件监听器on和发射器emit;每个节点都有个属性layoutBox,它表明了元素的在canvas上的盒子模型:

layoutBox: {
    x: 0,
    y: 0,
    width: 100,
    height: 100
}

canvas要实现事件处理与浏览器并无不同,核心在于:给定坐标点,遍历节点树的盒子模型,找到层级最深的包围该坐标的节点。
image.png

当点击事件发生在canvas上,可以拿到触摸点的x坐标和y坐标,该坐标位于根节点的layoutBox内,当根节点仍然有子节点,对子节点进行遍历,如果某个子节点的layoutBox仍然包含了该坐标,再次重复执行以上步骤,直到包含该坐标的节点再无子节点,这个过程称之为事件捕获

// 给定根节点树和触摸点的位置通过递归即可实现事件捕获
function getChildByPos(tree, x, y) {
    let list = Object.keys(tree.children);

    for ( let i = 0; i < list.length;i++ ) {
        const child = tree.children[list[i]];
        const box   = child.realLayoutBox;

        if (   ( box.realX <= x && x <= box.realX + box.width  )
            && ( box.realY <= y && y <= box.realY + box.height ) ) {
            if ( Object.keys(child.children).length ) {
                return getChildByPos(child, x, y);
            } else {
                return child;
            }
        }
    }

    return tree;
}

层级最深的节点被找到之后,调用emit接口触发该节点的ontouchstart事件,如果事先有对ontouchstart进行监听,事件回调得以触发。那么怎么实现事件冒泡呢?在事件捕获阶段我们并没有记录捕获的链条。这时候布局树的优势又体现出来了,每个节点都保存了自己的父节点和子节点信息,子节点emit事件之后,同时调用父节点的emit接口抛出ontouchstart事件,而父节点又继续对它自己的父节点执行同样的操作,直至根节点,这个过程称之为事件冒泡

// 事件冒泡逻辑
['touchstart', 'touchmove', 'touchcancel', 'touchend', 'click'].forEach((eventName) => {
    this.on(eventName, (e, touchMsg) => {
        this.parent && this.parent.emit(eventName, e, touchMsg);
    });
});

滚动列表实现

屏幕区域内,展示的内容是有限的,而浏览器的页面通常都很长,可以滚动。这里我们实现scrollview,如果标签内子节点的总高度大于scrollview的高度,就可以实现滚动。

1.对于在容器scrollview内的所有一级子元素,计算高度之合;

function getScrollHeight() {
    let ids  = Object.keys(this.children);
    let last = this.children[ids[ids.length - 1]];

    return last.layoutBox.top + last.layoutBox.height;
}

2.设定分页大小,假设每页的高度为2000,根据上面计算得到的ScrollHeight,就可以当前滚动列表总共需要几页,为他们分别创建用于展示分页数据的canvas:

this.pageCount = Math.ceil((this.scrollHeight + this.layoutBox.absoluteY) / this.pageHeight);

3.递归遍历scrollview的节点树,通过每个元素的absoluteY值判断应该坐落在哪个分页上,这里需要注意的是,有些子节点会同时坐落在两个分页上面,在两个分页都需要绘制一遍,特别是图片类这种异步加载然后渲染的节点

function renderChildren(tree) {
    const children = tree.children;
    const height   = this.pageHeight;

    Object.keys(children).forEach( id => {
        const child   = children[id];
        let originY   = child.layoutBox.originalAbsoluteY;
        let pageIndex = Math.floor(originY / height);
        let nextPage  = pageIndex + 1;

        child.layoutBox.absoluteY -= this.pageHeight * (pageIndex);

        // 对于跨界的元素,两边都绘制下
        if ( originY + child.layoutBox.height > height * nextPage ) {
            let tmpBox = Object.assign({}, child.layoutBox);
            tmpBox.absoluteY = originY - this.pageHeight * nextPage;

            if ( child.checkNeedRender() ) {
                this.canvasMap[nextPage].elements.push({
                    element: child, box: tmpBox
                });
            }
        }

        this.renderChildren(child);
        });
    }

4.将scrollview理解成游戏里面的Camera,只把能拍摄到的区域展示出来,那么所有的分页数据从上而下拼接起来就是游戏场景,在列表滚动过程中,只“拍摄”尺寸为scrollviewWidth*scrollViewHeight的区域,就实现了滚动效果。拍摄听起来很高级,在这里其实就是通过drawImage实现就好了:

// ctx为scrollview所在的canvas,canvas为分页canvas
this.ctx.drawImage(
    canvas,
    box.absoluteX, clipY, box.width, clipH,
    box.absoluteX, renderY, box.width, clipH,
);

5.当scrollview上触发了触摸事件,会改变scrollview的top属性值,按照步骤4不断根据top去裁剪可视区域,就实现了滚动。

上述方案为空间换时间方案,也就是在每次重绘过程中,因为内容已经绘制到分页canvas上了(这里可能会比较占空间),每次重绘,渲染时间得到了最大优化。

其他

至此,一个类浏览器的轻量级canvas渲染引擎出具模型:

  1. 给定XML+style对象可以渲染界面;
  2. 支持一些特定的标签:view、text、image和scrollview;
  3. 支持查询节点反向修改节点属性和样式;
  4. 支持事件绑定;

文章篇幅有限,很多细节和难点仍然没法详细描述,比如内存管理(内存管理不当很容易内存持续增涨导致应用crash)、scrollview的滚动事件实现细节、对象池使用等。有兴趣的可以看看源码:https://github.com/wechat-miniprogram/minigame-canvas-engine/tree/master/src
下图再补一个滚动好友排行列表demo:
screenshot.gif

调试及应用场景

作为一个完整的引擎,没有IDE怎么行?这里为了提高UI调试的效率(实际上很多时候游戏引擎的工作流很长,调试UI,改个文案之类的是个很麻烦的事情),提供一个简版的在线调试器,调UI是完全够用了:https://wechat-miniprogram.github.io/minigame-canvas-engine/
image.png

最后要问,费了这么大劲搞了个渲染引擎有什么应用场景呢?当然是有的:

  1. 跨游戏引擎的游戏周边插件:很有游戏周边功能比如签到礼包、公告页面等都是偏H5页面的周边系统,如果通过本渲染引擎渲染到离屏canvas,每个游戏引擎都将离屏canvas当成普通精灵渲染即可实现跨游戏引擎插件;
  2. 极致的代码包追求:如果你对微信小游戏有所了解,就会发现现阶段在开放数据域要绘制UI,如果不想裸写UI,就得再引入一份游戏引擎,这对代码包体积影响是很大的,而大部分时候仅仅是想绘制个好友排行榜;
  3. 屏幕截图:这点在普通和H5和游戏里面都比较常见,将一些用户昵称和文案之类的与背景图合并成为截图,这里可以轻松实现。
  4. 等等等......

参考资料

1.由 FlexBox 算法强力驱动的 Weex 布局引擎:https://www.jianshu.com/p/d085032d4788
2.网页性能管理详解:https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html
3.渲染性能:https://developers.google.cn/web/fundamentals/performance/rendering
4.简化绘制的复杂度、减小绘制区域:https://developers.google.com/web/fundamentals/performance/rendering/simplify-paint-complexity-and-reduce-paint-areas?hl=zh-CN

查看原文

赞 58 收藏 36 评论 8

yuanzm 发布了文章 · 2019-01-18

JavaScript物理引擎之Matter.js与Box2d性能对比

前言

在挑选JavaScript 2D物理引擎的时候,不外乎两种主流的选择:第一种是老牌的Box2D,最开始的版本是C++实现的,后来有了很多种实现,比如flash版本和js版本,具体可看:https://stackoverflow.com/que...;第二种是新潮的matter-js,matter-js比较轻量,API和文档都比较有友好。

这段时间先后折腾了matter-js和Box2D,因为项目需要在微信小游戏端运行,对性能要求比较高,最终还是选择了Box2D。

但凡涉及到这种需要经常看源码才能使用的库中文社区都非常少干货,这段时间折腾之后打算整理一些文章,分享给社区也作为一个知识备忘。

本文简单对两个引擎的性能在不同平台上进行对比,其中Box2D采用的是TypeScript实现的版本:https://github.com/flyover/bo..., 作者仍然在更新,并且我看了下CocosCreator内置的物理引擎也是基于这个进行的封装,社区还是可以得到保证的。matter-js采用的是0.14.2版本(感觉作者已经更新不动这个库了:),大半年都不怎么活跃了)。

测试案例

在屏幕随机位置重复创建相同的矩形刚体,使之自由落体到底部,计算不同刚体数量下,全部刚体落地后每一帧的物理计算平均耗时。下面是测试中的一些截图:
图片描述图片描述

影响性能的因素

  1. 机器本身的配置;
  2. JIT:苹果端微信小游戏没有JIT,性能会大打折扣;
  3. 刚体的随机性:刚体在随机位置生成的过程中,如果与其他刚体重叠,物理引擎需要更多的性能消耗来修正重叠,因此,每次运行测试用例数据上都不可避免会有波动。
  4. 引擎本身的设计:比如matter-js没有圆形的定义,创建圆形刚体本质上是创建25边形,而Box2d天然就设计了圆形刚体,所以对于圆形刚体,两个引擎会存在不小的差异。

数据采集

因为是测试物理引擎的性能,这里不考虑FPS,只采集物理引擎更新每一帧的时间,因为除开物理引擎,渲染引擎(PixiJS)也会带来性能消耗。

// Box2d数据打点
let positionIterations = 3;
let velocityIterations = 8;
let timeStep           = 1 / 60;
Performance.startPoint('box2dUpdateCost');
world.Step(timeStep, velocityIterations, positionIterations);
Performance.endPoint('box2dUpdateCost');
// matter-js数据打点
Performance.startPoint('matterUpdateCost');
matter.Engine.update(this.engine, 1e3 / this.fps);
Performance.endPoint('matterUpdateCost');
// 计算平均耗时
function calAverage(list, key) {
    let sum = list.reduce((total, curr) => curr[key] + total, 0);
    console.log(sum / list.length)
}

// 所有数据会收集到一个数组里面
let data = Performance.print();

//calAverage(data, 'matterUpdateCost');
calAverage(data, 'box2dUpdateCost');

Box2D数据

机型10个刚体20个刚体50个刚体100个刚体200个刚体300个刚体
MacBook Pro 20150.2ms0.4ms~0.5ms0.6ms~0.8ms1.3ms~1.6ms4.6ms~5.6ms7ms~8ms
iPhone7 Plus微信小游戏3.3ms~3.5ms4.5ms~5.5ms7.5ms~8.5ms13ms~14ms33ms60ms+
OPPO R11 Plus微信小游戏1.5ms~2.5ms1.8ms~3ms3.6ms6ms~8ms9ms~12ms17ms~19ms

matter-js数据

机型10个刚体20个刚体50个刚体100个刚体200个刚体300个刚体
MacBook Pro 20150.5ms~0.6ms0.6ms~1ms2ms~3ms3.5ms~4ms6ms~8ms12ms~13ms
iPhone7 Plus微信小游戏2.3ms~2.8ms3.0ms~3.5ms6.0ms~6.5ms11.5ms~12ms26ms~28ms45ms
OPPO R11 Plus微信小游戏1.5ms~2.5ms2.5ms5~6ms8ms12ms~14ms30ms

结论

在PC端,Box2d全面战胜了matter-js,在苹果的微信小游戏端,因为没有JIT,Box2d性能反而不如matter-js,而回到安卓的微信小游戏端,因为有JIT,Box2d同样是可以战胜matter-js的。

关于圆形刚体

上面提到了两个引擎对于圆形刚体的设计,因为matter-js没有正统的圆形,我大胆猜测圆形刚体的性能Box2D会大大高于matter-js!

特意去翻了下各自的源码,首先我们来看看matter-js的:

Bodies.circle = function(x, y, radius, options, maxSides) {
    options = options || {};
    var circle = {
        label: 'Circle Body',
        circleRadius: radius
    };
                                
     // approximate circles with polygons until true circles implemented in SAT
    maxSides = maxSides || 25;
    var sides = Math.ceil(Math.max(10, Math.min(maxSides, radius)));

    // optimisation: always use even number of sides (half the number of unique axes)
    if (sides % 2 === 1)
        sides += 1;
        
    return Bodies.polygon(x, y, sides, radius, Common.extend({}, circle, options));
};

从上面的代码可得,matter-js将25边形当成圆,这里在进行碰撞检测的时候,会比纯圆有更多的计算量,不知道matter-js作者是出于什么目的这样设计。

再来看看Box2D版本的实现:

class b2CircleShape extends b2Shape {
      constructor(radius = 0) {
          super(exports.b2ShapeType.e_circleShape, radius);
          this.m_p = new b2Vec2();
      }
      Set(position, radius = this.m_radius) {
          this.m_p.Copy(position);
          this.m_radius = radius;
          return this;
      }
}

与matter-js相比,Box2D的圆与多边形是独立的。

多说无益,我们对比下100个刚体状态下,两个引擎的数据对比,为了凸显差距,我们选择Box2D打不过matter-js的苹果端微信小游戏平台查看数据:

引擎耗时
Box2D8ms
matter-js25ms

我们可以得出一个有意思的结论:同样是100个刚体,矩形刚体的耗时是13ms~14ms,而圆形刚体的耗时下降到了8ms,这对于一些弹球类的游戏无疑是福音,据我的观察,100个圆形刚体在苹果端微信小游戏下面丝毫不会卡顿。而matter-js的耗时从11.5ms~12ms上升到了25ms,显然就是在越多边形碰撞检测需要的计算量越大!

查看原文

赞 12 收藏 7 评论 0

yuanzm 赞了文章 · 2018-06-08

Deno 并不是下一代 Node.js

这几天前端圈最火的事件莫过于 ry(Ryan Dahl) 的新项目 deno 了,很多 IT 新闻和媒体都用了标题:“下一代 Node.js”。这周末读了一遍 deno 的源码,特意写了这篇文章。长文预警(5000字,11图)。

0. 为什么开发 Deno?

这是我上周做的一张图,介绍了 JavaScript 的发展简史。刚才修改了一下,添加了对 Node.js 和 Deno 发布时间的标注。
Node.js 和 Deno 分别是 Ryan Dahl 在 2009 年和 2018 年,基于当年最新的前端技术开发的非浏览器 JavaScript 运行时

JavaScript 历史(Node &amp; Deno)

Ryan Dahl 开发 deno 并不是因为 “just for fun”,也不是为了取代 node。下面慢慢解释。

1. 目前 deno 只是一个 demo

这两天花时间看了 deno 的源码(好在是初级阶段,源码很少,也很容易理解),顺带看了所有的 issue 和 pr。不知道“从官方介绍来看,可以认为它是下一代 Node”是如何脑补出来的。

既然是 Node.js 之父的新作,在讨论中自然离不开 Node.js。而作者很皮的回复到:

The main difference is that Node works and Deno does not work : )

最大的区别就是:Node 可以工作,而 Deno 不行 : )

目前 Deno 只是一个 Demo,甚至连二进制发行版都没有。好在从源码编译比较简单(如果你使用的不是 Windows 系统)。

在 high-level 层面,Deno 提供了一个尽可能简单的 V8 到系统 API 的绑定。为什么使用 Golang 替代 C++ 呢,因为相比 Node 而言,Golang 让我们更加容易的添加新特性,比如 http2 等。

至于为什么不选择 Rust,作者没有回答。

我们再对比一下两者的启动性能。分别运行:

console.log('Hello world')

node vs deno

我之前写过一篇文章:Node.js 新计划:使用 V8 snapshot 将启动速度提升 8 倍,那如果我们使用 --without-snapshot 参数编译 Node.js 呢?

deno vs node(without-snapshot)

依然是相差悬殊,毕竟 deno 需要加载一个 TypeScript 编译器。毕竟是一个 demo 版本,希望以后用力优化。

对于性能提升还有一个思路就是,可以使用 LLVM 作为后端编译器把 TypeScript 代码编译为 WebAssembly 然后在 V8 里面运行,甚至可以直接把源码编译成二进制代码运行。Ryan Dahl 表示 deno 只需要一个编译器,那就是 TS。但是既然 deno 要兼容浏览器,那么 WebAssembly 应该也会被支持。

Deno 可以对 ts 的编译结果进行缓存(~/.deno/cache),所以目前关注的就是启动速度和初次编译速度。

要么就是在发布前先行编译,如此一来 deno 就脱离了开发的初衷了。deno 是一个 ts 的运行时,那么就应该可以直接运行 ts 代码,如果提前把 ts 编译成 js,那么 deno 就回退到 js 运行时了。

2. 初学者应该学习 Node.js 还是 Deno?

对于这个问题,Ryan Dahl 的回答干净利落:

Use Node. Deno is a prototype / experiment.

使用 Node。Deno 只是一个原型或实验性产品。

从介绍可以看到,Deno 的目标是不兼容 Node,而是兼容浏览器。

所以,Deno 不是要取代 Node.js,也不是下一代 Node.js,也不是要放弃 npm 重建 Node 生态。deno 的目前是要拥抱浏览器生态。

不得不说这个目标真伟大。Ryan Dahl 开发了 Node.js,社区构建出了整个 npm 生态。我在另一个回答 justjavac:纯前端开发眼里nodejs到底是什么? 里面写到“Node.js 是前端工程化的重要支柱之一”。

虽然后来 Ryan Dahl 离开 Node.js 去了 Golang 社区,但是现在 Ryan Dahl 又回来了,为 JavaScript 社区带来了 Golang,开发出了 Deno,然后拥抱浏览器生态。👍

我们看看 deno 的关于 Web API 的目标:

  • High level

    • Console √
    • File/FileList/FileReader/Blob
    • XMLHttpRequest
    • WebSocket
  • Middle level

    • AudioContext/AudioBuffer
    • Canvas

甚至还会包括 webGL 和 GPU 等的支持。

3. Deno 的架构

Parsa Ghadimi 绘制了一张关于 Deno 的架构图

Deno‘s architecture

底层使用了作者开发的 v8worker2,而 event-loop 则基于 pub/sub 模型。关于 v8worker 可以看看这个 PPT:https://docs.google.com/prese...

我比较好奇的是 deno 使用了 protobuf,而没有使用 Mojo。既然目标是要兼容浏览器,却不使用 Mojo,而是要在 protobuf 上重新造轮子,可见 Ryan Dahl 是真正的“轮子哥”啊。但是从 issue 中可以看出,Ryan Dahl 之前是没有听说过 Mojo 的,但是他看完 mojo 之后,依然觉得 protobuf 的选择是正确的。

Mojo 是 Google 开发的新一代 IPC 机制,用以替换旧的 Chrome IPC。目前 Chrome 的最新版本是 67,而 Google 的计划是在 2019 年的 75 版本用 mojo 替换掉所有的旧的 IPC。

Mojo 的思路确实和 protobuf 毕竟像,毕竟都是 Google 家的。旧的 IPC 系统是基于在 2 个进程(线程)之间的命名管道(IPC::Channel)实现的。这个管道是一个队列,进程间的 IPC 消息按照先进先出的顺序依次传递,所以不同的 IPC 消息之间有先后次序的依赖。相比之下,Mojo 则为每一个接口创建了一个独立的消息管道,确保不同接口的 IPC 是独立的。而且为接口的创建独立的消息管道的代价也并不昂贵,只需分配少量的堆内存。

Mojo 的架构设计:

我们可以看一下 Chrome 引入 Mojo 之后的架构变化。

之前:

之后:

是不是有点微服务的感觉。

熟悉 Java 的 Spring 的可以明显看出这个依赖倒置。Blink 本来是浏览器最底层的排版引擎,通过 Mojo,Blink 变成了要给中间模块。最近大热的 Flutter 也是基于 Mojo 架构的。

4. TypeScript VS JavaScript

deno 的介绍是一个安全的 TypeScript 运行环境。但是我们看源码就会发现,deno 集成进了一个 TypeScript 编译器,而入口文件中 ry/deno:main.go

// It's up to library users to call 
// deno.Eval("deno_main.js", "denoMain()") 
func Eval(filename string, code string) { 
    err := worker.Load(filename, code) 
    exitOnError(err) 
} // It's up to library users to call
// deno.Eval("deno_main.js", "denoMain()")
func Eval(filename string, code string) {
    err := worker.Load(filename, code)
    exitOnError(err)
}

使用 V8 运行的 deno_main.js 文件。是 JavaScript 而不是 TypeScript 。

在前面的分析中我们知道这会影响 deno 的初次启动速度。那么对于执行速度呢?从理论上,TypeScript 作为一种静态类型语言,编译完成的 JavaScript 代码会有更快的执行速度。我之前在《前端程序员应该懂点V8 知识》曾经提到过 V8 对于 JavaScript 性能提升有一项是 Type feedback

当 V8 执行一个函数时,会基于函数传入的实参(注意是实参,而不是形参,因为 JavaScript 的形参是没有类型的)进行即时编译(JIT):

但是当后面再次以不同的类型调用函数时,V8 会进行去优化(Deopt)操作。

(将之前优化完的结果去掉,称为“去优化”)

但是如果我们使用 TypeScript ,所有的参数都是由类型标注的,因此可以防止 V8 引擎内部执行去优化操作。

5. 对 deno 性能的展望和猜想

虽然 TypeScript 可以避免 V8 引擎的去优化操作,但是 V8 执行的是 ts 编译后的结果,我们通过字节码或者机器码可以看到,V8 依然生成了 Type Check 的代码,每次调用函数之前,V8 都会对实参的类型进行检查。也就是说,虽然 TypeScript 保证了函数的参数类型,但是编译成 JavaScript 之后,V8 并不能确定函数的参数类型,只能通过每次调用前的检查来保证参数的类型。

其次,当 V8 遇到函数定义时,并不知道参数的类型,而只有函数被调用后,V8 才能判断函数的类型,才对函数进行 Typed 即时编译。这里又有一个矛盾了,typescript 在函数定义时就已经知道了形参的类型,而 V8 只有在函数调用时才根据实参的类型进行优化。

所以,目前 deno 的架构还存在很多问题,毕竟只是一个 demo。未来还有很多方向可以优化。

V8 是一个 JavaScript 运行时,而 deno 如果定义为“安全的 TypeScript 运行时”,至少在目前的架构上,性能是有很大损失的。但是目前还不存在一个 TypeScript 运行时,退而求其次只能在 V8 前面放一个 TypeScript 编译器了。

执行流程是这样的:

虽然我在项目中没有使用过 TypeScript ,但是基本上我在项目里面写的第三方库都会提供一d.ts 文件。目前 TypeScript 最大的用途还是体现在开发和维护过程中。

我们想到的一个方式就是 fork 一份 V8 的源码,然后把编译流程整合进去。TypeScript 在编译为 JavaScript 的过程中也需要一份 AST,然后生成 js 代码。V8 执行 js 代码是再 parse 一份 AST,基于 AST 生成中间代码(ByteCode)。如果 TypeScript 可以直接生成对用的字节码则会提升运行时的性能。

不过 Ryan Dahl 大概不会这么干。但是也未必,毕竟社区已经把 TypeScript 的一个子集编译为 WebAssembly 了。

之前微软的 JScript 和 VBScript 在和 JavaScript 的竞争中败下阵来,而现在 TypeScript 势头正猛。虽然对 ES 规范的兼容束缚了 TypeScript 的发展,但很期待微软可以提供一个 TS 运行时,或者在 Chakra 引擎增加对 TS 运行时的支持。

6. 总结

不论如何,deno 是一个非常伟大的项目,但却不是“下一代 Node.js”。


PS:昨天 Ryan Dahl 在 JS Conf 做了《Design Mistakes in Node》的演讲,目前只有 PPT,还没有 Youtube 视频。而 8 年前的 2009 年,Ryan Dahl 也在 JS Conf 做了一次演讲,这次演讲诞生了 Node.js。

查看原文

赞 101 收藏 71 评论 29

yuanzm 关注了标签 · 2017-06-19

vue.js

Reactive Components for Modern Web Interfaces.

Vue.js 是一个用于创建 web 交互界面的。其特点是

  • 简洁 HTML 模板 + JSON 数据,再创建一个 Vue 实例,就这么简单。
  • 数据驱动 自动追踪依赖的模板表达式和计算属性。
  • 组件化 用解耦、可复用的组件来构造界面。
  • 轻量 ~24kb min+gzip,无依赖。
  • 快速 精确有效的异步批量 DOM 更新。
  • 模块友好 通过 NPM 或 Bower 安装,无缝融入你的工作流。

官网:https://vuejs.org
GitHub:https://github.com/vuejs/vue

关注 97353

yuanzm 回答了问题 · 2016-11-09

解决前端新手想用node写一个个人博客不知从何下手,希望有大神能指点迷津

可以看看 《Nodejs开发指南》

关注 5 回答 4

yuanzm 关注了问题 · 2016-11-09

解决前端新手想用node写一个个人博客不知从何下手,希望有大神能指点迷津

本人初学前端,打算用node.js做一个人博客的项目,可是却不知道改如何下手,直接学习express吗?
看网上有很多前端框架vue、react之类的也不知道改用哪个。不知可有有经验的热心朋友能指点迷津,谢谢?

关注 5 回答 4

yuanzm 回答了问题 · 2016-09-28

解决js函数中的函数问题?

arr[i]=function a(){
    return i;
}

这里你是在给arr[i]赋值,它的值是一个函数,函数的名字是a,而不是声明和定义函数a的意思,如果在arr[i]后面console.log(arr[i]),你会发现有五个a函数。

关注 5 回答 8

认证与成就

  • 获得 362 次点赞
  • 获得 8 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2014-09-24
个人主页被 4.4k 人浏览