반응형

이전에 유저의 명령을 undo, redo 하기 위해서 유저가 실행한 커맨드 하나하나를 기록하여 

스택에 쌓는 작업을 했었다.

 

그런데 다양한 인터렉션을 처리하려고 하다보니 여러 원자적인 연산(create, delete, update)들을 조합해 하나의 커맨드로 기록할 필요성이 생기게 되었다.

 

예를 들어 다음 예시 화면과 같이

부모 노드안에 자식 노드를 추가하려면 한번 추가할때마다 각각 두번의 연산이 필요하다. 

 

1. update 연산 -> 부모 노드(시작 스탭)의 width, height가 커져야 한다. 

2. add 연산 ->  자식 노드를 nodes 배열에 추가해야한다.

 

(undo, redo할땐 두 연산을 하나로 합쳐서 작동해야한다.)

 

이것을 간단한 코드 예시로 보자면 다음과 같다.

	const [nodes, setNodes] = useRecoilState(flowNodesState); // 배열 형태
	const [edges, setEdges] = useRecoilState(flowEdgesState); 
	

	const addChildNode = () => {
		....

		// this should be updateNodes
		setNodes((nodes: Node[]) => 부모 노드 크기 변경)

		// this should be addNodes
		setNodes((nodes: Node[]) => 새로운 자식 추가)
	}

 

setState 함수에서 인자를 이용하면 비록 setState가 비동기라도

기존 state가 아닌 새로 갱신된 state를 인자로 받아 마치 차례대로 실행하는것처럼 사용할 수 있다.

 

식으로 비유하자면

A : 처음 node State

F(x) : update 연산

G(x) : add 연산

 

새로운 상태 : G(F(A))

 

 

문제점 

 

그래서 setState를 히스토리를 기록하기 위해서 command 함수들로 바꾸면

내부적으로는 setState를 커맨드 하나마다 각각 실행하니 기존과 동일하게 실행되지 않을까? 하는 가설을 세웠지만 실제로 실행해보니 기대처럼 작동하지는 않았다.

 

코드 예시

더보기

 

	const [nodes, setNodes] = useRecoilState(flowNodesState);
	const [edges, setEdges] = useRecoilState(flowEdgesState);
	

	const addChildNode = () => {
		....

		updateNodes(부모 노드 크기 변경) 
        
        	addNodes(새로운 자식 노드 추가)
        
	}

 

즉, 내가 원하던건

 

A -> B -> C -> D 연산처럼 A에서 시작해 상태를 순서대로 업데이트 하는것이였다면

 

command들 함수를 이용하면 각각 command 내부에 들어있는 setState들이 서로 연관이 없다고 리엑트가 판단하여 인자값으로 갱신되지 않은 기존의 state를 반환해 A -> B, A -> C,  A->D 처럼 서로 독립적으로 실행이 되어버리는 것이였다.

 

그래서 한가지 아이디어를 냈는데

 

우리가 원하는건 A -> B -> C -> D 연산중에서 최종적으로 히스토리에 필요한것은 

undo했을때 기존의 값으로 되돌릴 A 상태와

redo했을때 되돌릴 D 상태다. 

 

그럼 커맨드들이 node를 인자값으로 받아 node를 return값으로 반환하는 여러가지 함수들을 만들고,

그 함수들을 순서대로 실행하게 이으면 A->B->C->D 연산을 할 수 있지 않을까?

 

그리고 history stack에는 A와 D만 저장하는 식으로 구현하면 될것이다.

 

이를 위해서 함수형 패러다임을 끼워넣어 구현해보기로 해보았따.

 

각 add, remove, update 등 상태를 변경하는 atomic한 함수를 만들고, 

아래의 pipe 함수를 이용해 순서대로 실행하는 식으로 구조를 변경하고,

마지막 갱신된 state를 setState해주는 식으로 구현하면 될것이다 

const pipe = (...fns) => { 
  return (...args) => {
    return fns.reduce( // 입력 받은 fns의 순서를 뒤집는다
      (res, fn) => [fn.call(null, ...res)], // 순서가 뒤집어진 fns 를 오른쪽부터 실행
      args // 초기값으로 받은 파라미터
    )[0];
  }
};

