Vue3, TS 환경에서 StoryBook 구성하기

sunlog·2022년 6월 16일
1
post-thumbnail
📢 우선, 결론부터 이야기하자면 vue3로 StoryBook을 구성하는 것은 추천하지 않습니다. (22년 06월 기준) 돌아가세요… 그럼에도 불구하고 시도하는 것에 의의를 두신다면 아래 내용 참고 부탁드립니다.
  • 주의 사항
    1. typescript + vue3 + vite 등의 환경 예시입니다.
    2. quasar 디자인 프레임워크를 사용한 예시입니다.

1. StoryBook 설치

npx -p @storybook/cli sb init --type vue

2. 디렉토리 및 script 확인

  • 프로젝트 루트 디렉토리에서 1번 명령어 실행시 루트 디렉토리에 .storybook, ./scr/storybook 폴더가 생성되며 package.json에 storybook, build-storybook 명령어가 추가로 자동 생성됨
  • npm run storybook 으로 6006포트에서 storybook 페이지가 열리는지 확인
  • package.json 참고
    • 설치 날짜에 따라 버전은 다를 수 있으므로 참고만

        "scripts": {
          ...
          "storybook": "start-storybook -p 6006",
          "build-storybook": "build-storybook"
        }, 
      "dependencies": {
          ...(생략)
        },
        "devDependencies": {
          ...
          "@storybook/addon-actions": "^6.5.8",
          "@storybook/addon-essentials": "^6.5.8",
          "@storybook/addon-interactions": "^6.5.8",
          "@storybook/addon-links": "^6.5.8",
          "@storybook/builder-vite": "^0.1.36",
          "@storybook/testing-library": "^0.0.11",
          "@storybook/vue3": "^6.5.8"
        }
      }

3. src/storybook 폴더 수정

  • src/storybook 하위 폴더에 기본으로 생성되는 파일 중 example 파일 (introduce 파일)은 우선 삭제하지 않고 그 외 모든 파일은 js 파일이기 때문에 삭제해도 됨 (우리는 ts 파일로 생성 할 것!)
  • src/components/ButtonComp.vue 파일 생성
    <template>
      <q-btn
        :label="props.label"
        flat
        :disable="props.disable"
        @click="emit('click:clicked')"
      />
    </template>
    
    <script lang="ts" setup>
    import { defineProps, ref } from 'vue';
    // emit
    const emit = defineEmits(['click:clicked']);
    // button 인터페이스
    interface Button {
      label?: string; // 버튼 라벨
      disable?: boolean; // disable 여부
    }
    const props = withDefaults(defineProps<Button>(), {
      label: '버튼 라벨명',
      disable: false,
    });
    
    </script>
  • src/storybook/button.stories.ts 파일 생성
    import GButton from '@/components/ButtonComp.vue';
    import { Meta, StoryFn } from '@storybook/vue3';
    
    export default {
      title: 'Atoms/Button',
      component: GButton,
      argTypes: {
        label: { control: 'text' },
        disable: { control: 'boolean' },
      },
    } as Meta<typeof GButton>;
    
    // 기본
    export const Basic: StoryFn<typeof GButton> = (args) => ({
      components: { GButton },
      setup() {
        return { args };
      },
      template: `
      <g-button v-bind="args"/>
      `,
    });
    
    // 라벨
    export const Label: StoryFn<typeof GButton> = (args) => ({
      components: { GButton },
      setup() {
        return { args };
      },
      template: `
      <g-button v-bind="args"/>
      `,
    });
    
    Label.args = {
      label: '라벨명',
    };
    
    //  선택 불가
    export const Disable: StoryFn<typeof GButton> = (args) => ({
      components: { GButton },
      setup() {
        return { args };
      },
      template: `
      <g-button v-bind="args"/>
      `,
    });
    
    Disable.args = {
      disable: true,
    };
    

