用户界面
通过阅读本章,你会了解到:
- 如何在任何的用户界面框架中构建重用的客户端组件。
- 如何来构建一个风格指南来可视化地测试这类重用组件。
- 在流星中有效地构建前端组件的模式。
- 如何构建具有可维护的、可扩展的用户界面
- 如何构建组件让其能够和多重数据源协同工作。
- 如何构建动画以让用户在发生改变时获得通知。
用户界面组件
在流星中,我们官方支持三种用户界面(UI)渲染库,Blaze, React 和 Angular。
Regardless of the rendering library that you are using, there are some patterns in how you build your User Interface (UI) that will help make your app's code easier to understand, test, and maintain. These patterns, much like general patterns of modularity, revolve around making the interfaces to your UI elements very clear, and avoiding using techniques that bypass these known interfaces.无论你使用何种渲染库,都有一些模式能够帮助应用代码获得更好地被理解,测试,以及维护。这些模式,像一些模块的通用模式,使得你的界面元素非常的清晰,并且避免使用一些旁门左道的界面技术。
在本章中,我们会介绍这些在用户界面中被称为“组件”的元素。虽然在一些系统中,你也可以将它们称为“模版”,将它们想象成为一些具有API和内部逻辑的组件,而不是只有一些HTML的模版,是一个更好的想法。
在开始之前,让我们考虑UI组件的两个分类,这通常很容易想到,“聪明的” 和 “重用的” :
重用的组件
一个重用的组件就是,不需要依赖于其余的环境就能够进行渲染的。它的渲染仅仅基于它所给予的直接输入(在Blaze中是它的 模版参数 , 在React中是 props)以及内部状态。
尤其在流星中,这意味着一个组件不需要访问来自任何全局的数据源—集合,存储,路由,用户数据,或类似的。例如,在Todos示例应用中, Lists_show
模版从渲染它的列表中获得一组todos,而不是直接从 Todos
或 Lists
集合中。
重用的组件具有很多优点:
它们很容易被理解—你不需要理解在全局数据存储中的改变是怎么进行的,只需要简单地知道该组件的参数是如何改变的。
它们很容易被测试—你不需要小心于渲染它们的环境,你所要做的就是提供正确的参数。
它们很容易地添加到组件风格指南中—就如我们在 组件风格指南 中看到的那样,当创建一个风格指南,一个干净的环境让一切变得容易开始工作。
你知道什么样的依赖来让它们在不同的环境中进行工作。
还有一种更严格的重用组件类型,一个 “纯的” 组件,它不具有任何的内部状态。例如在Todos应用中, Todos_item
模版决定基于它的参数进行单独的渲染。纯组件则更容易理解和测试相对于其余的重用类型组件,因此应该在任何地方优先考虑。
全局数据存储
那么哪些全局数据存储在你的重用组件中应该避免的呢?有一些。流星构建在开发速度优化过的基础之上,意味着你能够访问很多的全局的东西。虽然这对于构建 “聪明的” (参见下面)组件很方便,但你需要在重用的组件中避免这些数据源:
- 你的集合,还有
Meteor.users
集合, - 账号信息,像
Meteor.user()
和Meteor.loggingIn()
- 现在的路由信息
- 其余客户端的数据存储(请在 数据装载一章中 阅读更多相关信息)
聪明的组件
多数在你应用中的组件应该是能够被重用的,它们需要能够从别的地方获得传递过来的数据。这就是所谓的聪明的组件的由来。这样的组件典型地行为是:
- 订阅数据
- 从这些订阅中获取数据
- 从数据源,如路由,账号和你自己的数据源获取全局客户端的状态。
理想地,一旦某个聪明的组件组装了这样一组数据,它会将其传递给某个重用的子组件来渲染。聪明的组件通常不需要渲染任何除一个或几个重用的子组件之外的其它组件。这有利于将渲染和数据装载在你的测试中进行分离。(译者注:在你的应用中也是如此。)
典型的聪明组件的用例是,当你访问某个URL时,路由指向的 “页” 组件。这样的组件通常做以上提到的三件事情,接着将结果参数传递子组件中。在Todos示例应用中, listShowPage
就是这么做的,通过一些非常简单的HTML而做到的:
<template name="Lists_show_page">
{{#each listId in listIdArray}}
{{> Lists_show (listArgs listId)}}
{{else}}
{{> App_notFound}}
{{/each}}
</template>
该组件的JavaScript负责订阅和获取数据,以被 Lists_show
模版所使用:
Template.Lists_show_page.onCreated(function() {
this.getListId = () => FlowRouter.getParam('_id');
this.autorun(() => {
this.subscribe('Todos.inList', this.getListId());
});
});
Template.Lists_show_page.helpers({
// We use #each on an array of one item so that the "list" template is
// removed and a new copy is added when changing lists, which is
// important for animation purposes.
listIdArray() {
const instance = Template.instance();
const listId = instance.getListId();
return Lists.findOne(listId) ? [listId] : [];
},
listArgs(listId) {
const instance = Template.instance();
return {
todosReady: instance.subscriptionsReady(),
// We pass `list` (which contains the full list, with all fields, as a function
// because we want to control reactivity. When you check a todo item, the
// `list.incompleteCount` changes. If we didn't do this the entire list would
// re-render whenever you checked an item. By isolating the reactiviy on the list
// to the area that cares about it, we stop it from happening.
list() {
return Lists.findOne(listId);
},
// By finding the list with only the `_id` field set, we don't create a dependency on the
// `list.incompleteCount`, and avoid re-rendering the todos when it changes
todos: Lists.findOne(listId, {fields: {_id: true}}).todos()
};
}
});
可视化地测试重用组件
重用的组件的一个有用的属性是,你能够在任何地方渲染它们因为它们不需要依赖于复杂的环境。有用的是这可以成为一个组件 风格指南 或保护带。
要使用一个风格指南,你需要向应用添加两样东西:
一个“入口”的列表,其中每个入口是一个具有一组规范的组件。每个规范是一组参数列表,当传递到组件时,会触发不同的行为。
应用的开发版本的某个特殊路由,能够根据每个规范渲染每个组件。
例如,在Galaxy中,我们有一个组件风格指南,用来渲染每个重用的组件,要不就是根据某个时刻的一个规范,要不就是一次通过所有的规范。
这样的渲染使得快速可视化开发(在所有的可能的状态下)成为了可能。通常在一个复杂的应用中,通过“使用”该应用,获得某个特定组件状态,是非常困难的。如,在Galaxy中,在组件屏幕,输入很复杂的一个状态,如果两个相同应用部署同时发生。但在测试中,通过使用该应用试图让组件达到这样的状态却是非常困难的。
你可以从这个视频中学习到更多相关技术。
用户界面模式
这里有些模式对于构建你的流星应用是非常有用的。
通过 tap:i18n 进行国际化
国际化 (i18n)是应用的用户界面通用化的一组流程,它可以很容易地使用不同的语言渲染文本。在流星中,杰出的 tap:i18n
包 提供了可以构建翻译API,这可以被使用在你的组件和前端代码中。
放置并翻译
It's useful to consider the various places in the system that user-readable strings exist and make sure that you are properly using the i18n system to generate those strings in each case. We'll go over the implementation for each case in the section about tap:i18n
below.
- HTML templates. This is the most obvious place---in the content of UI components that the user sees.
- Client JavaScript messages. Alerts or other messages that are generated on the client side are shown to the user, and should also be translated.
- Server JavaScript messages and emails. Messages or errors generated by the server can often be user visible. An obvious place is emails and any other server generated messages, such as mobile push notifications, but more subtle places are return values and error messages on method calls. Errors should be sent over the wire in a generic form and translated on the client.
- Data in the database. A final place where you may want to translate is actual user-generated data in your database. For example, if you are running a wiki, you might want to have a mechanism for the wiki pages to be translated to different languages. How you go about this will likely be unique to your application.
Using `tap:i18n`
XXX: We're going to leave out this section until there is more clarity around modules in Meteor 1.3. Right now i18n is a bit complicated in the all-packages approach we have taken for the Todos example app.
事件处理
UI中的大部分是关于响应用户发起的事件的,这里有些步骤你应该用来保证你的应用执行良好,即使是在面对快速输入地情况下。一个应用对于用户动作的响应缓慢是最容易被注意到的性能问题。
在用户有动作的时候节流方法的调用
当用户有所动作时典型的操作是对于数据库发起一些改变。而要能够确保用户不会动作过快是很重要的。例如,如果你想要保存一个文本框中用户输入的文本,你应该采用一些步骤来确保发送的频率高于几百毫秒(译注:否则太快会造成一些不可预知的后果)。
如果你不这么做,可能会发生性能问题出现:用户网络上的洪水般的连接,却都是很小的变化,界面会因为用户的每个敲击而更新,那么就会造成性能问题,而且数据库会有很多的写入。
要节制写入,典型的做法是使用underscore的.throttle()
或 .debounce()
函数。例如,在Todos示例应用中,我们在用户输入的时候节制写入300ms:
Template.Todos_item.events({
// update the text of the item on keypress but throttle the event to ensure
// we don't flood the server with updates (handles the event at most once
// every 300ms)
'keyup input[type=text]': _.throttle(function(event) {
Todos.methods.updateText.call({
todoId: this.todo._id,
newText: event.target.value
}, (err) => {
err && alert(err.error);
});
}, 300)
});
Typically, you use .throttle()
if you are OK with the event happening during the user series of actions (i.e. you don't mind the multiple, throttled events happening over time, as in this case), whereas you use .debounce()
if you want the events to happen whenever (in this example) the user stops typing for 300ms or longer.通常你使用.throttle()
如果你觉得事件发生在一系列的用户动作(如,你不需要考虑多次,节流的发生的事件,在这种情况下)是ok的,而使用.debouce()
如果你的事件发生在无论何时(本例)用户停止打字大于等于300ms。
Limiting re-rendering限制重新渲染
Even if you aren't saving data over the wire to the database on every user input, sometimes you still may wish to update in-memory data stores on every user change. If updating that data store triggers a lot of UI changes, you can see poor performance and missed keystrokes when you update it too often. In such cases you can limit re-rendering by throttling in a similar way how we throttled the method call above. You could also use .debounce()
to ensure the changes happen only after the user has stopped typing.即使你不需要节省每次用户输入并且通过网络写入到数据库的数据,有时仍然需要在每次用户改变时更新内存数据存储。如果更新该数据存储出发很多UI变化,你可能会看到糟糕的性能并且在更新过于频繁的时候错过击键。这种情况下你可以通过相似的方法—就像我们节流上面的方法调用一样—进行截流来限制重新渲染。你也可以使用.debounce()
来确保改变仅仅发生在用户停止输入之后。
用户体验模式
用户体验,或简写为UX,描述的是用户和你的应用进行交互时的体验。有些用户体验模式是多数流星应用值得去探索的。很多这些模式,由于用户和应用之间的交互,需要和数据装载的方式结合在一起,所以和数据装载一章(讨论如何使用流星的发布和订阅来实现这些模式)有相似的地方。
订阅是否准备好
当你在流星中订阅数据时,数据并不是在客户端立即可用。通常用户需要等待数百毫秒,或者更长到几秒(依据连接的速度),直到数据到达。特别是在应用第一次开始时或者在屏幕之间进行切换,而屏幕又显示全新的数据,这种现象很明显。
There are a few UX techniques for dealing with this waiting period. The simplest is simply to switch out the page you are rendering with a generic "loading" page while you wait for all the data (typically a page may open several subscriptions) to load. As an example, in the Todos example app, we wait until all the public lists and the user's private lists have loaded before we try to render the actual page:有些UX技术可以应用在这等待的时间段内。一种最简单的方式,就是切换到通常的“装载中”的页面,当我们等待所有的数据(可能需要打开好几个订阅)装载完成。例如,在Todos示例应用中,在渲染该页面之前,我们需要等待直到所有的公共lists和用户私有的lists装载完成。
{{#if Template.subscriptionsReady}}
{{> Template.dynamic template=main}}
{{else}}
{{> App_loading}}
{{/if}}
我们使用Blaze的Template.subscriptionsReady
来达到目的,由于它会等待所有的现有组件所请求的订阅变为准备好。
每-组件装载中状态
Usually it makes for a better UX to show as much of the screen as possible as quickly as possible and to only show loading state for the parts of the screen that are still waiting on data. So a nice pattern to follow is "per-component loading". We do this in the Todos app when you visit the list page---we instantly render the list metadata, such as its title and privacy settings, and render a loading state for the list of todos while we wait for them to appear.
We achieve this by passing the readiness of the todos list down from the smart component which is subscribing (the listShowPage
) into the reusable component which renders the data:
{{> Lists_show todosReady=Template.subscriptionsReady list=list}}
And then we use that state to determine what to render in the reusable component (listShow
):
{{#if todosReady}}
{{#with list._id}}
{{#each todo in (todos this)}}
{{> Todos_item (todoArgs todo)}}
{{else}}
<div class="wrapper-message">
<div class="title-message">No tasks here</div>
<div class="subtitle-message">Add new tasks using the field above</div>
</div>
{{/each}}
{{/with}}
{{else}}
<div class="wrapper-message">
<div class="title-message">Loading tasks...</div>
</div>
{{/if}}
Showing placeholders显示占位符
You can take the above UI a step further by showing placeholders whilst you wait for the data to load. This is a UX pattern that has been pioneered by Facebook which gives the user a more solid impression of what data is coming down the wire. It also prevents parts of the UI from moving around when data loads, if you can make the placeholder have the same dimensions as the final element.
For example, in Galaxy, while you wait for your app's log to load, you see a loading state indicating what you might see:
Using the style guide to prototype loading state
Loading states are notoriously difficult to work on visually as they are by definition transient and often are barely noticeable in a development environment where subscriptions load almost instantly.
This is one reason why being able to achieve any state at will in the component style guide is so useful. As our reusable component Lists_show
simply chooses to render based on its todosReady
argument and does not concern itself with a subscription, it is trivial to render its loading state in a style guide.
Pagination
In the Data Loading article we discuss a pattern of paging through an "infinite scroll" type subscription which loads one page of data at a time as a user scrolls down the page. It's interesting to consider UX patterns to consume that data and indicate what's happening to the user.
A list component
Let's consider any generic item-listing component. To focus on a concrete example, we could consider the todo list in the Todos example app. Although it does not in our current example app, in a future version it could paginate through the todos for a given list.
There are a variety of states that such a list can be in:
- Initially loading, no data available yet.
- Showing a subset of the items with more available.
- Showing a subset of the items with more loading.
- Showing all the items - no more available.
- Showing no items because none exist.
It's instructive to think about what arguments such a component would need to differentiate between those five states. Let's consider a generic pattern that would work in all cases where we provide the following information:
- A
count
of the total number of items. - A
countReady
boolean that indicates if we know that count yet (remember we need to load even that information). - A number of items that we have
requested
. - A list of
items
that we currently know about.
We can now distinguish between the 5 states above based on these conditions:
countReady
is false, orcount > 0
anditems
is still empty. (These are actually two different states, but it doesn't seem important to visually separate them).items.length === requested && requested < count
0 < items.length < requested
items.length === requested && count > 0
count === 0
You can see that although the situation is a little complex, it's also completely determined by the arguments and thus very much testable. A component style guide helps immeasurably in seeing all these states easily! In Galaxy we have each state in our style guide for each of the lists of our app and we can ensure all work as expected and appear correctly:

A pagination "controller" pattern
A list is also a good opportunity to understand the benefits of the smart vs reusable component split. We've seen above that correctly rendering and visualizing all the possible states of a list is non-trivial and is made much easier by having a reusable list component that takes all the required information in as arguments.
However, we still need to subscribe to the list of items and the count, and collect that data somewhere. To do this, it's sensible to use a smart wrapper component (analogous to an MVC "controller") whose job it is to subscribe and fetch the relevant data.
In the Todos example app, we already have a wrapping component for the list that talks to the router and sets up subscriptions. This component could easily be extended to understand pagination:
const PAGE_SIZE = 10;
Template.Lists_show_page.onCreated(function() {
// We use internal state to store the number of items we've requested
this.state = new ReactiveDict();
this.getListId = () => FlowRouter.getParam('_id');
this.autorun(() => {
// As the `requested` state increases, we re-subscribe to a greater number of todos
this.subscribe('List.todos', this.getListId(), this.state.get('requested'));
this.countSub = this.subscribe('Lists.todoCount', this.getListId());
});
// The `onNextPage` function is used to increment the `requested` state variable. It's passed
// into the listShow subcomponent to be triggered when the user reaches the end of the visible todos
this.onNextPage = () => {
this.state.set('requested', this.state.get('requested') + PAGE_SIZE);
};
});
Template.Lists_show_page.helpers({
listArgs(listId) {
const instance = Template.instance();
const list = Lists.findOne(listId);
const requested = instance.state.get('requested');
return {
list,
todos: list.todos({}, {limit: requested}),
requested,
countReady: instance.countSub.ready(),
count: Counts.get(`list/todoCount${listId}`),
onNextPage: instance.onNextPage
};
}
});
UX patterns for displaying new data
An interesting UX challenge in a realtime system like Meteor involves how to bring new information (like changing data in a list) to the user's attention. As Dominic points out, it's not always a good idea to simply update the contents of a list as quickly as possible, as it's easy to miss changes or get confused about what's happened.
One solution to this problem is to animate list changes (which we'll look at in the animation section), but this isn't always the best approach. For instance, if a user is reading a list of comments, they may not want to see any changes until they are done with the current comment thread.
An option in this case is to call out that there are changes to the data the user is looking at without actually making UI updates. In a system like Meteor which is reactive by default, it isn't necessarily easy to stop such changes from happening!
However, it is possible to do this thanks to our split between smart and reusable components. The reusable component simply renders what it's given, so we use our smart component to control that information. We can use a local collection to store the rendered data, and then push data into it when the user requests an update:
Template.Lists_show_page.onCreated(function() {
// ...
// The visible todos are the todos that the user can actually see on the screen
// (whereas Todos are the todos that actually exist)
this.visibleTodos = new Mongo.Collection();
this.getTodos = () => {
const list = Lists.findOne(this.this.getListId());
return list.todos({}, {limit: instance.state.get('requested')});
};
// When the user requests it, we should sync the visible todos to reflect the true state of the world
this.syncTodos = (todos) => {
todos.forEach(todo => this.visibleTodos.insert(todo));
this.state.set('hasChanges', false);
};
this.onShowChanges = () => {
this.syncTodos(this.getTodos());
};
this.autorun((computation) => {
const todos = this.getTodos();
// If this autorun re-runs, the list id or set of todos must have changed, so we should
// flag it to the user so they know there are changes to be seen.
if (!computation.firstRun) {
this.state.set('hasChanges', true);
} else {
this.syncTodos(todos);
}
});
});
Template.Lists_show_page.helpers({
listArgs(listId) {
const instance = Template.instance();
const list = Lists.findOne(listId);
const requested = instance.state.get('requested');
return {
list,
// we pass the *visible* todos through here
todos: instance.visibleTodos.find({}, {limit: requested}),
requested,
countReady: instance.countSub.ready(),
count: Counts.get(`list/todoCount${listId}`),
onNextPage: instance.onNextPage,
// These two properties allow the user to know that there are changes to be viewed
// and allow them to view them
hasChanges: instance.state.get('hasChanges'),
onShowChanges:instance.onShowChanges
};
}
});
The reusable sub-component can then use the hasChanges
argument to determine if it show some kind of callout to the user to indicate changes are available, and then use the onShowChanges
callback to trigger them to be shown.
Optimistic UI
One nice UX pattern which Meteor makes much easier than other frameworks is Optimistic UI. Optimistic UI is the process of showing user-generated changes in the UI without waiting for the server to acknowledge that the change has succeeded, resulting in a user experience that seems faster than is physically possible, since you don't need to wait for any server roundtrips. Since most user actions in a well-designed app will be successful, it makes sense for almost all parts of an app to be optimistic in this way.
However, it's not always necessarily a good idea to be optimistic. Sometimes we may actually want to wait for the server's response. For instance, when a user is logging in, you have to wait for the server to check the password is correct before you can start allowing them into the site.
So when should you wait for the server and when not? It basically comes down to how optimistic you are; how likely it is that something will go wrong. In the case of a password, you really can't tell on the client, so you need to be conservative. In other cases, you can be pretty confident that the Method call will succeed, and so you can move on.
For instance, in the Todos example app, when creating a new list, the list creation will basically always succeed, so we write:
Template.App_body.events({
'click .js-new-list'() {
const listId = Lists.methods.insert.call((err) => {
if (err) {
// At this point, we have already redirected to the new list page, but
// for some reason the list didn't get created. This should almost never
// happen, but it's good to handle it anyway.
FlowRouter.go('App.home');
alert('Could not create list.');
}
});
FlowRouter.go('Lists.show', { _id: listId });
}
});
We place the FlowRouter.go('Lists.show')
outside of the callback of the Method call, so that it runs right away. First we simulate the method (which creates a list locally in Minimongo), then route to it. Eventually the server returns, usually creating the exact same list (which the user will not even notice). In the unlikely event that the server call fails, we show an error and redirect back to the homepage.
Note that the listId
returned by the list method (which is the one generated by the client stub) is guaranteed to be the same as the one generated on the server, due to the way that Meteor generates IDs and ensures they are the same between client and server.
Indicating when a write is in progress
Sometimes the user may be interested in knowing when the update has hit the server. For instance, in a chat application, it's a typical pattern to optimistically display the message in the chat log, but indicate that it is "pending" until the server has acknowledged the write. We can do this easily in Meteor by simply modifying the Method to act differently on the client:
Messages.methods.insert = new Method({
name: 'Messages.methods.insert',
schema: new SimpleSchema({
text: {type: String}
}),
run(message) {
// In the simulation (on the client), we add an extra pending field.
// It will be removed when the server comes back with the "true" data.
if (this.isSimulation) {
message.pending = true;
}
Messages.insert(message);
}
})
Of course in this scenario, you also need to be prepared for the server to fail, and again, indicate it to the user somehow.
Unexpected failures
We've seen examples above of failures which you don't really anticipate will happen. It's difficult and inefficient to defend against every possible error, however unlikely. However, there are some catch-all patterns that you can use for unexpected failures.
Thanks to Meteor's automatic handling of optimistic UI, if a method unexpectedly fails the optimistic changes will roll back and the Minimongo database will end up in a consistent state. If you are rendering directly from Minimongo, the user will see something that is consistent, even if it's not what they anticipated of course. In some cases when you have state you are keeping outside of Minimongo, you may need to make changes to it manually to reflect this. You can see this in the example above where we had to update the router manually after an operation failed.
However, it's terrible UX to simply jump the user to an unexpected state without explaining what's happened. We used a alert()
above, which is a pretty poor option, but gets the job done. One better approach is to indicate changes via a "flash notification", which is a UI element that's displayed "out-of-band", typically in the top right of the screen, given the user some indication of what's happened. Here's an example of a flash notification in Galaxy, at the top right of the page:
Animation
Animation is the process of indicating changes in the UI smoothly over time rather than instantly. Although animation is often seen as "window dressing" or purely aesthetic, in fact it serves a very important purpose, highlighted by the example of the changing list above. In a connected-client world where changes in the UI aren't always initiated by user action (i.e. sometimes they happen as a result of the server pushing changes made by other users), instant changes can result in a user experience where it's difficult to understand what is happening.
Animating changes in visiblity
Probably the most fundamental type of UI change that requires animation is when items appear or disappear. In Blaze, we can use the percolate:momentum
package to plug a standard set of animations from the velocity animation library
into such state changes.
A good example of this is the editing state of the list from the Todos example app:
{{#momentum plugin="fade"}}
{{#if instance.state.get 'editing'}}
<form class="js-edit-form list-edit-form">...</form>
{{else}}
<div class="nav-group">...</div>
{{/if}}
{{/momentum}}
Momentum works by overriding the way that child HTML elements appear and disappear. In this case, when the list component goes into the editing
state, the .nav-group
disappears, and the form
appears. Momentum takes care of the job of making sure that both items fade, making the change a lot clearer.
Animating changes to attributes
Another common type of animation is when an attribute of an element changes. For instance, a button may change color when you click on it. These type of animations are most easily achieved with CSS transitions. For example, we use a CSS transition for the hover state of links in the Todos example app:
a {
transition: all 200ms ease-in;
color: @color-secondary;
cursor: pointer;
text-decoration: none;
&:hover { color: darken(@color-primary, 10%); }
&:active { color: @color-well; }
&:focus { outline:none; } //removes FF dotted outline
}
Animating page changes
Finally, it's common to animate when the user switches between routes of the application. Especially on mobile, this adds a sense of navigation to the app via positioning pages relative to each other. This can be done in a similar way to animating things appearing and disappearing (after all one page is appearing and other is disappearing), but there are some tricks that are worth being aware of.
Let's consider the case of the Todos example app. Here we do a similar thing to achieve animation between pages, by using Momentum in the main layout template:
{{#momentum plugin="fade"}}
{{#if Template.subscriptionsReady}}
{{> Template.dynamic template=main}}
{{else}}
{{> App_loading}}
{{/if}}
{{/momentum}}
This looks like it should just work, but there's one problem: Sometimes the rendering system will prefer to simply change an existing component rather than switching it out and triggering the animation system. For example in the Todos example app, when you navigate between lists, by default Blaze will try to simply re-render the Lists_show
component with a new listId
(a changed argument) rather than pull the old list out and put in a new one. This is an optimization that is nice in principle, but that we want to avoid here for animation purposes. More specifically, we want to make sure the animation only happens when the listId
changes and not on other reactive changes.
To do so in this case, we can use a little trick (that is specific to Blaze, although similar techniques apply to other rendering engines) of using the fact that the {% raw %}{{#each}}{% endraw %}
helper diffs arrays of strings, and completely re-renders elements when they change.
<template name="Lists_show_page">
{{#each listId in listIdArray}}
{{> Lists_show (listArgs listId)}}
{{else}}
{{> App_notFound}}
{{/each}}
</template>
Template.Lists_show_page.helpers({
// We use #each on an array of one item so that the "list" template is
// removed and a new copy is added when changing lists, which is
// important for animation purposes.
listIdArray() {
const instance = Template.instance();
const listId = instance.getListId();
return Lists.findOne(listId) ? [listId] : [];
}
});