かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript 数字をカンマ区切りの文字列に変換したい

以前ゼロパディングで桁数を合わせる方法のメモを書いていましたが、三桁ごとにカンマ区切りのフォーマットもよくやるよな〜と思ったのでやり方のメモ。

こんなの

1234567890 => "1,234,567,890"
1234567890.1235 => "1,234,567,890.1235"
-1234567 => "-1,234,567"
-1234567.89054321 => "-1,234,567.89054321"

結論 Number.toLocaleStringIntl.NumberFormat を使うのが簡単

※ 但し小数点以下の有効桁数に注意

Number.toLocaleString を使う

Number.prototype.toLocaleString()
numObj.toLocaleString([locales [, options]])
新しい localesoptions 引数で アプリケーションは フォーマット変換で使われる言語を指定でき、関数の振る舞いをカスタマイズできます。古い実装では、localesoptions 引数は無視され、使われるロケールや返される文字列の形式は完全に実装依存です。
cf. Number.prototype.toLocaleString() - JavaScript | MDN

引数を指定しないとフォーマットはロケール依存になり、小数点以下は特定桁数で四捨五入される

const numbers = [1234567890, 1234567890.1235, -1234567, -1234567.89054321];

const numberFormat = (num: number): string => {
  return num.toLocaleString();
};

numbers.map((num) => numberFormat(num));
// => '1,234,567,890'
// => '1,234,567,890.124'
// => '-1,234,567'
// => '-1,234,567.891'

maximumFractionDigits オプションを使い小数点以下の桁数を保持する (※但し最大 20桁)

const numbers = [1234567890, 1234567890.1235, -1234567, -1234567.89054321];

const numberFormat = (
  num: number,
  options?: Intl.NumberFormatOptions
): string => {
  return num.toLocaleString(undefined, options);
};

numbers.map((num) => numberFormat(num, {maximumFractionDigits: 20}));
// => '1,234,567,890'
// => '1,234,567,890.1235'
// => '-1,234,567'
// => '-1,234,567.89054321'

₍ ᐢ. ̫ .ᐢ ₎👌

Intl.NumberFormat を使う

Intl.NumberFormat
new Intl.NumberFormat([locales [, options]]).format(number)
ローケルを指定しない基本的な使い方では、既定のローケルとオプションで書式化された文字列が返されます。
cf. Intl.NumberFormat - JavaScript | MDN

locales, optionstoLocaleString と同じ

引数を指定しないとフォーマットはロケール依存になり、小数点以下は特定桁数で四捨五入される

const numbers = [1234567890, 1234567890.1235, -1234567, -1234567.89054321];

const numberFormat = (num: number): string => {
  return new Intl.NumberFormat().format(num);
};

numbers.map((num) => numberFormat(num));
// => '1,234,567,890'
// => '1,234,567,890.124'
// => '-1,234,567',
// => -1,234,567.891'

maximumFractionDigits オプションを使い小数点以下の桁数を保持する (※但し最大 20桁)

const numbers = [1234567890, 1234567890.1235, -1234567, -1234567.89054321];

const numberFormat = 
  (options?: Intl.NumberFormatOptions) => 
  (locales?: string | string[]) =>
  (num: number): string => {
  return new Intl.NumberFormat(locales, options).format(num);
};

// 小数点以下 20 桁まで有効にするフォーマット
const format = numberFormat({ maximumFractionDigits: 20 });

const formatJP = numberFormat('ja-JP')
numbers.map((num) => formatJP(num));
// => '1,234,567,890',
// => '1,234,567,890.1235',
// => '-1,234,567',
// => '-1,234,567.89054321'

const formatDE = numberFormat('de-DE')
numbers.map((num) => formatDE(num));
// => 1.234.567.890',
// => '1.234.567.890,1235',
// => '-1.234.567',
// => '-1.234.567,89054321'

const formatFI = numberFormat('fi-FI')
numbers.map((num) => formatFI(num));
// => '1 234 567 890',
// => '1 234 567 890,1235',
// => '−1 234 567',
// => '−1 234 567,89054321'

₍ ᐢ. ̫ .ᐢ ₎👌

