377
349

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-07-26

昨年「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()
  1. 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
    }
  }
}
377
349
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
377
349

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?