FreeMina-自己开源的一个微信小程序的兼容框架,让小程序在浏览器运行

FreeMina: An open mina compatible framework for running in browser or webview.

一个兼容微信小程序Mina框架的开源框架

从小程序的设计来看,微信正走向封闭生态。我们开发的微信小程序很难在其他地方使用。

最近一段时间,我花了大量精力来查找相关资料。包括React、React Native。我本来不算一个JS程序员,但也为此学习或了解了Bable
WebPatch,ES2015等等一系列我原本不熟悉的内容。

真正有巨大收获的是 @phodal 大神发的五篇文章,
它还做了一个for fun的框架winv。 仔细学习了这个框架,并提交了一个补丁,改善了一点点小功能。昨天晚上,我就在考虑到底是在这个框架上修改,还是自己开一个。

反复思量,觉得如果要改进,那么基本上要重写所有的代码,和重开一个无益。
一个项目初始的架构很重要,差的架构让人难以提起改进的兴趣。另外,大神有6个月没有更新项目了。

总之,再次感谢 @phodal 。

FreeMina项目地址 https://github.com/taiji1985/freemina

设计目的和计划

完全兼容微信小程序的所有API。让微信小程序能移植到自己的APP上。

当然这个目标从现在看有些“宏伟”了。

要做的工作:

  • 解析wxml dom,并生成相应的html。
    这一点, @phodal 已经做了大量的贡献。但性能需要改进一下。
    另外,我学习了facebook的diff算法,准备在今后的改进中加入。

  • wxml中{{}}格式数据的处理。我给winv这个项目添加了 {{obj.name}}这样的支持。
    但还缺少if和for这两个非常重要的环节。

  • 事件系统。 目前已经实现了一些,但还远远没有完成。但大体的设计已经有了。

  • 打包等项目工具 。 微信小程序将所有的文件全部打包在一起。这个并非简单的用
    webpack进行打包。还对程序作了一定的预处理。对于将xml生成为js的做法,我觉得还需要考虑,到底需不需要这么做。
    json的处理相对简单,require进去就好。

    我实现打包工具的思路是

    1. 首先给Page打包,给添加上两个参数,把xml和文件名一起传给Page函数。
    2. 使用webpack等工具打包到一起。
  • App支持。 wx中有很多函数,没有App的帮助是无法实现的。
    这一部分的做法

    1. 在web中能用web试下你的用web实现,不能实现的暂不实现。
    2. 在App中,给出原生支持。。不过我目前只会android。苹果的没钱买那么贵的设备。毕竟玩票性质。。。

实现方案

项目工具

整个项目使用nodejs管理。使用gulp完成编译和监视文件自动编译的功能。
使用bable进行ES6的转义。 直接拿了别人项目的配置。。。(羞。。。)
详见package.json

项目入口

项目的入口是src/freemina.js

/**
 * Created by Tongfeng Yang on 2017/1/25.
 */
import Page from './Page'
import App from './App'
import FException from './FException'

const freemina={
    addPage(opt,name,wxml){
        console.log("add Page :"+name);
        if(!window.App ){
            throw new FException("App() function should be called before calling Page");
        }
        let p = new Page(opt,name);
        p.setWXml(wxml);
        window.App.addPage(name,p);
    },

    setApp(opt){
        console.log("set App called");
        window.App = new App(opt);
    },

    start(){
        window.Page  = this.addPage;
        window.App = this.setApp;
//        let e = new CustomEvent('onLaunch',{});
//        window.App.eventHandler(e)
    },
    finishLoad(){
        var e={type:"onLaunch",detail:{}};
        window.App.eventHandler(e);
    }

}

export default  freemina;

包含了setApp和addPage方法,这个方法在start方法中被暴露到window中,
所以,就可以使用App({})和Page({})的方法来使用它们。
setApp直接创建App类。 addPage方法首先创建Page对象,随后调用App的addPage方法
将其加入管理之中。并使用setWxml将wxml设置进去。

App类

先看代码

/**
 * Created by Tongfeng Yang on 2017/1/25.
 */

export default  class App{

    constructor(opt){
        this.opt = opt;
        this.pageMap = [];

        this.addPage=this.addPage.bind(this);
        this.eventHandler=this.eventHandler.bind(this);
        this.render=this.render.bind(this);
        this.regEvent.bind(this)();
    }

    regEvent(){

        document.addEventListener("onShow",(e)=>{
            //onLoad function of page is called succ

        });
    }

    addPage(name,p){
        if(this.pageMap.length==0){
            this.curPage = p;
        }
        p.setName(name);

        this.pageMap[name] = p;
    }

    eventHandler(e){ //CustomEvent
        if(this.opt[e.type]){
            this.opt[e.type](e.detail);
        }
        if(e.type == "onLaunch"){
            let ename = this.curPage.name+"_onLoad";
            e= new CustomEvent(ename,{});
            document.dispatchEvent(e);
        }
    }

