유틸리티 고르기
API 레퍼런스에는 모든 내보내기가 늘어서 있습니다. 이 페이지는 거기서 답할 수 없는 물음에 답합니다. 비슷한 둘 중에 무엇을 원하는지, 그리고 왜인지.
이럴 때 보세요. 필요한 것은 대충 알겠는데(「이걸 덜 자주 돌리고 싶다」, 「딱 한 번만」, 「이걸 저장하고 싶다」, 「워커와 이야기하고 싶다」) 어떤 내보내기가 그 일을 하는지 모를 때.
먼저, 플랫폼에 이미 있지 않나요
ranuts는 표준 라이브러리를 대신하려는 게 아닙니다. 플랫폼부터 찾아보고, 유틸리티는 그것이 정말로 무언가를 더해 줄 때만 쓰세요.
| 이것 대신에… | 플랫폼에 이미 있는 것 | ranuts 쪽을 쓸 때 |
|---|---|---|
cloneDeep(value) | structuredClone(value) | 값 안에 함수처럼 structuredClone이 받아 주지 않는 것이 들어 있을 때. 저쪽은 DataCloneError를 던지지만, cloneDeep은 옮길 수 있는 것을 옮기고 나머지는 참조로 남깁니다. |
getAllQueryString(url) | new URL(url).searchParams | 이터레이터 말고 평범한 객체를 한 번에 받고 싶을 때. |
localStorageGetItem(key) | localStorage.getItem(key) | 그 코드가 저장소가 없거나 막힌 곳에서도 돌 때. 래퍼는 예외를 던지지 않고 ''를 돌려줍니다(사파리 개인 정보 보호 모드, SSR, 샌드박스에 갇힌 iframe). |
escapeHtml(str) | textContent = str | 노드가 아니라 문자열을 만들고 있을 때. |
덜 자주 하기
「덜 부르고 싶다」는 네 가지 서로 다른 뜻일 수 있습니다.
| 하고 싶은 일 | 쓸 것 | 동작 |
|---|---|---|
| 몰아친 호출 가운데 마지막 하나만(검색창, 크기 조정) | debounce(fn, ms) | 몰아치기가 멎고 ms 뒤에 돕니다. 몰아치는 동안에는 아무것도 돌지 않습니다. |
| 몰아치는 동안 일정한 간격으로(스크롤 위치, 진행률 표시) | throttle(fn, ms) | 첫 호출은 곧바로 돌고, 그 뒤로는 ms마다 많아야 한 번입니다. |
| 평생 딱 한 번만 돌기를(초기화, 한 번뿐인 경고) | once(fn) | 첫 호출에서 계산하고, 이후의 호출은 모두 그 결과를 그대로 돌려줍니다. |
| 동시에 부른 쪽들이 진행 중인 요청 하나를 나눠 쓰기를 | singleFlight(fn) | once의 비동기 판입니다. 호출이 아직 끝나지 않은 동안 뒤이은 쪽들이 거기에 합류합니다. |
memoize는 once의 옛 이름이고 하는 일도 같습니다. 이름에서 짐작되는 것과 달리, 인자별로 결과를 캐시하지는 않습니다. 새로 쓰는 코드에서는 once라고 적으세요.
정작 중요한 차이는 이렇습니다. 키 입력 핸들러에 debounce를 걸면 사람이 타이핑하는 동안에는 아무것도 돌지 않습니다. throttle이면 내내 무언가가 돌긴 하되, 키마다 도는 것은 아닙니다. 검색어 추천에는 debounce가, 「남은 글자 수」 표시에는 throttle이 맞습니다.
비동기 작업을 고삐 쥐고 돌리기
| 하고 싶은 일 | 쓸 것 |
|---|---|
| 많은 일을 돌리되 한 번에 n 개까지만 | new QuestQueue({ simultaneous: n }) |
| 너무 오래 걸리는 프라미스를 포기하기 | withTimeout(promise, ms) |
| …게다가 예외 대신 기본값으로 이어 가기 | withTimeoutFallback(promise, ms, fallback) |
| 전혀 다른 곳에서 이행시킬 프라미스 | deferred() |
| Koa 식으로 비동기 단계를 잇되, 각 단계가 다음 단계를 감쌀 수 있게 | compose(middleware) |
전부 한꺼번에 돌리고 싶다면 Promise.all이 맞습니다. 「한꺼번에 전부」가 연결을 예순 개 열어 버릴 상황이라면 QuestQueue가 맞습니다. withTimeout은 reject하니 catch와 짝지어 쓰고, 시간 초과가 당신에게 오류가 아니라면 대체값을 주는 쪽을 쓰세요.
무언가를 저장하기
| 수명과 크기 | 쓸 것 |
|---|---|
| 새로고침을 견디는 짧은 문자열 | localStorageSetItem / localStorageGetItem / localStorageRemoveItem |
| 구조가 있는 데이터, 많은 레코드, 또는 몇 MB를 넘는 것 | new WebDB({ dbName, stores }). IndexedDB를 프라미스로 감싼 것 |
| 이 페이지에서 다음 페이지로 건네는 값 하나 | createHandoff({ dbName, storeName, key }) |
localStorage* 래퍼가 있는 까닭은, 저장소를 쓸 수 없는 곳(사파리의 개인 정보 보호 모드, 샌드박스에 갇힌 iframe, 사이트 데이터를 막아 둔 브라우저)에서 원래 호출이 예외를 던지기 때문입니다. 설정 하나를 못 읽는 것보다 읽다가 무너지는 쪽이 훨씬 나쁜 고장입니다. 래퍼는 ''를 돌려주고 그냥 나아갑니다.
createHandoff는 나머지 둘 다 맞지 않는 경우를 위한 것입니다. 페이지 이동을 딱 한 번만 견디고 그 뒤에는 사라져야 하는 값 말입니다.
다른 실행 맥락과 이야기하기
| 사이를 잇는 대상 | 쓸 것 |
|---|---|
| 페이지와 웹 워커 사이, 요청과 응답의 꼴 | new WorkerClient({ create }). 요청 아이디로 답을 짝지어 줍니다 |
MessagePort의 어느 두 끝이든 | createPortBridge(port) |
| 서로를 찾아야 하는 두 창이나 iframe | 한쪽에서 acceptPortBridge(), 다른 쪽에서 악수 |
워커가 물음에 답하는 형태라면 손을 뻗을 것은 WorkerClient입니다. 요청 아이디가 없으면 겹쳐 일어난 두 호출은 지금 온 답이 누구 것인지 가릴 수 없습니다. 브리지는 그보다 한 층 아래입니다. 오가는 것이 요청과 응답의 형태가 아니거나, 통로가 이미 있을 때 쓰세요.
객체 다루기
| 하고 싶은 일 | 쓸 것 | 참고 |
|---|---|---|
| 아무와도 나눠 쓰지 않는 사본 | cloneDeep(value) | 순환 참조와 자주 쓰는 내장 타입을 감당합니다. |
| 두 값이 같은지 알고 싶을 때 | isEqual(a, b) | 참조가 같은지가 아니라 속까지 비교합니다. |
| 두 객체를 합치고 싶을 때 | merge(a, b) | 얕은 병합입니다. b의 키가 이기고, a 쪽이 바뀝니다. |
| 몇몇 키를 떨구고 싶을 때 | filterObj(obj, keys) | 적어 준 키를 뺀 사본을 돌려줍니다. |
로캘과 텍스트
resolveLocale({ supported, … })는 당신이 갖춘 로캘 가운데 무엇을 쓸지를 늘 쓰이는 사슬(명시적 선택, 저장소,navigator.languages, 대체값)에서 고릅니다. 답하는 것은 「어느 언어인가」이지 「이 문자열이 무슨 뜻인가」가 아닙니다.createI18n/useI18n(ranuts/i18n)는 번역 엔진입니다. 평평한 메시지 사전,{param}끼워넣기, 실행 중 전환을 맡습니다.segmentByRanges와paginateText는 텍스트를 앉히는 데 씁니다. 앞엣것은 위치와 강조, 뒤엣것은 상자에 들어맞는 쪽으로 잘라 나누기입니다.
i18n 엔진을 쓰지 않더라도 resolveLocale은 쓸 만합니다. 이 함수가 내리는 판단, 곧 읽는 사람의 navigator.languages를 첫 항목만 보지 않고 순서대로 존중한다는 그 대목이야말로 직접 짜면 틀리기 쉬운 곳이니까요.
모델 응답을 스트리밍하기
세 층이 있고, 각각 따로도 쓸 수 있습니다.
ranuts/stream: SSE를 해석한 다음createStreamAccumulator()로 조각들을 하나의 스냅숏에 접어 넣습니다. 제공자를 가리지 않으므로 본문·추론·도구 호출 조각이 누가 내보냈든 같은 모양으로 정리됩니다.ranuts/conversation: 덧붙이기만 하는 이벤트 로그를createConversationEngine()으로 그릴 수 있는 노드에 비춥니다. 한 줄이 무엇인지 만 정하고, 그리지는 않습니다.- ranui의
<r-conversation>: 그 노드들을 실제로 그리는 요소입니다. 화면을 아래에 붙여 두고 줄을 맞춰 나갑니다.
본문만 그린다면 1층에서 멈춰도 됩니다. 대화 기록에 비출 만한 구조가 생기면 2층을, 스크롤과 줄 맞추기까지 해결되기를 바란다면 3층을 더하세요.
어느 진입점에서 가져올까
하위 경로는 저마다 독립된, 트리 셰이킹이 되는 배럴입니다. 그 심벌을 가진 쪽에서 가져오세요. 깊숙한 소스 경로를 직접 가리켜서는 안 됩니다.
| 가져올 곳 | 들어 있는 것 | 도는 곳 |
|---|---|---|
ranuts | 뿌리 배럴. 유틸리티와 visual 면 | 브라우저 + node |
ranuts/utils | DOM/BOM, 문자열, 객체, 숫자, 색, 시간, 저장소 등 | 브라우저 + node* |
ranuts/node | HTTP 서버, 라우터, 웹소켓, fs, 스트림, 미들웨어 | node 전용 |
ranuts/visual | 2D 렌더링 엔진(Canvas / WebGL / WebGPU) | 브라우저 전용 |
ranuts/i18n | 번역 엔진. DOM에 기대지 않습니다 | 브라우저 + node |
ranuts/sw | 캐시 전략과 프리캐시 규약의 워커 쪽 절반 | 서비스 워커 |
ranuts/vnode | Snabbdom 식 가상 DOM | 브라우저 |
ranuts/stream | SSE 해석, 모델 스트림 접기, 토큰 예산 | 브라우저 + node |
ranuts/conversation | 이벤트 로그에서 그릴 수 있는 대화 노드로 | 브라우저 + node |
* ranuts/utils는 품이 넓습니다. 대부분은 브라우저를 바라보지만, 순수한 도우미(문자열, 객체, 숫자, compose, cloneDeep 등)는 어디서나 돕니다. 브라우저 코드에서 ranuts/node를 가져오면 안 됩니다. fs·http·child_process를 끌고 들어옵니다.
그래도 못 고르겠다면
API 레퍼런스를 찾아보세요. 모든 내보내기가 소스에서 뽑아낸 시그니처와 한 줄 설명과 함께 실려 있습니다. 두 줄을 다 읽고도 둘이 여전히 서로 바꿔 써도 될 것처럼 보인다면, 그건 문서의 결함이니 알려 주시면 좋겠습니다.