toLocaleString, Intl.NumberFormat の桁数のオプション

  • minimumIntegerDigits … 整数の最小桁数。可能な値は1から21, デフォルトは 1
  • minimumFractionDigits ... 小数桁の最小数。可能な値は0から20, 数値フォーマットのデフォルトは 0
  • maximumFractionDigits … 小数桁の最大数。可能な値は0から20, 数値フォーマットのデフォルトは、minimumFractionDigits3 の大きい方
  • minimumSignificantDigits … 有効桁の最小数。可能な値は1から21, デフォルトは 1
  • maximumSignificantDigits … 有効桁の最大数。可能な値は1から21, デフォルトは 21

cf. Intl.NumberFormat() constructor - JavaScript | MDN

あまり用途はなさそうだけど、小数点以下が 20 桁以上でそれを保持したい場合は整数部と小数点以下に分離して、整数部分をフォーマットした後に小数点以下と文字列結合する必要がある

const format = (num: number): string => {
  const numStrs = String(num).split('.');
  return [
      Number(numStrs[0]).toLocaleString(),
      numStrs.slice(1).join(''),
    ].filter(Boolean).join('.');
};

format(111111.098765432109876543210987);
// => "111,111.098765432109876543210987"
format(-111111.098765432109876543210987);
// => "-111,111.098765432109876543210987" 

数字のフォーマットを自前で実装するパターン

文字列にして自前でフォーマットしてしまうなら小数点以下が丸められるトラップにははまらない。
ただ、そんなケースはあまり無さそうなので自前実装するメリットほぼ無さそうだけどスクリプト書いてみたので載せておきます。

再帰関数で , 区切りにする

整数部分を再帰関数で , 区切りにして、小数点以下と文字列結合する

const separate = (numStr: string, separator: string = ','): string => {
  const length = numStr.length;

  if (length > 3) {
    const splitIndex = length - 3;
    // 後ろから 3文字づつ確定していく
    return [
      separate(numStr.substring(0, splitIndex), separator),
      numStr.substring(splitIndex),
    ].filter(Boolean).join(separator);
  }

  return numStr;
};

type FormatOption = {
  separator?: string;
  decimalPoint?: string;
};

const formatNumber = (
  num: number,
  options?: FormatOption,
): string => {
  const numStrs = String(num).split('.');
  return [separate(numStrs[0], options?.separator), numStrs.slice(1).join('')]
    .filter(Boolean)
    .join(options?.decimalPoint || '.');
};

const numbers = [1234567890, 1234567890.1235, -1234567, -1234567.89054321];
numbers.map((num) => formatNumber(num));
// => '1 234 567 890',
// =>  '1 234 567 890.1235',
// =>  '-1 234 567',
// =>  '-1 234 567.89054321'

₍ ᐢ. ̫ .ᐢ ₎👌

正規表現を使うパターン

正規表現は自信が無いのでググって出てきた replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') を使います。
この正規表現も整数部分でしか上手く動作しないので、整数部分を正規表現, 区切りにしてから小数点以下と結合します

type FormatOption = {
  separator?: string;
  decimalPoint?: string;
};

const formatNumber = (
  num: number,
  options?: FormatOption,
): string => {
  const numStrs = String(num).split('.');
  return [
    numStrs[0].replace(
      /(\d)(?=(\d\d\d)+(?!\d))/g,
      `$1${options?.separator || ','}`
    ),
    numStrs.slice(1).join(''),
  ]
    .filter(Boolean)
    .join(options?.decimalPoint || '.');
};

const numbers = [1234567890, 1234567890.1235, -1234567, -1234567.89054321];
numbers.map((num) => formatNumber(num, { separator: '.', decimalPoint: ',' }));
// => '1.234.567.890',
// => '1.234.567.890,1235',
// => '-1.234.567',
// => '-1.234.567,89054321'

₍ ᐢ. ̫ .ᐢ ₎👌

所管

Intl.NumberFormat は今回のメモを書くにあたって調べていて初めて知りました。

