声明式编程风格的视图文档解决方案

背景

很多渲染引擎或者可视化编辑器都会将视图保存成文件落地,很多都会选xml作为数据格式来存储,少数会使用json,然而笔者之前做的可视化编辑器,就是用json作为数据格式的,方便快捷,随取随存,甚是快活,直到。。。

json vs xml(盗图)

直到脱离编辑器时,想手写视图文档的时候,才发现是那么的痛苦!而且,随着object和array的嵌套多起来,才是真的噩梦!后来沉思了许久,要不试试xml吧,以前在用白鹭的exml的时候感觉还行,几乎可以很直观地在脑中映射出视图来。
对于json和xml的对比,有人已经给出了详尽的结论:https://www.jianshu.com/p/1ff2bc9161f1
于是就增加了xml的文档解释器,然而痛苦确实减轻了许多,但是,使用xml并没有结合笔者的引擎的特殊性来考虑(笔者的引擎是基于E/C架构的实现),一个视图节点可以划分成4个部分:类名、节点属性、子节点、组件组。如果用xml的话,实现是可以实现的,只不过需要做很多特殊处理,比如组件组就会和子节点处于同一层,需要通过判断tagName来特殊处理;再比如节点属性里如果有object,那么就需要xml的子节点来存放,就又和子节点处于同一层了,需要通过首字母大小写来特殊处理。
上述的都是使用json和xml的痛点,直到想起同事曾今跟笔者说过的声明式编程。

声明式编程

那么啥是声明式编程,是否和函数式编程差不多的名词呢?
笔者经过查阅也是渐渐明白,现行的编程方式大概就3种:命令式编程、函数式编程和声明式编程。前两个就不多赘述了,搜索一下很多很多,唯独声明式编程,笔者才知道不久。
这样来理解:越接近自然语言,声明式编程的比重就越高,否则就是命令式编程的占比更高。
如果你还没理解,那么还是上个大开发论坛看看吧。

swiftUI

苹果新出的swift语言,就可以很自然地使用声明式编程,各种链式编程和流畅的语法,很似自然语言,所以官方给出了swiftUI的支持。
笔者也没用过,也不能多加描述,若是有兴趣可以去看看。https://developer.apple.com/xcode/swiftui/

简单实现

笔者是从事HTML5游戏开发的,自然会使用js,所以使用js来实现了相对可读性可编辑性更好的声明式编程实现。
看个示例:

const {Doc} = require('qunity');
const {Node, Rect, Text, StarBezier} = require('qunity-pixi');

return Doc({type: 'scene', name: 'main'}).kv({
    root: Node({name: 'Scenes'}).c([
        Node({name: 'MainScene', active: true}).c([
            Rect({name: 'bg', shapeWidth: 750, shapeHeight: 1334, fillColor: 0xcc202e,}),
            Node({name: 'slogan', y: 200, angle: "-3"}).c([
                Text({
                    name: 'title1',
                    x: 150,
                    text: 'LEAP',
                    style: {fill: 0xFFFFFF, fontSize: 160, fontFamily: 'Arial Black'}
                }),
                Text({
                    name: 'title1',
                    x: 150,
                    y: 140,
                    text: 'ON!',
                    style: {fill: 0xFFFFFF, fontSize: 220, fontFamily: 'Arial Black'}
                }),
            ]),
            Node({name: 'InfoBoard', x: -100, y: 600, angle: -3,}).c([
                Rect({name: 'bg', y: 70, shapeWidth: 1000, shapeHeight: 300, fillColor: 0x000, alpha: 0.7}),
                Text({
                    name: 'title',
                    x: 375,
                    text: "最新数据",
                    fontWeight: 'bold',
                    alpha: 0.7,
                    style: {fill: 0x000, fontSize: 60, fontFamily: '方正粗圆_GBK'}
                }),
            ]).s([
                {script: "/scripts/InfoBoard"}
            ]),
        ]),
    ]),
    asset: [
        {
            path: "images/1.png",
            uuid: "dd22921b-e7ff-4fbc-9e79-ebfe517b9943",
            url: "assets/images/1.png",
        }
    ],
})
模仿Leap-on游戏的首页

如上,就是视图文档解释出来的视图。

拆分

声明式编程什么?链式编程、相对丰富的方法库(可扩展),还有一个简易的解释器。

文档结构设计

看示例,导入几个方法,然后return整个文档实例,就是这么简单。

方法导入

