JavaScript
riot
riot.js

Riot.js ソースコード完全解説 v3対応版

More than 1 year has passed since last update.

昨年「Riot.js ソースコード完全解説」を書いたのですが、この1年でいろいろ状況が変わってしまいました。v2の間に相当コードが書き換わり、Riotも若干ぽっちゃり系に...(他の同種のライブラリに比べれば可愛いものですが)。そんな中、朗報です。近日v3がリリースされ、コードが整理されます。

本稿では、改めて「ソースコード解説」を試みたいと思います。 2016年7月現在nextブランチにあるコードをもとに説明するので、日々変わってしまうけど、ご参考まで。オススメのコースは、次の通り。


  • この記事に目を通して全体像をつかむ

  • 自力でソースコードを斜め読みし、もうちょっと深く理解する

  • プルリクエストしたい部分について、深読みする

  • がしがしプルリクエスト

riot-map.png

スクリプトのimport/export関係をざっくり図示してみました。以下にも主要ファイルを挙げておきます。


  • 公開API: lib/riot.js

  • Riotのコア部分: lib/browser/tag/core.js

  • タグ/DOM操作の多くはユーティリティ関数で: lib/common/util.js


    • タグに関するもの: tag-helper.js

    • DOMに関するもの: dom-helper.js

    • その他: misc.js



  • タグそのもの: lib/browser/tag/tag.js


    • DOMのパース (というよりはtraverse): parse.js

    • 表現(expression): if.js, each.js, named.jsなど

    • 更新: update.js



  • 定数: lib/browser/common/global-variables.js


基本方針


コーディングスタイル


  1. 行末のセミコロンなし

  2. 短いのが正義


  3. ==!=は挙動を理解して使う


  4. if文やfor文で、省略可能であれば{}を使わない

  5. 演算子の評価順を正しく考慮。冗長な()を使わない

  6. 独自の関数を優先的に使う(場面がある)



    • Object.assign()ではなく、extend()


    • [].forEach()ではなく、each()



  7. ES6で書くが、変換時に長くなるClassなどは使わない (現在のところ)


バンドル方法


  • ビルドなどのタスク実行にはmakeを使う。(詳細はMakefile参照)

  • バンドラはrollup

  • ES6変換はBabel


  • riot-observable, riot-tmpl, riot-compilerは外部リポジトリに (本バージョンから、riot-routeは含まれない)

  • 用途別に複数のバージョンを用意



    • riot.js: 通常版


    • riot.min.js: 上記の圧縮版


    • riot+compiler.js: コンパイラ付属版


    • riot+compiler.min.js: 上記の圧縮版


    • riot.csp.js: CSP(Content Security Policy)対応版


    • riot.csp.min.js: 上記の圧縮版




用例

解説中のコードについて、次のようにコメント、省略をするケースがあります。


  • // 原文コメント

  • //// 本稿によるコメント

  • .... 本稿によるコードの省略


tagファイルのコンパイル

コンパイラは当初、Riotのリポジトリに含まれていたのですが、v2.3から別リポジトリになっています。v2.3の時点で全面的に@aMarCruzによって全面的に書き換えられ、ロバストになりました。

基本的な実装のアイデアは、2.0当初と同じなので、昨年のコード解説を参照してもらえれば、読み解きやすいと思います。新しいriot-compilerには「これでもか」というくらいコメントが付いているので、ソースコードを直接どうぞ。


  • 正規表現だのみの簡易パーサ

  • ...だけど、だいぶ揉まれてたので、もうきっと大丈夫 w

  • 心配なひとは、specを参照してください


HTML, CSS, JavaScriptの分離

ここではコンパイラのソースには踏み込みませんが、概略だけ説明しておきます。Riot.jsでは、HTMLに若干のテンプレート変数({ message }みたいなやつ)を加えた、tagファイルを使ってカスタムタグを表現します。

<my-tag>