4. 지원되지 않는 플러그인 직접 수정

  • 위 내용까지만 진행해도 실행은 되겠지만 화면에 보이는 source code를 확인해보면 template 부분만 보이지 않고 export const~ 부터 모든 코드가 노출되는 것이 확인됨.
  • 아직 플러그인이 지원되지 않는 문제가 있음
  • .storybook/withSource.js 파일 작성
    • 코드 참고

      https://github.com/storybookjs/storybook/issues/13917

    • 주의사항 = lodash 설치 필요

      import { addons, makeDecorator } from '@storybook/addons';
      import kebabCase from 'lodash';
      import { h, onMounted } from 'vue';
      
      // this value doesn't seem to be exported by addons-docs
      export const SNIPPET_RENDERED = `storybook/docs/snippet-rendered`;
      
      function templateSourceCode(
        templateSource,
        args,
        argTypes,
        replacing = 'v-bind="args"'
      ) {
        const componentArgs = {};
        for (const [k, t] of Object.entries(argTypes)) {
          const val = args[k];
          if (
            typeof val !== 'undefined' &&
            t.table &&
            t.table.category === 'props' &&
            val !== t.defaultValue
          ) {
            componentArgs[k] = val;
          }
        }
      
        const propToSource = (key, val) => {
          const type = typeof val;
          switch (type) {
            case 'boolean':
              return val ? key : '';
            case 'string':
              return `${key}="${val}"`;
            default:
              return `:${key}="${val}"`;
          }
        };
      
        return templateSource.replace(
          replacing,
          Object.keys(componentArgs)
            .map((key) => ' ' + propToSource(kebabCase(key), args[key]))
            .join('')
        );
      }
      
      export const withSource = makeDecorator({
        name: 'withSource',
        wrapper: (storyFn, context) => {
          const story = storyFn(context);
      
          // this returns a new component that computes the source code when mounted
          // and emits an events that is handled by addons-docs
          // this approach is based on the vue (2) implementation
          // see https://github.com/storybookjs/storybook/blob/next/addons/docs/src/frameworks/vue/sourceDecorator.ts
          return {
            components: {
              Story: story,
            },
      
            setup() {
              onMounted(() => {
                try {
                  // get the story source
                  const src = context.originalStoryFn().template;
      
                  // generate the source code based on the current args
                  const code = templateSourceCode(
                    src,
                    context.args,
                    context.argTypes
                  );
      
                  const channel = addons.getChannel();
      
                  const emitFormattedTemplate = async () => {
                    const prettier = await import('prettier/standalone');
                    const prettierHtml = await import('prettier/parser-html');
      
                    // emits an event  when the transformation is completed
                    channel.emit(
                      SNIPPET_RENDERED,
                      (context || {}).id,
                      prettier.format(`<template>${code}</template>`, {
                        parser: 'vue',
                        plugins: [prettierHtml],
                        htmlWhitespaceSensitivity: 'ignore',
                      })
                    );
                  };
      
                  setTimeout(emitFormattedTemplate, 0);
                } catch (e) {
                  console.warn('Failed to render code', e);
                }
              });
      
              return () => h(story);
            },
          };
        },
      });
  • .storybook/main.ts 수정
    const { loadConfigFromFile, mergeConfig } = require('vite');
    const path = require('path');
    
    module.exports = {
      stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
      addons: [
        '@storybook/addon-links',
        '@storybook/addon-essentials',
        '@storybook/addon-interactions',
      ],
      framework: '@storybook/vue3',
      core: {
        builder: '@storybook/builder-vite',
      },
      typescript: {
        check: false,
        checkOptions: {},
        reactDocgen: 'react-docgen-typescript',
        reactDocgenTypescriptOptions: {
          shouldExtractLiteralValuesFromEnum: true,
          propFilter: (prop) =>
            prop.parent ? !/node_modules/.test(prop.parent.fileName) : true,
        },
      },
      features: {
        storyStoreV7: true,
      },
    
      async viteFinal(previousConfig) {
        const { config } = await loadConfigFromFile(
          path.resolve(__dirname, '../vite.config.ts')
        );
    
        return mergeConfig(previousConfig, {
          ...config,
          plugins: [],
        });
      },
    };
  • .storybook/preview.js 파일 수정
    import '@quasar/extras/roboto-font/roboto-font.css';
    // These are optional
    import '@quasar/extras/material-icons/material-icons.css';
    
    // Loads the quasar styles and registers quasar functionality with storybook
    import 'quasar/dist/quasar.css';
    import { app } from '@storybook/vue3';
    import { Quasar } from 'quasar';
    import '@styles/index.scss';
    
    import { withSource } from './withSource';
    // This is also where you would setup things such as pinia for storybook
    app.use(Quasar, {});
    
    export const parameters = {
      actions: { argTypesRegex: '^on[A-Z].*' },
      controls: {
        matchers: {
          color: /(background|color)$/i,
          date: /Date$/,
        },
      },
    };
    
    export const decorators = [withSource];

5. 결론

  • 위 내용까지 수정 후 run storybook으로 실행하면 source code가 원하는 모습으로 출력됨.
  • 하지만 위 소스 중 template 부분이 문자열 형태로 삽입되어 있음.
  • 추후에 오타 입력시 바로 확인이 어렵고 멀티 컴포넌트를 구성 할 경우 알아보기 힘든 큰 단점이 있음.
  • 따라서, vue로 storybook을 구성하는 것은 현재로썬.. 어려워보임

1개의 댓글

comment-user-thumbnail
2022년 7월 26일

좋은 자료 감사합니다. Vue 3에 대한 만족도가 점점 떨어지네요...

답글 달기