    render(){
        if(this.curPage){
            curPage.render();
        }
    }

}

使用ES6实现的,老实说,如果不是能用class,我是不愿意入js这个坑的。
但一堆堆的bind还是亮瞎了我眼。。。

App对象维护一个Page对象列表。和一个curPage指向当前对象。
目前,默认认为第一个注册的Page是入口。(因为还没实现App的配置,所以暂时忍一下吧!!!)

下面是事件处理的核心eventHandler。
我们在写App时这么写:
App({
onLaunch:function(){...}
})
这个传进app的是一个对象或者说是hashmap。在app的构造函数中,传递给了this.opt
eventHandler被调用时,给出了一个e,这个e可以是CustomEvent,也可以是

{type:'onLaunch',detail:{}}

这样的对象。如果收到上述的这个onLaunch消息,这个函数就会判断opt中(就是你传进的对象)
是否有这个方法。如果有,则调用它。

调用万onLaunch开始调用Page的onLoad了。怎么调用呢?
在这里发出一个消息。如果页面的名字是index,那么就发出
index_onLoad消息。index这个页面会监听这个消息,进而收到这个onLoad事件。

下面来看Page的实现

Page的实现

先贴代码。

/**
 * Created by Tongfeng Yang on 2017/1/25.
 */
import WXmlParser from "./WXmlParser"

const event_list = [
    'onLoad','onDestory','render'
];
export default class Page{
    constructor(opt){
        this.opt = opt;


        this.eventHandler=this.eventHandler.bind(this);
        this.registerEventHandler=this.registerEventHandler.bind(this);
        this.removeEventListener=this.removeEventListener.bind(this);
        this.setName=this.setName.bind(this);
        this.render=this.render.bind(this);
        this.setWXml=this.setWXml.bind(this);
        this.fireMyEvent = this.fireMyEvent.bind(this);
        this.getData =this.getData.bind(this);
    }

    setName(name){
        if(name == this.name)return;
        this.removeEventListener();
        this.name = name;
        this.registerEventHandler();
    }

    removeEventListener(){
        for(var e in event_list ){
            let ename = event_list[e];
            console.log("removeEventListener:"+this.name+'_'+ename);
            document.removeEventListener(this.name+'_'+ename);
        }
    }

    registerEventHandler(){
        for(var e in event_list ){
            let ename = event_list[e];
            console.log("addEventListener:"+this.name+'_'+ename);
            document.addEventListener(this.name+'_'+ename,this.eventHandler);

        }
    }

    getData(){
        return this.opt.data;
    }

    eventHandler(e){ //CustomEvent
        console.log("page this = "+this);
        console.log(this);
        let type = e.type.slice(this.name.length+1); // eg : index_onLoad , remove 'index_'
        console.log("recv event "+e.type);
        if(this.opt[type]){
            this.opt[type]({});
        }else if(this[type]){
            this[type].bind(this)();
        }else{
            console.log("Page: unknown event "+ type);
        }
        if(type == 'onLoad'){ //if onload finish ,start to render
            this.render();
            //this.fireMyEvent.bind(this)('render');
        }
    }


    setWXml(wxml){
        this.wxml = wxml;
    }

    render(){
        console.log("render called");
        let template = this.wxml;
        let parser =new WXmlParser(this.getData());
        let domJson = parser.stringToDomJSON(template)[0];
        let dom = parser.jsonToDom(domJson);
        document.getElementById('app').appendChild(dom);
        this.fireEvent('onShow');//for App object
    }
    fireMyEvent(type){
        type = this.name+"_"+type;
        console.log("fireEvent "+type);
        document.dispatchEvent(new CustomEvent(type,{}));

    }
    fireEvent(type){
        console.log("fireEvent "+type);
        document.dispatchEvent(new CustomEvent(type,{}));
    }

}


构造函数中又是一堆晃瞎我眼的bind。另外你换进来的那个对象仍然被存到了opt中。
setName 函数是被App调用的。 设置了这个页面的名字。在名字设定后,就会注册一堆事件监听者。
注册的列表在event_list这个变量里。以后这个列表可以逐渐完善。
上面说到的那个index_onLoad事件就是通过页面名字和事件名拼接出来的。

事件监听函数是eventHandle。把onLoad这个字眼从index_onLoad中切除来。

        let type = e.type.slice(this.name.length+1); // eg : index_onLoad , remove 'index_'

然后查找this.opt就是你传进来的那个对象是否有onLoad的声明。
如果有,则调用,如果没有,则尝试在this中查找,如果还是没有,就真的没有了。

下面说比较重要的渲染问题

WXmlParser渲染wxml文件

这部分参考了winv,里面也有少量我贡献的嗲吗,我只是对其做了重构,以方便调用。看代码

/**
 * Created by Tongfeng Yang on 2017/1/25.
 * Some code copied from https://github.com/phodal/winv  ,which is under MIT .
 */

class Utils{
    removeTemplateTag(str){
        return str.substr(2, str.length - 4);
    }