オプションで locale に合ったフォーマットができるので Number.toLocaleStringIntl.NumberFormat を使えば良さそう。
ただし、デフォルトでは小数点以下が丸められるので丸めたくない時は maximumFractionDigits オプションを使う必要がある。

ただこの2つの方法オプションも似ているので同じようなものが 2 つ存在していてどちらの方が良いチョット解ってない。2021年7月7日現在では toLocaleString は現行の標準になっているのでこっちを使うでいいのかな?

, これ コンマなのカンマなの???????


[参考]

夏樹みくる ちゃんハピバ 🎉🎂

Next.js ブラウザ戻る/進む (History.back / History.forward) の時に常にページトップ (スクロール量をリセット) で表示させたい

Next.js で作ったアプリでブラウザの戻る / 進む ( router.back() History.back() / History.forward() ) が実行された際に、最後に見ていた時のスクロール量が保持されるのでスクロール量を 0 にして遷移させたかったのメモ

scrollRestoration オプションを使う

ブラウザの History APIHistory.scrollRestoration というブラウザのスクロール量を保持しておく機能があり、デフォルト値はスクロール量を保持しておく auto になっていて Next.js もこれを踏襲している。

History.scrollRestoration
auto The location on the page to which the user has scrolled will be restored.
manual The location on the page is not restored. The user will have to scroll to the location manually.
cf. History.scrollRestoration - Web APIs | MDN

Next.js にも scrollRestoration のオプションが有りデフォルトが false (history.scrollRestoration = "auto")。
このオプションを true にするとスクロール量の保持を手動で制御する history.scrollRestoration = "manual" になり、ブラウザの戻る / 進む をした際にスクロール量が 0 (ページトップ) で表示されるようになる。

next.config.jsscrollRestoration のオプションを設定する

ブラウザの戻る / 進む が実行された時に常にページトップ (スクロール量 0) で表示したい場合、next.config.js に下記のオプションを追加すればOK

next.config.js (ファイルがない場合はプロジェクトのルートディレクトリに作成)

module.exports = {
  experimental: {
    scrollRestoration: true
  }
}

ファイルを保存して yarn dev でアプリを再起動すれば、ブラウザの戻る / 進むの際にスクロール量がリセットされ常にページトップで表示されるようになっているかと思います。

Feed のようなアプリだと戻った時に元のスクロール位置にいてほしかったりするのですが、アプリやサービスによってはページトップで表示できないと UX が悪いということもあると思います。そんな時はこの scrollRestoration を使えばサービス全体の設定ができそうです。
ページごとに個別に対応したい場合は History APIscrollRestoration を使ってページごとに実装する必要がありそうです。


[参考]

Next.js × Chakra UI レスポンシブ値を変更できる useBreakpointValue で複数の値を扱いたい時のハマりどころ

Chakra UI の useBreakpointValue() を使って Button コンポーネントの size をレスポンシブで変更できるようにしました。
useBreakpointValue() が保持した値を返すので、ボタンのラベルも合わせて保持できるのでは?と Next.js のアプリで試していて罠にハマったのでメモ。

環境
@chakra-ui/icons: ^1.0.13
@chakra-ui/react: ^1.6.3
next: ^10.0.0

複数の値を useBreakpointValue() で保持する

import { Button, useBreakpointValue } from '@chakra-ui/react';

const ResponsiveButton: VFC = () => {
  const buttonSize = useBreakpointValue(['sm', 'md']);
  const buttonLabel = useBreakpointValue(['MyButton', 'MyResponsiveButton']);

  return (
    <Button type="button" size={buttonSize}>
      {buttonLabel}
    </Button>
  );
}

レスポンシブで変更する値それぞれに useBreakpointValue() を使えば当然複数の値をレスポンシブで変更できます。
ただ値が多くなると全ての値で hooks を呼び出すのはパフォーマンス的にもあまりよろしくないと思います。
今回は buttonSizebuttonLabel を同じブレイクポイントで変更したいので、これをセットにして useBreakpointValue() に渡せば合理的だと考えました。

useBreakpointValue の型は次のように定義されています

// chakra-ui/packages/media-query/src/use-breakpoint-value.ts
export function useBreakpointValue<T = any>(
  values: Record<string, T> | T[],
  defaultBreakpoint?: string,
): T | undefined

