021 The modification system can be implemented at the compilation level. The essence of such a system is simple: search for a specific string / regular expression in file and then replace it with the capabilities of regular expressions.
When designing an extension API, the foremost challenge is balancing flexibility with safety. Extensions should be able to change and influence and replace as much as possible. But at the same time, we want to steer devs towards writing maintainable, safe code. There aren't many things more likely to alienate an end user than random, unexplicable crashes, where the error messages, if shown at all, are technical jargon, and not something they can address.
The problem is exponentially amplified as the number of extensions increases. What happens when 2 extensions want to modify the same thing? What if they want to do so in different, even contradictory ways? And things become even more complicated when extensions are given the ability to import from, and modify, each other. Not only does core
(and bundled exts) need to be designed in a way that incentivizes good practice, but so do all the many extensions outside of our control.
And so, we find ourselves needing to make compromises. There's a spectrum of extensibility "mechanisms":
- The most radical is direct source code modification, as you suggested with regex. But this is sandworm-infested quicksand:
- On the frontend, code is minified/compressed as each extension's source is built by webpack. Variable/function names, layouts, etc are simply not available in the final output; recognizing patterns to replace is impossible. See this forum's JS source. If we didn't do this, bundle size would increase drastically. Running webpack on all extensions together isn't an option, because Flarum needs to run on the most basic PHP shared hosts without a Node runtime.
- Not just the contents and general structure, but the syntactical choices of code would become fixed in place between major releases as public API. We couldn't rename variables, factor out logic. change formatting, etc, because that might break extensions.
- Multiple extensions extending the same thing would be impossible.
- Injecting some code with invalid or nonsensical syntax would become trivial.
- Neither JavaScript nor PHP are regular languages, just like with HTML. You cannot soundly operate on a non-regular language with regular expressions.
- Many more.
- A slightly less radical option would be transforming the underlying AST. But every issue other than the regular language soundness issue still applies.
- More towards the middle is the ability to override exposed "top-level" values. If you can replace / extend any named function, class, or object, you still have a lot of flexibility, albeit with the caveat that you can't directly change the implementation of functions. But this becomes a lot more interesting:
- You can still screw up, quite a lot. But the range of errors is vastly smaller and more understandable. You could try to extend/override something that doesn't exist. That's easy to test for and report in helper code. You could return a value with an incorrect type. But you'll usually get a sensible, traceable type error, and we can provide TypeScript tooling that, while not completely sound, goes a long way in reducing these kinds of issues.
- Interactions with other extensions are still iffy, but much less dangerous. If two extensions override a function that returns a list by returning the same list with stuff added, this will actually work quite well! If two extensions override a function by returning a new value unrelated to the original return, the first extension's action will likely be a no-op.
- Developers of core and extensible extensions can declare, usually via comments or
public
/private
designation, some functions/methods/values/etc that are considered "Public API", and will not experience breaking changes between major releases. This gives devs freedom to make changes, refactor code, add features, etc without breaking downstream extensions.
- On the safer side is providing a set of use case driven "extender" functions, each of which is deliberately exposed to modify some part of the program, and has a rigorously defined type signature. This offers a highly stable public API, can explicitly deal with multiple extensions extending the same thing, and reduces boilerplate. Unit tests can be written to ensure extenders work, too. The downside is limited flexibility: an extender needs to be explicitly created for each use case.
Flarum prefers (4), and falls back to (3):
- On the backend, we have plenty of well-tested
Extender\*
s. Most config / class bindings / etc is done in the IoC Container, which can be accessed by the ServiceProvider extender.
- On the frontend, we are still building out extenders. Currently, most extension is done in the (3) style via the
override
and extend
helpers, which allow replacing any method. The frontend requires much more flexibility in extension than the backend, since we want the UI design to be extendable and themeable.
Notably, on the frontend, we tend to discourage extending the view
method directly. Instead, "extensible" components will often have a minimal view
method, with most of the contents provided by helper functions, which often use the ItemList util: https://docs.flarum.org/extend/frontend#itemlist.
Let's go back to the Button
example. The vast majority of the complexity comes from extensibility. Extensions and subclasses need to be able to change the button's contents. If they had to reimplement the view
method, that would be a lot of work and complexity. So instead, we factor out a small getButtonContent
method that's simple to extend and replace. How would you enable this in Vue
?
021 has long meaningless constructions like onclick={this.onClick.bind(this)} instead of @click="onClick".
I agree that all the .bind(this)
is a pain, but without it, extension code couldn't access properties / other methods of components. Vue's template DSL allows for shorter code, but at the cost of being further from the underlying JavaScript it must eventually compile to. This magic constrains extensibility.
It's also worth noting that if your components don't need to be extensible, you can write much simpler code. But as Clark mentioned, the majority of frontend extension code extends/overrides existing elements. And this isn't really doable with Vue.
All this being said, I agree with you that there's a lot to be improved.
- Recently, @SychO implemented the export registry, which simplified how all the classes/functions are exposed to extensions. This is a step towards making it possible to override default-exported functions, which opens up some interesting possibilities.
- I agree that the helpers (such as
icon
) are weird. I wish we could use functional components for everything, but that makes overriding much harder (impossible even, if a helper functional component is located in the same file).
- We have some nasty complex inheritance trees. It would probably be better to factor those out into simpler, composable components.
- I wish that we could separate out templates from code. That's really difficult to do with SPAs, and would probably require tooling that's beyond what we can build and mantain.
I also believe that Vue, React, Mithril, Preact, and any other virtual dom-based framework are much more similar than "Comparing JS Frameworks 2023 Edition" blog posts like to argue. At the end of the day, we have functions that produce vdom, lifecycle hooks, and state tools. From the perspective of a developer writing code, most differences are just syntax.
Personally, my frontend framework of choice is Bonsai. It's written in OCaml, which is compiled to JS. It's type-safe, performant, incremental, has fantastic tools for state management and composition, and forces you to think through edge cases. It wouldn't be possible for Flarum though, because extending from the outside and theming is quite difficult.