昨年「Riot.js ソースコード完全解説」を書いたのですが、この1年でいろいろ状況が変わってしまいました。v2の間に相当コードが書き換わり、Riotも若干ぽっちゃり系に...(他の同種のライブラリに比べれば可愛いものですが)。そんな中、朗報です。近日v3がリリースされ、コードが整理されます。
- 参考: v3のロードマップ
本稿では、改めて「ソースコード解説」を試みたいと思います。 2016年7月現在 でnext
ブランチにあるコードをもとに説明するので、日々変わってしまうけど、ご参考まで。オススメのコースは、次の通り。
- この記事に目を通して全体像をつかむ
- 自力でソースコードを斜め読みし、もうちょっと深く理解する
- プルリクエストしたい部分について、深読みする
- がしがしプルリクエスト
スクリプトの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
- DOMのパース (というよりはtraverse):
- 定数:
lib/browser/common/global-variables.js
基本方針
コーディングスタイル
- 行末のセミコロンなし
- 短いのが正義
-
==
や!=
は挙動を理解して使う -
if
文やfor
文で、省略可能であれば{}
を使わない - 演算子の評価順を正しく考慮。冗長な
()
を使わない - 独自の関数を優先的に使う(場面がある)
-
Object.assign()
ではなく、extend()
-
[].forEach()
ではなく、each()
- 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()
と同様だが、後方互換が維持されているため、引数としてcss
とattrs
は省略可能。 - 定義場所:
lib/browser/tag/core.js
function tag(name, tmpl, css, attrs, fn) {
....
}
マウントから表示まで
大雑把な流れとしては、次の通り。
-
riot.tag2()
関数で登録済みの、タグの実装を取得 - タグをインスタンス化
- タグの実装(HTMLテンプレート)を
innerHTML
に突っ込む - パースする、というよりDOMをトラバースして、解析
- 与えられたデータで、タグの内容を更新
- テンプレート変数(expression)を更新
- マウント完了イベント
以下、処理の中で「幹」になる部分に限定して処理を追っていきます。
見通しがつきにくくなるので、each
属性やif
属性、<yield />
などの処理については省略しますが、一旦「幹」を理解すれば、枝葉については実際のコードを読むほうが早いでしょう。
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()
- タグをマウントする
- 内部処理:
- tagファイルに書かれた初期化関数(
<script></script>
の中身)を実行 - 自分自身(のDOM)をテンプレートとして解析(parseExpressions())
- タグの内容をtagUpdate()で更新
- 解析済みのDOMを対象に付け替え
- マウント完了イベントを発行
- tagファイルに書かれた初期化関数(
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をトラバースしながら、内容に応じて子タグを生成。同時に、テンプレート変数を拾い出しておく。
- 内部処理: 次のように場合分け
- 定義場所:
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()
的なもの。ただし、fn
がfalse
を返した場合の挙動が異なるので、挙動に注意。
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
}
}
}