かもメモ

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

リストの中から特定の index の前後を n 件の範囲のデータを取得したい。

Pagination みたいにリストの中から常に n 件の値を取得する方法を考えてみた。(車輪の再発明)

要件

  • リストが n 件以上ある時は 常に n 件 表示する
  • current を中心として前後に n / 2 件づつの範囲を取る
  • 偶数件の範囲を取る場合は後ろの方を 1 件多くする
  • current が前後に n / 2 件取れない場合は先頭から又は最後から n 件取得する

current を中心に n 件 の範囲をとる

偶数件の範囲を取る場合は後ろを 1 件多くする

current を中心にできない場合は先頭から n 件の範囲に

current を中心にできない場合は最後から n 件の範囲に

実装イメージ

完成イメージ

Sample

See the Pen Untitled by KIKIKI (@kikiki_kiki) on CodePen.


current の index から開始と終了の index を求める

リストから抽出するので Array.slice() を使う想定で開始 start と 終了 end (範囲の index + 1) を取得できれば良い

type GetSliceRangeProps = {
  itemLength: number;
  currentIndex: number;
  displayNum: number;
};

const getSliceRange = ({
  itemLength,
  currentIndex,
  displayNum
}: GetSliceRangeProps): {
  start: unumber;
  end?: number;
} => {
  // 表示数がリストより多い場合は全件表示する
  if (displayNum > itemLength) {
    return {
      start: 0,
    };
  }
  // current の前後に表示する要素数
  // 表示数が偶数の場合、前が 1つ少なくなるので Math.floor で少ない数に合わせる
  const half = Math.floor((displayNum - 1) / 2);

  // current が前に必要な数に満たない場合は、先頭から表示件数分を表示する
  if (currentIndex < half) {
    return {
      start: 0,
      end: displayNum,
    };
  }

  // currentIndex + 後ろに必要な数 がリスト長以上になってしまう場合はリストの後ろから表示件数分を表示する
  // index は 0 からで、リストの要素数 length は 1 からのカウントなのでリストの最後の要素の index と比較する
  const lastItemIndex = itemLength - 1;
  if (currentIndex + half >= lastItemIndex) {
    return {
      start: itemLength - displayNum
    };
  }
  
  const start = currentIndex - half;
  const end = start + displayNum;
  
  return {
    start,
    end,
  };
};

// 使い方
const {start, end} = getSliceRange({
  itemLength: MyArray.length,
  currentIndex,
  displayNum
});

MyArray.slice(start, end);

index と length 混ざってると混乱しがち。
おわり


[参考]

Node.js Express のレスポンスで Unhandled Promise Rejection エラーが発生

