(If you missed part 1 of my Blazor exploration, check it out.)
One of the more useful paradigms that came along with modern front-end frameworks is the use of components. The idea is that you can create these little islands of functionality that can be reused, tested in isolation, and even shipped in libraries. I used to naively think that this was overkill and unnecessary beyond plain vanilla form controls that have been around for centuries, but the ability to move data around between them and build compound thingies is of course indistinguishable from magic. Each of the big three Javascript frameworks, in my opinion, had a better approach than the previous one (Angular, React, then Vue). I thought Vue in particular was the least clumsy and I've enjoyed messing with it. My hang up is still the crazy chain of npm dependencies and the fact that the tooling is still hard to get just right quickly without a lot of experience. I think Blazor improves on this in every way. It felt completely natural to try it out for MLocker, my personal cloud music locker app.
Web assembly (WASM) is shockingly fast in all of the browsers already, and the binaries for the app are pretty tiny. The somewhat valid criticism of Blazor is that there's an initial bandwidth cost to download the .NET bits one time. MLocker requires about 4.5MB compressed of stuff the first time down, which is expensive-ish compared to a React app that has been properly webpacked. However, I compare that to the average news or content site, where it pulls more than that on every page (because of ads and trackers), and it seems like less of a big deal. In fact, the trade-off I've made is that I cache as much as possible on the client, because your library and playlists don't change all that often. For my library of almost 8,000 songs, the start up cost for MLocker is about 11k. Yeah, 11,000 bytes. If I version up, the framework bits are still cached, and the new binary is around 150k.
Where I have not yet gone is the full progressive web app route. I've gone as far as making it "installable," and it works great on Windows and Android, but it does still require connectivity. Again, if the pull is 11kb on average to start up, that's not a big deal, but I've not yet enabled full-on airplane mode yet. As it turns out, that's a lot trickier, and the boilerplate code shipping with Blazor only covers the simplest of use cases.
Back to components... Blazor by convention uses the file name of a component, like Widget.razor
, to identify an instance of the component in markup. So in this case, <Widget />
would predictably create an instance of the component in the file of the same name. You can nest these to your heart's content, but the trick is to of course move data and events through these components, and there are a number of ways to do that. I've done this three different ways in MLocker: Cascading values, parameters and different flavors of shared state.
Cascading values are the most weird of the three ways, and the first thing I tried. The playlists, albums and artists all use these when persisting values from the parent to the child, and I suspect that won't last after some refactoring. For example, you'll find this nugget in the Album component:
<CascadingValue Value="_currentAlbum"> <Albums_Detail/> </CascadingValue>
Weird, right? The detail component then has a property like so:
[CascadingParameter] protected Album Album { get; set; }
The idea here is that children further down the stack can all have access to this object, but that seems cosmically weird that you would ever want to do that, trusting that something down the tree has a value there set by an ancestor. Like I said, this will not likely last in the code base, because a straight-up parameter makes more sense. Let me get into this deeper and combine with the third thing where we move data and state around through a class added via dependency injection.
Playlists and albums can both be added to the songs queued in the player, and the easiest way to do that is via a shared component. To do this, I have a component called AddListToQueueButton
, which is added to other components via the simple markup:
<AddListToQueueButton SongList="_songList" />
You'll notice that this takes a parameter called SongList
, which in HTML we call an attribute. Albums and playlists keep a List<Song>
object on hand, and since that's the data we need to add songs to the queue, it's what we want to pass to this button component. The component is pretty simple, so here's the whole thing:
@using MLocker.Core.Models @using MLocker.WebApp.Services @inject IPlayerService PlayerService <span class="@_queueButtonClass" @onclick="EnqueueList" title="Add to queue"></span> @code { [Parameter] public List<Song> SongList { get; set; } private string _queueButtonClass; protected override void OnParametersSet() { _queueButtonClass = "addToQueueButton"; } private void EnqueueList() { _queueButtonClass = "doneAddToQueueButton"; foreach (var song in SongList) PlayerService.EnqueueSong(song); } }
Let's start with the [Parameter]
attribute, which maps to the value we passed into the markup above. Like I said, this is way more deliberate and obvious to me than cascading parameters. So now we have this tiny component with a button, which is to say there's a <span>
with a click handler wired up. We have all the songs involved, too, so now we need to get those to the player I wrote about in part 1.
I'm not going to go deep into dependency injection here, but it works the same as it does in the server-side bits. The Blazor docs explain this pretty well. By adding @inject IPlayerService PlayerService
to the top of the component, we now have access to the same singleton instance that the rest of the app has. When the user clicks the button, it fires EnqueueList()
, which feeds the songs from the parameter to the service and those will play after the existing queue is exhausted.
Binding data is well documented, so I won't do that here either, but it's important to call out that Blazor can do one or two-way data binding. For the unfamiliar, one-way means that some source of truth can change the view of that data, while two-way means that changing the view of the data can change the source of truth. It has been debated to death about why two-way data binding is bad, usually pointing to performance issues, since you could by way of changing text in a box cascade a bunch of events responding to the change, or that it's simply harder to debug and maintain. I don't think it's necessary to be dogmatic about it, as long as you understand the implications of what you're doing, and the person who sees your code next can also understand.
There are some obvious cases for component reuse in MLocker, starting with the song lists. They appear on the part of playlists, artists and albums, and they all use the same code. Using simple boolean parameters, I can turn on the display of track numbers for albums, but not for playlists. The song lists themselves render thousands of row components, and these communicate state to a separate context menu component via a series of properties in a dumb state box of static members.
There is some other magic available for components, like CSS that rides along side of the components. That's probably more of interest to people building component libraries, but it's a nice touch. You can build templated components, which is like a throwback to the ASP.NET Webforms repeaters, in a good way. The best magic of all though is the Virtualize
element, which is like a foreach
loop, only the stuff not on screen is not rendered. Like I said, I have almost 8,000 songs in my library, and that list scrolls, even on my phone, like it's native, especially after all of the album art is cached. It's really fantastic.
No comments yet.