kbone源码粗阅,框架实现方式解析

微信团队推出了一个开源方案kbone,他们对kbone的描述是kbone 是一个致力于微信小程序和 Web 端同构的解决方案。
其使用方式不需要更改react、vue等的底层,直接能将框架代码运行在小程序上,听上去与如今流行的跨平台方案uni-app和tarojs实现上完全不同。所以他具体是什么实现的,是这篇文章主要探究的地方。

一、阅读文档寻找信息

了解一个库的第一步,当然是阅读文档,在kbone的文档中能找到如下关键信息。

负责提供 dom/bom api 的 js 库和负责渲染的自定义组件,也就是 kbone 中的 miniprogram-render 和 miniprogram-element,可以看到 kbone 最终生成的小程序代码里会依赖这两个 npm 包。除此之外还需要一个 webpack 插件来根据原始的 Web 端源码生成小程序代码,因为小程序代码包和 Web 端的代码不同,它有固定的结构,而这个插件就是 mp-webpack-plugin。
miniprogram-render、miniprogram-element 和 mp-webpack-plugin 这三个包即是 kbone 的核心。

kbone提供了dom和bom的接口,并且miniprogram-render、miniprogram-element 和 mp-webpack-plugin 三个包是关键。
根据名字来看,mp-webpack-plugin这个包做的是一些编译代码的活,主要需要了解的编译后代码的样子,其实现源码不必深入探究。
miniprogram-element应该就是对dom的实现,miniprogram-render则是将dom树渲染成微信小程序。

二、dom接口

通常阅读源码最好带着问题和目的去看。我们先下载一个kbone的react模板,程序最开始的代码如下

  const container = document.createElement('div')
  container.id = 'app'
  document.body.appendChild(container)

这几行代码调用了dom的接口,调用了之后发生了什么就是接下来需要探究的事。首先我们通过miniprogram-render抛出的document对象找到createElement方法。

    // miniprogram-render/src/document
    /**
     * 内部所有节点创建都走此接口,统一把控
     */
    $$createElement(options, tree) {
        const originTagName = options.tagName
        const tagName = originTagName.toUpperCase()
        let wxComponentName = null
        tree = tree || this.$_tree

        const constructorClass = CONSTRUCTOR_MAP[tagName]
        if (constructorClass) {
            return constructorClass.$$create(options, tree)
        // eslint-disable-next-line no-cond-assign
        } else if (wxComponentName = checkIsWxComponent(originTagName, this.$$notNeedPrefix)) {
            // 内置组件的特殊写法,转成 wx-component 节点
            options.tagName = 'wx-component'
            options.attrs = options.attrs || {}
            options.attrs.behavior = wxComponentName
            return WxComponent.$$create(options, tree)
        } else if (WX_CUSTOM_COMPONENT_MAP[originTagName]) {
            // 自定义组件的特殊写法,转成 wx-custom-component 节点
            options.tagName = 'wx-custom-component'
            options.attrs = options.attrs || {}
            options.componentName = originTagName
            return WxCustomComponent.$$create(options, tree)
        } else if (!tool.isTagNameSupport(tagName)) {
            return NotSupport.$$create(options, tree)
        } else {
            return Element.$$create(options, tree)
        }
    }

通过tagName分成了五种分支

  1. 特殊处理的组件(Input、Image、Video等)。
  2. 微信原生组件
  3. 微信自定义组件
  4. 不受支持的tag
  5. 其他组件(div、span等)

分支1应该是对于微信特殊组件的兼容的一些脏活累活,23 是对微信自己组件的一些处理,而我们最关心的应该是div、span等html标签如何处理成wxml的,所以主要看分支5
通过对浏览器dom的了解,我们可以猜出Element应该是仿照浏览器Element类所实现的一个类,抛出的是一个Element的实例,那么接下来就开始找appendChild干了什么,在Element类的代码miniprogram-render/src/node/element.js中。

    appendChild(node) {
        if (!(node instanceof Node)) return

        let nodes
        let hasUpdate = false

        if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
            // documentFragment
            nodes = [].concat(node.childNodes)
        } else {
            nodes = [node]
        }

        for (const node of nodes) {
            if (node === this) continue
            if (node.parentNode) node.parentNode.removeChild(node)

            this.$_children.push(node)
            node.$$updateParent(this) // 设置 parentNode

            // 更新映射表
            this.$_updateChildrenExtra(node)

            hasUpdate = true
        }

        // 触发 webview 端更新
        if (hasUpdate) this.$_triggerMeUpdate()

        return this
    }

这段代码可以看出,将node插入到当前元素的children中,然后触发更新。和所猜的差不多,kbone既然实现了dom接口,那么肯定是讲整个页面变成了一个dom树,而appenChild,removeChild等接口则是操作这颗dom树。
那么这个triggerMeUpdate触发后,应该是触发了渲染。顺着这个事件往下找,会发现实际上是进入到了EventTarget类中触发了$$childNodesUpdate事件,全局搜这个事件会发现进入了的自定义组件。

// miniprogram-element/src/index.js

this.domNode.addEventListener('$$childNodesUpdate', this.onChildNodesUpdate);

这个onChildNodesUpdate则是个节流后的方法。

onChildNodesUpdate() {
    // 判断是否已被销毁
    if (!this.pageId || !this.nodeId) return

    // 儿子节点有变化
    const childNodes = _.filterNodes(this.domNode, DOM_SUB_TREE_LEVEL - 1)
    const oldChildNodes = this.data.wxCompName || this.data.wxCustomCompName ? this.data.innerChildNodes : this.data.childNodes
    if (_.checkDiffChildNodes(childNodes, oldChildNodes)) {
        const dataChildNodes = _.dealWithLeafAndSimple(childNodes, this.onChildNodesUpdate)
        const newData = {}
        if (this.data.wxCompName || this.data.wxCustomCompName) {
            // 内置组件/自定义组件
            newData.innerChildNodes = dataChildNodes
            newData.childNodes = []
        } else {
            // 普通标签
            newData.innerChildNodes = []
            newData.childNodes = dataChildNodes
        }

        this.setData(newData)
    }

    // 触发子节点变化
    const childNodeStack = [].concat(childNodes)
    let childNode = childNodeStack.pop()
    while (childNode) {
        if (childNode.type === 'element' && !childNode.isLeaf && !childNode.isSimple) {
            childNode.domNode.$$trigger('$$childNodesUpdate')
        }

        if (childNode.childNodes && childNode.childNodes.length) childNode.childNodes.forEach(subChildNode => childNodeStack.push(subChildNode))
        childNode = childNodeStack.pop()
    }
},

这个方法就是递归通知子组件进行update,然后进行了一次setData(newData)。

三、element组件

在element中做出了一系列的操作更新了组件的data,所以我们需要着重了解element如何进行渲染的。

data: {
    wxCompName: '', // 需要渲染的内置组件名
    wxCustomCompName: '', // 需要渲染的自定义组件名
    innerChildNodes: [], // 内置组件的孩子节点
    childNodes: [], // 孩子节点
},

从data中可以看到这几个数据,源码中亲切地标上了注释,通过打断点的方式也可以看到newData具体的内容。可以看出关键就在childNodes这个描述子节点的数组,然后我们看他的wxml文件



.......