Express で作った API を Lambda を実行していたらログに次のようなエラーが出力されていました。
Unhandled Promise Rejection Error: [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
Lambda からは問題なく JSON が返ってきていたのでで問題はないのですが、エラーをこのままにしておくのは気持ち悪い…

Unhandled Promise Rejection

Unhandled Promise Rejection はざっくりいえば Promise が reject された際に cactch する処理が無い場合に派生するエラーです。 詳しい仕様に関しては こちらのブログ が大変参考になりました。

今回の場合はエラーの文面にクライアントに送信した後に Header をセットしようとしてあると書かれています。
これは下記のようにエラーハンドリングや場合分けでレスポンスを返す際に returnを書き忘れた時によく出くわすエラーです。

if (!token) {
  res.send({message: 'Incorrect token.'});
}
res.send({message: 'success'});

token が無い時に早期 retuen のつもりでレスポンスを返しているが、res.send は処理を止めないので、そのまま次の res.send が実行されレスポンスの二重送信になってしまう。
👇 return を使って処理を止めるようにすれば OK

if (!token) {
  return res.send({message: 'Incorrect token.'});
}
return res.send({message: 'success'});

Response の二重送信が原因だった

今回のケースは Router で実行する関数を async 関数にしていたので、おそらく二重にレスポンスを返してしまっている箇所があり二回目のレスポンスがエラーとなり、これが Promise.reject 扱いとなり Unhandled Promise Rejection になっているものと推察しました。

エラーが発生していたコード

router.get(
  '/api/foo',
  async(req: Request, res: Response<IAccount>, next: NextFunction) => {
    //  色々処理
    return res.json({ ...responseData }).sendStatus(200);
});

// Promise.reject をキャッチしてエラーのレスポンスを返す
app.use(errorHandler);

router 内で発生した例外は errorHandler でキャッチする構成。(参考 Express な API でエラーハンドリングするよ - かもメモ)
正常にレスポンスが返ってきている場合もエラーが発生していた。

res.json(), res.sendStatus() は両方ともレスポンスを返すメソッドだった

res.json([body])
Sends a JSON response. This method sends a response (with the correct content-type) that is the parameter converted to a JSON string using JSON.stringify().

res.sendStatus(statusCode)
Sets the response HTTP status code to statusCode and sends the registered status message as the text response body. If an unknown status code is specified, the response body will just be the code number.
cf. Express 5.x - API Reference

ドキュメントを読むと res.json(), res.sendStatus() 共にレスポンスを返すメソッドだと書かれていました。 つまり res.json().sendStatus() としてしまっている箇所が Json を返した後に status 200 だけのレスポンスを返す処理になってしまっていたのが原因でした…

Status Code の設定は status() を使おう

res.status(code)
Sets the HTTP status for the response. It is a chainable alias of Node’s response.statusCode.

router 内のレスポンス部分を下記のように修正すれば OKでした

router.get(
  '/api/foo',
  async(req: Request, res: Response<IAccount>, next: NextFunction) => {
    //  色々処理
-   return res.json({ ...responseData }).sendStatus(200);
+   return res.status(200).json({ ...responseData });
});
所感

今回は本当に単純ミスでした…

教訓。ドキュメントをちゃんと読もう!

おわり。


[参考]

TypeScript default import にハマる

TypeScript Express で API を開発していて cors を使おうとした際にエラーになってハマったのでメモ

"TypeError: cors is not a function"

次のようなコードでエラーになった

import * as cors from 'cors';

const app = express();
const router = express.Router();

app.use(cors());

=> TypeError: cors is not a function

cors が function ではない???? なんでや工藤!!!!

CommonJS 形式のモジュールの import 方法に問題があった

import * as cors from 'cors'; これ。
お作法通り import cors from 'cors' だと VSCode の赤線が出て ESLint が下記のようなメッセージを出していた

Module '"/XXX/node_modules/@types/cors/index"' can only be default-imported using the 'esModuleInterop' flagts(1259)
index.d.ts(58, 1): This module is declared with using 'export =', and can only be used with a default import when using the 'esModuleInterop' flag.

import * as cors from 'cors' にすればこのメッセージが消えたので、安易にそうしてしまっていたのが問題だった。

結論: CommonJS なモジュールをインポートする時 default import (import Module from 'module') と namespace import を使った default import (import * as Module from 'module') の挙動には違いがある!

cors (v2.8.5) は module.exports でエクスポートされていた

(function () {
  // 略
  // can pass either an options hash, an options delegate, or nothing
  module.exports = middlewareWrapper;
}());

cf. cors/index.js at master · expressjs/cors · GitHub

module.exports されているモジュールを * as (namespace import) でデフォルトインポートした場合は .default プロパティを通じてでしかアクセスができない

TypeScriptのデフォルトではCommonJSなどのモジュールをES6と同様に扱うため、

  • a namespace import like import * as moment from "moment" acts the same as const moment = require("moment")
  • a default import like import moment from "moment" acts the same as const moment = require("moment").default

cf.
TypeScriptのesModuleinteropフラグを設定してCommonJSモジュールを実行可能とする | DevelopersIO
TypeScript: TSConfig Reference - Docs on every TSConfig option

なので namespace import を使った デフォルトインポートをした場合は下記の書き方らな問題がなかった

import * as cors from 'cors';

app(cors.default());

ただし上記の書き方は見慣れないので、 default import を使ってもエラーを表示しないようにするのが健全

tsconfig の設定で default import を許容するのが健全

esModuleInterop を有効にすると CommonJS なモジュールを default import で扱えるようになる

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    // ...
+   "esModuleInterop": true,
  }
}

👇 default import してもエラーが表示されなくなる

import cors from 'cors';

app(cors);

📝 esModuleInteropallowSyntheticDefaultImports

default import の型の解決自体は allowSyntheticDefaultImports が担っていて、 esModuleInterop はトランスパイル時のコードの変換も含んでいる
esModuleInterop を有効にしたら自動的に allowSyntheticDefaultImports も有効になる
もともとビルドのエラーが発生してないのであれば allowSyntheticDefaultImports を有効にするだけでも良いと思う

allowSyntheticDefaultImports
When set to true, allowSyntheticDefaultImports allows you to write an import like: import React from "react"; instead of: import * as React from "react";
This flag does not affect the JavaScript emitted by TypeScript, it only for the type checking. This option brings the behavior of TypeScript in-line with Babel, where extra code is emitted to make using a default export of a module more ergonomic.

esModuleInterop
Enabling esModuleInterop will also enable allowSyntheticDefaultImports.
cf.
TypeScript: TSConfig Reference - Docs on every TSConfig option
TypeScript の esModuleInterop フラグについて - 30歳からのプログラミング


CommonJS と ESModule

CommonJS

module.exports, exports したモジュールを require() でインポートする形式
Node.js では今も主流の方法

