Tech stack
Lexical
Meta(Facebook)에서 만든 오픈소스 텍스트 에디터 입니다. React 기반으로 확장이 용이한 구조로 되어 있습니다. Plugin 방식의 확장을 위해 별도의 구조를 잡지 않고 React의 context를 사용하여 child node에서 plugin 컴포넌트를 추가하는 방식을 사용하고 있습니다.
builder & canvas
Builder는 canvas 영역을 포함하는 전체 작성 화면을 말합니다.
canvas는 Iframe으로 되어 있기 때문에 root window와 iframe window 사이에 데이터 동기화가 필요합니다.
가령 Builder의 Navigator 영역에서 컴포넌트를 재배치하면 canvas 영역에서도 컴포넌트 재배치가 이루어져야 합니다. 그리고 그 반대의 동작도 가능해야 합니다.
이러한 기능을 구현한 것이 pubsub
입니다.
pubsub
의 설정은 각각 useBuildStore
, useCanvasStore
에서 합니다.
immerhin
은 이 builder와 canvas의 동기화 과정에서도 store의 변경에따라 publish 하고 이를 subscribe하는 측에서 다시 store 업데이트를 하는데 사용합니다.
Component tree 생성
@webstudio-is/react-sdk
패키지는 ReactSdkContext
를 통해 렌더링 정보를 공유 합니다.
컴포넌트 트리의 엘리먼트는 Instance
라고 합니다.
export const Instance = z.object({
type: z.literal("instance"),
id: InstanceId,
component: z.string(),
label: z.string().optional(),
children: z.array(z.union([Id, Text])),
});
component
는 해당 Instance
가 어떠한 컴포넌트인지 children
은 인스턴스는 그 자식들을 가리킵니다.
다음은 Instance
map을 구성하는 예시 입니다.
const instances = new Map([
createInstancePair("root", "Body", [{ type: "id", value: "box1" }]),
createInstancePair("box1", "Box", [
{ type: "id", value: "box11" },
{ type: "id", value: "box12" },
{ type: "id", value: "box13" },
]),
createInstancePair("box11", "Box", []),
createInstancePair("box12", "Box", []),
createInstancePair("box13", "Box", []),
]);
createElementsTree
는 Instance
map 정보를 받아 ReactSdkContext
를 구성하고 Provider
를 제공합니다.
Instance
는 WebstudioComponent
에 의해 최종 렌더링 합니다.
WebstudioComponent
는 high order component로서 Instance
가 가리키는 React 컴포넌트를 생성하고, 필요한 props 정보를 반환합니다.
export const WebstudioComponent = forwardRef<
HTMLElement,
WebstudioComponentProps
>(({ instance, instanceSelector, children, components, ...rest }, ref) => {
const { [showAttribute]: show = true, ...instanceProps } = useInstanceProps(
instance.id
);
const props = {
...instanceProps,
...rest,
[idAttribute]: instance.id,
[componentAttribute]: instance.component,
};
if (show === false) {
return <></>;
}
const Component = components.get(instance.component);
if (Component === undefined) {
return <></>;
}
return (
<Component {...props} ref={ref}>
{renderWebstudioComponentChildren(children)}
</Component>
);
});
createElementsTree
에서 Component
prop을 통해 WebstudioComponent
를 전달하면 최종 결과물을 렌더링 합니다.
Builder의 canvas에서는 createElementsTree
의 Component
prop에 WebstudioComponentPreview
또는 WebstudioComponentCanvas
를 대입합니다.
먼저 WebstudioComponentPreview
를 살펴 보면 Instance
별 style과 관련된 내용을 추가로 적용하는 코드가 전부 입니다.
export const WebstudioComponentPreview = forwardRef<
HTMLElement,
WebstudioComponentProps
>(({ instance, instanceSelector, children, components, ...restProps }, ref) => {
const instanceStyles = useInstanceStyles(instance.id);
useCssRules({ instanceId: instance.id, instanceStyles });
const { [showAttribute]: show = true, ...instanceProps } = useInstanceProps(
instance.id
);
const props = {
...mergeProps(restProps, instanceProps, "merge"),
[idAttribute]: instance.id,
[componentAttribute]: instance.component,
};
if (show === false) {
return <></>;
}
const Component = components.get(instance.component);
if (Component === undefined) {
return <></>;
}
return (
<Component {...props} ref={ref}>
{renderWebstudioComponentChildren(children)}
</Component>
);
});
반면 WebstudioComponentCanvas
의 구현은 WebstudioComponentPreview
의 기능과 더불어 <TextEditor/>
컴포넌트의 Lexical 에디터를 확장한 wysiwyg editing 기능이 있습니다.
마치며
Web studio의 구조를 중심으로 살펴 보았습니다.
먼저 iframe으로 나누어진 canvas 영역으로 인해 pubsub
즉 postMessage
를 사용한 프로토콜 사용이 불가피 했습니다.
물론 imerhin
을 사용하여 store 동기화를 서버와 하듯이 iframe간에 동기화에도 사용하고 있지만 궁극적으로 postMessage
를 사용한 별도의 프로토콜 레이어의 구현과 운용은 불필요한 복잡도를 가져다 준 것은 아닌가 생각합니다.
이러한 복잡도에 비해 얻어지는 뚜련한 장점은 찾을 수 없었습니다.
Remix의 사용도 과한 측면이 보입니다. SSR이 구지 필요한가 싶은 애플리케이션이기 때문입니다. 사용자 생셩형 컨텐츠의 경우 빠른 렌더링과 SEO등을 위해 SSR이 필요하지만 Web studio는 CSR로 충분하다고 생각합니다. tRPC를 사용하면서 Remix와 연동하는 부분도 인상 깊었지만 뚜렷한 장점을 찾을 수 없었습니다.
하지만 웹저작툴의 기본적인 구조와 설계시 고려사항을 고민해볼 수 있어 좋았습니다. 앞으로도 꾸준히 발전하기를 응원해 봅니다.