ExtJSは豊富なコンポーネントが用意されています。しかしながら、現実のアプリケーション開発ではどうしても、自分でコンポーネントを作成するようなケースが出てきます。カスタムコンポーネントの作成については、ExtJSのドキュメントにも説明はありますが、実際の開発ではHTML要素のレンダリングなどExtJSの内部を知る必要があります。
他のコンポーネントを包含しない、単体で動作するコンポーネントは一般的にExt.Componentを派生させて作成します。その時に、HTMLのレンダリング処理やイベントハンドラをどのように設定すればよいのか、イロハが分かるような資料を目指して本記事を作成しました。
環境:
まず、Sencha公式サイトで紹介されている"Hello World"を動かします。
index.html: 公式サイトのサンプルでは"ext-all.js"を読み込んでいますが、幸い"ext-all-debug.js"が置いてあるようでしたのでそちらに修正してます。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <link rel="stylesheet" type="text/css" href="http://cdn.sencha.com/ext/gpl/5.0.0/build/packages/ext-theme-neptune/build/resources/ext-theme-neptune-all.css"> <script type="text/javascript" src="http://cdn.sencha.com/ext/gpl/5.0.0/build/ext-all-debug.js"></script> <script type="text/javascript" src="app.js"></script> </head> <body> </body> </html>
app.js: 公式サイトのままですので割愛します。
動作確認ができましたら、"app.js" をロードしている部分をサンプルのJSファイルに順次書き換えて、練習していきます。
Ext.Component で autoEl, html, tpl, renderTpl 設定を使い、どの設定がどのようなDOM要素にレンダリングされるのかステップバイステップで見ていきます。
元ネタ:
空のExt.Componentをレンダリングする、以下の様なJavaScriptファイルを作成します。
t1.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', {renderTo: Ext.getBody()}); } });
index.html: app.jsをロードしていた部分を、t1.jsをロードするよう修正します。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <link rel="stylesheet" type="text/css" href="http://cdn.sencha.com/ext/gpl/5.0.0/build/packages/ext-theme-neptune/build/resources/ext-theme-neptune-all.css"> <script type="text/javascript" src="http://cdn.sencha.com/ext/gpl/5.0.0/build/ext-all-debug.js"></script> <script type="text/javascript" src="t1.js"></script> </head> <body> </body> </html>
ブラウザアドオンなどの開発者ツールからDOM構造をダンプすると、以下のようになりました。Ext.Componentが空っぽのdiv要素としてレンダリングされているのを確認できます。
<html lang="en"> ... <body id="ext-element-1" class="x-body x-gecko"> # ここから、quicktips系で自動的に追加されてるっぽい。 <div id="ext-quicktips-tip" class="x-tip x-layer x-tip-default x-border-box" data-sticky="true" style="display: none;"> (長いので省略) </div> # これが、t1.jsで作成した Ext.Component のDOM要素 <div id="component-1010" class="x-component x-component-default x-border-box"></div> </body> </html>
Ext.Componentのconfigurationで、height, width, border, style を指定してみると、style属性に反映されました。
t2.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', { height: 100, width: 200, border: 5, style: { borderColor: 'red', borderStyle: 'solid' }, renderTo: Ext.getBody() }); } });
→DOM要素だけを抜き出し、属性で見やすく改行:
<div id="component-1010" style="border-color:red;border-style:solid;border-width:5px 5px 5px 5px;width:200px;height:100px;" class="x-component x-component-default x-border-box"></div>
Ext.Component の html 設定を使うと、HTML文字列をそのままコンテンツとしてDOM要素に組み込まれます。
t3.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', { html: '<i>hello</i>', renderTo: Ext.getBody() }); } });
→DOM要素を確認:
<div id="component-1010" class="x-component x-component-default x-border-box"> <i>hello</i> </div>
静的なHTML要素を構築したい場合は、autoEl 設定を使うこともできます。autoEl 設定ではExt.DomHelperで使える階層化されたオブジェクト要素を設定します。
t4.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', { autoEl: { tag: 'ul', cls: 'class-t4', children: [ { tag: 'li', html: 'item1' }, { tag: 'li', html: 'item2' }, { tag: 'li', cn: [{ tag: 'a', href: 'http://www.example.com/', html: 'linktest'}] } ] }, renderTo: Ext.getBody() }); } });
→DOM要素だけを抜き出し、見やすく改行:
<ul id="component-1010" class="class-t4"> <li>item1</li> <li>item2</li> <li><a href="http://www.example.com/">linktest</a></li> </ul>
autoEl と html 設定は併用ができる。ただし、静的なHTML要素をレンダリングするだけであれば、この組み合わせを使うケースは無いかもしれません。
t5.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', { autoEl: { tag: 'p', cls: 'class-p', html: 'hello', children: [ { tag: 'li', html: 'item1' }, { tag: 'li', html: 'item2' }, { tag: 'li', cn: [{ tag: 'a', href: 'http://www.example.com/', html: 'linktest'}] } ] }, html: '<i>html configuration</i>', renderTo: Ext.getBody() }); } });
→DOM要素だけを抜き出し、見やすく改行:autoElのtagを<p>タグに指定してみたら、微妙に壊れてしまった・・・。
<p id="component-1010" class="class-p"> <i>html configuration</i> hello </p> <li>item1</li> <li>item2</li> <li><a href="http://www.example.com/">linktest</a></li> <p id="ext-element-3"></p></body>
tpl 設定と data 設定を使うと、Ext.XTemplate/Ext.Templateを使ったテンプレート処理を埋め込むことができます。これまでのautoEl, html と組み合わせてどの順番で出力されるのか確認してみます。
※なお、autoElのタグは<p>だと中にブロック要素を入れられないのか分断されてしまってたので、<div>にしました。
t6.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', { autoEl: { tag: 'div', cls: 'class-abc', html: ' <s>hello</s>', children: [ { tag: 'a', href: 'http://www.example.com/', html: 'item1' } ] }, html: '<i>html configuration</i>', tpl: [ '<ul>', '<tpl for=".">', '<li>{.:htmlEncode}</li>', '</tpl>', '</ul>' ], data: ['item1', 'item2', '<b>item3</b>'], renderTo: Ext.getBody() }); } });
→DOM要素だけを抜き出し、見やすく改行:
<div id="component-1010" class="class-abc"> <i>html configuration</i> <ul> <li>item1</li> <li>item2</li> <li><b>item3</b></li> </ul> <s>hello</s> <a href="http://www.example.com/">item1</a> </div>
renderTpl 設定は、後述しますが html や tpl 設定で構築した要素全体を制御する、全体的なテンプレートを設定しています。
Ext.AbstractComponentにおいて、renderTplのデフォルト値は以下のようになっています。テンプレートを使っており、この中の "this" はExt.XTemplateのインスタンスを指し、それのrenderContent()メソッドの内容を出力しています。後で見ていきますが、renderContent()は最終的に html や tpl 設定を出力します。ここでrenderTplを上書きすることで、htmlやtpl設定を無視するようなレンダリングも可能となります。
renderTpl: '{%this.renderContent(out,values)%}',
テンプレートの中では、Ext.AbstractComponent のドキュメントもあるように renderData で渡される値を使うことができます。
実際に、autoEl, renderTpl, renderData, html, tpl をすべて併用したサンプルを作ってみます。
t7.js:
Ext.application({ name : 'MyApp', launch : function() { Ext.create('Ext.Component', { autoEl: { tag: 'div', cls: 'class-abc', html: ' <s>hello</s>', children: [ { tag: 'a', href: 'http://www.example.com/', html: 'item1' } ] }, html: '<i>html configuration</i>', tpl: [ '<ul>', '<tpl for=".">', '<li>{.:htmlEncode}</li>', '</tpl>', '</ul>' ], data: ['item1', 'item2', '<b>item3</b>'], renderTpl: [ // id, uiCls, baseCls, componentCls などは デフォルトでrenderDataとしてassign済み '<div id="{id}" class="{uiCls} {baseCls} {componentCls}">', '<h2>{title:htmlEncode}</h2>', '<tpl for="iter">', '<p>{.}</p>', '</tpl>', '{%this.renderContent(out,values)%}', '</div>' ], baseCls: 'myapp-custom', componentCls: 'myapp-component', renderData: { title: 'renderTpl() <s>test</s>', iter: [ 'sub-block1', 'sub-block2', 'sub-block3' ] }, renderTo: Ext.getBody() }); } });
→DOM要素だけを抜き出し、見やすく改行:
<div id="component-1010" class="class-abc"> <div class=" myapp-custom myapp-component" id="component-1010"> <h2>renderTpl() <s>test</s></h2> <p>sub-block1</p> <p>sub-block2</p> <p>sub-block3</p> <i>html configuration</i> <ul> <li>item1</li> <li>item2</li> <li><b>item3</b></li> </ul> </div> <s>hello</s> <a href="http://www.example.com/">item1</a> </div>
ここまでのサンプルから、以下の様な階層構造でHTML要素が構築されていくことを確認しました。
autoEl.tag + autoEl.cls renderTpl + renderData # 以下、renderTpl中の '{%this.renderContent(out,values)%}' により出力される html tpl + data autoEl.html autoEl.children
autoEl.tag の要素の中身は、Ext.Component#update()により動的に変更することが可能です。
ただし、以下のサンプルコードで試すと renderTpl + renderData も含めて、autoEl.tagの中身が丸ごと変更されてしまいました。
renderTplなど避けて、上手く特定の要素以下を update() で更新する方法については今回は未調査です。
update()の引数については、Ext.dom.Element#update()の引数と同じですので、そちらのAPIドキュメントが参考になります。第二引数の loadScripts は、HTMLソースを第一引数に指定した際、scriptタグが含まれていた場合にそれを実行するか否かを制御できるようです(デフォルトはfalseで実行しない)。
t8.js:
Ext.application({ name : 'MyApp', launch : function() { var cmp = Ext.create('Ext.Component', { autoEl: { tag: 'div', cls: 'class-abc', html: ' <s>hello</s>', children: [ { tag: 'a', href: 'http://www.example.com/', html: 'item1' } ] }, html: '<i>html configuration</i>', tpl: [ '<ul>', '<tpl for=".">', '<li>{.:htmlEncode}</li>', '</tpl>', '</ul>' ], data: ['item1', 'item2', '<b>item3</b>'], renderTpl: [ '<div id="{id}" class="{uiCls} {baseCls} {componentCls}">', '<h2>{title:htmlEncode}</h2>', '<tpl for="iter">', '<p>{.}</p>', '</tpl>', '{%this.renderContent(out,values)%}', '</div>' ], baseCls: 'myapp-custom', componentCls: 'myapp-component', renderData: { title: 'renderTpl() <s>test</s>', iter: [ 'sub-block1', 'sub-block2', 'sub-block3' ] }, renderTo: Ext.getBody() }); Ext.create('Ext.Button', { text: 'update(data)', renderTo: Ext.getBody(), handler: function() { cmp.update(['item4', 'item5', 'item6']); } }); Ext.create('Ext.Button', { text: 'update(html(, loadScripts : default = false))', renderTo: Ext.getBody(), handler: function() { cmp.update('<b>update(html)</b><script>alert("hello");</script>'); } }); Ext.create('Ext.Button', { text: 'update(html, loadScripts : true)', renderTo: Ext.getBody(), handler: function() { cmp.update('<b>update(html)</b><script>alert("hello");</script>', true); } }); } });
→最初に表示された段階でDOM要素だけを抜き出し、見やすく改行:
<div id="component-1010" class="class-abc"> <div class=" myapp-custom myapp-component" id="component-1010"> <h2>renderTpl() <s>test</s></h2> <p>sub-block1</p> <p>sub-block2</p> <p>sub-block3</p> <i>html configuration</i> <ul> <li>item1</li> <li>item2</li> <li><b>item3</b></li> </ul> </div> <s>hello</s> <a href="http://www.example.com/">item1</a> </div>
→"update(data)"ボタンをクリックすると、renderTplの要素も含めてごっそり、tpl + update()で渡した新しいデータのテンプレート処理内容に入れ替わりました。
<div id="component-1010" class="class-abc"> <ul> <li>item4</li> <li>item5</li> <li>item6</li> </ul> </div>
→"update(html(, loadScripts : default = false))"ボタンをクリックすると、renderTplの要素も含めてごっそり、指定したHTMLソースに入れ替わりました。scriptタグ中のJSは実行されませんでした。
<div id="component-1010" class="class-abc"> <b>update(html)</b> <script>alert("hello");</script> </div>
→"update(html, loadScripts : true)"ボタンをクリックすると、renderTplの要素も含めてごっそり、指定したHTMLソースに入れ替わりました。scriptタグ中のJSは実行されました。
<div id="component-1010" class="class-abc"> <b>update(html)</b> </div>
renderTo 設定が指定されていれば、Ext.AbstractComponent のコンストラクタ中でmixinされた Ext.util.Renderable クラスの render() メソッドが呼ばれます。
ext/src/AbstractComponent.js:
Ext.define('Ext.AbstractComponent', { ... constructor : function(config) { var me = this, i, len, xhooks; ... if (me.renderTo) { me.render(me.renderTo); // EXTJSIV-1935 - should be a way to do afterShow or something, but that // won't work. Likewise, rendering hidden and then showing (w/autoShow) has // implications to afterRender so we cannot do that. } ...
Ext.util.Renderable#update()では、初めてのレンダリング(= me.el が空)の場合は getRenderTree() でDOM要素の設定情報(= Ext.DomHelperが扱えるオブジェクト階層)を構築した後、Ext.DomHelperを使って親要素に追加してます。
render: function(container, position) { var me = this, el = me.el && (me.el = Ext.get(me.el)), // ensure me.el is wrapped vetoed, tree, nextSibling; Ext.suspendLayouts(); container = me.initContainer(container); nextSibling = me.getInsertPosition(position); if (!el) { // 初回はelが空っぽなので、getRenderTree() でDOM要素の設定情報を構築する。 tree = me.getRenderTree(); if (me.ownerLayout && me.ownerLayout.transformItemRenderTree) { tree = me.ownerLayout.transformItemRenderTree(tree); } // tree will be null if a beforerender listener returns false if (tree) { // treeを構築したら、親要素に追加/挿入 if (nextSibling) { el = Ext.DomHelper.insertBefore(nextSibling, tree); } else { el = Ext.DomHelper.append(container, tree); } me.wrapPrimaryEl(el); } } else { // 既にelが構築済みの場合の追加/挿入処理 ... } if (el && !vetoed) { // レンダリングで要素の追加/購入処理が発生したら、finishRender()を呼ぶ。 me.finishRender(position); } Ext.resumeLayouts(!me.hidden && !container.isDetachedBody); },
Ext.util.Renderable#getRenderTree() では、"beforerender"イベントが未設定 or 設定済みならイベントを発生させ、その戻り値がtrue の場合にExt.util.Renderable#getElConfig() の戻り値を返します。
getRenderTree: function() { var me = this; if (!me.hasListeners.beforerender || me.fireEvent('beforerender', me) !== false) { me.beforeRender(); // Flag to let the layout's finishRenderItems and afterFinishRenderItems // know which items to process me.rendering = true; if (me.el) { // Since we are producing a render tree, we produce a "proxy el" that will // sit in the rendered DOM precisely where me.el belongs. We replace the // proxy el in the finishRender phase. return { tag: 'div', id: (me.$pid = Ext.id()) }; } return me.getElConfig(); } return null; },
Ext.util.Renderable#getElConfig() では、autoElプロパティが設定されていればそれを使い、未設定であればdivタグ要素を使って、Ext.DomHelperが扱える形のDOM要素を表すオブジェクト階層を構築して返します。また、frameInfoは設定していないため、config.tplは initRenderTpl() の戻り値が返します。
getElConfig : function() { var me = this, autoEl = me.autoEl, frameInfo = me.getFrameInfo(), config = { tag: 'div', tpl: frameInfo ? me.initFramingTpl(frameInfo.table) : me.initRenderTpl() }, protoEl = me.protoEl, i, frameElNames, len, suffix, frameGenId, frameData; me.initStyles(protoEl); protoEl.writeTo(config); protoEl.flush(); if (Ext.isString(autoEl)) { config.tag = autoEl; } else { Ext.apply(config, autoEl); // harmless if !autoEl } ... return config; },
Ext.util.Renderable#initRenderTpl() では、"renderTpl"にテンプレートが設定されていれば Ext.util.Renderable#setupRenderTpl() を呼び、ばテンプレートインスタンスのセットアップを行います。
Ext.AbstractComponent においては、renderTplはデフォルトのテンプレートが設定済みなので、明示的にnullやundefinedで上書きしていない限りは、テンプレートインスタンスがセットアップされることになります。
initRenderTpl: function() { var tpl = this.getTpl('renderTpl'); if (tpl && !tpl.renderContent) { this.setupRenderTpl(tpl); } return tpl; },
Ext.util.Renderable#setupRenderTpl() では、テンプレートインスタンスの "renderBocy()", "renderContent()" メソッドの実体をmixinされたExt.util.Renderableインスタンスの doRenderContent メソッドを参照するよう調整します。
setupRenderTpl: function (renderTpl) { renderTpl.renderBody = renderTpl.renderContent = this.doRenderContent; },
Ext.util.Renderable#doRenderContent() では、ここでようやく、mixinされたインスタンスに"html"か"tpl"プロパティがあれば、順繰りにDOM要素として追加していきます。
doRenderContent: function (out, renderData) { // Careful! This method is bolted on to the renderTpl so all we get for context is // the renderData! The "this" pointer is the renderTpl instance! var me = renderData.$comp; if (me.html) { Ext.DomHelper.generateMarkup(me.html, out); delete me.html; } if (me.tpl) { // Make sure this.tpl is an instantiated XTemplate if (!me.tpl.isTemplate) { me.tpl = new Ext.XTemplate(me.tpl); } if (me.data) { //me.tpl[me.tplWriteMode](target, me.data); me.tpl.applyOut(me.data, out); delete me.data; } } },
initRenderTpl()からのテンプレートセットアップ処理で寄り道してしまいましたが、getElConfig()に戻ると、autoElはデフォルトではdivタグ用に初期化されていますが、autoEl設定が指定されていればExt.apply()でマージしています。
getElConfig : function() { var me = this, autoEl = me.autoEl, frameInfo = me.getFrameInfo(), config = { tag: 'div', tpl: frameInfo ? me.initFramingTpl(frameInfo.table) : me.initRenderTpl() }, protoEl = me.protoEl, i, frameElNames, len, suffix, frameGenId, frameData; ... if (Ext.isString(autoEl)) { config.tag = autoEl; } else { Ext.apply(config, autoEl); // harmless if !autoEl } ... return config; },
この結果、以下の様なオブジェクトがgetRenderTree()の戻り値として返されます。
{ tag: 'div', //デフォルト。 tpl: (renderTpl + renderDataを処理するExt.XTemplateのインスタンス), // 以下、指定されていれば。 html: ..., css: ..., children: ... }
このオブジェクトが、最終的にrender()メソッド中で Ext.DomHelper の insertBefore()/append() メソッドに渡され、そのなかで最終的にautoElやrenderTplのDOM構築が処理されます。
// tree will be null if a beforerender listener returns false if (tree) { if (nextSibling) { el = Ext.DomHelper.insertBefore(nextSibling, tree); } else { el = Ext.DomHelper.append(container, tree); } me.wrapPrimaryEl(el); }
render()の処理の流れを、autoEl, renderTpl, html, tpl の階層と重ねあわせてみると、以下の様な枠組みになるようです。(ちょっと分かりづらいかも・・・)
# Ext.AbstractComponent -> Ext.util.Renderable#render() -> getRenderTree() -> Ext.DomHelper.[insertBefore|append]() autoEl.tag + autoEl.cls # Ext.AbstractComponent#renderTpl(default='{%this.renderContent(out,values)%}') -> Ext.util.Renderable#initRenderTpl() renderTpl + renderData # 以下、renderTpl中の '{%this.renderContent(out,values)%}' により出力される html tpl + data autoEl.html autoEl.children
HTML要素をカスタマイズした後は、イベント設定を考えることになります。
ExtJSでは Ext.util.Observable というmixin用のクラスが用意されており、Ext.AbstractComponentにmixinされています。
そのため、イベントの定義や追加、削除を Ext.Component の派生クラスから簡単にできるようになっています。
コンポーネントにおけるイベントの使い方については、以下の公式ドキュメントで解説されています。
今回は以下の様なサンプルコンポーネントを作って、実際にfireEvent()やボタンクリックでイベントの発生を確認してみました。
t9.js:
Ext.define('My.Cmp', { extend: 'Ext.Component', baseCls: 'my-cmp', componentCls: 'my-cmp-component', constructor: function(config) { var me = this; config = config || {}; Ext.apply(me, config); me.renderData = me.renderData || {- title: 'My.Cmp Title' }; me.callParent([config]); // deprecated in ExtJS 5.0 //me.addEvents('foo', 'bar', 'baz'); }, listeners: { foo: function(el) { console.log('default foo event', el); }, bar: Ext.emptyFn, // definition only baz: function(el) { console.log('default baz event', el); } }, renderTpl: [ '<h2 id="{id}-title" class="{baseCls}-title {componentCls}-title">{title}</h2>', '<div>', '<input class="my-cmp-btn1" type="button" name="btn1" value="btn1.click()"><br>', '<input class="my-cmp-btn2" type="button" name="btn2" value="btn2.click()"><br>', '</div>' ], renderSelectors: { button1: 'input.my-cmp-btn1', button2: 'input.my-cmp-btn2' }, afterRender: function() { // renderTpl中の要素に対してrernderSelectorsでアクセスできる // タイミングとしてafterRender()を採用 var me = this; me.button1.on('click', function() { console.log('button1-click', this); }, me); me.button2.on('click', function() { console.log('button2-click', this); this.fireEvent('baz', this); }, me); } }); Ext.application({ name : 'MyApp', launch : function() { var cmp = Ext.create('My.Cmp', { renderTo: Ext.getBody() }); cmp.on('foo', function(el) { console.log('foo-event', el); }, cmp); Ext.defer(function() { cmp.fireEvent('foo', cmp); }, 1000); Ext.defer(function() { cmp.fireEvent('bar', cmp); }, 2000); Ext.defer(function() { cmp.fireEvent('baz', cmp); }, 3000); } });
画面表示後、Ext.defer() で 'foo', 'bar', 'baz' イベントをfireEvent()し終わった状態:
(consoleログをクリアしてから) "btn1.click()"ボタンをクリックした状態:
(consoleログをクリアしてから) "btn2.click()"ボタンをクリックした状態:
Ext.Component がlistenできるのは、あくまでもmixinされたExt.util.Observableを経由した、ExtJS上でのイベント処理となります。
t9.js ではbutton要素のclickイベントを、afterRender()中で設定していましたが、これはrenderSelectors経由で取得したExt.dom.Elementインスタンスのon()メソッドを使っています。
Ext.dom.Elementクラスのon()メソッドは、ExtJS 4.2 における実装は以下のようにExt.EventManager#on()メソッドに委譲している状態です。
on: function(eventName, fn, scope, options) { Ext.EventManager.on(this, eventName, fn, scope || this, options); return this; },
ブラウザごとの細かい調整など泥臭い処理については、Ext.EventManagerの内部に封じ込められており、開発者は意識しなくて済むようになっています。
コンポーネントをカスタマイズして複雑なDOM要素を構築し、さらにDOM要素のイベントハンドラを調整するような場合に、最終的にそのDOM要素をExt.dom.Elementでラップしたインスタンスを取得できれば、Ext.EventManagerの恩恵を受けることができます。
ただし、DOM要素の取得で Ext#get()(=Ext.dom.Element#get()) や Ext#select()(=Ext.dom.Element#select()) などを使うと、root要素を指定できないため、document空間全体から要素を探す必要があります。そうなると、カスタムコンポーネントでHTML要素を構築するときにid指定を上手く使うか、class指定の衝突を常に意識する必要が出てきます。
renderSelectors によりDOM要素を保存したり、root要素を指定できる Ext#query()(=Ext.dom.Query#select()) を使って Ext.Component#getEl() をroot要素にして探索空間をインスタンスに限定するなど、工夫が必要になると感じました。
コメント