<p>{ message }</p>
<script>
this.message = "Hello world!"
</script>
<style>
p { color: blue }
</style>
</my-tag>

もし上記のtagファイルがあったとすると、次のように内容を分割する必要があります。


  • HTMLテンプレート: <p>{ message }</p>

  • JavaScript: this.message = "Hello world!" ※タグの初期化関数

  • CSS: p { color: blue }

実のところ、Riotのコンパイラが行うのはこの分割と、多少の整形のみです。いわゆる本格的なコンパイラとは異なり、構文解析していないのでシンプルかつ高速です。

コンパイラを通すと、上記のtagファイルは、次のようなJavaScriptのコードに変換されます。(実際には改行なし)

riot.tag2(

'my-tag',
'<p>{ message }</p>',
'p { color: blue }',
'',
function(opts) { this.message = "Hello world!" }
)

プリコンパイルする場合は、各種プリプロセッサに対応します。


  • Jade

  • TypeScript, CoffeeScript, LiveScript, Babel

  • Sass, LESS, Scss, Stylus ※LESSのみブラウザでも可


カスタムタグの読み込み

ここからは、コードの内容をしっかり見ていきます。


riot.tag2()


  • カスタムタグを登録する。基本的には手動で使わない。tagファイルをriot-compilerでコンパイルすると、tag2()を使う形式になる。

  • 定義場所: lib/browser/tag/core.js

function tag2(name, tmpl, css, attrs, fn) {

if (css) styleManager.add(css, name) //// スタイルマネージャにCSSを登録
....
__TAG_IMPL[name] = { name, tmpl, attrs, fn } //// キャッシュに登録
....
return name
}

ここで、__TAG_IMPL[name]に入っているのは、こういうデータ:

{

name, // (1) タグ名
tmpl, // (2) HTMLテンプレート
attrs, // (3) tagファイル内で、タグの属性として定義されているもの
fn // (4) 初期化関数
}

もし、下記のようなtagファイルだとすると...

<my-tag attr1="abc" attr2="xyz">

<p>{ message }</p>
<script>
this.message = "Hello world!"
</script>
</my-tag>

中身はこう。

{

name: 'my-tag',
tmpl: '<p>{ message }</p>',
attrs: 'attr1="abc" attr2="xyz"', // 配列ではなく文字列な点に注意
fn: 'this.message = "Hello world!"'
}


riot.tag()


  • 手動でtagを組む場合に使う。(あまり推奨されない)

  • 基本的な挙動は、tag2()と同様だが、後方互換が維持されているため、引数としてcssattrsは省略可能。

  • 定義場所: lib/browser/tag/core.js

function tag(name, tmpl, css, attrs, fn) {

....
}


マウントから表示まで

大雑把な流れとしては、次の通り。



  1. riot.tag2()関数で登録済みの、タグの実装を取得

  2. タグをインスタンス化

  3. タグの実装(HTMLテンプレート)をinnerHTMLに突っ込む

  4. パースする、というよりDOMをトラバースして、解析

  5. 与えられたデータで、タグの内容を更新

  6. テンプレート変数(expression)を更新

  7. マウント完了イベント

以下、処理の中で「幹」になる部分に限定して処理を追っていきます。

見通しがつきにくくなるので、each属性やif属性、<yield />などの処理については省略しますが、一旦「幹」を理解すれば、枝葉については実際のコードを読むほうが早いでしょう。

mounting-tags.png


riot.mount()


  • Riotでタグをマウントするときに使う、公開API。


  • riot.mount('#somewhere', 'my-tag')のように特定のDOMとタグを指定して使う。

  • 内部処理: CSSの挿入後、mountTo()で実際にタグをマウント。

  • 定義場所: lib/browser/tag/core.js

function mount(selector, tagName, opts) {

let tags = []
....
styleManager.inject() //// スタイルを`<head>`に挿入
....
//// root(DOM要素)にriotTag(riotタグ)をマウント
let tag = mountTo(root, riotTag || root.tagName.toLowerCase(), opts)
....
return tags //// マウントしたタグのインスタンスを返す ※複数の場合もある
}