    isTemplateTag(string){
        return /{{[a-zA-Z1-9\\.]+}}/.test(string);
    }
}
export default class WXmlParser{
    constructor(data){
        this.data= data;
        this.stringToDomJSON=this.stringToDomJSON.bind(this);
        this.nodeToJSON=this.nodeToJSON.bind(this);
        this.jsonToDom=this.jsonToDom.bind(this);
        this.domParser = this.domParser.bind(this);
        this.getData = this.getData.bind(this);
        this.utils  = new Utils();
    }

    stringToDomJSON(string){
        string = '<div class="page"><div class="page__hd">' + string + '</div></div>';
        var json = this.nodeToJSON(this.domParser(string));
        if (json.nodeType === 9) {
            json = json.childNodes;
        }
        return json;
    }

    getData(key) {
        if(!key)return null;
        var ka = key.split(".");
        var ret = this.data[ka[0]];
        for(var i = 1;i<ka.length;i++){
            if(!ret)return null; //can't find !
            ret= ret[ka[i]];
        }
        return ret;
    }

    nodeToJSON(node){
        // Code base on https://gist.github.com/sstur/7379870
        node = node || this;
        var obj = {
            nodeType: node.nodeType
        };
        if (node.tagName) {
            obj.tagName = 'winv-' + node.tagName.toLowerCase();
        } else if (node.nodeName) {
            obj.nodeName = node.nodeName;
        }
        if (node.nodeValue) {
            obj.nodeValue = node.nodeValue;
            if(this.utils.isTemplateTag(node.nodeValue)){
                obj.nodeValue = this.getData(this.utils.removeTemplateTag(node.nodeValue));
            }
        }
        var attrs = node.attributes;
        if (attrs) {
            var length = attrs.length;
            var arr = obj.attributes = new Array(length);
            for (var i = 0; i < length; i++) {
                var attr = attrs[i];
                arr[i] = [attr.nodeName, attr.nodeValue];
            }
        }
        var childNodes = node.childNodes;
        if (childNodes) {
            length = childNodes.length;
            arr = obj.childNodes = new Array(length);
            for (i = 0; i < length; i++) {
                arr[i] = this.nodeToJSON(childNodes[i]);
            }
        }
        return obj;
    }

    jsonToDom(obj)
    {
        // Code base on https://gist.github.com/sstur/7379870
        if (typeof obj == 'string') {
            obj = JSON.parse(obj);
        }
        var node, nodeType = obj.nodeType;
        switch (nodeType) {
            case 1: //ELEMENT_NODE
                node = document.createElement(obj.tagName);
                var attributes = obj.attributes || [];
                for (var i = 0, len = attributes.length; i < len; i++) {
                    var attr = attributes[i];
                    node.setAttribute(attr[0], attr[1]);
                }
                break;
            case 3: //TEXT_NODE
                node = document.createTextNode(obj.nodeValue);
                break;
            case 8: //COMMENT_NODE
                node = document.createComment(obj.nodeValue);
                break;
            case 9: //DOCUMENT_NODE
                node = document.implementation.createDocument('http://www.w3.org/1999/xhtml', 'html', null);
                break;
            case 10: //DOCUMENT_TYPE_NODE
                node = document.implementation.createDocumentType(obj.nodeName);
                break;
            case 11: //DOCUMENT_FRAGMENT_NODE
                node = document.createDocumentFragment();
                break;
            default:
                return node;
        }
        if (nodeType == 1 || nodeType == 11) {
            var childNodes = obj.childNodes || [];
            for (i = 0, len = childNodes.length; i <  len; i++) {
                node.appendChild(this.jsonToDom(childNodes[i]));
            }
        }
        return node;
    }

    domParser(string){
        var parser = new DOMParser();
        return parser.parseFromString(string, 'text/xml');
    }
}

Page.js中的渲染函数


render(){
        console.log("render called");
        let template = this.wxml;
        let parser =new WXmlParser(this.getData());
        let domJson = parser.stringToDomJSON(template)[0];
        let dom = parser.jsonToDom(domJson);
        document.getElementById('app').appendChild(dom);
        this.fireEvent('onShow');//for App object
    }

基本原理首先通过DOMParser将wxml解析一下(domParser)。编程一个dom对象。将其
变为domJSON.然后再讲domJSON转换会dom对象。这一步中包含{{}}标签的处理。
用的正则表达式匹配。

最后,这个问题还是很多的。 比如那个appendChild。。在后面的开发中会替换成
diff和apply。
渲染完了,发送事件。

TODO

  1. 事件机制还不完善。
  2. 渲染的diff和apply的实现。
  3. setData这个核心的函数实现。
  4. 参照微信文档进行界面完全兼容
  5. 完善wx的API函数(可能会用Android实现)
    IOS就算了,听说基于WebView的通不过审核!
    另外,我没钱买Mac。。。

最后

感谢您的阅读。如果有可能请贡献些代码。。。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容