Fable 3, Elmish, .NET 5, React, Webpack 5, npm. Demo code at https://github.com/kerams/fable-lazy-load-localization-sample.
At some point during the lifetime of your Fable application you may decide you need to support more than one language (in this post I am concerned with UI texts only, not data that is sent from the server) as well as the ability to switch the desired language on the fly. Let's say you have to support several languages with a ton of text each. Including them in your main application file/bundle will obviously make using them very straightforward and easy, but would prove incredibly wasteful. A single user will hardly use multiple languages, so having them download something they will never see or make use of does not make much sense.
While you could try using a full-fledged framework like react-i18next to handle all your localization needs, I'll detail the simplest means of getting parts of your UI localized that I could think of--an interface and an object expression implementing that interface for each language.
Firstly, we're going to set up Webpack's code splitting. In webpack.config.js
you can see the following:
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
commons: {
test: /node_modules/,
name: "vendors",
chunks: "all"
}
}
},
}
This instructs Webpack to package the application into these bundles/chunks:
app
- code in theClient
project as well as Fable runtime bits and referenced Fable librariesvendor
- npm package dependenciesruntime
- caused byruntimeChunk: 'single'
. It contains some shared code (not exactly sure what) that would be copied in all other bundles without the option. HMR does not work for me in Webpack 5 without this runtime chunk, so I chose to include it.- One bundle for each language
Next, in Common.fs
we define an interface for texts that each language will implement and that the components will use to retrieve UI texts. Additionaly we create a localization React context to be able to access ILocInfo
. This is not a necessity strictly speaking, but in non-trivial projects you tend to use localized texts everywhere and having to pass ILocInfo
to props of every component would certainly do my head in.
type ILocInfo =
abstract Lang: string
abstract Hello: string -> string
let localizationContext = createContext JS.undefined<ILocInfo>
Here localizationContext
is set to undefined
. The reason is the fact that not even the default language is loaded initially, so there really is not much to work with in terms of defaults. This also means we have to prevent any actual content rendering until the localization file is download (see view
later on). An alternative would be using JsInterop.createEmpty<ILocInfo>
and letting the content render, you'll just see no texts anywhere. I suppose it does not really matter, because unless something goes very wrong, the load should be incredibly fast. Use either approach, or, if you wish, even make one of the languages part of the main bundle by converting one of the object expressions (see below) to a class and instantiating it in the init
function.
Then we create Localization.fr.fs
and Localization.en.fs
, and implement ILocInfo
in each using an object expression (this produces more compact JS compared to defining a proper class) that we then export as the default in its JS module.
We can't refer to these implementations directly (otherwise they'd be part of the main bundle), instead we have to load the containing JS files at runtime with importDynamic
. Webpack will cleverly recognize this and bundle the files separately, as you can see in the bundling output. The function returns a Promise
(since the import is an IO operation), so it has to be properly wrapped as an Elmish command.
let changeLanguageCommand lang =
Cmd.OfPromise.either (fun () -> importDynamic ("./Localization." + lang + ".fs.js")) () (Ok >> LocalizationReceived) (Error >> LocalizationReceived)
The returned value is sadly not the object expression we exported previously, but some JS wrapper object. Luckily, accessing the default export dynamically is easy enough with ?
:
| LocalizationReceived l ->
match l with
| Ok l ->
{ model with Localization = Some (l?``default``) }, []
At this point all that remains is providing the localization context for nested children when an implementation is present and a couple of components to consume the context with a hook or a regular context consumer:
[<Feliz.ReactComponent>]
let DeepComponent () =
let localization = Hooks.useContext localizationContext
localization.Hello "Random Person" |> str
[<Feliz.ReactComponent>]
let OtherComponent () =
contextConsumer localizationContext (fun x -> str x.Lang)
let view model dispatch =
match model.Localization with
| Some l ->
contextProvider localizationContext l [
LanguageSelector model.SupportedLanguages model.SelectedLanguage dispatch
DeepComponent ()
OtherComponent ()
]
| _ -> str "Loading localization"
It's time to run the demo. The very first thing to notice is Webpack output. Webpack has magically read our intentions and created one bundle for every file that matches Localization.*.fs.js
that we might lazy load.
Now when we navigate to http://localhost:8080
and inspect the requests, we see that the French localization file is loaded a tiny bit later than app.js
, initiated in the init
function. No sight of English yet:
Then choose English in the dropdown and the file gets loaded on demand:
To round off I want to mention one noticeable drawback of this approach. When editing the interface or localization files, HMR does not kick in, so you will have to live with manual refreshes until you are finished with those texts and move on to something else.