Riot.js 源码解析 【二】 组件详解

riot 组件

使用 riot 编写组件是以 .tag 后缀结尾的文件,我们的 html, css, js 都可以放在里面。这些文件在编译阶段会被编译成对应的js代码(见Riot.js 源码解析 【一】 基础内容)。每个组件都是一个 Tag 对象(代码在 /tag/tag.js 里面),里面包含了对象的各种属性和方法。本文主要介绍,riot 组件的生命周期,更新原理(一个粗粒度的 virtual dom)以及简单更新优化手段。

Tag 对象

每一个 riot.js 组件其实就是一个 Tag 对象。Tag 类被定义在 /tag/tag.js 文件下面。Tag 类基本内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// impl 包含组件的模板,逻辑等属性
export default function Tag(impl = {}, conf = {}, innerHTML) {
...各种属性初始化
defineProperty(this, '__', {...})
defineProperty(this, '_riot_id', ++__uid)
defineProperty(this, 'refs', {})
...
// 定义组件更新方法
defineProperty(this, 'update', function tagUpdate(data){...}.bind(this))
// 定义组件 mixin 方法
defineProperty(this, 'update', function tagMixin(data){...}.bind(this))
// 定义组件加载方法
defineProperty(this, 'mount', function tagMount(data){...}.bind(this))
// 定义组件卸载方法
defineProperty(this, 'mount', function tagUnmount(data){...}.bind(this))
}

riot 生命周期

riot 组件状态分为以下几个部分:

  • before-mount:标签被加载之前
  • mount:标签实例被加载到页面上以后
  • update:允许在更新之前重新计算上下文数据
  • updated:标签模板更新后
  • before-unmount:标签实例被卸载之前
  • unmount:标签实例被从页面上卸载后

riot.js 采用事件驱动的方式来进行通讯,我们可以采用如下函数来监听上面的事件,例如处理 update 事件:

1
2
3
4
5
6
7
<riot-demo>
<script>
this.on('update', function() {
// 标签更新后的处理
})
</script>
</riot-demo>

组件状态触发时机

当我们调用 riot.mount() 渲染指定组件的时候,riot 会从 __TAG_IMPL 中获取相对应的已经注册好的模板内容,并生成相应的 Tag 实例对象。并且触发其上的 Tag.mount() 函数,最后将 Tag 对象缓存到 __TAGS_CACHE 中。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function mountTo(root, tagName, opts, ctx) {
var impl = __TAG_IMPL[tagName], // 获取 html 模板
implClass = __TAG_IMPL[tagName].class, // ?
tag = ctx || (implClass ? Object.create(implClass.prototype) : {}),
innerHTML = root._innerHTML = root._innerHTML || root.innerHTML
var conf = extend({
root: root,
opts: opts
}, {
parent: opts ? opts.parent : null
})
if (impl && root) Tag.apply(tag, [impl, conf, innerHTML]);
if (tag && tag.mount) {
tag.mount(true)
// add this tag to the virtualDom variable
if (!contains(__TAGS_CACHE, tag)) __TAGS_CACHE.push(tag)
}
return tag
}

Tag.mount() 函数流程如下:

a.png

组件加载阶段,首先会整理标签上所有的 attribute 的内容,区分普通属性,和带有表达式 expr 的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
defineProperty(this, 'mount', function tagMount() {
...
parseAttributes.apply(parent, [root, root.attributes, (attr, expr) => {
// 检测 expr 是否在 RefExpr 的原型链中
if (!isAnonymous && RefExpr.isPrototypeOf(expr)) expr.tag = this;
// 挂载在 root.attributs 上面 root 为组件所在的 dom 对象
attr.expr = expr
instAttrs.push(attr)
}])
// impl 对象包含组件上的各种属性,包括模板,逻辑等内容
implAttrs = []
walkAttrs(impl.attrs, (k, v) => {
implAttrs.push({
name: k,
value: v
})
})
// 检查的是 implAttrs
parseAttributes.apply(this, [root, implAttrs, (attr, expr) => {
if (expr) expressions.push(expr) //插入表达式
else setAttr(root, attr.name, attr.value)
}])
...
}).bind(this)

初始化这些表达式内容,然后为组件添加全局注册的mixin 内容。接下来,会执行我们为组件添加的函数内容,此时触发 before-mount 事件。触发完毕后,解析标签上的表达式,比如 if each 等内容,然后执行组件的 update() 函数。