ES Module (ESM)

export default, export したモジュールを import でインポートする形式
主にブラウザのフロントエンドで採用されている方法

フロントエンド開発をしている場合 CommonJS で書かれたライブラリを ES Module でインポートするケースがあるのでこの場合の挙動を抑えておくと良さそう。


CommonJS の export

module.exports

ES Module で言うところの export default に近い

// increment.js
module.exports = (i) => i + 1;

default import

🙆

import increment from './increment';
increment(1); // => 2

// 好きな名前で import できる
import Foo from './increment';
Foo(1); // => 2

* as 使った namespace import

🙅‍♀️

import * as increment from '../libs/increment';
increment(1);
// => TypeError: increment is not a function

🙆

// module.export されたモジュールは default という名前で import されている
import * as increment from './increment';
increment.default(1); // => 2

// * as も好きな名前でインポートできる
import * as Foo from './increment';
Foo.default(1); // => 2

📝 module.exports = { default: Module, key: Module }

レアな書き方だと思うけど、default キーを使ったオブジェクトが module.exports されているモジュールを ESM でインポートした場合は .default を通じてでないと Module にアクセスができない
default キー以外のプロパティは exports されたものと同じ扱いになる

default import

// increment.js
module.exports = {
  default: (x) => x + 1,
  name: 'increment',
};

🙅‍♀️

import increment from "./increment";
increment(2);
// => TypeError: (0 , _incrementDefault.default) is not a function

module.exports = { default: Module } の時はオブジェクトとして export されているので increment はオブジェクトになるのでそのまま関数を呼び出すことは当然できない

🙆

import increment from "./increment";
increment.default(2); // => 3
increment.name; // => "increment"

// 好きな名前で import できる
import Bar from './increment';
Bar.default(3); // => 4
Bar.name; // => "increment"
⚠ named import できるが default は特殊な名前なので注意が必要

🙅‍♀️

import { default, name } from './increment';
name; // => "increment"
default(1);
// => SyntaxError: Unexpected token

🙅‍♀️

import { default as Alias } from './increment'
Alias(1);
// => TypeError: (0 , _incrementDefault.default) is not a function

console.log(Alias);
// => { default: ƒ default(), name: "increment" }

🙆

import { default as Alias } from './increment'
Alias.default(1); // => 2
Alias.name; // => "increment"
  • default プロパティは特殊な名前なのでそのまま named import できない。
  • { default as Alias } を使う必要があるが、Alias は default で定義された値ではなくモジュール全体をデフォルト import した際のオブジェクトになる
  • module.exports = { default: Module } されているモジュールは as を使ってインポートしても default import したときと同じになる

* as 使った namespace import

// increment.js
module.exports = {
  default: (x) => x + 1,
  name: 'increment',
};

🙅‍♀️

import * as Alias from './increment'
// default 以外のプロパティにはアクセスできる
Alias.name; // "increment"
Alias(1);
// => Alias is not a function

Alias.default(1);
// => Alias.default is not a function

console.log(Alias);
// => { name: increment", default: { default: ƒ default(), name: "increment" } }

* as import した際は .default でインポートされたオブジェクトにアクセスできていたので Alias.defaultmodule.exports されたオブジェクトになっている

🙆

import * as Alias from './increment'
Alias.name; // "increment"
Alias.default.default(1); // => 2
Alias.default.name; // "increment"

module.exports = { default: Module } のように default キーが使われたオブジェクトが module.exports されているモジュールを * as import すると Module にアクセスするには default.default としなければならなくなる


exports.Module

ES Module で言うところの export に近い

// decrement.js
exports.decrement = (x) => x - 1;

default import

🙆

import Alias from "./decrement"
console.log(Alias)
// => { decrement: f() }
Alias.decrement(2); // => 1

default import したオブジェクト内に exports したモジュールが含まれている

* as 使った namespace import

🙆

import * as Alias from "./decrement"
console.log(Alias)
// => { decrement: f(), default: { decrement: ƒ () } }
Alias.decrement(2); // => 1
Alias.default.decrement(3); // => 2

* as を使った namespace import だとインポートしたオブジェクト内に default というプロパティが含まれるが exports されたモジュールには default import と同じ方法でアクセスができる

exports.Module = Xmodule.exports = { Module: X } と同じ

exports が使われている CommonJS のモジュールを使用する際は named import を使うのが良い

exports されたモジュールは * as を使うかどうかに関わらず default import せずに named import をするのが分かりやすい

import { decrement } from "./decrement";
decrement(2); // => 1

所感

"ふんいき" でコード書いてると稀によくある。。。
少しだけ理解が進んだので良かった。
ちゃんと理解していくのが真の近道ですね…

おわり。


[参考]