すべての良い一日。
base.jsでの高度な単一ページアプリケーションの作成に関する興味深い一連の記事を続けます。
前回は、コレクションの操作方法を学び、完全なインタラクティブリストを実装しました。
今回は、VKontakte向けの本格的なクライアントの作成を開始します。
つまり、承認、ニュース、友人、音楽のダウンロードを実装します。
メニューとナビゲーション
まず、タイトルとメニューを備えたシンプルなページを実装します。
メニュー項目(タブ)をクリックすると、別のURLに移動し、アクティブなメニュー項目を強調表示します。
将来のアプリケーションのメインファイルのコードを見てみましょう。
let Node = require('basis.ui').Node; let Header = require('app.ui.header.component'); let Menu = require('app.ui.menu.component'); require('basis.app').create({ title: 'VK Client by Basis.JS', element: new Node({ template: resource('./template.tmpl'), binding: { header: 'satellite:', menu: 'satellite:' }, satellite: { header: Header, menu: Menu } }) });
必要なモジュールを接続します: Node 、ヘッダーとメニューコンポーネント(以下で検討します)。
次に、 basis.app.create()メソッドを使用してアプリケーションを作成します。 もちろん、これを使用せずに、以前と同じように実行できます。新しいノードを作成し、ページ上のいくつかの要素に配置します。
ただし、basis.jsには、ページタイトルとページ上のアプリケーションのルートコンポーネントの配置に関連するロジックをカプセル化する、basis.appヘルパーがあります。
また、アプリケーションのテンプレートを見てください:
<div class="container"> <!--{header}--> <!--{menu}--> <hr/> </div>
タイトルとメニューは、アプリケーションのルートコンポーネントのサテライトです。
現時点では、ヘッダーコンポーネントは非常に単純です。
let Node = require('basis.ui').Node; module.exports = Node.subclass({ template: resource('./template.tmpl') // <h1> !</h1> });
彼の仕事は挨拶をすることです。 後で改善します。
しかし、メニューコンポーネントは特に興味深いものです。
let Node = require('basis.ui').Node; let Value = require('basis.data').Value; let router = require('basis.router'); let currentPage = Value.from(router.route(':page').param('page')); module.exports = Node.subclass({ template: resource('./template.tmpl'), // <div class="btn-group btn-group-lg"/> childClass: { template: resource('./item.tmpl'), selected: currentPage.compute((node, page) => node.url == page), binding: { title: 'title' }, action: { click() { router.navigate(this.url); } } }, childNodes: [ {title: '', url: 'news'}, {title: '', url: 'friends'}, {title: '', url: 'audio'} ] });
すぐにcurrentPage変数の内容に注目しましょう。
現在のルートは常にここに保存され、変更を追跡できます。
メニュー項目の選択されたプロパティでこの値を使用します。
つまり、特定のメニュー項目のアクティビティは、現在のルートに依存します。
現在のメニュー項目のURLが現在のルートと一致する場合、このメニュー項目はselected = trueです。
したがって、一度に1つのメニュー項目のみが選択されます。
特定のアイテムをクリックすると、指定したURLへのナビゲーションが発生します。
文書の対応するセクションで、 basis.js に組み込まれているルーターの詳細を読むことができます 。
次に、メニュー項目テンプレートを見てみましょう。
<b:define name="active" from="selected" type="bool"/> <button type="button" class="btn btn-default {active}" event-click="click"> {title} </button>
各メニュー項目はボタンです。 選択したアイテムがtrueの場合、 アクティブなクラスをボタンに追加し、そうでない場合は削除します。
以上です。 ナビゲーション付きメニュー-完了。
これで、メニュー項目をクリックすると、対応するURLへの遷移があります。
デフォルトのルートである小さな些細な点が残っていました。
ルートを指定せずにアプリケーションを単に開くと、メニュー項目は選択されません。
デフォルトルートがNewsになるように修正しましょう。
アプリケーションのメインファイルを変更します。
// ... let router = require('basis.router'); let defaultRoute = 'news'; require('basis.app').create({ title: 'VK Client by Basis.JS', element: new Node({ // ... }) }).ready(() => { router.route('*page').param('page').as(page => page || router.navigate(defaultRoute, true)); });
アプリケーションが初期化されるとすぐに、ルートの変更の追跡を開始します。
ルートが指定されていない場合、ユーザーをデフォルトルートに転送します。
ログイン
ここで、 VKontakte APIを使用し、 それを使用して承認を実装します。
VK API(以降、単にAPIと呼びます)のラッパーを見てください。 完全に検討するのではなく、重要な点のみを検討します。
API自体は、 basis.data.Valueの継承であることに注意してください。
これは、他のデータソースと同様に、状態があることを意味します。
- ユーザーが許可されていない場合は未定義
- 承認中の処理
- 認証成功後、 準備完了
- エラーの場合のエラー
モデルの状態の変化がどのように実装されるかを見てみましょう。 これを行うには、 login()およびlogout()メソッドを使用します。
login() { this.setState(STATE.PROCESSING); this.isLoggedIn().then( () => this.setState(STATE.READY), () => { global.VK.Auth.login(response => { this.setState(response.session ? STATE.READY : STATE.UNDEFINED); }, config.perms); } ); }, logout() { global.VK.Auth.logout(); this.setState(STATE.UNDEFINED); }
login()を呼び出すと、APIはPROCESSING状態になります。
次はチェックです-ユーザーがすでにログインしている場合、すぐにAPIをREADY状態にします。 そうでない場合は、VK APIのVK.Auth.login()メソッドを使用してログインします。 VK APIによる認証プロセスは、ユーザー名とパスワードの入力を求めるウィンドウが表示されるという事実に基づいています。
ウィンドウが閉じられると(承認が成功またはキャンセルされた)、転送されたコールバックが呼び出され、モデルの最終状態が設定されます:承認が成功した場合はREADY 、承認がキャンセルされた場合はUNDEFINED 。
logout()を呼び出すことにより、 VK.Auth.logout()メソッドを使用してセッションを破棄し、APIをUNDEFINED状態にします。
それでは、もう1つの重要なメソッドcallApi()を見てみましょう。
callApi(method, params = {}) { return this.isLoggedIn() .catch(e => Promise.reject(new Error(' !'))) .then( () => { return new Promise((resolve, reject) => { basis.object.complete(params, {v: config.version}); global.VK.api(method, params, response => { if (response.error) { reject(new Error(response.error.error_msg)); } else { resolve(response.response); } }); }); }, e => { this.setState(STATE.ERROR, e.message); throw e; } ); }
このメソッドの本質は、VK APIを介してリクエストを送信することです。 各リクエストを実行する前に、承認の可用性を確認します。 承認がない場合(たとえば、2つのブラウザータブでアプリケーションを開き、その1つで終了をクリックした場合)、エラーをスローし、APIをERROR状態にします。 すべてが認証で問題ない場合、リクエストを実行します。 サーバーがリクエストへの応答でエラーを通知した場合、エラーをスローし、APIをERROR状態にします。 それ以外の場合は、結果を返します。
このため、VK APIでの作業の微妙な違いを無視して、モデル状態でのみ操作できます。
let STATE = require('basis.data').STATE; let Value = require('basis.data').Value; let Node = require('basis.ui').Node; let router = require('basis.router'); let Header = require('app.ui.header.component'); let Menu = require('app.ui.menu.component'); let vkApi = require('app.vkApi'); let apiState = Value.state(vkApi); let defaultRoute = 'news'; require('basis.app').create({ title: 'VK Client by Basis.JS', element: new Node({ // , active: apiState.as(state => state == STATE.READY), // disabled: apiState.as(state => state == STATE.PROCESSING), template: resource('./template.tmpl'), binding: { header: 'satellite:', menu: 'satellite:', // , error: apiState.as(state => state == STATE.ERROR && state.data) }, satellite: { header: Header, menu: Menu }, action: { // "" login() { vkApi.login(); } } }) }).ready(() => { router.route('*page').param('page').as(page => page || router.navigate(defaultRoute, true)); // vkApi.login(); });
次に、テンプレートにこれらのプロパティを適用します。
<div class="container"> <div b:show="{active}"> <!--{header}--> <!--{menu}--> <hr/> </div> <div class="jumbotron text-center" b:hide="{active}"> <h1> VK Client <small> powered by basis.js</small> </h1> <div class="alert alert-danger" b:show="{error}"> {error} </div> <button class="btn btn-primary btn-lg" event-click="login" disabled="{disabled}"></button> </div> </div>
ユーザーがログインするまでようこそ画面を表示します。そうでない場合は、メニューとタイトルを表示します。
承認ボタンは、承認プロセス中はブロックされます。
また、メインメニューを終了するボタンを追加します。
<div> <div{childNodesElement} class="btn-group btn-group-lg"/> <button class="btn btn-primary btn-lg pull-right" event-click="logout"></button> </div>
そして、メニューコンポーネントで、このボタンのクリックを処理します。
let vkApi = require('app.vkApi'); // ... module.exports = Node.subclass({ // ... action: { logout() { vkApi.logout(); } } // ... });
いいね! これで、VKontakteにログインできるアプリケーションと、モデルの状態を追跡する便利なメカニズムができました。 先に進みます。
Pages
この記事の一部として、ニュース、友人、音声録音の3つのページを実装しています。
メニュー項目を切り替えると、対応するページが表示されます。
まず、他のすべてを継承する共有ページを作成します。
let Value = require('basis.data').Value; let Expression = require('basis.data.value').Expression; let STATE = require('basis.data').STATE; let Node = require('basis.ui').Node; module.exports = Node.subclass({ active: basis.PROXY, binding: { loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING), error: Value.query('childNodesState').as(state => state == STATE.ERROR && state.data), empty: node => new Expression( Value.query(node, 'childNodesState'), Value.query(node, 'childNodes.length'), (state, itemCount) => !itemCount && state == STATE.READY ) }, handler: { activeChanged() { if (this.active) { this.dataSource.deprecate(); } } } });
<div> <div class="alert alert-info" b:show="{loading}">...</div> <div class="alert alert-warning" b:show="{empty}"> </div> <div class="alert alert-danger" b:show="{error}">{error}</div> <div{page} b:hide="{loading}"/> </div>
たまたま、3つのページすべてに共通の作業ロジックがあります。
- 何かのリストをアップロードする
- ショーの碑文のローディングをロードするとき
- エラー表示エラーテキストの場合
- 空のリストがロードされている場合、碑文リストが空であることを示します
前回も同じようなことをしました。
3つのページすべてのコンポーネントでコードを複製しないように、別のファイルに配置します。
そこで何が起こるかを詳しく見てみましょう:
抽象ページは、特定のバインダーといくつかの詳細を含む単なるノードです。
前回詳細に調べたので、これらのバインダーについてはここでは説明しません。
今、私たちは他の何かにもっと興味を持っています。
アクティブなものは:basis.PROXY ?
前回、データセットの状態がUNDEFINEDまたはDEPRECATEDで、アクティブなコンシューマがある場合にのみ、データセットの同期が開始されることがわかりました。 これらの2つの条件は、ダイヤルプロセスを開始するために必要です。 ここで、「 アクティブな消費者がいるとき 」に関する部分にもっと興味があります。
コンシューマーは、別のデータオブジェクトで表されるデータ(さらには現在のデータ)を必要とするエンティティ( base.data.AbstractDataの継承者 )です。
アクティブなコンシューマは、プロパティがactive = trueのコンシューマです 。
デフォルトでは、 dataSourceがNodeに割り当てられると、 Nodeは自動的にこのセットのコンシューマになります。
素晴らしい、消費者がいます。 しかし、彼はアクティブですか?
繰り返しますが、デフォルトでは 、 Nodeはアクティブなコンシューマではありません(プロパティactive = false )。
「 アクティブを追加しましょう。ノードの説明にtrueを追加すると、問題は解決します 」-提案できます。
すべてがそれほど単純ではありません。 スマートアプリケーションを作成していますか? これは、アプリケーションの起動時にセットを一度だけ同期するだけでなく、必要に応じてデータを更新する必要があることを意味します。
3つのページとそれぞれに3つのセット(ニュース、友人、録音)があります。 このセットを必要とするタブに移動したときにのみ、セットの同期を開始します。 したがって、データを更新するメカニズムを実装するだけでなく、「 遅延 」データ同期も追加します。 つまり、必要な場合にのみ同期します。
これに基づいて、タブに切り替えるとき、対応するセットの状態をDEPRECATEDに変換する必要があります。
しかし、タブに切り替えたかどうかはどうやってわかりますか?
おそらく、私たちは元の質問からどんどん遠ざかっているとすでに考え始めているでしょう。
しかし、これはそうではありません。 もう少しすると、すべてのプロットラインがどのようにマージされるかがわかり、全体像が明確になります。
では、タブに切り替えたかどうかをどのようにして知るのでしょうか?
dataSourceと同様に、 Nodeは自動的にすべてのサテライトとその所有者のコンシューマになります。 タブを切り替えるときに、対応するページがアプリケーションのルートノードのサテライトになるようにします。
したがって、ページがサテライトになった時点でページを反応させ、その時点でそのセットの状態をDEPRECATEDに変換できます。
// ... module.exports = Node.subclass({ // ... handler: { ownerChanged() { if (this.owner) { this.dataSource.deprecate(); } } } });
いいね! ページがアプリケーションのルートコンポーネントのサテライトになると、データセットはDEPRECATED状態になります。
しかし、もう一度言いましょう。「 データセットは、その状態がUNDEFINEDまたはDEPRECATEDで、アクティブなコンシューマがある場合にのみ同期を開始します。 」
セットの切り替え状態と消費者の存在により、それを把握しました。 しかし、アクティビティはどうですか? ページに単にactive:trueを追加すると、ページは常にアクティブになり、そのデータソースは、このデータが必要かどうかに関係なく、作成後すぐにデータの同期を試みます。
同期が単に不可能な場合があるため、これは私たちには完全に適していません。 たとえば、承認手続きがまだ完了していない場合や、インターネットが切断されている場合です。
ページ自体でこれらのケースを処理しないようにするには、 active:base.PROXYプロパティを追加します。これにより、 ノードは、 ノード自体がアクティブなコンシューマを持つ場合にのみアクティブになる特別なモードになります。
これを知っているので、 ownerChangedを追跡する必要はなく、 activeChangedをサブスクライブします 。 したがって、アクティブなコンシューマの出現時にのみ、データを同期するようにセットを強制します。
ページコンポーネントの結果のコードをもう一度見てみましょう。
let Value = require('basis.data').Value; let Expression = require('basis.data.value').Expression; let STATE = require('basis.data').STATE; let Node = require('basis.ui').Node; module.exports = Node.subclass({ active: basis.PROXY, binding: { loading: Value.query('childNodesState').as(state => state == STATE.PROCESSING), error: Value.query('childNodesState').as(state => state == STATE.ERROR && state.data), empty: node => new Expression( Value.query(node, 'childNodesState'), Value.query(node, 'childNodes.length'), (state, itemCount) => !itemCount && state == STATE.READY ) }, handler: { activeChanged() { if (this.active) { this.dataSource.deprecate(); } } } });
したがって、ページは、アクティブなコンシューマがある場合にのみアクティブになります。
上記のように、ページのコンシューマーはアプリケーションのコンポーネントになります。覚えている場合は、ユーザーが承認されたときにのみアクティブになります。
ページがアクティブになると、そのデータソースの状態はDEPRECATEDになります。
そこで、さまざまなケースの処理をメインアプリケーションに委任しました。
これで画像がきれいになります:
- 3つのページ(ニュース、友達、音声)があります
- 各年配の女性は、VK APIを使用してデータを受信できる独自のデータセットを持っています
- タブを切り替えると、アプリケーションのメインコンポーネントのサテライトが対応するタブページになります
- ページは、アクティブなコンシューマーがある場合にのみアクティブです
次に、考慮されたページコンポーネントの継承者の作成に移りましょう。
ニュースから始めましょう:
let Page = require('../Page'); module.exports = new Page({ template: resource('./list.tmpl'), childClass: { template: resource('./item.tmpl'), binding: { text: 'data:', date: Value.query('data.date').as(format('%D.%M.%Y %H:%I:%S')) } } });
私はあなたの裁量でテンプレートを残します。
他の2ページのコードも同様です。
ページにはまだデータソースがありません。 当面はこの質問に戻りますが、開いているタブに対応するページを表示する方法を見てみましょう。
アプリケーションのメインファイルを変更します。
// ... let pageByName = { news: resource('./ui/pages/news/component.js'), friends: resource('./ui/pages/friends/component.js'), audio: resource('./ui/pages/audio/component.js') }; require('basis.app').create({ title: 'VK Client by Basis.JS', element: new Node({ // ... binding: { // ... page: 'satellite:' }, satellite: { // ... page: router.route(':page').param('page').as(page => pageByName[page]) } }) }) // ...
名前ページのサテライトは、 pageByName変数のマップに基づいて現在のルートに一致するコンポーネントになります。 次に、このサテライトの使用をテンプレートに追加する必要があります。
<div class="container"> <div b:show="{active}"> <!--{header}--> <!--{menu}--> <hr/> <!--{page}--> </div> ... </div>
これで、ページにデータソースがある場合、アプリケーションは動作を開始します。
データソース
VK APIのラッパーは上に示されています。 とりわけ、ニュース、友人、およびオーディオ録音のリストを取得する方法があります。 データソースとして、 basis.entity-型付きエンティティを使用します。
ニュースのタイプを説明しましょう。
let STATE = require('basis.data').STATE; let entity = require('basis.entity'); let vkApi = require('app.vkApi'); let News = entity.createType('News', { text: String, date: Date }); News.extendReader(data => data.date *= 1000); News.all.setSyncAction(() => vkApi.news().then(News.all.set)); module.exports = News;
各ニュースは、テキストと日付の2つのフィールドで構成されています。
リーダーを拡張していることに注意してください。 この機能は、データが型のインスタンスになる前にデータを変更する必要がある場合に使用されます。
また、各タイプにはallプロパティがあります。これは、このタイプの作成されたすべてのオブジェクトのセットです。
News型のインスタンスを作成すると、 News.allコレクションに配置されます。
このセットでは、 syncActionを定義します。 つまり 、同期が必要な場合に呼び出されるメソッドです。
必要なのは、VKontakteからデータを取得してNews.all.set()メソッドに渡すだけです。これにより、 Newsタイプの既存のインスタンスが新しいインスタンスに置き換えられます。
この方法でメソッドコンテキストを明示的に記述する必要がないことに注意してください: News.all.set.bind(News.all) 。
このメソッドには、使いやすいようにNews.allのコンテキストが既にあります。
syncActionで指定されたメソッドがプロミスを返す場合、プロミスの状態に応じてデータセットの状態が自動的に決定されることにも注意してください。
News.allは、ニュースページのデータソースとして転送できるようになりました。 したがって、ページがアクティブ化された時点で、 News.all状態は非推奨に移行され、 syncActionセットに記述されている同期プロセスが開始されます。
News.all
同様に、残りの2つのタイプについて説明します。
友達エンティティ
let entity = require('basis.entity'); let vkApi = require('app.vkApi'); let Friends = entity.createType('Friends', { photo: String, first_name: String, last_name: String }); Friends.extendReader(data => data.photo = data.photo_100); Friends.all.setSyncAction(() => vkApi.friends().then(Friends.all.set)); module.exports = Friends;
オーディオエンティティ
let entity = require('basis.entity'); let vkApi = require('app.vkApi'); let Audio = entity.createType('Audio', { artist: String, title: String, duration: Date }); Audio.extendReader(data => data.duration *= 1000); Audio.all.setSyncAction(() => vkApi.audio().then(Audio.all.set)); module.exports = Audio;
次に、ページのデータソースとしてNews.allを指定します。
let Value = require('basis.data').Value; let Page = require('../Page'); let News = require('app.type.news'); let format = require('basis.date').format; let dataSource = News.all; module.exports = new Page({ template: resource('./list.tmpl'), dataSource: dataSource, childClass: { template: resource('./item.tmpl'), binding: { text: 'data:', date: Value.query('data.date').as(format('%D.%M.%Y %H:%I:%S')) } } });
同様に、対応するセットを他のページに示します。
友達ページ
let Page = require('../Page'); let Friends = require('app.type.friends'); let dataSource = Friends.all; module.exports = new Page({ template: resource('./list.tmpl'), dataSource: dataSource, childClass: { template: resource('./item.tmpl'), binding: { photo: 'data:', first_name: 'data:', last_name: 'data:' } } });
音声ページ
let Value = require('basis.data').Value; let Page = require('../Page'); let Audio = require('app.type.audio'); let format = require('basis.date').format; let dataSource = Audio.all; module.exports = new Page({ template: resource('./list.tmpl'), dataSource: dataSource, childClass: { template: resource('./item.tmpl'), binding: { artist: 'data:', title: 'data:', duration: Value.query('data.duration').as(format('%I:%S')) } } });
マークアップは、あなたの裁量で、または記事の最後にあるリポジトリへのリンクによって行われます。
プレゼンテーションでは、マークアップの種類はまったく重要ではなく、どのようなものでもかまいません。
すべて準備が整いましたが、さらに改善を加えましょう。
ページ404を追加します。これを行うには、メインファイルを変更します。
// ... let pageByName = { news: resource('./ui/pages/news/component.js'), friends: resource('./ui/pages/friends/component.js'), audio: resource('./ui/pages/audio/component.js'), notFound: resource('./ui/pages/404/component.js') }; require('basis.app').create({ title: 'VK Client by Basis.JS', element: new Node({ satellite: { header: Header, menu: Menu, page: router.route(':page').param('page').as(page => pageByName[page] || pageByName.notFound) } // ... }) }) // ...
行ったのは、ルートマップに新しいルートを追加し、ルート変更の追跡を変更することだけでした。 必要なルートがルートマップで見つからない場合は、 notFoundルートを使用します 。
ところで、コンポーネントはrequireではなくリソースを介して接続されていることに気づきましたか?
リソースを使用すると、遅延コンポーネントの初期化を実装できます。
つまり、コンポーネントはすぐに初期化されるのではなく、初めて必要になったときにのみ初期化されます。
リソースの詳細については、ドキュメントの対応するセクションを参照してください。
そしてもう一つ。実際、VKontakteの壁では、テキストニュースだけでなく、テキストのないビデオ/写真も見ることができます。これらのケースの処理については別の機会に扱いますが、今のところは、テキストを含むニュースのみを表示するようにニュースをフィルタリングしてみましょう。これを行うには、ニュースコンポーネントを変更します。
let Page = require('../Page'); let Value = require('basis.data').Value; let News = require('app.type.news'); let format = require('basis.date').format; let Filter = require('basis.data.dataset').Filter; let textOnlyNews = new Filter({ source: News.all, state: Value.query('source.state'), rule: 'data.text', deprecate() { this.source.deprecate(); } }); module.exports = new Page({ template: resource('./list.tmpl'), dataSource: textOnlyNews, childClass: { template: resource('./item.tmpl'), binding: { text: 'data:', date: Value.query('data.date').as(format('%D.%M.%Y %H:%I:%S')) } } });
ニュースページのデータソースを、テキストのないすべてのニュースを破棄するフィルターに置き換えるだけでした。
そして最後に...ヘッダーコンポーネントのアニメーション化:
let Node = require('basis.ui').Node; let STATE = require('basis.data').STATE; let DataObject = require('basis.data').Object; let vkApi = require('app.vkApi'); let dataSource = new DataObject({ data: { firstName: '', lastName: '' }, syncAction() { return vkApi.me().then(me => { this.update({ firstName: me.first_name, lastName: me.last_name }); }); } }); module.exports = Node.subclass({ active: basis.PROXY, delegate: dataSource, template: '<h1> {firstName} {lastName}!</h1>', binding: { firstName: 'data:', lastName: 'data:' } });
したがって、ヘッダーコンポーネントは、アカウント所有者の名前と姓を受け取って表示することを学習しました。
結論は終わりではありません
そのため、本日、base.jsでVKontakte用の本格的なクライアントを作成しました。アプリケーションはログインして、サーバーとデータを同期できます。
前回と同様に、FRPの道をたどり、主にデータの操作に重点を置いているという事実に注意してください。つまり、アプリケーションがタスクを実行するようにデータフローを構築します。同時に、basis.jsの仕様は、ループがなく、多数のブランチがあるため、クライアントコードはかなり線形になります。少なくとも示されたタスクはそれらなしで解決できます。
次の記事では、クライアントを改善し、その機能を向上させます。
base.jsに関心をお寄せいただきありがとうございます!
貴重なアドバイスをいただいたlahmatiyに感謝します;)
便利なリンク:
- 記事のコードを含むリポジトリ
- ドキュメントbase.js
- ソースコードのbasis.js
- gitter- , basis.js