update() 函数中,首先会检查用户是否定义了组件的 shouldUpdate() 函数,如果有定义则传入两个参数,第一个是想要更新的内容(即调用this.update() 时传入的参数)。第二个为接收的父组件更新的 opts 内容。若该函数返回值为 true 则更新渲染,否则放弃。 (这里需要注意,Tag.mount() 阶段由于组件尚未处于记载完毕状态,因此不会触发 shouldUpdate() 函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
defineProperty(this, 'update', function tagUpdate(data) {
...
// shouldUpdate 返回值检测
if (canTrigger && this.isMounted && isFunction(this.shouldUpdate) && !this.shouldUpdate(data, nextOpts)) {
return this
}
...
// 扩展opts
extend(opts, nextOpts)
if (canTrigger) this.trigger('update', data)
update.call(this, expressions)
if (canTrigger) this.trigger('updated')
return this
}).bind(this);

之后会触发 update 事件,开始渲染新的组件。渲染完毕后触发 updated 事件。

加载完毕后,修改组件状态 defineProperty(this, 'isMounted', true)。如果渲染的组件不是作为子组件的话,我们就触发自身的 mount 事件。否则的话,需要等到父组件加载完毕后,或者更新完毕后(已经加载过了),再触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
defineProperty(this, 'mount', function tagMount() {
...
defineProperty(this, 'root', root)
defineProperty(this, 'isMounted', true)
if (skipAnonymous) return
// 如果不是子组件则触发
if (!this.parent) {
this.trigger('mount')
}
// 否则需要等待父组件的状态渲染状态
else {
const p = getImmediateCustomParentTag(this.parent)
p.one(!p.isMounted ? 'mount' : 'updated', () => {
this.trigger('mount')
})
}
return this
}).bind(this)

当我们调用 tag.unmount 卸载组件的时候,首先会触发 before-unmount 事件。再接下来清除所有的属性和事件监听等内容后,触发 ‘unmount’ 事件。

update 问题

我们知道除了手动调用 tag.update的方式更新组件,我们通过绑定 dom 事件也能自动触发。这主要是因为在 riot 组件中绑定事件,默认会追加调用 update 的方法。相关代码如下:

1
2
3
4
5
6
7
8
function handleEvent(dom, handler, e) {
// 执行事件回调
handle.call(this, e);
...
// 阻止 自动 update
if (!settings.autoUpdate) return
...
}

这样的话,任何事件操作即使没有引起UI重新渲染,也会触发 update, updated 阶段。我们可以通过设置 riot.settings.autoUpdate (default true) 来更改这种行为。

更新原理

在 riot.js 中,想要更新组件我们必须手动调用 tag.update() 方法才可以或者通过绑定 dom 事件触发(通过模板绑定的事件,会在回调执行完毕后自动触发 tag.update ),并不能做到实时的更新处理。例如:

1
2
3
4
5
6
7
8
9
10
11
<riot-demo>
<h1>{ title }</h1>
<button click={ handleClick }>修改内容</button>
<script>
this.title = "标题"
handleClick() {
this.title = "新标题";
this.update(); // 调用 update 方法才能重新渲染组件
}
</script>
</riot-demo>

riot.js 并没有提供 virtual dom 的功能,而是实现了一个粗粒度的 virtual dom。riot.js 为每个组件创建的 tag 对象中都保存一个 expressions 数组,更新的时候遍历 expressions 数组,对比旧值,如果有变化就更新DOM。这种更新机制类似angular的脏检查,但是仅有一轮检查(单项数据流)。更新处理依照模板类型来处理:

  • 文本内容的,直接: dom.nodeValue = value
  • 值为空,而且关联的 DOM 属性是 checked/selected 等这种没有属性值的,移除对应的属性
  • 值为函数的,则进行事件绑定
  • 属性名为 if,则做条件判断处理
  • 做了 show/hide 的语法糖处理:
1
2
3
4
export function toggleVisibility(dom, show) {
dom.style.display = show ? '' : 'none'
dom['hidden'] = show ? false : true
}
  • 普通属性的,直接设置其值

riot.js 和 react 一样也有 props(静态,riot 中为 opts) 和本身数据(动态),具有和 react 一样的输入。但是输出的时候,由于没有 virtual dom UI的更新并没有集中处理,是分散的。
riot.js 采用的这种方式,代码量上大大的减少,但是也带来了比较严重的性能问题。

性能问题

首先我们来看一段 vue 代码:

1
2
3
4
5
6
7
8
<div id="demo">
<ul>
<li v-for="item in items">
{{ item.name }} --- {{ item.age }}
</li>
</ul>
<button v-on:click="handleClick">更新列表项</button>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var demo = new Vue({
el: '#demo',
data: {
items: [
{ name: 'tgy', age: 23},
]
},
methods: {
handleClick: function() {
this.items = [
{ name: 'tgy', age: 23},
{ name: 'hy', age: 22},
]
}
},
mounted: function() {
console.log("组件挂载完毕");
document.querySelector("li").extraType = "origin";
},
updated: function() {
console.log("组件更新完毕");
console.log(document.querySelector("li").extraType);
}
})

代码很简单,单击按钮,为列表添加一条新数据。在组件挂载完毕后,为第一个 li 的 property 上面添加了 extraType 属性。列表更新后,再去访问这个 li 的 extraType 属性。运行结果如下:

b.png

不出意料,可以正常访问到 li 的type属性。这说明了,在更新过程中,第一个 li 节点仅仅是 textContent 发生了改变而不是重新创建的。这样的结果得益于 virtual dom 算法,保证更新最小变动。同样的我们用 riot 来重写上面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<content-demo>
<ul>
<li each={ items }>{ name } -- { age }</li>
</ul>
<button class="btn" click={ handleClick }>订阅内容</button>
<script>
let self = this;
this.items = [
{"name": "tgy", age: 23}
];
handleClick() {
this.items = [
{"name": "tgy", age: 23},
{"name": "hy", age: 22}
]
}
this.on('mount', function() {
console.log("组件加载完毕");
document.querySelector("li").extraType = "origin";
})
this.on('updated', function() {
console.log("组件更新完毕");
console.log(document.querySelector("li").extraType);
})
</script>
</content-demo>

查看运行结果:

c.png

extraType 找不到了,所有的 li 节点都被重新构建了。这里面发生了什么,查看源码 /tag/each.js。渲染逻辑代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
export default function _each(dom, parent, expr) {
...
expr.update = function updateEach() {
...
each(items, function (item, i) {
// 仅仅记录 items 是对象的
var
doReorder = mustReorder && typeof item === T_OBJECT && !hasKeys,
// 旧数据
oldPos = oldItems.indexOf(item),
// 是新的
isNew = oldPos === -1,
pos = !isNew && doReorder ? oldPos : i,
tag = tags[pos],
// 必须追加
mustAppend = i >= oldItems.length,
// 必须创建 isNew
mustCreate = doReorder && isNew || !doReorder && !tag
// 有key值得时候需要 mkitem
item = !hasKeys && expr.key ? mkitem(expr, item, i) : item
// 必须创建一个新 tag
if (mustCreate) {
tag = new Tag(impl, {
parent,
isLoop,
isAnonymous,
tagName,
root: dom.cloneNode(isAnonymous),
item,
index: i,
}, dom.innerHTML)
// mount the tag
tag.mount()
if (mustAppend)
append.apply(tag, [frag || root, isVirtual])
else
insert.apply(tag, [root, tags[i], isVirtual])
if (!mustAppend) oldItems.splice(i, 0, item)
tags.splice(i, 0, tag)
if (child) arrayishAdd(parent.tags, tagName, tag, true)
} else if (pos !== i && doReorder) {
// move
// 移动
if (contains(items, oldItems[pos])) {
move.apply(tag, [root, tags[i], isVirtual])
// move the old tag instance
tags.splice(i, 0, tags.splice(pos, 1)[0])
// move the old item
oldItems.splice(i, 0, oldItems.splice(pos, 1)[0])
}
if (expr.pos) tag[expr.pos] = i
if (!child && tag.tags) moveNestedTags.call(tag, i)
}
// 缓存原始数据到节点上
tag.__.item = item
tag.__.index = i
tag.__.parent = parent;
// 如果不是创建的,我们需要更新节点内容。
if (!mustCreate) tag.update(item)
})
// remove the redundant tags
// 删除多余的标签
unmountRedundant(items, tags)
// 记录旧的数据
// clone the items array
oldItems = items.slice()
// dom 插入节点
root.insertBefore(frag, placeholder)
}
}

这段为列表渲染逻辑,遍历新的数据items中的每一下 item。在原始数据 oldItems 中去查找(oldItems.indexOf(itemId)),是否存在 item 项。如果不存在,则标记 isNews 为 true。之后走到 if 的 mustCreaete 为 true 的分支,去创建一个新的 tag(将 li 节点看成是一个tag)。以此类推,当全部创建完毕后,删除旧的节点(unmountRedundant(items, tags))。在断点下,可以清楚看到节点的变化情况:

d.gif

优化手段

综上所述,riot.js 的更新逻辑仅仅是判断新旧数据项是否为同一对象。为此,为了减少 DOM 的变动,降低渲染逻辑。我们修改handleClick函数:

1
2
3
handleClick() {
this.items.push({"name": "hy", age: 22})
}

这样输出结果就会和 vue 的保持一致,并没有创建新的 tag,而是利用了已经存在的内容。源码中,这种情况下 isNews 为 false,从而避开了 创建标签。而仅仅是通过 tags.splice(i, 0, tags.splice(pos, 1)[0]); 来移动位置,if (!mustCreate) { tag.update(item); } 更新节点内容。
保证数据项对象地址不变,仅仅是修改上面的不可变对象的值,将大大的提高 riot.js 的渲染效率。

1
2
3
4
5
6
7
8
// 更新第一个li内容
// 不推荐写法,对象发生变化;
this.items[0] = {"name": "hy", age: 23};
// 推荐写法,仅仅是修改对象中的值
this.items[0].name = "hy";
this.items[0].age = 22;

数据处理插件

实际阶段,我们就需要有一个插件,能够帮我们在处理数据项变动的时候,尽量保证大部分数据项地址不发生变化,从而提高 riot 的渲染性能。曾经考虑过使用 Immutable.js 来处理,但是需要修改 riot.js 代码才能实现。所以觉得自己弄个,开发中….

参考资料