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進去就好。我實現打包工具的思路是
- 首先給Page打包,給添加上兩個參數,把xml和文件名一起傳給Page函數。
- 使用webpack等工具打包到一起。
-
App支持。 wx中有很多函數,沒有App的幫助是無法實現的。
這一部分的做法- 在web中能用web試下你的用web實現,不能實現的暫不實現。
- 在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
- 事件機制還不完善。
- 渲染的diff和apply的實現。
- setData這個核心的函數實現。
- 參照微信文檔進行界面完全兼容
- 完善wx的API函數(可能會用Android實現)
IOS就算了,聽說基于WebView的通不過審核!
另外,我沒錢買Mac。。。
最后
感謝您的閱讀。如果有可能請貢獻些代碼。。。