Объекты семейства Intl создаются относительно дорого.
При каждом вызове конструктора движок Jav * aScript:
Создание экземпляров:
Intl.DateTimeFormatIntl.NumberFormatIntl.RelativeTimeFormatIntl.CollatorIntl.ListFormatIntl.PluralRulesможет становиться заметной нагрузкой в:
Плохая практика:
const prices = [10, 20, 30]
const result = prices.map(price => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
}).format(price)
})
Форматтер создаётся заново для каждого элемента массива.
Правильный подход:
const formatter = new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
})
const prices = [10, 20, 30]
const result = prices.map(price => formatter.format(price))
Создание форматтера происходит один раз.
Даже если метод .format() работает быстро, конструктор
Intl.* может быть существенно тяжелее.
Условная модель затрат:
| Операция | Стоимость |
|---|---|
Создание Intl.NumberFormat |
высокая |
Вызов .format() |
низкая |
| Повторное использование | оптимально |
В горячих участках кода разница становится критичной.
Пример проблемного рендера:
function renderRows(rows) {
return rows.map(row => {
return {
date: new Intl.DateTimeFormat("ru-RU").format(row.date),
amount: new Intl.NumberFormat("ru-RU").format(row.amount)
}
})
}
При 10 000 строк:
Оптимизированная версия:
const dateFormatter = new Intl.DateTimeFormat("ru-RU")
const numberFormatter = new Intl.NumberFormat("ru-RU")
function renderRows(rows) {
return rows.map(row => {
return {
date: dateFormatter.format(row.date),
amount: numberFormatter.format(row.amount)
}
})
}
Самый простой способ — вынести форматтер в область выше.
const currencyFormatter = new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
})
export function formatPrice(value) {
return currencyFormatter.format(value)
}
Такой подход особенно эффективен:
Если приложение поддерживает несколько языков, одного экземпляра недостаточно.
Плохой пример:
function formatPrice(value, locale) {
const formatter = new Intl.NumberFormat(locale)
return formatter.format(value)
}
Каждый вызов создаёт новый объект.
Оптимизированный вариант:
const cache = new Map()
function getFormatter(locale) {
if (!cache.has(locale)) {
cache.set(
locale,
new Intl.NumberFormat(locale)
)
}
return cache.get(locale)
}
function formatPrice(value, locale) {
return getFormatter(locale).format(value)
}
Разные опции создают разные форматтеры.
Например:
new Intl.NumberFormat("ru-RU")
new Intl.NumberFormat("ru-RU", { style: "currency" })
Это разные конфигурации.
Поэтому ключ кэша должен учитывать:
Пример:
const cache = new Map()
function getNumberFormatter(locale, options = {}) {
const key = JSON.stringify([locale, options])
if (!cache.has(key)) {
cache.set(
key,
new Intl.NumberFormat(locale, options)
)
}
return cache.get(key)
}
Использование:
const formatter = getNumberFormatter("ru-RU", {
style: "currency",
currency: "RUB"
})
console.log(formatter.format(1500))
Использование JSON.stringify() для генерации ключа не
всегда безопасно.
Проблемный пример:
const a = {
style: "currency",
currency: "USD"
}
const b = {
currency: "USD",
style: "currency"
}
Логически объекты одинаковы, но порядок ключей может различаться.
Результат:
JSON.stringify(a)
JSON.stringify(b)
может дать разные строки.
Более надёжный подход:
function stableKey(locale, options = {}) {
const sorted = Object.entries(options)
.sort(([a], [b]) => a.localeCompare(b))
return JSON.stringify([locale, sorted])
}
Практичный паттерн — единый registry.
const formatterCache = new Map()
function getFormatter(type, locale, options = {}) {
const key = JSON.stringify([
type,
locale,
options
])
if (formatterCache.has(key)) {
return formatterCache.get(key)
}
let formatter
switch (type) {
case "number":
formatter = new Intl.NumberFormat(locale, options)
break
case "date":
formatter = new Intl.DateTimeFormat(locale, options)
break
case "relative":
formatter = new Intl.RelativeTimeFormat(locale, options)
break
default:
throw new Error("Unknown formatter")
}
formatterCache.set(key, formatter)
return formatter
}
Использование:
const formatter = getFormatter(
"date",
"ru-RU",
{
dateStyle: "long"
}
)
console.log(formatter.format(new Date()))
Если ключом являются сами объекты конфигурации, можно использовать
WeakMap.
const cache = new WeakMap()
function getFormatter(options) {
if (!cache.has(options)) {
cache.set(
options,
new Intl.NumberFormat("ru-RU", options)
)
}
return cache.get(options)
}
Использование:
const options = {
style: "currency",
currency: "RUB"
}
const formatter = getFormatter(options)
Преимущества:
Недостаток:
В больших системах количество комбинаций локалей и опций может стать огромным.
Например:
Бесконечный Map способен привести к росту памяти.
Решение — ограниченный кэш.
Простейший LRU:
class LRUCache {
constructor(limit = 100) {
this.limit = limit
this.map = new Map()
}
get(key) {
if (!this.map.has(key)) {
return null
}
const value = this.map.get(key)
this.map.delete(key)
this.map.set(key, value)
return value
}
set(key, value) {
if (this.map.has(key)) {
this.map.delete(key)
}
this.map.set(key, value)
if (this.map.size > this.limit) {
const firstKey =
this.map.keys().next().value
this.map.delete(firstKey)
}
}
}
Использование:
const cache = new LRUCache(50)
function getFormatter(locale, options) {
const key = JSON.stringify([locale, options])
let formatter = cache.get(key)
if (!formatter) {
formatter = new Intl.NumberFormat(
locale,
options
)
cache.set(key, formatter)
}
return formatter
}
Одна из самых частых ошибок — создание форматтеров прямо внутри компонента.
Плохо:
function Price({ value }) {
const formatted =
new Intl.NumberFormat("ru-RU").format(value)
return <span>{formatted}</span>
}
При каждом рендере создаётся новый объект.
Правильный подход — useMemo.
import { useMemo } fr om "react"
function Price({ value, locale }) {
const formatter = useMemo(() => {
return new Intl.NumberFormat(locale)
}, [locale])
return (
<span>
{formatter.format(value)}
</span>
)
}
Во Vue аналогичная проблема встречается в computed и template.
Плохой пример:
function formatPrice(value) {
return new Intl.NumberFormat("ru-RU")
.format(value)
}
Лучше:
const formatter =
new Intl.NumberFormat("ru-RU")
function formatPrice(value) {
return formatter.format(value)
}
Либо:
const formatters = new Map()
function getFormatter(locale) {
if (!formatters.has(locale)) {
formatters.set(
locale,
new Intl.NumberFormat(locale)
)
}
return formatters.get(locale)
}
На сервере проблема особенно заметна:
Плохой обработчик:
app.get("/report", (req, res) => {
const formatter =
new Intl.DateTimeFormat(req.locale)
const result = data.map(item => {
return formatter.format(item.date)
})
res.json(result)
})
Если запросов тысячи, постоянное создание объектов становится дорогим.
Лучше использовать глобальный кэш:
const cache = new Map()
function getDateFormatter(locale) {
if (!cache.has(locale)) {
cache.set(
locale,
new Intl.DateTimeFormat(locale)
)
}
return cache.get(locale)
}
Intl.Collator особенно важен для сортировки.
Плохой пример:
array.sort((a, b) => {
return new Intl.Collator("ru")
.compare(a, b)
})
Коллатор создаётся при каждом сравнении.
Правильно:
const collator = new Intl.Collator("ru")
array.sort((a, b) => {
return collator.compare(a, b)
})
Это критически важно, потому что:
Intl.Collator дорого;Без кэша:
function formatMinutes(value) {
return new Intl.RelativeTimeFormat("ru")
.format(value, "minute")
}
С кэшем:
const formatter =
new Intl.RelativeTimeFormat("ru")
function formatMinutes(value) {
return formatter.format(value, "minute")
}
Плохой пример:
function getPlural(count) {
return new Intl.PluralRules("ru")
.select(count)
}
Оптимизация:
const pluralRules =
new Intl.PluralRules("ru")
function getPlural(count) {
return pluralRules.select(count)
}
Часто используется паттерн factory.
function createNumberFormatter(locale) {
const cache = new Map()
return function(options = {}) {
const key = JSON.stringify(options)
if (!cache.has(key)) {
cache.set(
key,
new Intl.NumberFormat(
locale,
options
)
)
}
return cache.get(key)
}
}
Использование:
const ruFormatterFactory =
createNumberFormatter("ru-RU")
const currencyFormatter =
ruFormatterFactory({
style: "currency",
currency: "RUB"
})
В production-системах форматтеры иногда создаются заранее.
Например:
const formatters = {
currencyRU: new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
}),
currencyUS: new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}),
dateRU: new Intl.DateTimeFormat("ru-RU")
}
Преимущества:
Недостаток:
Альтернатива — lazy initialization.
let formatter = null
function formatPrice(value) {
if (!formatter) {
formatter =
new Intl.NumberFormat("ru-RU")
}
return formatter.format(value)
}
Форматтер создаётся только при первом использовании.
Иногда применяется pooling.
const pool = {
ru: new Intl.NumberFormat("ru-RU"),
en: new Intl.NumberFormat("en-US"),
de: new Intl.NumberFormat("de-DE")
}
function format(locale, value) {
return pool[locale].format(value)
}
Подход эффективен:
Проверять эффективность оптимизации необходимо через benchmark.
Пример:
console.time("without-cache")
for (let i = 0; i < 100000; i++) {
new Intl.NumberFormat("ru-RU")
.format(i)
}
console.timeEnd("without-cache")
С кэшем:
const formatter =
new Intl.NumberFormat("ru-RU")
console.time("with-cache")
for (let i = 0; i < 100000; i++) {
formatter.format(i)
}
console.timeEnd("with-cache")
Разница может составлять десятки раз.
Избыточное кэширование тоже вредно.
Необязательно кэшировать форматтеры:
Кэширование оправдано, если:
Ошибка:
cache.set(locale, formatter)
Проблема:
new Intl.NumberFormat("ru", {
style: "currency"
})
new Intl.NumberFormat("ru", {
style: "percent"
})
Форматтеры разные, а ключ одинаковый.
Плохо:
items.sort((a, b) => {
return new Intl.Collator("ru")
.compare(a, b)
})
Плохо:
function Component() {
const value =
new Intl.NumberFormat("ru")
.format(1000)
return <div>{value}</div>
}
Опасный пример:
const cache = new Map()
без:
class FormatterCache {
constructor(lim it = 100) {
this.limit = limit
this.cache = new Map()
}
buildKey(type, locale, options) {
const sorted =
Object.entries(options || {})
.sort(([a], [b]) =>
a.localeCompare(b)
)
return JSON.stringify([
type,
locale,
sorted
])
}
get(type, locale, options = {}) {
const key =
this.buildKey(type, locale, options)
if (this.cache.has(key)) {
const value = this.cache.get(key)
this.cache.delete(key)
this.cache.set(key, value)
return value
}
let formatter
switch (type) {
case "number":
formatter =
new Intl.NumberFormat(
locale,
options
)
break
case "date":
formatter =
new Intl.DateTimeFormat(
locale,
options
)
break
case "relative":
formatter =
new Intl.RelativeTimeFormat(
locale,
options
)
break
default:
throw new Error("Unknown formatter")
}
this.cache.set(key, formatter)
if (this.cache.size > this.limit) {
const oldest =
this.cache.keys().next().value
this.cache.delete(oldest)
}
return formatter
}
}
Использование:
const intlCache =
new FormatterCache(200)
const formatter =
intlCache.get(
"number",
"ru-RU",
{
style: "currency",
currency: "RUB"
}
)
console.log(
formatter.format(150000)
)