mountTo()


  • DOMにRiotタグをマウントし、タグのインスタンスを返す。

  • 内部処理: Tag()のインスタンス生成後、tagMount()を呼ぶ。

  • 定義場所: lib/browser/util/tag/helper.js

function mountTo(root, tagName, opts) {

var tag = __TAG_IMPL[tagName] //// タグ実装のキャッシュ
....

if (tag && root) tag = new Tag(tag, conf, innerHTML) // インスタンス化

if (tag && tag.mount) {
tag.mount(true) //// タグをマウント!
// add this tag to the virtualDom variable
if (!contains(__VIRTUAL_DOM, tag)) __VIRTUAL_DOM.push(tag)
}

return tag //// インスタンスを返す
}

__TAG_IMPL[tagName]に入っている内容については、前述のriot.tag2()を参照。


Tag()


  • タグを表現する「クラス」 ※クラス的に使われるだけで、実体は関数

  • 内部処理: observable化、HTMLテンプレートを元にmkdom()でDOMを作成

  • 定義場所: lib/browser/tag/tag.js

function Tag(impl, conf, innerHTML) {

var
self = observable(this), //// observable化
opts = inherit(conf.opts) || {}, //// optsそのもの
parent = conf.parent,
....
expressions = [],
root = conf.root, //// マウント先のDOM要素
tagName = conf.tagName || root.tagName.toLowerCase(),
....
dom

//// すでにマウントされている場合に、マウント解除
if (impl.name && root._tag) root._tag.unmount(true)
....
defineProperty(this, '_riot_id', ++__uid) //// _riot_idは変更不可
extend(this, { parent, root, opts }, item)
defineProperty(this, 'tags', {}) //// tagsプロパティは、子タグのインスタンスへの参照を保持
//// 指定タグの実装を元にDOMの作成
//// ※ innerHTMLが、<yield />の内容を置き換えるために渡される点に注意
dom = mkdom(impl.tmpl, innerHTML)
....
//// 公開メソッド update, mixin, mount, unmount は上書きできない
defineProperty(this, 'update', function tagUpdate(data) {....})
defineProperty(this, 'mixin', function tagMixin() {....})
defineProperty(this, 'mount', function tagMount(forceUpdate) {...})
defineProperty(this, 'unmount', function tagUnmount(keepRootTag) {....})
}


tagMount()


  • タグをマウントする

  • 内部処理:


    1. tagファイルに書かれた初期化関数(<script></script>の中身)を実行

    2. 自分自身(のDOM)をテンプレートとして解析(parseExpressions())

    3. タグの内容をtagUpdate()で更新

    4. 解析済みのDOMを対象に付け替え

    5. マウント完了イベントを発行



function tagMount(forceUpdate) {

....
updateOpts(item) //// optsの更新
if (impl.fn) impl.fn.call(self, opts) //// 初期化関数を実行
....
parseExpressions(dom, self, expressions, false) //// レイアウトの解析
self.update(....) //// データを元にタグの内容を更新
....
//// domの内部ノードを、rootに付け替え
while (dom.firstChild) frag.appendChild(dom.firstChild)
if (root.stub) root = parent.root
root.appendChild(frag)
....
defineProperty(self, 'root', root)
self.isMounted = true
....
self.trigger('mount') //// マウント完了イベント
....
}


tagUpdate()


  • タグの内容の更新

  • 内部処理: 自身をdataの内容で上書きし、update()を呼ぶ

function tagUpdate(data) {

....
//// ループの中の場合、親要素からデータを継承
if (isLoop && anonymous) inheritFrom(self.parent)
....
extend(self, data) //// 自身をdataの内容で上書き
updateOpts()
if (self.isMounted) self.trigger('update', data) //// 更新前イベント
update(expressions, self) //// 更新処理の本体
if (self.isMounted) self.trigger('updated') //// 更新後イベント

return this
}