cf. chakra-ui/packages/media-query/src/use-breakpoint-value.ts | GitHub

保持される値が T = any なので配列・オブジェクトで保持する事ができそうです。

🙅 Next.js で useBreakpointValue() に保持させるデータを配列で渡すとエラーになった

変数は分割代入で取れるので const [buttonSize, buttonLabel] = [size, label] の形にしようと思いました。

const ResponsiveButton: VFC = () => {
  const [buttonSize, buttonLabel] = useBreakpointValue([
    ['sm', '登録'],
    ['md', '登録する'],
  ]);

  return (
    <Button type="button" size={buttonSize}>
      {buttonLabel}
    </Button>
  );
}

うまくいきそうです。
しかし実際には次のようなエラーが発生してしまいました。
=> Uncaught TypeError: Invalid attempt to destructure non-iterable instance. In order to be iterable, non-array objects must have a [Symbol.iterator]() method.

🙅 Object 形式でも同様

export const ResponsiveButton: VFC = () => {
  const { buttonSize, buttonLabel } = useBreakpointValue([
    { buttonSize: 'sm', buttonLabel: '登録' },
    { buttonSize: 'md', buttonLabel: '登録する' },
  ]);
  // 略 
}

=> TypeError: Cannot read property 'buttonSize' of undefined

Next.js だと初回レンダリング時に useBreakpointValue() が undefined を返すのがエラーの原因

最初複数の値をセットで保存できないのかと思ったのですが、元の文字列の値を保持する状態に戻して console.log で確認すると次のようになっていました

const ResponsiveButton: VFC = () => {
  const buttonSize = useBreakpointValue(['sm', 'md']);
  console.log({ buttonSize });
  // 略
}

👇

1. { buttonSize: undefined }
2. { buttonSize: "sm" }

初回レンダリング時に useBreakpointValue() が返す値が undefined になっていました。なので先のコードでは配列形式のデータを想定して分割代入で変数に値を代入しようとしていたので初回レンダリング時に次のような処理になりエラーが発生してしまったようです

const [buttonSize, buttonLabel] = undefined;
// => Uncaught TypeError: undefined is not iterable

const {buttonSize, buttonLabel} = undefined;
// => TypeError: Cannot destructure property 'buttonSize' of 'undefined' as it is undefined.

useBreakpointValue の実装を見てみる

useBreakpointValue の実装を見てみると、useBreakpoint で breakpoint を取得していて、これが Falsy だと undefined を返すようになっており、breakpoint の取得は useBreakpointuseEffect 内で window.matchMedia を使って取得するようになっていました。

