Kbone 解析 & 结合 LuLu 初实践(React向)

1,394 阅读9分钟

解释:LuLu 是公司H5项目的基础库,Kbone则是小程序跨端框架

一.Kbone 简单介绍

由于公司主要项目几乎是 React 主导,所以本文会主要从 React 的视角来解析这个框架。事实上,Kbone 适合 Vue、React、Preact。不同框架的视角会有一些不一致,不过本质是相同的。

本身 reactDom 是为浏览器环境而生的,因为只有浏览器环境才有 dom 元素,为了让 reactDom 能在小程序环境中依然运行自如,kbone 实现了一个适配器,在适配层里模拟出了浏览器环境,最终实现出同构。

二.Kbone 核心原理

2.1 核心库 render

render 作为渲染适配器,主要为了扫清渲染前的一切跨端问题。通过模拟出浏览器环境,让 Web 端的代码不会出现异常。 浏览器环境常规内核 Api,被作者分为以下几个层级:

  1. bom (BOM是browser object model的缩写,简称浏览器对象模型)
  2. event(浏览器的 event 事件)
  3. node(DOM的标准规中的 Node对象)
  4. tree (主要提供解析DOM节点树的属性和方法)
  5. 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 模型靠拢,这部分的源码结构如下:

  1. Node
  2. Element

node-elemnt

  1. HTMLTextAreaElement
  2. HTMLVideoElement
  3. HTMLInputElement
  4. Image
  5. HTMLCanvasElement
  6. 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 事件机制

  1. event
  2. event-target

这里会多说一些,主要先说一下 React 事件机制。react自身实现了一套自己的事件机制,包括事件注册、事件的合成、事件冒泡、事件派发等,虽然和原生的是两码事,但也是基于浏览器的事件机制下完成的。

react 的所有事件并没有绑定到具体的dom节点上而是绑定在了document 上,然后由统一的事件处理程序来处理,同时也是基于浏览器的事件机制(冒泡),所有节点的事件都会在 document 上触发。

所以总结来说 react 事件注册过程其实主要做了2件事:事件注册、事件存储:

  1. 事件注册 - 组件挂载阶段,根据组件内的声明的事件类型-onclick,onchange 等,给 document 上添加事件 -addEventListener,并指定统一的事件处理程序 dispatchEvent。
  2. 事件存储 - 就是把 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

  1. cookie
  2. history:自建一个路由栈,来维护这个状态
  3. storage:如 wx.getStorageSync 和 wx.setStorage 来替代实现
  4. location:如 location.open 更改为小程序的跳转方式。会自动判断 switchTab 还是 navigateTo 的方式,由于小程序不存在域名,会通过写死假域名的方式来作。

  1. navigator: 主要写死的方式模拟了一些appName,userAgent。动态的部分通过小程序Api 获得 手机品牌,手机型号,操作系统版本。
  2. performance:构建了一个几乎空的对象,小程序无法实现performance api。如 timing 和 navigation,目前小程序没有完备的性能统计能对上这个接口。
  3. screen: 使用 wx.getSystemInfoSync 来得到 width 和 height。但是离真实的 screen 对象仍然有差距。不支持 availHeight,availTop,colorDepth,orientation, pixelDepth等字段。

2.1.4 tree(纯体力活,自己实现还是很费劲的。。)

  1. parser: 主要负责将 html 解析成 ast,有时间学习一下基础
  2. query-selector:看注释好像部分参考 jquery/sizzle 的实现。等有时间再看看咋实现的
  3. 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

  1. 其中一部分原因由于 浏览器自带 的 user agent stylesheet。

这是 Luui 在我们官网的示例:

然而在小程序中我们渲染出来的样子如下:
在小程序中 h5 的 button 标签会被渲染为 小程序 的view 标签。之所以不渲染为小程序的 button 标签,因为小程序的 button 标签承载了很多小程序业务功能,两者在功能上存在很大出入不说。 小程序的 button 组件也有小程序自带样式。

  1. 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