笔者使用了commonjs的风格,用require来导入方法。因为笔者的引擎是通过分模块的,所以做了两个导入来源。其中qunity只提供了Doc方法,用于创建并返回文档实例,qunity-pixi则提供了视图节点的创建方法,用于创建各种视图节点实例。(关于qunity这个库,笔者这里就不打广告了,之后会开源,一个基于EC架构实现的HTML5游戏引擎,一笔带过)

文档导出

文档导出并没有使用export,而是一个return,原因下面会讲到。

解释器

解释器比较简单,主要是提供上述要导入的方法和接受返回的文档实例。
如果你熟悉commonjs工作原理,那可以跳过

Doc方法

let func = new Function('require', docSource);
let doc = func(requireMethod);
return doc;

很简单,通过一个Function来包裹执行(这就是为什么在视图文档里是直接用return来返回整个文档实例,如果用commonjs的风格,则需要提供module参数用来接收返回值),避免使用eval而外部入侵。传入一个require参数,而这个require方法则会接受一个id的参数。

function requireMethod(id) {
    return requireContext[id];
}

id可能会是qunityqunity-pixi,然后根据id返回内容。
那么requireContext是一个怎么样的结构呢?

const requireContext = {
    'qunity': {
        Doc: function (props) {
            let obj = {
                kv,
                p,
            };
            setTimeout(function () {
                delete obj['kv'];
                delete obj['p'];
            });
            return obj.p(props);
        }
    },
    'qunity-pixi': pixiNodes,
};

看到了吗,就是一个map,分别是qunityqunity-pixi的内容。
qunity里有一个Doc的方法,实例化一个object,然后存放两个方法,kvp,最后执行obj.p(props)并返回obj
那么kvp这两个方法是什么呢?直接上代码:

function kv(props) {
    for (let key in props) {
        this[key] = props[key];
    }
    return this;
}
function p(props) {
    injectProps(app, this, props);

    if (props.active !== false && this.setActive) {
        this.setActive(true);
    }

    return this;
}

代码比较简单,kv是做键值对赋值的,p是深度赋值用的(耦合度较高),因为笔者的引擎需要,Doc方法只给出了这两个方法。
至此,Doc方法完成了,在视图文档中只要调用Doc(...).kv(...).p(...)可以生成一个文档的实例了。

节点方法

节点方法全部由qunity-pixi导出。

const entityNames = Object.keys(app.entityDefs);
for (let entityName of entityNames) {
    pixiNodes[entityName] = function (props) {
        let entity = app.createEntity(entityName);
        entity['kv'] = kv;
        entity['p'] = p;
        entity['c'] = c;
        entity['s'] = s;
        setTimeout(function () {
            delete entity['kv'];
            delete entity['p'];
            delete entity['c'];
            delete entity['s'];
        });
        return p.call(entity, props);
    }
}

笔者的引擎里注册的节点的定义全部在app.entityDefs上,然后遍历存放到pixiNodes上,每个节点都有相同的链式调用方法,其中c和s方法看下面代码:

function c(children) {
    for (let child of children) {
        app.addDisplayNode(child, this);
    }
    return this;
}

function s(components) {
    Object.defineProperty(this, '$componentConfigs', {
        value: components,
        writable: false,
        enumerable: false,
    });
    return this;
}

c方法是用来添加视图子节点的,s方法是用来存放组件的配置信息的。
为了能达到链式编程的目的,每个方法都需要返回自身。
为了不污染原本的节点属性,需要把存放在节点上的kv/p/c/s之类的方法都删除掉,但是也不能使用就删除,所以笔者想到的时候用setTimeout,在回调里将他们全部删除。

小结

解释器主要是为视图文档的执行提供上下文,并能实行视图文档和接受视图文档。

其他顾虑

编辑器schema

笔者在手写视图文档的时候,发现智能提示功能并没效果,这是肯定的,因为并没有对这些上下文方法给出定义。
答案是直接使用typescript的声明文件来做,自己写d.ts文件或者用工具生成上下文的d.ts,这样,就能有很好的智能提示了。(注:目前idea的支持较好)


智能提示

这样就可以很舒服地使用智能提示来手写视图文档了。

设计时映射到视图节点树

这个是什么应用场景呢?就是编辑器一般都会有一个视图节点树的组件来展示层级关系,那怎么在设计时从视图文档映射出这个视图节点树呢?
笔者想到的就是ast,用抽象语法树来处理,使用esprima之类的库来静态生成ast,然后分析ast,得到最终的视图节点树。

其他顾虑暂未想到。。。

总结

目前,简单版本的解释器还在开发和使用中,会不断迭代更新,解耦,达到最终的方便快捷直观。
声明式编程对于视图节点文档实在是太友好了,但也不值局限于次,声明式编程可以用于更多的DSL中,结果必定超预期!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容