export function useBreakpointValue<T = any>(
  values: Record<string, T> | T[],
  defaultBreakpoint?: string,
): T | undefined {
  const breakpoint = useBreakpoint(defaultBreakpoint)
  const theme = useTheme()

  if (!breakpoint) return undefined
  // ...

cf. chakra-ui/packages/media-query/src/use-breakpoint-value.ts | GitHub

つまり yarn dev で実行している時の初回レンダリングは useEffect 前の値が一度返されていて、この時初期値の undefined が返されているようです。 Next.js では サーバーサイドの処理も同じコンポーネントで行われるので、クライアントサイドでのみ実行される useEffect 内でないと window オブジェクトが存在しないので matchMedia が使えず breakpoint が取得できないのでこのような仕様になっているのだと思います。

⚠️ defaultBreakpoint オプションが効かない 追記: @chakra-ui/react v1.6.4 より古いの場合

実装には defaultBreakpoint の指定ができるようになっており、この値がデフォルトのブレイクポイントとして使われるっぽいのですが、指定してみたのですが type error になり type error を ignore してもデフォルトの値は返されず undefined が返され上手く動作しませんでした。

import { useBreakpointValue } from '@chakra-ui/react';

export const ResponsiveButton: VFC = () => {
  const { buttonSize, label } = useBreakpointValue(
    {
      sm: { buttonSize: 'sm', buttonLabel: '登録' },
      md: { buttonSize: 'md', buttonLabel: '登録する' },
    },
    // defaultBreakpoint
    'sm', // => Expected 1 arguments, but got 2.
  );
  // … 略
};

実装された PR は merge されているのですが、Chakra UI のドキュメント にも記載がないので Chakra UI @chakra-ui/react: ^1.6.3 ではまだ使えないオプションなのかもです。

追記: @chakra-ui/react@1.6.4 以降なら使えそう

16-06-2021
@chakra-ui/react@1.6.4
Media Query v1.1.0
🚀 useBreakpointValue() now supports receiving a defaultBreakpoint as the second argument to support SSR/SSG.
cf. chakra-ui/CHANGELOG.md | GitHub

2021 6/16 にリリースされた @chakra-ui/react v1.6.4 からは使えるっぽいです。

解決方法: 自前でデフォルト値を設定する

useBreakpointValuedefaultBreakpoint オプションが使えず、初期レンダリング時に undefined が返ってくるので、useBreakpointValue() || defaultValue として自前でデフォルト値を設定してしまえば分割代入を使っていてもエラーになりません。

Array パターン

import { useBreakpointValue } from '@chakra-ui/react';

type ButtonSizeType = string;
type ButtonLabelType = string;
type BreakpointType = [ButtonSizeType, ButtonLabelType];

const ResponsiveButton: VFC = () => {
  const responsiveValues: BreakpointType[] = [
    ['sm', '登録'],
    ['md', '登録する'],
  ];
  const [buttonSize, buttonLabel] =
    useBreakpointValue(responsiveValues) || responsiveValues[0];
}

初回レンダリング時は undefined || responsiveValues[0] となり responsiveValues[0] の値が使われるようになります。

Object パターン

import { useBreakpointValue } from '@chakra-ui/react';
import { BaseBreakpointConfig } from '@chakra-ui/theme-tools';


type ResponsiveValue = {
  buttonSize: string;
  buttonLabel: string;
};

type BreakpointTypes = {
  [key in keyof BaseBreakpointConfig]?: ResponsiveValue;
};

const ResponsiveButton = () => {
  const breakpointValues: BreakpointTypes = {
    sm: { buttonSize: 'sm', buttonLabel: '登録' },
    md: { buttonSize: 'md', buttonLabel: '登録する' },
  };
  const defaultValue = breakpointValues.sm;
  const { buttonSize, buttonLabel } =
    useBreakpointValue(breakpointValues) || defaultValue;

BreakpointTypes の key が Partial になっているので、defaultValue の指定が breakpointValues オブジェクトに存在するキーに限定できてないですが、Next.js の初回ロード時は defaultValue が使われるようにできました。

追記: まとめ

  1. デフォルトだと useBreakpointValue() が初回レンダリング時に undefined を返すので、分割代入で値を受け取ろうとするとエラーになる
  2. Chakra UI v1.6.4 以降なら useBreakpointValue の第二引数 (defaultBreakpoint) オプションで初期値になるブレイクポイントを指定する
  3. Chakra UI が v1.6.4より古い場合は useBreakpointValue() || defaultValue で初期値を自前で指定してしまう
所管

GitHub のコードを見に行くとデフォルト値を指定できる defaultBreakpoint オプションがあり、これを渡せばサクット解決☆(ゝω・)
と思ったのですが、オプションが使えなかったというヲチで || を使うハック的な方法で何とか解決できました。
バグ踏みの達人なので、Chakra UI まだまだハマりそうです…

今回は Next.js の環境だったからこうなったのですが、React でも SSR をする環境だと SSR 時は node.js で実行されるので window オブジェクトがなく同じ問題にハマるだろうな〜と思いメモを書くことにしました。
そういえば、以前のお仕事で JSX 内でメディアクエリを利用してコンポーネントの出し分けをするコンポーネントを作成していて、SSR の際にメディアクエリが取得できず、結局クライアントで再度 React 描画時にコンポーネントの表示変更行われ Layout Shift (CLS) が発生してしまう問題に悩まされたことを思い出しました。 (その記事を描こうと思ったまま書いていなかった…


[参考]

チャクラ宙返りのおもちゃ流石にもう出てこないか…