freemina

Project Url: taiji1985/freemina
Introduction: A free mina compatible framework for running in browser or webview. mina is the framework of wechat app.一个微信小程序框架的兼容框架,可以运行于浏 览器和 webview 中。后期会支持将自己的 App 打包成 android 等平台应用
More: Author   ReportBugs   
Tags:

一个兼容微信小程序 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

编译方法

先执行 npm install 安装相关的包 修改源码后执行 gulp build 如果想在源文件被编辑后自动重新编译,执行 gulp watch

测试方法

打开 index.html 即可

微信小程序放哪里?

目前项目还很不完善, 你可以查看 test.js 中内容,并对其进行修改。 这个 test.js 中博包含两个页面,默认显示第一个,点击后显示第二个。

App({
    data: {
        word:'helo'
    },
    hello(){
        console.log('sss');
    },
    onLaunch:function(){
        this.hello();
    }
});

//--------
var wxml = '<view class="container"  bindtap="haha"><text class="item">{{first}}</text><text>{{obj.name}}</text></view>';

Page({

    data: {
        first: 'hello, world',
        obj:{
            name:'yang',
            age:13
        }
    },
    onLoad:function(){
        console.log('page loaded');
    },

    onShow: function () {

    },
    haha: function () {
        console.log('tap event ....');
        wx.navigateTo({
            url: 'index2'
        })
    }

},'index',wxml)

//-------- 第二个页面
var wxml_2 = '<view class="container"><text class="item">{{first}}</text><text>{{obj.name}}</text></view>';

Page({

    data: {
        first: 'sssss',
        obj:{
            name:'yang',
            age:13
        }
    },
    onLoad:function(){
        console.log('page loaded');
    },

    onShow: function () {

    },

},'index2',wxml_2)

项目入口

项目的入口是 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。。。

最后

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

Apps
About Me
GitHub: Trinea
Facebook: Dev Tools