p.s) 위 함수 동작원리에 대한건 https://velog.io/@nakta/FP-in-JS-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%A1%9C-%EC%A0%91%ED%95%B4%EB%B3%B4%EB%8A%94-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%95%A8%EC%88%98-%EC%BB%B4%ED%8F%AC%EC%A7%80%EC%85%98-%EC%BB%A4%EB%A7%81-s7k2z039vb

를 참고해주세요. 

 

 

이를 간단하게 요약한 코드 예시로 보면 다음과 같다.

const NodesPipeExecution =
		(...fns) => {
			const element: Node[] = nodes.slice();

			return (option?: OptionType) => {
				const options = { ...option, isAtomicCommand: false, doesHistoryBeRecorded: false };

				const { newElement } = pipe(fns)({newElement : element, option : options}); // 함수를 순서대로 실행

				triggerUpdate(newElement) // 실제 history를 기록하고 setState를 반영하는 함수
			};
		};

 

실제 사용 예시

 

NodesPipeExecution(
		updateNodesPipe(부모 노드 크기 업데이트), 
		addNodesPipe(새로운 노드),
)()

 

그런데 이와 같이 함수들을 직접 넘기는 식으로 구현하면 

모든 함수와 파라미터를 알아야 되니 불편하지 않을까? 라는 피드백을 받아서 

 

정해진 인터페이스에 알맞게 넘기면 내부적으로 처리하는 식으로 다시 리펙토링 했다.

 

export type QueryType<T> = ((element: T) => boolean) | { [key: string]: string }[];

export interface AddExecutionPipeType<T> {
	type: "add";
	item: (() => T[]) | T[];
}

export interface RemoveExecutionPipeType<T> {
	type: "remove";
	query: QueryType<T>;
}

export interface UpdateExecutionPipeType<T> {
	type: "update";
	query: QueryType<T>;
	onChange: (element: T) => T;
}

export type ExecutionPipeType<T> = AddExecutionPipeType<T> | RemoveExecutionPipeType<T> | UpdateExecutionPipeType<T>;


const onNodesChange = (functionArray: ExecutionPipeType<Node>[]) => {
		NodesPipeExecution(
			...functionArray.map((func: ExecutionPipeType<Node>) => {
				const { type, item, query, onChange } = func as AddExecutionPipeType<Node> &
					RemoveExecutionPipeType<Node> &
					UpdateExecutionPipeType<Node>;
				switch (type) {
					case "add":
						return addNodesPipe(item);
					case "remove":
						return removeNodesPipe(query);
					case "update":
						return updateNodesPipe(query, onChange);
					default:
						return new Error("developer type error");
				}
			})
		)();
	};

 

실제 사용 예시



onNodesChange([
			{
				type: "update",
				query: 부모 노드 선택,
				onChange: (element: Node) => {
					부모 노드 크기 변경
				},
			},
			{
				type: "add",
				item: 새로운 자식 노드 생성,
			},
		]);

 

함수형 패러다임을 맛만 본거긴 하지만 실제로 해보니 재밌었다.

 

 

reference

 

https://velog.io/@nakta/FP-in-JS-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%A1%9C-%EC%A0%91%ED%95%B4%EB%B3%B4%EB%8A%94-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%95%A8%EC%88%98-%EC%BB%B4%ED%8F%AC%EC%A7%80%EC%85%98-%EC%BB%A4%EB%A7%81-s7k2z039vb

 

FP in JS (자바스크립트로 접해보는 함수형 프로그래밍) - 함수 컴포지션, 커링

두 번째 글입니다. 함수형 프로그래밍에서는 함수의 조합으로 원하는 값을 만들어 냅니다. 함수의 조합인 함수 컴포지션에 대해서 살펴보도록 하겠습니다. 그리고 커링 기법을 이용해 함수 컴

velog.io

 

반응형

'Front-end > React' 카테고리의 다른 글

react Dialog tab trap 만들기  (1) 2023.05.01
프론트엔드 테스트 정리 요약  (1) 2022.11.30
react-flow에 undo, redo 구현  (0) 2022.09.03
Suspense를 위한 wrapper 만들기  (0) 2022.07.28
react 17(개인 공부용)  (1) 2022.06.09

+ Recent posts