본 포스팅은 이미 JavaScript로 작성된 React App(프레임워크는 CRA)을 TypeScript로 포팅해보려고 한다. 그 중, 새로 알게된 점을 위주로 정리 차원의 포스팅으로 모든 코드가 나와있지 않다.
1. 라이브러리 설치
해당 프로젝트 터미널 내에서 npm을 사용하여 React 프로젝트에 필요한 라이브러리를 설치한다.
- @types/react와 @types/react-dom: React와 React DOM에 대한 TypeScript 타입 정의 파일
- @typescript-eslint/eslint-plugin와 @typescript-eslint/parser: TypeScript 프로젝트에서 ESLint를 사용하기 위한 플러그인과 파서 (ESLint는 JavaScript와 TypeScript 코드의 일관성과 품질을 검사하는 도구)
- eslint-config-prettier와 eslint-plugin-prettier: Prettier와 ESLint의 통합을 제공하는 패키지 (Prettier는 코드 포맷팅 도구로, 코드 스타일을 일관성 있게 유지하는 데 도움) 이 패키지들을 사용하여 ESLint와 Prettier를 함께 사용할 수 있음
- eslint-plugin-react와 eslint-plugin-react-hooks: React 애플리케이션에 대한 추가적인 ESLint 규칙과 플러그인 (React 컴포넌트와 훅과 관련하여 잠재적인 문제를 검출하고 해결하는 데 도움)
npm install -D typescript @types/react @types/react-dom @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks prettier
eslint 에러가 뜨는 모습
2. 환경 구성
(1) tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["es6", "dom"],
"rootDir": "src",
"module": "CommonJS",
"esModuleInterop": true,
"target": "es5", // 6 하면?
"sourceMap": true,
"moduleResolution": "node",
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"strictNullChecks": true,
"allowJs": true
},
"include": ["./src"],
"exclude": ["node_modules", "build"]
}
(2) .eslintrc.js
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
extends: [
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
rules: {
"prettier/prettier": [
"error",
{
doubleQuote: true,
semi: true,
useTabs: false,
tabWidth: 4,
printWidth: 80,
bracketSpacing: true,
arrowParens: "avoid",
},
],
},
parserOptions: {
parser: "@typescript-eslint/parser",
},
};
3. TS로 포팅하기
(1-1) index.js
멘토님께 들은 꿀팁인데 CRA 공식문서의 <Adding TypeScript> 챕터에서 템플릿을 세팅한 후, 거기서 위의 2번의 내용(tsconfig.json) 등 초기 세팅에 대한 감을 잡으면 좋다고 하셨다.
그렇게 세팅하고 보니, index.js의 코드가 타입스크립트로 이렇게 작성되어 있었다.
index.js (확장자만 tsx로 바꾼 상태)
- 해당 오류는 타입스크립트가 document.getElementById('root')의 반환 타입을 Element | DocumentFragment로 예상하고 있지만, 실제로는 HTMLElement | null이 반환됐기 때문에 발생하는 것이다.
- getElementById 메서드는 해당 ID를 가진 요소를 찾으면 해당 요소를 반환하고, 찾지 못하면 null을 반환한다.
시도 1) 타입 단언으로 index.js를 포팅한 코드 (웬만하면 타입 단언은 쓰지 않는 습관을 들이자!!)
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
시도 2) null 체크를 명시적으로 수행하기
- getElementById가 null을 반환하는 경우를 처리하기 위해, if (root) 문으로 null인지 아닌지를 체크하여 렌더링을 수행한다.
- <TS에 React18 적용하기> 블로그 참고
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element");
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
시도 3) 강제 단언 연산자(!)를 사용하여 타입 단언하기 (웬만하면 타입 단언은 쓰지 않는 습관을 들이자!!)
- 쉽게 말하면 개발자가 컴파일러에게 약을 파는 것이다. "나만 믿어! null 절대절대 안 나와. 진짜야@@"
- 즉, getElementById의 반환 타입을 Element | DocumentFragment로 강제 단언하여 null이 아님을 단언한다.
- 하지만 이 방법은 getElementById가 null을 반환하는 경우 런타임 에러가 발생할 수 있으므로, 해당 요소가 항상 존재함을 확신할 수 있는 경우에만 사용해야 한다.
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
(1-2) index.tsx
시도 4) 타입을 명시해주었다.
const rootElement: HTMLElement | null = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element"); // 🟣 설명(1)
const root: ReactDOM.Root = ReactDOM.createRoot(rootElement); // 🟣 설명(2)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
🟣 설명 (1) 예외 처리를 해줘야 하는 이유?
해주지 않는 경우, getElementById가 해당 id를 가진 요소를 찾지 못한 경우 null을 반환시키기 때문에 아래와 같이 에러가 뜬다.
🟣 설명 (2) ReactDOM.Root 타입은 뭐지?
ReactDOM의 타입 중 하나로 React 애플리케이션을 렌더링하기 위한 Root 인스턴스를 나타낸다.
- 일반적으로 ReactDOM.createRoot() 메서드를 사용하여 Root 인스턴스를 생성한다.
- createRoot() 메서드는 ReactDOM.Root 타입의 인스턴스를 반환한다.
- ReactDOM.Root 인스턴스는 가상 돔의 업데이트를 관리하는 주체이다.
- 다시 말해, React 애플리케이션의 루트 컴포넌트를 가상 돔에 렌더링하고, 필요한 경우 가상 돔을 업데이트하여 실제 DOM에 반영하는 역할을 담당한다.
- ReactDOM.createRoot() 메서드를 사용하여 ReactDOM.Root 인스턴스를 생성하고, 생성된 인스턴스에 대해 render() 메서드를 호출하여 가상 돔의 초기 렌더링 및 업데이트를 수행한다.
(2-1) App.js
- Todos 컴포넌트는 'Todo list'를 렌더링하는 메인 컴포넌트이다.
기존 코드
export default function Todos() {
const [todos, setTodos] = useState([]);
const addTodo = (todo) => {
if (!todo.text || /^\s*$/.test(todo.text)) {
return;
}
const newTodos = [todo, ...todos];
setTodos(newTodos);
};
.
.
.
return (
<div>
<div className="todo-app">
<h1>To Do List</h1>
<h2>오늘은 무슨 일을 계획하나요?</h2>
<TodoForm onSubmit={addTodo} />
<Todo
todos={todos}
completeTodo={completeTodo}
removeTodo={removeTodo}
/>
</div>
</div>
);
.
.
(2-2) App.tsx
포팅한 코드
- 우선 srx/types 폴더를 만들어서, 다른 컴포넌트에서도 중복 사용이 되는 TodoItem의 인터페이스를 만들어주고, export 해주었다.
export interface TodoItem {
id: number;
isComplete: boolean;
text: string;
}
- 그 후, TodoItem을 import 하여 사용해주었다.
import { TodoItem } from "./types/TodoItemType";
.
.
.
export default function Todos() {
// useState의 경우 초기 상태가 명확하지 않을 때나 타입이 복잡한 구조인 경우에는 명시해준다.
const [todos, setTodos] = useState<TodoItem[]>([]);
const addTodo = (todo: TodoItem) => {
if (!todo.text || /^\s*$/.test(todo.text)) { // 🟣 설명(1)
return;
}
const newTodos = [todo, ...todos];
setTodos(newTodos);
};
.
.
.
🟣 설명 (1) /^\s*$/.test
/^\s*$/.test는 정규 표현식을 사용하여 문자열이 공백 문자로만 이루어져 있는지를 확인하는 코드이다. (예: " ")
- / : 정규 표현식 시작
- ^ : 문자열의 처음부터
- $ : 문자열의 끝까지
- \s : 공백 문자가
- * : 0회 이상 반복해서(빈문자열 포함)
- .test : 나타나고 있니?
- / : 정규 표현식 끝
따라서 !todo.text는 text의 값이 undefined, null, '' 인 경우를 걸러주고, 위의 정규 표현식은 공백 문자로 이루어진 ' ' 의 경우를 걸러준다고 할 수 있겠다.
(3) Todo.tsx
.
.
interface TodosProps {
todos: TodoItem[];
removeTodo: (id: number) => void; // 🟣 설명(1)
completeTodo: (id: number) => void;
}
export default function Todo({ todos, completeTodo, removeTodo }: TodosProps) {
// : FC<TodoProps> ? 🟣 설명(2)
.
.
🟣 설명 (1) Todo 컴포넌트의 속성의 타입 정의
부모로부터 전달받은 prop의 데이터 type도 선언해준다.
- 자식 컴포넌트에서 명시해 놓은 데이터 타입과 부모로부터 넘겨받은 데이터 타입이 일치하지 않으면 콘솔에 에러 경고문이 띄워진다.
- 부모 컴포넌트에서 이미 todos에 대한 타입 선언을 해줬는데 자식 컴포넌트에서 또 해주는 이유가 뭔지 궁금하다.
// 함수 넘기는 법
func(): void // func: () => void 와 같다. 파라미터와 함수의 return이 없을 경우
func2(id: number): void // 파라미터가 있고 함수의 return이 없을 경우
func3(id: string): TodoItem // 파라미터와 return값이 있을 경우
🟣 설명 (2) React.FC를 써야 하나?
React.FC는 React 함수형 컴포넌트의 타입을 정의하기 위한 방식 중 하나이다. React.FC를 쓰지 않는 것을 추천한다는 블로그 글이 있던데, 이유는 아래와 같다.
- React.FC는 기본적으로 컴포넌트가 children prop을 자동으로 포함하도록 정의되어 있다.
- 따라서 children prop을 명시적으로 사용하고 싶지 않은 경우에도 강제로 포함되기 때문에 일부 혼동을 야기할 수 있다.
- 또한 React.FC는 기본적으로 모든 prop이 선택적으로 처리되기 때문에 필수 prop을 명시적으로 지정하기 어렵다.
최신의 React 버전에서는 React.FC보다는 위와 같이 props 옆에 타입을 정의해주는 방식을 사용하는 것이 권장된다고 한다.
// FC의 구조는 아래와 같다.
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P> | undefined;
contextTypes?: ValidationMap<any> | undefined;
defaultProps?: Partial<P> | undefined;
displayName?: string | undefined;
}
- 만~~~약 필요에 의해 React.FC를 쓰는 경우 아래와 같이 제네릭 타입으로 TodosProps 타입을 지정해줄 수 있겠다.
const Todo: React.FC<TodosProps> = ({ todos, completeTodo, removeTodo }) => {
.
.
export default Todo;
최근에는 컴포넌트의 타입을 지정하지 않고 화살표 함수나 함수 선언문으로 컴포넌트를 정의하는 방식이 일반적이다. 코드의 가독성을 향상시키고 불필요한 제약을 피할 수 있으며, TypeScript의 타입 추론 기능을 활용할 수 있기 때문이다. 타입을 명시적으로 지정하고 싶거나, 속성(props)의 타입이 복잡한 경우에는 인터페이스를 사용하여 타입을 정의할 수 있다.
(4) TodoForm.tsx
interface TodosProps {
onSubmit: (todo: TodoItem) => void;
}
export default function TodoForm({ onSubmit }: TodosProps) {
.
.
const inputRef = useRef<HTMLInputElement | null>(null); // 🟣 설명(1)
useEffect(() => {
if (inputRef.current) inputRef.current.focus();
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { // 🟣 설명(2)
setInput(e.target.value);
};
const handleSubmit = (e: React.FormEvent) => { // 🟣 설명(2)
e.preventDefault();
.
.
onSubmit({
id: number,
text: input,
isComplete: false,
});
.
.
🟣 설명 (1) const inputRef = useRef<HTMLInputElement | null>(null);
useRef 훅은 제네릭을 사용하여 타입 정보를 전달할 수 있다.
- inputRef의 타입을 지정하려면 useRef의 제네릭 매개변수로 해당 요소의 타입을 전달해주어야 한다.
- const inputRef = useRef(null);</htmlinputelement | null>는 inputRef가 HTMLInputElement 또는 null을 참조할 수 있다는 것을 나타낸다.
- 즉, useRef의 제네릭 매개변수로 HTMLInputElement | null을 전달함으로써 타입스크립트에게 inputRef가 어떤 타입을 참조할 수 있는지 알려줄 수 있다.
- 이렇게 inputRef의 타입을 명시적으로 지정하면 해당 요소에 대한 타입 안정성을 확보할 수 있다.
useState의 타입을 지정하는 경우는 보통 아래와 같다.
- 상태가 null일 수도 있고 아닐수도 있을때
- 유저의 로그인 여부에 따라 userInfo 의 값이 null이 될 수도 있는 경우이다.
type Information = { name: string; description: string };
const [info, setInformation] = useState<Information | null>(null);
.
.
export interface UserInfo {
userid: string | undefined;
username: string | undefined;
}
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
- 상태의 타입이 까다로운 구조를 가진 객체이거나 배열일 때
- 배열인 경우에는 해당 배열이 어떤 타입으로 이루어진 배열인지 추론할 수 있도록 Generics을 명시하는 것이 좋다.
type Todo = { id: number; text: string; done: boolean };
const [todos, setTodos] = useState<Todo[]>([]);
🟣 설명 (2) event 타입 지정하기
event에도 타입을 지정해주어야 한다. 각 이벤트 타입은 이벤트 객체의 속성과 메서드를 정의하여 특정 이벤트에 대한 정보를 제공한다.
이벤트 타입은 보통 아래와 같은 것들이 자주 쓰인다.
- React.ChangeEvent<T>:
- 주로 <input>, <select>, <textarea> 등의 값이 변경되었을 때 발생하는 이벤트이다.
- <T>에는 이벤트 대상 엘리먼트 타입을 지정해준다.
- 예시: React.ChangeEvent<HTMLInputElement>
- React.MouseEvent<T>:
- 주로 마우스 이벤트 (클릭, 마우스 오버, 드래그 등)에 사용된다.
- 예시: React.MouseEvent<HTMLButtonElement>
- React.KeyboardEvent<T>:
- 주로 키보드 이벤트 (키 누름, 키 뗌 등)에 사용된다.
- 예시: React.KeyboardEvent<HTMLInputElement>
- React.FormEvent<T>:
- <form> 엘리먼트에서 발생하는 이벤트에 사용된다.
- 예시: React.FormEvent<HTMLFormElement>
- React.FocusEvent<T>:
- 주로 포커스 이벤트 (포커스 인, 포커스 아웃 등)에 사용된다.
- 예시: React.FocusEvent<HTMLInputElement>
- React.SyntheticEvent
- 함수 내 이벤트가 여러개 있을 때 아울러서 작성할 수 있는 이벤트 타입이다.
일단은 이벤트 타입만 지정해주었고, 아래와 같이 handleChange 함수에서만 오류가 났다.
- 오류가 발생하는 이유는 e.target의 타입이 EventTarget로 추론되어서이다.
- EventTarget 인터페이스는 이벤트를 수신하는 모든 객체를 대표하는 타입이기 때문에, 타입스크립트에서는 e.target이 EventTarget으로 추론된다.
- EventTarget 인터페이스에는 이벤트를 등록하고 삭제하는 메서드인 addEventListener와 removeEventListener가 포함되어 있다.
- 즉, EventTarget 타입은 value 속성을 가지고 있지 않기 때문에 오류가 발생한다.
- 해결하기 위해서는 e.target의 타입을 적절한 요소 타입으로 명시적으로 지정해면 된다.
React.ChangeEvent<HTMLInputElement>는 input 요소에서 발생하는 변경 이벤트를 나타내는 타입이다.
- React.ChangeEvent는 input, textarea, select와 같은 입력 요소에서 발생하는 이벤트를 다루기 위한 제네릭 타입이다.
- 입력 요소의 값에 접근하기 위해 e.target.value와 같은 속성을 사용해야 하므로, React.ChangeEvent의 제네릭 타입에는 해당 입력 요소의 타입을 명시해주어야 한다.
- HTMLInputElement 타입을 사용하여 e.target을 형변환할 수 있고, 이를 통해 value 속성에 접근할 수 있다.
- 아래와 같이 타입을 명시적으로 지정하면 타입 체크가 정상적으로 이루어지고, 오류가 해결된다.
React.FormEvent는 form 요소에서 발생하는 이벤트를 다루기 위한 제네릭 타입이다.
- 따라서 React.FormEvent를 사용할 때에는 <T>를 지정하지 않아도 문제가 없다.
- React.FormEvent는 e.preventDefault()와 같은 공통적인 이벤트 핸들링 기능을 제공한다.
- React.FormEvent를 사용하는 것은 form 요소에서 발생한 이벤트를 다루기 위해서이며, 값을 직접 접근하는 것이 아니라 다른 로직을 수행하기 위한 용도이다.
'FE > TypeScript' 카테고리의 다른 글
[TS] 제네릭 / 데코레이터 (0) | 2023.05.31 |
---|---|
[TS] JavaScript를 TypeScript로 포팅 하기 (0) | 2023.05.31 |
[TS] 타입 별칭 / 타입 추론 / 타입 단언 (0) | 2023.05.31 |
[TS] 열거형(Enum) / 인터페이스 / 클래스 / 상속 (0) | 2023.05.31 |
[TS] TypeScript 등장 배경 / 장점 / 데이터 타입 (0) | 2023.05.30 |