mkdom()


  • タグの実装をもとに、DOMを作成する

  • 内部処理: mkEl()で要素を生成、<yield />の処理をして、setInnerHTML()で内容をセット。要素を返す。

  • 定義場所: lib/browser/tag/mkdom.js

function mkdom(templ, html) {

var el = mkEl(GENERIC, isSVGTag(tagName)) //// 要素の作成
....
//// <yield />がある場合に、htmlで置き換え
templ = replaceYield(templ, html)

if (tblTags.test(tagName))
//// テーブル関係のタグ(theadとか)は特別な処理
el = specialTags(el, templ, tagName)
else
//// それ以外は、innerHTMLに内容をセット
setInnerHTML(el, templ)

el.stub = true

return el
}


parseExpressions()



  • walkNodes()で、DOMをトラバースしながら、内容に応じて子タグを生成。同時に、テンプレート変数を拾い出しておく。

  • 内部処理: 次のように場合分け


    1. テキストノード

    2. each属性あり: _each()に処理をまわす

    3. if属性あり: IfExpr()をインスタンス化

    4. カスタムタグの場合: getTag()で、カスタムタグであるかの判定をしつつ、タグの実装をを取得。実装をもとに、子タグを生成。



  • 定義場所: lib/browser/tag/parse.js

function parseExpressions(root, tag, expressions, includeRoot) {

var base = { parent: {children: expressions } } //// 最初のコンテキスト

walkNodes(root, function(dom, ctx) {
....
//// テキストノードで、テンプレート変数を含む場合
if (dom.nodeType == 3 .... && tmpl.hasExpr(dom.nodeValue))
parent.children.push({ dom, expr: dom.nodeValue })
....
//// each属性で繰り返しが設定されている場合
if (attr = getAttr(dom, 'each')) {
parent.children.push(_each(dom, tag, attr)); return false
}
//// if属性が指定されている場合
if (attr = getAttr(dom, 'if')) {
parent.children.push(new IfExpr(dom, tag, attr)); return false
}
....
//// カスタムタグの場合
var tagImpl = getTag(dom) //// カスタムタグかどうかを判定
if (tagImpl && (dom !== root || includeRoot)) {
var conf = { root: dom, parent: tag, hasImpl: true }
parent.children.push(initChildTag(tagImpl, conf, dom.innerHTML, tag))
return false
}
//// 通常のHTML要素の場合、属性内のテンプレート変数を処理
parseAttributes(dom, dom.attributes, tag, (attr, expr) => {
if (!expr) return
parent.children.push(expr)
})

return { parent }
}, base)
}


update()


  • テンプレート変数(expression)ごとに値を再計算して、タグを更新。

  • 内部処理: tmpl()で、テンプレート変数を計算。各種場合分けをして、最終的にはdomに内容をセット。

  • 定義場所: lib/browser/tag/update.js

function update(expressions, tag) {

each(expressions, (expr, i) => {
var
dom = expr.dom,
attrName = expr.attr,
value = tmpl(expr.expr, tag), //// テンプレートを処理して文字列を得る
parent = dom && dom.parentNode,
isValueAttr = attrName == 'value'

//// 真偽値の場合
if (expr.bool) value = value ? attrName : false
//// `null`なら空文字列に
else if (value == null) value = ''
//// Riotタグの場合 (_riot_idが付いているかで判断)
if (expr._riot_id) {
if (expr.isMounted) expr.update() //// タグの更新
else { expr.mount(); .... } //// マウントされてなければ、今すぐマウント
return
}
//// Riotタグ以外の場合でも、IfExprや_eachにはupdate()メソッドがあるので、それを実行
if (expr.update) { expr.update(); return }
//// 直前の値をoldに保持
var old = expr.value
expr.value = value
....
if (isValueAttr && dom.value == value || !isValueAttr && old === value)
return //// 変更がなければ何もしない
....
//// 元々の属性を削除
remAttr(dom, attrName)
//// 関数なら、イベントハンドラとして登録
if (isFunction(value)) setEventHandler(attrName, value, dom, tag)
//// show/hide属性の場合
else if (/^(show|hide)$/.test(attrName)) {
if (attrName == 'hide') value = !value
dom.style.display = value ? '' : 'none'
}
//// value属性の場合
else if (attrName == 'value') dom.value = value
//// 「riot-」で始まる属性名の場合。例: <img riot-src="{ expr }">
else if (startsWith(attrName, RIOT_PREFIX) && attrName != RIOT_TAG) {
if (value) setAttr(dom, attrName.slice(RIOT_PREFIX.length), value)
} else {
....
//// 真偽値属性の場合、それをセット
if (expr.bool) { dom[attrName] = value; .... }
//// 属性をセット
if (value === 0 || value && typeof value !== T_OBJECT)
setAttr(dom, attrName, value)
}
})
}


