前言
上一篇文章《weex-html5 扩展开发指引》中介绍了 weex-html5 扩展组件、模块的基本步骤和方法。在组件扩展的内容里提了几个扩展组件的关键性的问题,这几个问题涉及到组件的实现以及一些原理和工具。本篇将会就 weex-html5 组件的基类、管理类、组件渲染的执行流程以及一些重要的注意事项和最佳实践展开讨论。
先来回味一下前篇中提到的,在组件扩展过程中可能遇到的问题:
- 在组件的 constructor 里需要干些什么?
- 在组件的其他方法中分别需要做哪些事情?
- 有哪些可以直接调用的父类的原型方法?
- 组件从注册到渲染到页面上的执行流程是怎样的?
先不着急回答这几个问题,我们先来了解一下 weex-html5 组件架构的基本原理。
组件基础
weex-html5 的所有组件都是从一个最基础的基类继承而来,基类中包含基本的 渲染操作 和一些 __辅助方法__。每个组件都有一个 id 用于 jsfm (weex-jsframework) 对其进行索引。在web渲染端,管理这些索引,以及响应 jsfm 的操作指令,并做一些组件渲染周期的管理,这些事情是由组件的管理者 ComponentManager
负责的。每一个 weex 的实例都包含一个 ComponentManager
的实例。
基类 Component 和 Atomic
每个组件在定义的时候都需要实现一个 init
方法,用于 weex 对该组件进行注册。在 weex.install(yourComponent)
的过程中会向这个方法里注入 Weex 这个类。你可以通过 Weex.Component
获取到这个类的构造函数,也可以通过 Weex.Atomic
获取 Atomic 类的构造函数(其他暴露在 Weex 上的静态属性还包括 ComponentManager
, utils
以及 config
)。
Component 是 weex-html5 自定义组件的始祖,一切组件包括 Atomic 都是从这个基类继承而来。 Atomic 是 不包含任何子组件 的组件,相比 Component 来说有更严格的限制,并且不需要重写它的 createChildren
、appendChild
、inserBefore
以及 removeChild
等操作子节点的方法。简单来说,如果你要定义一个可以有子组件的组件,那么继承 Component 就可以,如果你要定义一个不应当包含任何子组件的组件(比如表单组件 input),那么需要继承 Atomic.
下面对这些方法分别进行介绍:
获取其他关键信息的方法
getWeexInstance
获取当前的 weex 实例getComponentManager
获取当前 weex 实例对应的 ComponentManager 实例
用于组件索引的方法
getParent
获取父组件getParentScroller
向上获取最近的 scrollable 组件(滑动组件,目前有 list、scroller 等 ),如果不在 scrollable 组件内部,则返回 nullgetRootScroller
获取最顶层的 scrollable 组件getRootContainer
获取当前 weex 页面的 root 节点,一般是document.body
下 id 为 weex 的节点isScrollable
当前组件是否是 scrollable 组件isInScrollable
当前组件是否是其他 scrollable 组件的子孙组件
渲染操作相关方法
渲染操作相关方法是 weex 组件渲染执行流程中的重要环节。weex 组件的执行流程可以在 Component 组件的构造函数中找到:
export default function Component (data, nodeType) {
this.data = data
this.node = this.create(nodeType)
this.createChildren()
this.updateAttrs(this.data.attr || {})
const classStyle = this.data.classStyle
classStyle && this.updateStyle(classStyle)
this.updateStyle(this.data.style || {})
this.bindEvents(this.data.event || [])
}
从代码里可以看出一个组件的基本构造流程为:
绑定数据 (data) -> 创建节点 (create) -> 创建子节点 (createChildren) -> 更新属性值 (updateAttrs) -> 更新样式 (updateStyle) -> 绑定事件 (bindEvents)
这个组件构造完毕后需要挂载到某个页面中已经存在的父节点中,这时候就会(通过 ComponentManager )调用父节点的 appendChild
或 insertBefore
方法,所以这两个方法也非常重要,但是一般组件不需要重写这两个方法,除非需要在这里做一些特殊的逻辑处理。
create
创建当前组件在 weex 页面中所占据的具体 dom 节点。比如<image>
组件的 create 方法里就创建了一个<div>
元素,并为该元素添加了 class 类名。weex 预置了两个通用的类名weex-container
和weex-element
,用于消除 web 组件和基于css-layout库的 native 组件之间的样式差异,建议总是添加这两个类名中的一个。
weex-container 和 weex-element 类的默认样式如下:
.weex-container {
box-sizing: border-box;
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-box-orient: vertical;
-webkit-flex-direction: column;
flex-direction: column;
flex-shrink: 0;
align-items: stretch;
box-align: stretch;
align-content: flex-start;
position: relative;
border: 0 solid black;
margin: 0;
padding: 0;
min-width: 0;
}
.weex-element {
box-sizing: border-box;
position: relative;
flex-shrink: 0;
border: 0 solid black;
margin: 0;
padding: 0;
min-width: 0;
}
createChildren
创建子节点。这里仅限于创建 data.children 中的节点,如果当前节点的 append 方式 是append="node"
这种 weex 的默认处理方式,那么子节点不会被塞到 data.children 里处理,如果当前节点的 append 方式是append="tree"
方式,此时该节点的子节点都需要父节点通过处理 data.children 来创建。如果你不知道怎么处理 data.children 里的每个数组元素,可以直接丢给ComponentManager.createElement(data.children[i])
来处理。实际上 Component 基类里就是这么做的,当然你也可以自己去做一些特殊处理appendChild
添加一个子节点到子节点列表的末尾insertBefore
添加一个子节点到指定的位置 (指定index
)removeChild
删除一个子节点updateAttrs
更新属性值,不推荐直接重写此方法,后面会介绍如何配置属性的 setterupdateStyle
更新样式,不推荐直接重写此方法,后面会介绍如何配置样式的 setterbindEvents
绑定事件,不推荐直接重写此方法,后面会介绍如何配置事件的额外参数以及 updatorunbindEvents
解绑事件,一般不需要重写此方法
组件周期相关方法
onAppend
组件被挂载到页面时的执行勾子,基类已经在这里做了一些处理,不要直接重写这个方法addAppendHandler
为组件添加挂载时的执行勾子,如果想要在组件被挂载时执行一些代码,可以调用这个方法
其他重要方法
dispatchEvent
在当前组件的 node 上触发一个事件(如果 DSL 开发者绑定了这个事件类型的监听器,那么这个监听器会被触发)enableLazyload
为当前组件的 node 节点指定懒加载的属性img-src
为某个指定的 src,这样懒加载控制器会识别当前节点为 image 并对当前节点进行懒加载控制,这个一般不会用到,开发者比较常用的是下一个方法fireLazyload
用于手动触发某个组件或者节点内部的 image 组件进行懒加载。之所以会有这个方法是由于某些特殊情况下组件进入了视口,其中的图片节点应该进行加载时,懒加载因为某些原因却没有正确执行,这种情况下就可以手动调用此方法
配置信息
配置信息是方便组件对属性、样式和事件进行定制的一种手段。通过配置可以简化代码,规范组件行为,避免不必要的代码冗余,提高代码复用度,方便开发者进行扩展。
attr
属性 setter 配置,基类的为空对象style
基类配置两组样式的 setter, 一组和positon
相关,即除了基本的relative
和absolute
,更支持了 position 的fixed
,sticky
值。另外对flex
相关的样式做了归一化的处理,开发者不用去写多套 flex 降级名称。归一化后的 flex 样式及其支持的值为:
样式名 | 支持的值 |
---|---|
flex | number |
align-items | flex-start, flex-end, stretch, center |
justify-content | flex-start, flex-end, center, space-between |
event
事件配置,基类的为空对象
在后面的 组件扩展实践 小节里对如何做组件配置做了详细解释。
管理者componentManager
ComponentManager 是组件的大管家,不仅仅需要管理当前注册的组件类型,管理当前 weex 实例的所有组件以及它们的 ref id,还要负责监听组件的 appear 事件,判断当前的渲染状态,并串联处理组件生命周期的各个阶段。
静态方法
ComponentManager 包含几个静态方法,可以直接通过 ComponentManager.xxx
调用。
getInstance(id)
获取对应 id 的 ComponentManager 实例registerComponent
注册组件,我们自定义的组件在内部就是通过这个方法注册进来的getScrollableTypes
获取 scrollable 组件类型(比如list
,scroller
等)的数组
实例方法
每个 ComponentManager 实例都实现了 jsfm 里 vdom 的 Listener 的里的方法,在 jsfm 里的 Listener 负责创建对应虚拟 dom 操作的指令发送给 native 端的 callNative
桥接器。而在 weex-html5 里 ComponentManager 接管了这个接口,并将 dom 操作的指令转换为真实的组件增添删除以及其他操作。
这些组件操作相关的方法,在 native 平台以及旧版的 weex-html5(< v0.3.0) 中是通过 bridge 的 callNative
接收 dom
模块的 API 调用实现的。在新版本的 weex-html5 (>= v0.3.0)中 ComponentManager 接管了 dom
模块的几乎所有方法(除了 scrollToElement
这个比较特殊的方法)。
这些方法包括:
createBody
创建页面根节点addElement
添加一个组件removeElement
移除一个组件moveElement
移动一个组件setAttr
更新属性值setStyle
更新样式setStyles
更新多个样式addEvent
添加事件监听removeEvent
移除事件监听
上述方法对于我们组件开发者来说其实是透明的,一般不会被用到。另一些方法则是你可能会用到的:
getComponent
weex 里每个组件都有一个唯一的 id, 在 weex 里这个 id 叫做 ref. ComponentManager 内部存储了一个组件的 ref 和实例映射的 map. 一些针对某个组件进行的操作,比如setAttr
,setStyle
,removeElement
等都会根据这个 map 去查找对应 ref 的组件。ComponentManager 同时提供了这个专门通过 ref 获取对应组件的方法。 在组件中可以通过this.getComponentManager().getComponent(ref)
获取某个组件。createElement
如果你要自己实现组件的appendChild
或者createChildren
等方法,你得到的入参一般是组件的 data,这时调用 ComponentManager 的createElement(data)
返回的就是对应data.type
指定类型的组件的实例。这时这个方法是必须调用的,因为只有 ComponentManager 中有注册的组件类型信息。
另一些你不会直接调用,但是在组件里可能会间接用到的方法:
rendering
weex 组件基于优化的考虑会在组件做频繁 dom 操作期间做一些提高可用性减少页面阻塞的操作。ComponentManager 通过这个方法向 global (window) 对象注册了两个事件,renderbegin
和renderend
. 某个固定时间间隔内(默认为 800ms)没有任何 dom 操作时会出发renderend
事件,一旦有 dom 操作就会触发renderbegin
. 这里的 dom 操作特指addElement
,removeElement
和moveElement
这三个操作。如果你的组件需要在'频繁 dom 操作期间'做一些优化操作,比如关闭某些特性,可以考虑监听这两个事件。handleAppend
在组件挂载之后调用onAppend
方法。你不会直接用到这个方法,但是组件的onAppend
的执行依赖这个方法。另外组件的appear
和disappear
事件也是在这里绑定的,图片的懒加载也是在这里触发。
页面的构建流程
前面已经介绍了在基类 Component 的构造函数里的执行流程。这里跳出单个组件的构造过程,我们来看整个页面是如何被构造并渲染出来的。这个过程涉及到 jsfm 里如何编译模板、绑定数据、监听变化并构造虚拟 dom 等等,这些原理限于篇幅这里就不多做介绍了,展开会是个很大的话题。我们把 jsfm 看作一个实体,来看 jsfm 和 render 之间的通信过程,以及 ComponentManager 实例和各个组件之间的协作过程。
ComponentManager 做为 Listener 挂载在 jsfm 的 vdom(虚拟 dom) 里,在 jsfm 的 Document 实例里包含它的引用。vdom 的所有添加删除元素的操作,都会触发 ComponentManager 的对应方法,转变为真实的组件操作。
在 vdom 里有个 documentElement 的概念,类似 html 里的 documentElement,相当于整个页面的根标签。在这个根标签里添加的节点,被称为 body. 在 append body 的过程中会调用 ComponentManager 的 createBody
方法,这时 weex 页面的根节点就是 createBody
这个方法创建出来的。
当 vdom 里需要添加一个元素,首先触发其父元素的 appendChild (Element.prototype.appendChild
) 或者 insertBefore (Element.prototype.insertBefore
) 方法,这个操作被翻译到 Listener 中,也就是 ComponentManager 的 addElement
方法。在 ComponentManager 中会根据传入的 index 判断是调用自己的 appendChild
(在末尾添加元素) 还是 insertBefore
(在中间插入元素)。
在 ComponentManager 的 appendChild
和 insertBefore
方法中首先会根据 parentRef 找到父组件,然后调用父组件的对应的方法(如果没有 override 的话就是基类 Component 的对应方法)。在这些方法中又会调用 ComponentManager 的 createElement
方法创建要替添加的子组件。
子组件也可能会有子元素,如果 DSL 里指定了一个组件的 append 属性为 append=tree
,那么添加这个组件的时候,它的子组件的信息都放在了 data.children
里。这时子组件的构建过程中会调用 createChildren
方法创建子组件。反之如果 DSL 不指定 append 或者指定其为 append=node
(默认方式),此时 data.children
一般为空,而它的子组件会通过下一次的 addElement
(ComponentManager.prototype.addElement
) 调用被创建。
整个页面就是从 createBody
开始,接着向 body 节点里 addElement
添加新的组件,并在这个新的组件里 addElement
添加它自己的子组件,这样不断迭代构造出来的。每个组件再通过自身的构造渲染流程(create
, createChildren
, updateAttrs
, updateStyle
, bindEvents
等等),把自己按照一定的模板和样式渲染到页面中。
组件扩展实践
基本原理是很简单的,但是实际扩展组件的过程中,需要关注一些最佳实践和注意事项。
组件的构造函数
一个组件实例化的入口总是它的构造函数。在基类 Component 和 Atomic 的构造函数里已经做了大部分的函数调用,前面已经提过,它们的执行顺序是:
绑定数据 (data) -> 创建节点 (create) -> 创建子节点 (createChildren) -> 更新属性值 (updateAttrs) -> 更新样式 (updateStyle) -> 绑定事件 (bindEvents)
在自定义组件的构造函数里就不需要重复去做这些事情了,只需要调用基类的构造函数即可:
function init (Weex) {
const Component = Weex.Component
// ...
// weex-hello-web 的构造函数:
function Hello (data) {
// 在子类的构造函数里调用基类的构造函数
Component.call(this, data)
}
Hello.prototype = Object.create(Component.prototype)
// ...
}
这样这个组件就可以跑起来。当然你可以向其中添加一些其他的逻辑,比如存储 data 里的属性,做一些初始化的操作,这取决于你组件所承载的功能。建议组件内部抽象的以及数据相关的初始化逻辑放到组件的构造函数里,而涉及到具体 Dom 构建和操作的初始化逻辑放到 create
方法里。
配置属性、样式和事件
扩展一个组件,不仅仅要定制它的构造函数,创建节点及子节点的过程,还要定义它能接受的参数。拿weex-hello这个简单的组件 demo 做例子,它带有一个属性叫做 value
,可以设置的样式包括 txt-color
和 bg-color
,并在 click
事件的参数里传递了一个 value
值(e.value)。
配置属性
const attr = {
// 定义 value 这个属性的 setter
value (val) {
this.value = val
this.inner.textContent = `Hello ${val}!`
}
}
这里 value
是一个 setter,接受新的属性值(val
)做为参数,你所要做的事情就是定义这个 setter,每次这个属性更新的时候这个 setter 都会被执行。在 setter 里的 this
会绑定为当前的 component 实例。
配置样式
const style = {
// 定义 text-color 这个样式的 setter
txtColor (val) {
this.inner.style.color = val
},
// 定义 bg-color 的 setter
bgColor (val) {
this.inner.style.backgroundColor = val
}
}
这里 txtColor
的 setter 的参数接受的是 txt-color
的样式值。同理 bgColor
对应的是组件的样式 bg-color
. 你所要做的事是定义这两个 setter 的内容。在这个例子里仅仅是将 this.inner
这个 dom 元素的对应样式更新为指定的值,复杂组件可能需要你做更多事情。和 attr 的 setter 一样,函数体里的 this
会绑定为当前的 component 实例。
配置事件
const event = {
click: {
// 定义 click 事件对象的额外参数
extra () {
return {
value: this.inner.textContent
}
}
}
}
事件配置相比属性和样式的配置更复杂一些。首先需要指定对哪个事件类型做配置,例子里需要定制的事件类型只有一个 click
。一个事件可以进行三种配置,分别为 extra
、updator
和 setter
.
extra
配置事件参数传递的额外信息,比如在上面的例子里需要往 event 对象里增加一个value
值,DSL 开发者可以通过evt.value
得到这个值:
methods: {
// 在 click 的回调函数里获取 value 值
handleClick (evt) {
console.log('value is:', evt.value)
}
}
注意 extra
是一个函数,需要返回一个额外数据对象,这个函数的 this
也是绑定为当前 component 的实例的。
updator
weex 目前由于自身的限制无法做到数据双向绑定,用户操作导致的数据变更需要 DSL 开发者在事件监听里获取并进行手动更新,而手动更新数据可能导致 jsfm 发送冗余的更新操作消息。updator 可以认为是 weex 的一种数据静默更新机制,当用户操作导致某个 attr 或者 style 的值发生变更时,会把对应的值传给 jsfm ,这样当 DSL 开发者手动更新数据时 jsfm 已经将该值更新过了,不会再发冗余的消息setter
直接替换掉事件监听函数,并不推荐使用这种方式进行事件绑定
统统绑定到 prototype
定义了 attr, style 和 event,还需要把它们绑定到 prototype 上。这里也有一些技巧,并不是直接把 prototype 加上这些属性就可以了。我们来看这段代码:
function init (Weex) {
// ...
const extend = Weex.utils.extend
extend(Input.prototype, proto)
extend(Input.prototype, { attr })
extend(Input.prototype, {
style: extend(Object.create(Atomic.prototype.style), style)
})
extend(Input.prototype, { event })
// ...
}
这里的 extend 就是简化版的 Object.assign
,这段代码很好理解。需要注意的是,style 不是直接挂载到 prototype 上面的,因为基类(这个例子是 Atomic)已经包含 style 属性,即 position
的 setter 以及 flex
规范化的 setter. 所以需要把原来的 style 都继承下来,再用 extend 添加新的 style 进来。
屏幕适配系数:scale
weex 是如何适配不同大小屏幕的?这个问题涉及到组件在页面上的最终展现。如果你扩展的组件有自定义的属性或者样式,涉及到尺寸大小的,需要非常注意这一块。每个组件在被创建之前,会由 ComponentManager 将当前屏幕的 scale 值注入组件的 data (在除了 constructor 以外的任何组件方法中都可以通过 this.data.scale
访问到)中。那么这个 scale 到底是什么?
weex 中的设计尺寸是 __750px__,也就是说 weex 认为所有屏幕的宽度都是归一化的 __750px__. 当真实屏幕不是 750px,weex 会自动将设计尺寸映射到真实尺寸中去,这个 scale
就是这种映射的比例。它的计算公式是 当前屏幕尺寸 / 750
.
所以在扩展组件的时候,如果用户传入一个尺寸值,比如说 375
,这个值是相对于 750
的设计尺寸来说的。你只需要将这个值乘以 scale, 就是适配当前屏幕的真实尺寸:value = 375 * this.data.scale
. 它应该占据真实屏幕一半的大小。
组件的生命周期
一个组件的生命周期包括初始化、构建、挂载以及移除等。组件开发者可以在其中各个阶段进行控制。
- __初始化__:通过组件的构造函数实现初始化的逻辑控制
- __构建__:通过重写组件的
create
方法实现构建阶段的逻辑控制 - __挂载__:基类 Component 在 onAppend 方法中已经做了一些处理(检测
appear
事件的触发条件,如果满足条件则触发该事件),如果需要在挂载阶段做一些自己的处理,可以在初始化的逻辑里调用addAppendHandler
方法向 onAppend 中添加代码:
function MyComponent (data) {
this.addAppendHandler(() => {
// 节点挂载以后需要执行的逻辑
})
Component.call(this, data)
}
这个调用操作也可以放在组件继承完成以后的任何时刻进行:
function init (Weex) {
const Atomic = Weex.Atomic
const extend = Weex.utils.extend
function Input (data) {
Atomic.call(this, data)
}
Input.prototype = Object.create(Atomic.prototype)
extend(Input.prototype, proto)
// 添加 onAppend handler
Input.prototype.addAppendHandler(() => {
// 节点挂载以后需要执行的逻辑
})
// ...
}
- __移除__:在基类的代码里可能找不到这个函数,因为基类没有做额外的处理。如果你需要在组件被移除时做一些操作,比如连接断开、资源释放等,可以为你的组件添加一个
onRemove
方法,组件在被移除时会自动调用这个方法。
获取当前 weex 实例的 id
和 scale 类似,组件在被构建之前就已经在 ComponentManager 的 createElement 方法里,将 id 注入到组件的 data 里。在组件的生命周期的任何时刻都可以通过 this.data.instanceId
获取到当前 weex 实例的 id.
获取当前 weex 实例
在组件的生命周期任何时刻都可以通过 this.getWeexInstance
获取到当前 weex 实例。这个方法只是 this.getComponentManager().getWeexInstance()
的简化版,一般也不太会用到。
获取 ComponentManager
ComponentManager 包含静态方法和实例方法。在 init
方法里可以通过 Weex.ComponentManager
获取到 ComponentManager 这个类,并在接下来的代码里调用它的静态方法。在自定义组件的方法实现中,可以通过 this.getComponentManager()
获取到当前 ComponentManager 的实例。一般可能用到的是 createElement
, getComponent
这两个方法。
utils
utils 是 weex-html5 内部一套工具函数,不是很推荐业务上直接使用。后续可能进行相关的重构,但是有几个使用比较频繁的方法应该不会有大的变动:
extend(target, ...src)
其实就是Object.assign
,将后面传入的对象的键值对拷贝给 target,相当于 mixin. 需要注意越靠后的对象的键值对具有越优先的覆盖权detectWep
判读当前设备是否支持webp
格式图片detectSticky
判断当前设备是否支持原生的position: sticky
throttle(func, wait)
函数限流,func
为要限流的函数,wait
为调用时间最小间隔,单位为 msisPlainObject
是否是[object Object]
getType
返回对象的真实类型,如string
,number
,object
,date
, 等等
可以通过 init
方法里注入的 Weex
上挂载的 Weex.utils
获取到 utils 的方法:
function init (Weex) {
// 通过注入的 Weex 取得挂载的 utils 对象.
const utils = Weex.utils
// ...
}
小结
本文接着前篇《weex-html5 扩展开发指引》的话题,介绍了 weex-html5 里的组件基类,执行流程,生命周期,屏幕适配,组件管理以及组件定义过程中的一些最佳实践和注意事项等。通过这些介绍相信大家对文章开头提出的几个问题已经有了答案。对于 weex-html5 组件扩展还有什么问题或建议欢迎与我交流。