解释:LuLu 是公司H5项目的基础库,Kbone则是小程序跨端框架
一.Kbone 简单介绍
由于公司主要项目几乎是 React 主导,所以本文会主要从 React 的视角来解析这个框架。事实上,Kbone 适合 Vue、React、Preact。不同框架的视角会有一些不一致,不过本质是相同的。
本身 reactDom 是为浏览器环境而生的,因为只有浏览器环境才有 dom 元素,为了让 reactDom 能在小程序环境中依然运行自如,kbone 实现了一个适配器,在适配层里模拟出了浏览器环境,最终实现出同构。
二.Kbone 核心原理
2.1 核心库 render
render 作为渲染适配器,主要为了扫清渲染前的一切跨端问题。通过模拟出浏览器环境,让 Web 端的代码不会出现异常。 浏览器环境常规内核 Api,被作者分为以下几个层级:
- bom (BOM是browser object model的缩写,简称浏览器对象模型)
- event(浏览器的 event 事件)
- node(DOM的标准规中的 Node对象)
- tree (主要提供解析DOM节点树的属性和方法)
- window & document (浏览器最重要的 根对象)
2.1.1. node模块 (最重要的 DOM Element相关)
这个模块是最重要的。如上图所示 ReactReconciler 是 react 的 调度模块,它用于发起组件的挂载、卸载、重绘机制。但不负责端的渲染工作。
因此无论是最新的 React 16 的 Fiber reconciler 还是 React 15 的 Stack reconciler。kbone 不用关心 React 内部的复杂细节,无论最后如何调度,一定在渲染时会落地地调用浏览器端 Api,如 document.createElement 和 document.createTextNode。因此 kbone 会专注于如何在小程序端实现自己的 document.createElement。kbone实现的 element 会尽可能地往浏览器的 element 模型靠拢,这部分的源码结构如下:
- Node
- Element
node-elemnt
- HTMLTextAreaElement
- HTMLVideoElement
- HTMLInputElement
- Image
- HTMLCanvasElement
- HTMLAnchorElement
下面大致走一下粗粒度代码流程,假设我写一个非常简单的 React 代码:
--- React 代码 ----
import React from 'react';
import { View } from 'luui';
const IndexPage = () => {
return (
<View className="greeting">
<div>Hello LuLu</div>
</View>
);
};
然后会经过 React 运行时将其变为 VNode (随便写的结构。。。):
{
"id": 1,
"type": "view",
"props": {
"className": "greeting"
},
"children": [
{
"id": 2,
"type": "div",
"props": {},
"text": "Hello LuLu"
}
]
}
然后经过 ReactDOM 调用 kbone 模拟的 document.createElement,最后创建出小程序渲染组件需要的 kbone 的 Element 对象,
最后带这个数据结构,就可以在小程序模板中渲染出来了:
<template name="subtree">
<block wx:for="{{childNodes}}" wx:key="nodeId" wx:for-item="item1">
<block wx:if="{{item1.type === 'text'}}">{{item1.content}}</block>
<image wx:elif="{{item1.isImage}}" class="{{item1.class || ''}}" style="{{item1.style || ''}}" src="{{item1.src}}"></image>
<view wx:elif="{{item1.isLeaf || item1.isSimple}}" >{{item1.content}}
</block>
</template>
2.1.2 event 事件机制
- event
- event-target
这里会多说一些,主要先说一下 React 事件机制。react自身实现了一套自己的事件机制,包括事件注册、事件的合成、事件冒泡、事件派发等,虽然和原生的是两码事,但也是基于浏览器的事件机制下完成的。
react 的所有事件并没有绑定到具体的dom节点上而是绑定在了document 上,然后由统一的事件处理程序来处理,同时也是基于浏览器的事件机制(冒泡),所有节点的事件都会在 document 上触发。
所以总结来说 react 事件注册过程其实主要做了2件事:事件注册、事件存储:
- 事件注册 - 组件挂载阶段,根据组件内的声明的事件类型-onclick,onchange 等,给 document 上添加事件 -addEventListener,并指定统一的事件处理程序 dispatchEvent。
- 事件存储 - 就是把 react 组件内的所有事件统一的存放到一个对象里,缓存起来,为了在触发事件的时候可以查找到对应的方法去执行。
那么回到正题现在需要将 React 的事件合成机制和 小程序的事件机制结合起来
下面以一个 button, 假设我们写了一段 React 代码:
--- React 代码 ----
class ButtonDemo extends React.Component {
onClick (e) {
console.log('getuserinfo', e)
}
render () {
return (
<button onClick={this.onClick.bind(this)}>分享</button>
)
}
}
根据之前所说,在 React 运行时,会在 document 发生事件注册和存储。回到 render 库视角,我们可以看到 react 的 onClick 回调被存储到了 handlers,当然如果你实际调试 handlers ,你会发现它在 react 中已经被层层包装,最后定位到你的回调函数:
class Document extends EventTarget {
addEventListener(eventName, handler, options) {
this.documentElement.addEventListener(eventName, handler, options)
}
}
class EventTarget {
addEventListener(eventName, handler, options) {
eventName = eventName.toLowerCase()
const handlers = this.$_getHandlers(eventName, isCapture, true)
handlers.push(handler)
}
}
然后经过另外一个 Element 库,渲染出 React 的 button 的 VNode 对应的小程序组件 button后,VNode 对应的小程序组件被点击后,再从小程序组件的 onTap 事件通知,触发 document 的 dispatchEvent,再然后就是 React 自身基于 VNode 的事件合成机制了,找到 VNode 对应的回调函数:
---- 对应的小程序 代码 ----
<button
wx:elif="{{wxCompName === 'button'}}"
id="{{id}}"
bindtap="onTap"
>
onTap(evt) {
this.callEvent('click', evt, {button: 0})
},
callEvent(eventName, evt, domNode) {
// domNode 继承 EventTarget
EventTarget.$$process(eventName, {
event: new Event({
name: eventName,
target: domNode,
eventPhase: Event.AT_TARGET,
detail: evt && evt.detail,
}),
currentTarget: domNode,
})
}
---- render 库模拟浏览器的 document & eventTarget ----
class Document extends EventTarget {
dispatchEvent(evt) {
this.documentElement.dispatchEvent(evt)
}
}
class EventTarget {
dispatchEvent(evt) {
if (evt instanceof CustomEvent) {
EventTarget.$$process(this, evt)
}
return true
}
static $$process(target, eventName, miniprogramEvent, extra, callback) {
//事件捕获阶段
//处于目标阶段
//事件冒泡阶段
}
}
2.1.3 bom
- cookie
- history:自建一个路由栈,来维护这个状态
- storage:如 wx.getStorageSync 和 wx.setStorage 来替代实现
- location:如 location.open 更改为小程序的跳转方式。会自动判断 switchTab 还是 navigateTo 的方式,由于小程序不存在域名,会通过写死假域名的方式来作。
- navigator: 主要写死的方式模拟了一些appName,userAgent。动态的部分通过小程序Api 获得 手机品牌,手机型号,操作系统版本。
- performance:构建了一个几乎空的对象,小程序无法实现performance api。如 timing 和 navigation,目前小程序没有完备的性能统计能对上这个接口。
- screen: 使用 wx.getSystemInfoSync 来得到 width 和 height。但是离真实的 screen 对象仍然有差距。不支持 availHeight,availTop,colorDepth,orientation, pixelDepth等字段。
2.1.4 tree(纯体力活,自己实现还是很费劲的。。)
- parser: 主要负责将 html 解析成 ast,有时间学习一下基础
- query-selector:看注释好像部分参考 jquery/sizzle 的实现。等有时间再看看咋实现的
- tree:基于 dom 树的一些方法封装
2.2 核心库 element
这个库主要将 VNode 渲染以微信定义的组件方式渲染。如何从 VNode 的 type 来选择具体哪个微信组件主要交给了库中最重要的 element 组件来实现。
从下面的 element 组件代码可以简单看到可以通过 render 库传递过来的当前 VNode 的类型,来按类型去调用不同的 微信内置组件去渲染,其中可以包含自定义类型的组件。然后如果下面有子节点,在用另外的 template subtree 去渲染子节点。
<cover-image
wx:if="{{wxCompName === 'cover-image'}}"
></cover-image>
<cover-view
wx:elif="{{wxCompName === 'cover-view'}}"
><template is="subtree-cover" data="{{childNodes: innerChildNodes}}"/></cover-view>
<scroll-view
wx:elif="{{wxCompName === 'scroll-view'}}"
><template is="subtree" data="{{childNodes: innerChildNodes, inCover}}"/></scroll-view>
<view
wx:elif="{{wxCompName === 'view'}}"
><template is="subtree" data="{{childNodes: innerChildNodes, inCover}}"/></view>
<!-- 基础内容 -->
<icon
wx:elif="{{wxCompName === 'icon'}}"
></icon>
<progress
wx:elif="{{wxCompName === 'progress'}}"
></progress>
<text
wx:elif="{{wxCompName === 'text'}}"
><template is="subtree" data="{{childNodes: innerChildNodes, inCover}}"/></text>
<!-- 表单组件 -->
<button
wx:elif="{{wxCompName === 'button'}}"
><template is="subtree" data="{{childNodes: innerChildNodes, inCover}}"/></button>
... 此处忽略 n 个组件
<web-view
wx:elif="{{wxCompName === 'web-view'}}"
></web-view>
<!-- 其他 -->
<block
wx:elif="{{wxCompName === 'not-support'}}"
>{{content}}</block>
<!-- 自定义组件 -->
<custom-component
wx:elif="{{!!wxCustomCompName}}"
><template is="subtree" data="{{childNodes: innerChildNodes, inCover}}"/></custom-component>
<!-- 子节点 -->
<template wx:else is="subtree" data="{{childNodes, inCover}}"/>
下面再看一下子节点的模板 subtree 的代码:
<block wx:for="{{item8.childNodes}}" wx:key="nodeId" wx:for-item="item9">
<block wx:if="{{item9.type === 'text'}}">{{item9.content}}</block>
<image wx:elif="{{item9.isImage}}"></image>
<view wx:elif="{{item9.isLeaf || item9.isSimple}}">{{item9.content}}
<block wx:for="{{item9.childNodes}}" wx:key="nodeId" wx:for-item="item10">
<block wx:if="{{item10.type === 'text'}}">{{item10.content}}</block>
<image wx:elif="{{item10.isImage}}"></image>
<view wx:elif="{{item10.isLeaf}}">{{item10.content}}</view>
<element wx:elif="{{item10.type === 'element'}}"></element>
</block>
</view>
<element wx:elif="{{item9.type === 'element'}}"></element>
</block>
可以看到subtree 的功能是不断得循环渲染,如果可以用view或image直接渲染出来的就直接用这些简单的基础类型,如果是其它类型,则又交还给 element 组件去选择对应的类型。 所以 element 组件和 subtree 的关系是互相依赖的关系:Element 负责选择当前 node 的对应的微信组件,然后将下面的 childNodes 再交给 Subtree,Subtree 再循环渲染 ,当 Subtree 碰到 image(因为 img 标签下面不会再有节点了) 或者 view 搞不定的类型时,再交给 Element 去处理,依次下去。
三.Kbone 在 LuLu 上的初实践
如果直接把 LuLu 放到 Kbone 中跑是跑不起来的,需要修复很多 LuLu 中的差异性的 BUG,然后一些基本功能目前可以正常使用,个人感觉 LuLu 大概还有一半的工作量需要做一些兼容工作。
下面是最终成果:
调用 LuPage 的 showLoading
luui 测了几个5.6 个组件吧,除了样式,样式问题下面会解释,没有发现什么大问题:
LuLu 日志上报功能正常:
障碍
React 15 会出现 className 无法渲染的问题,React 16 无问题
这个还在研究中,为什么 React 15 无法渲染,有时间得慢慢调试一下
全局变量缺失,如 URL变量缺失,还有类型 window.assign 等等,容器变量的缺失
这个没啥好办法,框架刚起步,期待未来慢慢不全
PageManeger 目前一些路由机制出现问题,还在梳理中,不过应该问题不大
Image 等对象没有使用 window.Image 的方式调用
css 语法错误和显示bug
- 其中一部分原因由于 浏览器自带 的 user agent stylesheet。
这是 Luui 在我们官网的示例:
然而在小程序中我们渲染出来的样子如下: 在小程序中 h5 的 button 标签会被渲染为 小程序 的view 标签。之所以不渲染为小程序的 button 标签,因为小程序的 button 标签承载了很多小程序业务功能,两者在功能上存在很大出入不说。 小程序的 button 组件也有小程序自带样式。- rem 在小程序中的大小与 h5 定义的标准不一致
lupage.showToast("123")
下面是微信官方文档中对rem的定义:
rem(root em): 规定屏幕宽度为20rem;1rem = (750/20)rpx
所以直接在小程序中使用 rem 会出现属性变大的问题,需要使用 postcss 或者 plugin 对 rem 进行 rpx 的转换。
准备用 postcss 按转换比例解决该问题
四. 简单性能测试
长列表全量更新,4000 条数据 10s测试时间 每秒更新一次 性能结果:
下面的脚本执行时间越短则越快:
Kbone:
Taro:
原生:
这里本来想采用程序埋点的方式,制定如下计时时机:
- 计时开始时机:交互事件触发,框架赋值之前,如:上拉加载(onReachBottom)函数开头
- 计时结束时机:页面渲染完毕(微信setData回调函数开头)
但是发现重写 setData 方法埋点时间无法在 Kbone 生效。看来以后还得琢磨一下如何给 Kbone测试时间,无奈采用上面那个方法。某种程度上js加载时间就是性能时间,姑且这么看吧。
个人感觉性能上:原生 》 Taro 》Kbone 》Chameleon