_each()

Riotのソースリーディングの中で、最大の難関かも...。けど、長すぎるので、略!

function _each(dom, parent, expr) {

....
}


ユーティリティ関数


lib/browser/util/misc.js


defineProperty()



  • Object.defineProperty()のショートカット。

  • デフォルトは、以下をオプションとし、上書きできないプロパティを付与したい場合に使う。


    • プロパティに列挙せず: enumerable: false

    • 変更不可: writable: false

    • 設定可能: configurable: true



function defineProperty(el, key, value, options) {

Object.defineProperty(el, key, extend({
value,
enumerable: false,
writable: false,
configurable: true
}, options))
return el
}


each()


  • 基本的には、[].forEach()的なもの。ただし、fnfalseを返した場合の挙動が異なるので、挙動に注意。

function each(list, fn) {

const len = list ? list.length : 0

for (let i = 0, el; i < len; ++i) {
el = list[i]
if (el != null && fn(el, i) === false)
i--
}
return list
}


extend()


  • ほぼObject.assign()。ただしwritableかどうかのチェックが入る点が異なる。

function extend(src) {

var obj, args = arguments
for (var i = 1; i < args.length; ++i) {
if (obj = args[i]) {
for (var key in obj) {
if (isWritable(src, key))
src[key] = obj[key]
}
}
}
return src
}


lib/browser/util/tag-helpers.js

mountTo()については、上述のとおり。


getTag()



  • domから、カスタムタグの実装を返す。タグ名または、riot-data-isあるいはdata-is属性がキーとなる

function getTag(dom) {

return dom.tagName && __TAG_IMPL[
getAttr(dom, RIOT_TAG_IS)
|| getAttr(dom, RIOT_TAG)
|| dom.tagName.toLowerCase()
]
}


lib/browser/util/dom-helpers.js


mkEl()



  • createElement()のショートカット。SVGを考慮する。

function mkEl(name, isSvg) {

return isSvg ?
document.createElementNS('http://www.w3.org/2000/svg', 'svg') :
document.createElement(name)
}


setInnerHTML()



  • innerHTMLに値をセット。SVGを考慮する。

function setInnerHTML(container, html) {

if (!isUndefined(container.innerHTML))
container.innerHTML = html
// some browsers do not support innerHTML on the SVGs tags
else {
const doc = new DOMParser().parseFromString(html, 'application/xml')
const node = container.ownerDocument.importNode(doc.documentElement, true)
container.appendChild(node)
}
}


walkNode()


  • 渡されたDOMの内容を順繰りにトラバースして、fnの処理をこなす

  • 内部処理: まず自身についてfnを実行し、子要素のすべてについて再帰的にwalkNode()を呼ぶ

  • 定義場所: lib/browser/util/dom-helpers.js

function walkNodes(dom, fn, context) {

if (dom) {
const res = fn(dom, context)
let next
// stop the recursion
if (res === false) return

dom = dom.firstChild

while (dom) {
next = dom.nextSibling
walkNodes(dom, fn, res)
dom = next
}
}
}