My journey so far with web components

posted by Jeff | Friday, September 2, 2022, 12:08 AM | comments: 0

As I get closer to the release of POP Forums v19 (and there sure are a ton of things baked in there!), I'm starting to think back about my first experience using web components. For years I've talked about trying to modernize the front-end of the app, choosing instead to focus on scalability, but there isn't much room left to squeeze more performance out of it in practical terms, or at least not for what I need. What I kept coming back to was the fact that forums are mostly walls of text, and with tens of thousands of indexed threads on Google, I wasn't going to risk two decades of investment to break it with exotic and unnecessary appification of the, uh, app. Still, a big old file of spaghetti Javascript wasn't sustainable either, even if I did in the last release get away from the old jQuery dependency.

Where I landed was the use of web components, in a limited scope, for specific things that acted as little islands around the page. I quite literally wrote down the following requirements for myself:

  • Avoid a massive build with a never-ending chain of npm packages. That's a house of cards.
  • Try not to add any additional libraries. As it is, the two I'm primarily using, Bootstrap and TinyMCE, get weird when you start using them as modules.
  • Use TypeScript. Oddly enough, I've never written code with TS myself, even though I'm no stranger to it in code reviews.
  • Make sure the dev time experience is fast.
  • Come up with some clever way to react to state changes, if possible, without libraries.

Reactive components and state

For the most part, I hit all of those goals, with mixed results. My first step was to solve the reactive state problem. The most obvious case for this was when a new notification came in via SignalR (web sockets), and I had to update the count number in the UI. I prototyped some stuff and submitted something to StackExchange's Code Review, and while I got only one answer, it was the validation that I was looking for. It evolved a bit once I started using it, but the short story is that components would inherit from a base class, register themselves with a state class deriving from another base, and updating the component UI if a "watched" property on the state class changed. So for private message counts, I have a state class like so:

export class UserState extends StateBase {
  ...
  @WatchProperty
  newPmCount: number;
  ...

When another class changes the value of that newPmCount, the base class notifies any elements that registered to listen to that property. So the whole class that represents the new private message count badge looks like this:

export class PMCount extends ElementBase {
constructor() {
    super();
}

getDependentReference(): [StateBase, string] {
    return [PopForums.userState, "newPmCount"];
}

updateUI(data: number): void {
    if (data === 0)
        this.innerHTML = "";
    else
        this.innerHTML = `${data}`;
}

The ElementBase is an abstract class, and you have to implement the getDependentReference and updateUI members. The first wants to know what instance of a StateBase derived class and property name that it should listen to, while the second takes the new data values from the watched property and does something to the UI. You can look at the source to see what's involved with StateBase, ElementBase and WatchProperty, but it's all mostly straight forward.

With that out of the way, I started to build components. Here's the deal though... there is not a big chain of nested components here. Most of them do not use a shadow DOM and depend on the CSS of the rest of the app. While I could have gone the route of componentizing all the things, I wouldn't have gained much because there's almost no reuse. One thing I did experiment with is including templates, for a forum topic row (the thing with the topic title, last post date, etc.) for example, in the regular markup. In retrospect, this doesn't make a lot of sense, because there's no programmatic checking to make sure the template is valid, but it seemed like a good idea since in those cases they were emulating markup that was being generated on the server side. The templates are just as close to the server-side code as they are the front-end code.

Client-side localization and date formatting

I wanted to get the server out of the business of formatting dates and updating them ("2 minutes ago"), but I always struggled with how I would get the language appropriate text to the browser, like "less than a minute ago" or "Today 2:32pm." The forum is localized in six languages. First I wrote a localization manager that called back to the server and got an array of things it might need to render. This is mercifully cached by the browser, bit it's only 682 bytes for the English version when compressed. I think the Chinese was the largest payload. What's it's loaded, components that need it, like the time formatter, can get the localized strings and do their thing. I don't know if this is the "right" way to do it, but I'm super happy with it. Dates all over the page update every minute when they're displaying recent times.

Tooling

Visual Studio seemed to work fine for writing the code here, but I could never get the debugging to work. It's not a big deal to use the debugger in the browser, but that was weird. Not being tied to Visual Studio also meant that I could use VS Code at the same time, if I wanted to, and I often did because it's faster.

The hotness of Visual Studio is the hot reload when you change stuff, but the trick here is that I wanted to move the code into the Razor Class Library (RCL) where all of the views lived. The web app would then consume that library. Earlier in the year when I started this, I could observe hot reload when you saved most any C# file, but I couldn't trigger browser refreshes after transpiling the TypeScript in the RCL. Eventually this started working when I used the tsconfig.json to make it single-file transpile to the project's wwwroot folder. Once that was in place, reload started working on every save.

I decided the simplest way to use this script was to transpile down to one file on save, which is what I did in tsconfig.json. It comes down in 24k once compressed, and only once because of browser caching. No module loaders or webpack to mess with, just another script reference.

Since I'm not using modules, I needed a way to reference Bootstrap and TinyMCE in a few places, and to do that I just stubbed out the parts I needed with a bunch of declare definitions, to satisfy the compiler. I know this is also not ideal, but it works well enough.

Results

The results are easier to maintain, but I'm sure anyone who spends all day in Javascript would not care for what I produced. I changed the way I formatted code and used inconsistent syntaxes (think anonymous functions, there are at least two ways to write those) from one thing to the next. The placement of code is inconsistent across the components, state and service classes, which makes it confusing to figure out where the thing you need to change is. The real-time chat implementation and the topic state are good examples of this inconsistency. I could improve this by using a linter, which isn't out of the question. But for better or worse, the new functionality is much better than it would have otherwise been. I'm proud of the selective post quoting (highlight the quote, push the button, it appears in the text box). Building the real-time chat, especially trying to position it right in the window and not scroll erratically, was challenging.

It's not perfect, but I learned a ton, and I didn't have to buy-in to any new dependencies. I still use Vue.js for the admin part of the forums, but even that takes on the low commitment vibe with a straight-up script reference and one script file against one markup page. The version history shows 30 significant updates, some of which are quite old. Not having image uploads all of this time was particularly embarrassing.


Comments

No comments yet.


Post your comment: