At the St. Louis Angular Lunch this month (which we sponsor), Mark Volkmann of OCI gave a talk on localization. In his talk he addresses how to setup AngularJS services and filters to determine which rules to apply for i18n and l10n runtime support. AngularJS includes good i18n support, probably because of extensive use inside Google across their global operations.
Here is the talk, transcribed to text. This is a lightly edited, draft transcription, so any errors are probably from that process.
Alright. I'm going to talk about internationalization and localization and AngularJS. And so first let's start out by defining those terms. And this is something that I was so sure I understood what they meant. It was like cemented in my mind and then someone pointed out that I wasn't explaining it correctly.
So, the correct definition of these is: Internationalization is when you're setting up your app so that, potentially, you can adapt it to other languages easily. You haven't actually done the work to integrate a specific language yet, but you've made your app ready for this. And often that is abbreviated to i18n because there's 18 letters in between the "i" and the "n".
And then localization is when you pick a specific language, and you actually do make your app able to display things formatted in the proper way for that language, and showing text in the right way for that language.
Previously I had been looking at it like internationalization is formatting things like dates and times and numbers, and the localization was the text part of it. But those are certainly two things we want to address here.
And now you're all set up and you can use those filters. You can see some examples of that coming up here. But that's all you're getting from Angular out of the box, is this formatting of those kinds of values. It's not doing any translating, like "Hello" should be "Hola" In Spanish.
Another problem with this is that you just get one language. It doesn't matter how many of those files you downloaded, there's just one that is active, and you can't change it at run time in your app. Often you’d like to let the user pick a language. Maybe the locale that their browser supports says that it's English, but they'd prefer to read their page in French. You want to give them that ability. So if you want that, one to way to get it is this thing: angular-dynamic-locale that you can download.
So it's an Angular module, and if you want to use that, then you can download multiple files from that extras directory for all the languages that you want to support, downloaded that file. It's name starts with TMH, which I think that's the author's initials. Then you pull that in with a script tag. You have to have a dependency on this module. Then you're going to inject this service in wherever you need it. Basically that means wherever you want to change what the current locale is. And then whenever you want to change that, you say tmhDynamicLocale.set and pass it a language. And you can pass it just an abbreviation for a language, or it can also include a country code like the -us there.
And so now you can change that at run time. But even this is only giving you the ability to change the formatting that's being done on numbers and currencies and dates.
Participant: And given that that was from this person, I'm assuming this hasn't got an official relationship with …
Mark: That's right. So, as far as I know, there is no official Angular-blessed thing that does this, although this is not the only thing that gives you that capability. It's just one that I found, and got to work on our project.
So the next thing you're going to want is to be able to actually translate text. "Hello" To "Hola". And so that's not directly supported in Angular, but you can easily write a custom filter that does this, and you'll find that if you look for someone else's solution to this problem, they're going to do it pretty much the same way that I've done it. Maybe they'll have more features, but the basic approach of using a filter seems to be the way that people attack this problem.
So I'm going to walk through an example, but if you want to take a peek at that code I've got it out in Github. There's a lot of examples there, and one of them is called locale.
So this is what I'm after. I want to have this basic UI where I've got this drop-down where I can change the language, and then I'm displaying different kinds of values, so I've got a number that's long enough that I want some commas in there, and there's a decimal point. I've a currency, I've got a date, and I have a time.
So I want those to be formatted differently when I change to French and to Spanish. And, of course, all these labels need to change their text when I switch to a different language. And to go along with this, all my translations are in a Json file, so I have a separate one for each language. You notice that my English one is empty.
Well, a nice feature, I think, of the way I've implemented this is that when I specify a string that I want to display in an HTML file, and then I say, "Pass it through this filter." When it looks it up, if it doesn't find a match, it's just going to keep it the same as it was. And that means that in my HTML I can just specify the English strings, and I don't have to give a translation.
I still want to have this file though, because there are cases where what I want to display is really long. Like a whole paragraph of text. And I don't want to put that in my HTML, so in those cases, I'll make a shorter string. For example, I might call it "Sign up-terms". And then I would put that key in here "Sign up-terms" and then say "What's the full paragraph in English?" And then I would have a corresponding key for all the other languages.
You see here that I have some unicode characters entered in with a double backslash in front of the "u". So you can look those up in some table and type them in. And I'm pretty sure that if you are adept at hitting the right key strokes, especially on a Mac, that would just put in the actual character, I think that would work too.
So let me demonstrate this thing actually running. Here it is, we've got the English version, and then I switched to French. Everything has changed. Notice that my big number here has spaces instead of commas and the decimal point linked to a comma. A different currency, a different way of displaying the date. The time was the same for French. If I switch to Spanish, they prefer the "AM" to be lower case, but everything else has changed.
Participant: So this is working in conjunction with the default Angular capabilities that do all the date and time formatting for you…
Mark: Yes, exactly.
Participant: …and you're doing the labels, stuff like that…
Mark: Right. So the default Angular filters for this stuff, being able to change my language I'm getting from that tmhDynamicLocale, and then I'm handling the labels.
Alright. So let's look at the code that is doing this for me. So we're starting off with my HTML file, and I'm pulling in Angular itself. I'm omitting which version I'm using, you fill that in with the full URL. And there's the TMH library that are downloaded. This locale.js is my own code that is going to handle the translation for those labels. And then this demo.js is what's going to define my Angular controller that's going to put some data on the scope that I can use here. So here's the name of my controller, and then here you see the first example of how this language translation works.
You give it a string inside a binding expression, and then you run that through this filter, L10n. And so that's my filter, we'll see that code coming up. So everywhere where I have a label, I'm piping it into L10n. Before the places where I want to do some special formatting of certain kinds of values, here I'm using Angular zone number filter, the currency filter, the date filter, and then another use of date filter where I'm passing a parameter to it to tell it that I really just want the time portion of that thing.
And I am watching for changes to the lang property that is starting out as my browser default locale. So you'll see this code coming up in just a bit, so it's going to default to English for me. But in the drop-down, if I pick a different value, it's going to trigger this watch, and I'll tell my locale service that I want to change the language.
So that's going to have tell that TMH thing to change the language, and I'm going to need that so that I can do the right language translations. So all of this part of the code is just putting data out on the scope that I'm displaying on my page. Any questions on this part?
Alright. So now we're looking at my locale service. There's two things I need to define here. The locale service, and then on the next slide that L10n filter.
So, one of the things I'm trying to handle here is to make it so that if you hit refresh in the browser you don't lose all of your data. And I've been finding more and more in Angular apps that that's a primary usage of session storage to hold onto things so that if the user hits refresh you can get back to a good state. And so the things that I want to remember are the current language that I'm displaying, and then I have a map of translations. So you saw those Json files, so my translations, that's a map itself. I have some key, some value for the translation for a certain language, but then I want to have a map of those, so I have English translations, maps to this object of all of those, and then French and Spanish, so that's what translations is holding. A map of all of my translation maps.
I'm using this $interpolate service to allow you, if you choose, to have binding expressions inside your translations. If I go back here, what if you wanted the word or the phrase for "Birthday" to be inside this lunchtime string? Well, you can use double curly braces inside there and say "Birthday" and then double curly braces. And then it would be able to evaluate that.
Actually I gave a wrong example. What I meant to say is that there's something on the scope. "Birthday" isn't on the scope, but some other piece of data on the scope. And you want it to be part of your translation string. So in order for that to work, I have to use that $interpolate service. So that's why that's injected. We saw that before, so that I can switch how the filtering is working for currencies and dates, and those sorts of things.
So down here we see how I can retrieve my current default language. And so the browser is going to have this navigator property, and either language is going to be set, or user language, it depends on what browser you're using, so I get it one way or another.
And then in this example, I'm going to remove the country code part of it. So if came back and it said "en-us", I'm just going to get "en". But, an improvement to this might be to keep that. It's just that if you want to do that, you have to think about having additional translation files, and having logic that says, "If you wanted 'en-us', but all I have is an en Json file, then you should use that one." So I didn't take the step of putting in that logic. So I'm only now in this version supporting a language code and not a country code, so I’m stripping that.
So you'll see, on the next slide, I believe, a call to this function to load in translations. And that's just a matter of reading in a Json file. You give me the name of the language, and I'm assuming that you have a directory called L10n and inside there will be en.json, apar.json. And so I use the http service to get that file, and the content type is Json, and for instance I get that successfully, then the translations are in data right here, and I add it to the map where the key is the language code.
And I'm also going to take all of my translations, turn that into a Json string and store that in session storage. And you'll see, coming up, and where I have to pull that out if the user hits refresh in the browser.
So on the next slide we're going to see a call to load translations. We're still inside locale.js, so if you call setLang, and that's something I would do if the user picks something from the drop-down, then I'm checking to see if you just changed it to the same thing that it already is. If you did that, I don't need to do anything. But if it's different, then I need to tell this TMH module to change the language so that it displays currencies and dates correctly.
And then I'm looking to see if I've already loaded the translations for that language. Because if I started with English and I loaded that, and then I switched to French and I loaded that, and then I switch back to English, I already have those. I don't need to get the Json file again. But if I don't have it, then I'll load the translations for that language. And that's going to end up causing a digest cycle, because it's making an http request. It's using $http. And so that's enough to trigger the whole UI to update with the results of those new translations.
And then I'm going to remember that language that you just switched to. That is now the current language, and I also have that available in a local variable that was defined on the previous page.
Participant: Given that that can survive refreshes, how do you ever tell your web app, "Hey, I love translations. I want to…I really want to rely on that."
Yeah, you gotta kill that browser session if you want to do that, and then open it up again. I think killing the cab is the best way that should end that session, I think. And open a new browser tab.
So you pass in some phrase, and passing in your scope is optional. That's only needed if you know that the translation string has a binding expression in it, or if you suspect that it might. There's never a harm in passing it, it's just that if you don't pass it, and there was a binding expression in it, then it's not going to work.
So I'm going to grab all the translations for the current language. I have it in "t" which is now mappable. And then I'm going to look up that phrase, and it might not be there. Remember, in the case of English, I don't have them there. So in that case, my result will be null.
And then I say if you gave me a scope, and I had a result, then let's do an interpolate on that, and that allows it to evaluate the binding expressions. And then finally at the end, I'm going to give back to you either the result, or if I don't have one, just the phrase that you passed in. That's what allows me to not put anything in the English file.
So that's my service, and then we need to look at the filter, the L10n filter. So this is returning a function, this is the implementation of my filter. You pass in a phrase and optionally a scope. Here's where I’m handling the browser refresh. If you refresh the browser, I won't have a value for currentLang anymore. If I skip back a couple of slides, right here I had this local variable, currentLang. That will be lost if you refresh the browser.
So, if I don't have one, then I'm going to go to session storage and get it, and I'll grab all of my translations out of session storage and restore that. So now I've got it back, and then I can call translate that we saw on the previous page. So the filter is making use of that service.
That's really all there is to it. I think it's a relatively simple approach. It's been shown to work well in a pretty large app that I'm working on now. I think it's just amazing that it's okay to do this translation onto every label that is visible in every digest cycle. Think about what's going on there. Everything that kicks off a digest cycle. You might have, say, thirty labels on your page, and it's repeatedly doing this lookup, usually not changing it at all, and it just doesn't matter. Aren't computers wonderful.
I've thought about this, that this can't possibly be good, and I should look for some better solution, but there just is no motivation to do it, because I've never noticed a performance problem like this.
I don't know details about a lot of other approaches, but one that I've seen mentioned is called angular-translate. And I looked at that a bit last night, and they're doing the exact same thing.
Participant: Yeah. I was going to say it's exactly what you've got.
Mark: As I said earlier, there might be more features in this, and so maybe you want to use this instead of just copying my code, I don't know. But mine's a pretty light-weight, fairly easy-to-understand implementation. So as I said, it's out on Github, feel free to grab it and use it if you like.
Participant: Do you think you'd get in trouble with memory if you have a lot of supporting languages with a lot of keys?
Mark: Yeah, that could be. May be the case. I would guess, though, that in most applications they're only going to need to support a handful of languages. And certainly, if I was trying to support like twenty languages, and I had a thousand streams in each one, I might start to worry about…
Participant: And then couldn't you just make it load only the current language?
Mark: Yeah. Right now I'm saving every mapping, thinking that you might want to switch back to some other language, so I could reduce that.
Participant: Can you flip back to slide just before? Right there. I've noticed that you put the check for the population current language and then the loading current language inside of the filter itself. Is there somewhere else that you could do that check? Because again, like you were saying, I try to keep my filters extraordinarily lean because they're called constantly.
Mark: So you're worried about this check.
Participant: I know that it's just a single check of a bool, but it seems like that's the sort of thing that I would immediately go, "Where else can I put that?" so the only thing I’m doing is the absolute minimum…
Mark: Right, so the questions is, what other event would indicate to me that a browser…
Participant: Yeah, that's what I'm wondering. Is there some other hook that you could hang that off of?
Mark: Possibly, but I don't know off the top of my head what it would be.
Participant: So I guess this first call for localization maybe could go in another place..
The other thing I was wondering was if there was some way to load all this stuff into a reference on the scope and then all you'd really be doing is like the property lookup instead of a filter call each time. Because again, like you, I'm concerned about the number of times that filter is going to be called. Could there be some sort of memoization technique you could use to minimize the hit. Have you looked at this like in Batarang, to see what the performance impact is?
Mark: No, I have not.
Participant: I'd be really curious to see how much overhead this is adding. If you're not feeling it, then there's probably not much, but…
Mark: Yeah. So if I wanted to use an approach like that, then instead of have this main big number here I'd have a reference to some scope property. But as soon as I do that, the ability for somebody who isn't really very versed in Angular to maintain this HTML goes down a bit.
Participant: With that in mind, the other question I had was: Have you thought about possibly turning, say, "Label" into your own directive and doing some of the stuff just automatically? Because I'm thinking, every bit of syntax that's added in there makes it less likely that — makes it more likely a programmer is going to have to modify this as opposed to…
Mark: Right. The main reason I didn't do that is that there are other places where I do the translation besides the labels. But I could have a number of directives.
Participant: One of the things that I've been thinking about is, designers or non-programmers are really good at dealing with HTML tags. That they can cope with. As soon as you start adding curly braces and quotes and vertical bars, and anything else in there, they're going to screw at least one of those characters up along the way. But if you gave them something they could wrap around text — so maybe if we had a directive that would be the "t" directive, or whatever, would that be an approach that — behind the scenes it's doing all the same stuff, but it's just tags.
That's an idea I've been toying with, I haven't actually worked on it yet, but I've been thinking about, like I said, I've been thinking about internationalization for a couple apps of mine. And since I knew this was already an approach, I was thinking of taking a different approach, to see if it was something a little easier…
But I think behind the scenes, we could still be doing all of this stuff, it would just look more like raw HTML on the front end.
Mark: Yeah. That's an interesting idea.
Participant: I'm mainly telling you that because I know you, and I know that tomorrow you'll be like, "That was idea.” And you’ll code it up and I get a break.
Mark: Oh, by the way, I want to mention passing in the scope, in case your translations had binding expressions in them. The way you do that from the filter, is you just say ":this". Right in there I put a patch on ":this". And that passes the scope to the filter.
Any other questions?
Participant: Did we let you get to the last slide? I was…
Mark: I did. Yeah.
Participant: I see a 14…
Mark: Oh, why is there a 14 here? It's section 14…
Participant: Okay. I was thinking there were 14 slides and we didn't let you get all the way there.
Mark: No, no.
Alright, thanks. Get ready for the next talk.