40
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

この記事は「株式会社オープンストリーム Advent Calendar 2019」の5日目の記事です。

「Vue.jsとAtomic Designを実践した」という事例が増えてきましたね✨
そこで今回はNuxt.jsとStorybookを用意しながらAtomic Designを実践したお話をできればなと思います。

(自明的に)この記事の対象としては次の3つになります。

  • Nuxt.jsを使いたい(これ一つで完結したWebアプリを作りたい, SPAを簡単に導入したいなどなど)
  • Storybookを使いたい
  • Atomic Designを取り入れたい

開発環境

macOSと

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.1
BuildVersion:   19B88

$ node -v
v13.2.0

$ npm -v
6.13.1

$ yarn -v
1.21.0

Windowsで動作確認をしています。

C:\Windows>ver

Microsoft Windows [Version 10.0.18363.476]

...

$ node -v                       
v13.2.0                         
                                
$ npm -v                        
6.13.1                          
                                
$ yarn -v                       
1.21.0                          

Atomic Design

はじめにUIを一番小さい要素である原子(Atoms)に分割して、それを下の図のように順番に組み合わせて意味のあるUIパーツやテンプレート(ページ)を作るデザイン手法です。

Atomic Design.png
( http://atomicdesign.bradfrost.com/chapter-2/ より引用 )

(かなりざっくり言ってしまうと)例えばWebページでいうボタンやフォームのラベルなどがAtomsに該当します。

atomic-design-parts.png

小さい要素であるAtomsから順番に作っておくと、例えば新しい機能を作る中で「同じようなボタンを新しく作りたい」と感じたときは

  • すでにあるAtomsからMoleculesを作る
  • すでにあるOrganismsに別のデータ(文言など)を投入してTemplatesに追加して配置する

など すでに書いたものを流用することができます。

Atomic Designの参考となる記事

Atomic Designについては、こちらの記事などがわかりやすいです。
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/

Atomic Designの元となる書籍はこちらから見ることができます。
http://atomicdesign.bradfrost.com/

「一番小さい単位であるAtomsではどのくらいまで分割すればいいのか」など、各コンポーネントの大きさについてはこちらがわかりやすいです。
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-division

Nuxt.jsでAtomic Designを実現する際のファイル構成

NuxtでAtomic Designを実現するとき、Atoms/Molecules/Organisms/Templatesは components 以下に作るためコンポーネントとして扱います。

Atomsを組み合わせてMoleculesを作る…というようにコンポーネント間の親子関係が発生します。

directory.png

作るサンプル

スライダーを動かすと「レビューらしきもの」の表示件数を変えることができて、「星を表示」の操作で各「レビューらしきもの」についている星を隠したりするサンプルを考えてみましょう。

2019-12-05.gif

バックエンドのところは作りません。そこはNuxt.jsとか他のものでAPIを作って…
SPAのページを遷移してもデータを保持できるようにVuexを使って…も今はナシです🙅後にしましょう

サンプルコードはこちらに置いておきます。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205

プロジェクトの作成

Nuxtのプロジェクトを作るときと同様です。 create-nuxt-app を使っています😄

$ npx create-nuxt-app nuxt-atomic-design-20191205

create-nuxt-app v2.12.0
✨  Generating Nuxt.js project in nuxt-atomic-design-20191205
? Project name nuxt-atomic-design-20191205
? Project description My learning Nuxt.js + Atomic Design Project
? Author name ysd-marrrr
? Choose the package manager Yarn
? Choose UI framework Bulma
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules
? Choose linting tools ESLint, Prettier
? Choose test framework None
? Choose rendering mode Single Page App
? Choose development tools jsconfig.json (Recommended for VS Code)

componentsにAtoms/Molecules...を作成するため、ディレクトリを予め用意します、

$ mkdir -p ./components/{atoms,molecules,organisms,templates}

Atomic DesignでいうPagesはそのまま ./pages に置きます。

Storybookの設定

こちらもNuxtにStorybookを導入するときと同じようにします。
http://tacamy.hatenablog.com/entry/2019/05/27/113131

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

Nuxtのコンポーネントをそのまま使いたいので、 ../components を使うように ./.storybook/config.js を修正します。

./.storybook/config.js
import { configure } from '@storybook/vue';

configure(require.context('../components', true, /\.stories\.js$/), module);

Storybook導入時のサンプルは要らないので削除します。

$ rm -rf ./stories

StorybookにBulmaのCSSを適用する

Nuxt用のWebpackとStorybook用のWebpackは別物なので、Storybook用の設定を追加します。
StorybookでもBulmaを使いたいので、ファイルを読み込めるようsass-loaderなどを追加します。

$ yarn add -D node-sass sass-loader mini-css-extract-plugin

BulmaでWebpackを使うときと同様に**Storybookのwebpack.config.js**を編集します。
https://bulma.io/documentation/customize/with-webpack/#3-5-create-a-webpack-config-webpack-4

./.storybook/webpack.config.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = ({ config }) => {
  config.resolve.alias['@'] = path.resolve(__dirname, '../')
  config.module.rules.push({
    test: /\.scss$/,
    use: [
        MiniCssExtractPlugin.loader,
        {
          loader: 'css-loader'
        },
        {
          loader: 'sass-loader',
          options: {
            sourceMap: true
          }
        }
      ]
  });
  config.plugins.push(new MiniCssExtractPlugin({
    filename: 'css/mystyles.css'
  }));
  return config
}

この設定を追加しないとStorybookを起動したときにコンポーネントが見つからない旨のエラーが出ます

ERROR in ./components/atoms/sliders/Slider.stories.js
Module not found: Error: Can't resolve '@/components/atoms/sliders/Slider.vue' in '~/Projects/nuxt-atomic-design-20191205/components/atoms/sliders'
@ ./components/atoms/sliders/Slider.stories.js 2:0-59 6:14-20
@ ./components sync .stories.js$
@ ./.storybook/config.js
@ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true&quiet=true

また、MiniCssExtractPluginが設定されていないとStorybookにBulmaが適用できません。

私の環境ではこの後にcore-jsをダウングレードしないと core-jsの不具合でNuxtのビルドができなくなってしまったので、必要に応じでダウングレードします。
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4

$ yarn add core-js@2.6.9

次にStorybookにおける各コンポーネントでBulmaのCSSを読み込ませます。 .storybook 内に CommonCss.vue を作ります。

./.storybook/CommonCss.vue
<template>
  <div class="decorator">
    <slot name="story"></slot>
  </div>
</template>

<script>
  export default {
    name: 'CommonCss'
  }
</script>

<style lang="scss">
  @charset "utf-8";
  @import "~bulma/bulma";
</style>

CommonCss.vue を最初に読み込むように config.js を修正します。 この読み込みでトラブルがあると「コンポーネント単体でSCSSが効かない」などおかしな挙動に襲われます(実体験)

./.storybook/config.js
import { configure, addDecorator } from '@storybook/vue';
import CommonCss from './CommonCss.vue';

addDecorator(story => ({
  components: { CommonCss },
  render(h) {
    return (
      <common-css>
        <story slot="story"></story>
      </common-css>
    )
  }
}))

// automatically import all files ending in *.stories.js
configure(require.context('../components', true, /\.stories\.js$/), module);

Atoms

コンポーネントを作る際は、コンポーネントの本体となる .vue ファイルと、Storybookの設定である .stories.js を用意します。
また、Atoms/Molecules...の中にも大量にコンポーネントを作るため、意味のある単位でその中にディレクトリを作っておくと良いでしょう。

スライダー

$ mkdir ./components/atoms/sliders

コンポーネントのファイルもスライダーだけのシンプルなものにしましょう。

./components/atoms/sliders/Slider.vue
<template>
  <input type="range" value="10" min="1" max="20" />
</template>

Storybookの storiesOf() の第一引数はStorybookの左側に表示される階層なので、./componentsと同じにしておきましょう。

./components/atoms/sliders/Slider.stories.js
import { storiesOf } from '@storybook/vue';
import Slider from '@/components/atoms/sliders/Slider.vue';
 
storiesOf('atoms/sliders/Slider', module).add('default', () => ({
  components: { Slider },
  template: '<slider />'
}));

storybook-structure.png

切り替えボタン

$ mkdir ./components/atoms/tab

切り替えボタンも同様に作りましょう。BulmaのTabsを流用します。
https://bulma.io/documentation/components/tabs/#styles

ボタンの文字は仮のものを入れておきましょう。後で本当の内容に直せます。

レビュー表示部

$ mkdir ./components/atoms/labels
$ mkdir ./components/atoms/boxes

レビュー表示部も同様です。こちらは

  • タイトル
  • レーティング(星5つです!)
  • 本文
  • それらを囲む枠(Box)

に分けます。
ボタンのときと同じく 内容を入れたい衝動 に駆られますが、ここは抑えて仮の文字を入れましょう。 本当の文字はもっと上のレイヤーの Pages で入れるべきです。

枠はBulmaのBoxを流用しちゃいましょう。
https://bulma.io/documentation/elements/box/

Boxの中にパーツを配置したいため、BoxのコンポーネントではSlotを用います。

./components/atoms/boxes/Box.vue
<template>
  <div class="box"><slot /></div>
</template>

ちなみに、このAtomsの段階ではBoxのwidthは100%と横幅いっぱいにします。実際に配置するときはCSSフレームワークのグリッドシステムなどで横幅を決めます。

box-width-100.png

わかりやすいように仮に文字を入れたサンプルですが、影がついているBoxの横幅がいっぱいに広がっています。

Molecules

molecules.png

Atomsを組み合わせてMoleculesを作ります。現時点では コンポーネントのファイルでAtomsコンポーネントを読み込む以外は同じ作りにします。

Storybookの設定はMoleculesになっても変わりはなく、Atomsと同様にコンポーネントを指定します。

./components/molecules/reviews/ReviewBox.stories.js
import { storiesOf } from '@storybook/vue'
import ReviewsBox from '~/components/molecules/reviews/ReviewsBox.vue'

storiesOf('molecules/reviews/ReviewsBox', module).add('default', () => ({
  components: { ReviewsBox },
  template: '<reviews-box />'
}))

レビューの星

$ mkdir ./components/molecules/ratings

星を繰り返し表示できるようにします。
この場合は v-for で「リストの要素に応じて繰り返す」のではなく「x回繰り返す」実装にするため、次に紹介されている parseInt(), index in x のような方法にしました。

v-for において必須である v-bind:key が重複してしまう問題は解決できていません。
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-range

星を表示する数は仮のものを用意します。

./components/molecules/ratings/Ratings.vue
<template>
  <div class="ratings">
    <span v-for="activeStar in stars" :key="activeStar">
      <rating-star />
    </span>
  </div>
</template>

<script>
import RatingStar from '@/components/atoms/ratings/RatingStar.vue'
export default {
  components: { RatingStar },
  data() {
    return {
      stars: 3
    }
  }
}
</script>

スライダー

$ mkdir ./components/molecules/sliders

このMoleculesでは次のAtomsを読み込んで組み合わせていきます。

  • どんなことをするスライダーなのか、その名前(実際の値は後述する props を使う方法で入れます)
  • スライダー本体
  • スライダーの値を表示する部分
./components/molecules/sliders/DisplayAmountSlider.vue
<template>
  <div class="display-stars-panel columns">
    <div class="column is-one-fifth"><default-label /></div>
    <div class="column is-two-fifths"><slider /></div>
    <div class="column is-one-fifth"><default-label /></div>
  </div>
</template>

<script>
import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue'
import Slider from '@/components/atoms/sliders/Slider.vue'
export default {
  components: {
    DefaultLabel,
    Slider
  }
}
</script>

Organisms

organisms.png

Moleculesを組み合わせてOrganismsを作ります。
こちらもStorybookの設定はMolecules, Atomsと同様にコンポーネントを読み込ませます。

コントロールパネル(スライダー+ボタン)

$ mkdir ./components/organisms/controls

Moleculesと同様にこのコンポーネントで

  • スライダー部
  • 切り替えボタン

を組み合わせます。

レビュー表示部(子コンポーネントにSlotを使った場合)

このようにSlotを設定した子コンポーネントの タグの中に パーツを配置すると、子コンポーネントで定義した <slot/> の位置に入ります。

./components/molecules/reviews/ReviewBox.vue
<template>
  <box>
    <rating-star />
    <review-title />
    <default-label />
  </box>
</template>

<style lang="scss" scoped></style>

<script>
import RatingStar from '@/components/atoms/ratings/RatingStar.vue'
import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue'
import ReviewTitle from '@/components/atoms/labels/ReviewTitle.vue'
import Box from '@/components/atoms/boxes/Box.vue'

export default {
  components: { RatingStar, DefaultLabel, ReviewTitle, Box }
}
</script>

Templates

Organismsを組み合わせてTemplatesを作ります。今回は一つだけ作ります。
こちらもStorybookの設定はOrganisms, Molecules, Atomsと同様にコンポーネントを読み込ませます。

あとのPagesでデータを入れるので、この段階でまだ実際のデータを入れないようにしましょう。

templates.png

また、Nuxtのlayoutsと混同しそうになりますが、layoutsは各ページで共通して表示するヘッダーを記述するだけにとどめます。
Templatesはページごとに異なる内容で、Atomsから組み上げたときに作ります。
(ヘッダーを作り込むときはAtomsから必要になるかもしれません)

Pages

pages.png

Templatesを読み込んで(後ほど実データを投入して)Pagesを作ります。 こちらは components ディレクトリではなく pages ディレクトリを使います。

実際のデータを入れず、コンポーネントを組み合わせてここまで実装するとこんな感じになると思います。
(ヘッダー部分は layouts ディレクトリのファイルにて設定しています)

pre-pages.png

親子コンポーネント間のやり取り(props, events)

ここからは作成した各コンポーネントに実際のデータを入れていく作業になります。

図のほうがわかりやすいので、例えばスライダーを操作したときのデータのやり取りを図にすると次の通りになります。

props-events.png

スライダーをユーザーが操作した際に子コンポーネントに反映させる場合もそうですが、 親のpagesで設定した初期値を子コンポーネントに渡すときも props を使います。

ラベルに文字を表示する: props

props を設定して、渡された値をそのまま表示できるようにします。
後述する「propsを直接変更しないように実装する必要がある」という課題を解決するためにpropsの名前は末尾にProp とつけています。

./components/atoms/labels/DefaultLabel.vue
<template>
  <span>{{ displayTextProp }}</span>
</template>

<script>
export default {
  props: {
    displayTextProp: {
      type: String
    }
  }
}
</script>

使う側からはv-bindpropsにデータを投入します。
この実装では、

  • DefaultLabelとしてラベルを使いまわしていて
  • 「表示する数を選択」の文言は 表示する件数を制御するスライダーの Moleculesの段階で表示できればいいので

そのまま入れてしまいます。

./components/molecules/silders/DisplayAmountSlider.vue
<template>
  <div class="display-stars-panel columns">
    <!-- Moleculesの段階で固定の文言として表示したいので入れる -->
    <div class="column is-one-fifth"><default-label :display-text-prop="'表示する数を選択'"/></div>
    
    <!-- ここから下は親の設定値を動的に反映させるので後で -->
    <div class="column is-two-fifths"><slider /></div>
    <div class="column is-one-fifth"><default-label /></div> 
  </div>
</template>

Storybookで「Atoms単体」として出したいときのためにpropsのデフォルト値を設定する

propsdefaultを設定すると、AtomsだけをStorybookで開いたときに「ラベルだと真っ白…」ということがなくなります。

./components/atoms/labels/DefaultLabel.vue
  props: {
    displayTextProp: {
      type: String,
      default: 'Type text here'
    }
  }

また、ESLintの設定によってはpropsdefaultを設定しないとwarningが出ることがあります。

21:5 warning Prop 'displayAmountProp' requires default value to be set vue/require-default-prop

ただし、ボタン(タブ)のデータとなるpropsにもdefaultを設定して、これを子コンポーネント全部に入れるとなると……悲惨なことになります。
propsdefaultが必要なのはわかりますが…これはまだ解決できていません🙇‍♂️

 ... 
  props: {
    availableOptionsProp: {
      type: Array,
      default: function() {
        return [
          { value: 'false', text: 'A' },
          { value: 'true', text: 'B' }
        ]
      }
    }
  },
  data: function() {
    return {
      availableOptions: this.availableOptionsProp
    }
  }

子コンポーネントから動的な値を親に送る: events / this.$emit()

例えば「スライダーの値を変更したとき、その値を使って何か フロントエンド(クライアント)側で 処理をしたい」という場面があります。
その時にはeventにメソッドを紐づけて、メソッド内のthis.$emit()で変更された値を親に送ります。

子で処理しようとすると「どこで処理しているのかわからない」「コンポーネントを使いまわしたいけどイベントで勝手に動く」など混乱の原因になります。

./components/atoms/sliders/Slider.vue
<template>
  <div>
    <input
      type="range"
      min="1"
      max="20"
      v-model="sliderValue"
      @change="onSliderChange"
    />
  </div>
</template>

<script>
export default {
  props: {
    sliderValueProp: {
      type: Number,
      default: 0
    }
  },
  data: function() {
    return {
      sliderValue: this.sliderValueProp
    }
  },
  methods: {
    onSliderChange: function() {
      this.$emit('sliderUpdate', this.sliderValue)
    }
  }
}
</script>

events.png

さらにイベントを親に渡したいときは 受け取ったイベントにmethodsを紐づけて、もう一度イベントを送ります。
子コンポーネントから this.$emit() の第2引数で送った値はmethodsで定義したメソッドの引数で受け取ることができます。

/components/molecules/sliders/DisplayAmountSlider.vue
...
    <div class="column is-two-fifths"><slider @sliderUpdate="onSliderUpdate"/></div>
</template>

<script>
import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue'
import Slider from '@/components/atoms/sliders/Slider.vue'
export default {
  components: {
    DefaultLabel,
    Slider
  },
  props: {
    displayAmountProp: {
      type: Number,
      default: 0
    }
  },
  data: function() {
    return {
      displayAmount: this.displayAmountProp
    }
  },
  methods: {
    onSliderUpdate: function(newValue){
      this.$emit('onSliderUpdate', newValue)
    }
  }
...

親で処理が終わった後、そのデータを子に反映させたいときはpropsを用います。

./pages/index.vue
<template>
  <div class="root-contents">
    <main-template
      :display-amount-prop="displayAmount"
      :is-display-stars-prop="isDisplayStars"
      :display-stars-options-prop="displayStarsOptions"
      :review-data-prop="actualDisplayData"
      @onDisplaySettingsChanged="onDisplaySettingsChanged"
    />
  </div>
</template>

さらにpropsを子コンポーネントに渡したいときは、一度propsの値を受けてから再度propsを設定します。

./components/templates/MainTemplate.vue
<template>
  <div class="container">
    <controll-panel
      :display-amount-prop="displayAmount"
      :is-display-stars-prop="isDisplayStars"
      :display-stars-options-prop="displayStarsOptionsProp"
      @onDisplaySettingsChanged="onDisplaySettingsChanged"
    />
    ...
</template>

<script>
...
export default {
  components: {
    ControllPanel,
    ReviewsBox
  },
  props: {
    displayAmountProp: {
      type: Number,
      default: 0
    },
...

props.png

v-bindなどでpropsを直接変更させないために

スライダーの値を取得したいときに次の実装をすると問題が出ます。

  • スライダーのvaluepropsを紐づける: 反応しない
  • スライダーのv-bindpropsを紐づける: 反応するが warningが出る

vue.esm.js:628 [Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "sliderValueProp"

そのため「v-modelに紐づけて変更する用」にdataを用意しないといけません。
コンポーネントのdataなのかpropsなのかを区別する必要があるため、propsとしている変数は末尾にPropsとつけています。

このようにdataも設定すると より子コンポーネントで状態を保持している感が強まるので 混乱しないためにも

  • 親コンポーネントに変更後の値を渡して
  • 親で通信などの処理をしてから

その結果を子に反映させる実装をします。

親からpropsを受け取りたい場合はdataではなくcomputedにしないと反映されない

…と言いたいのですが、propsの値をdataで受ける実装にしてしまうと、親からデータが渡ってきたときに それが反映されません。

反映されない例
<script>
export default {
  ...
  props: {
    displayAmountProp: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      displayAmount: this.displayAmountProp
    }
  },
  ...

この場合 data の代わりに computed を使います。

反映される例
<script>
export default {
  ...
  props: {
    displayAmountProp: {
      type: Number,
      default: 0
    }
  },
  computed: {
    displayAmount() {
      return this.displayAmountProp
    }
  },
  ...

props/eventsでコンポーネント間のデータのやり取りをしていて一番ハマったポイントです。
スライダーなどユーザーの操作で変更される可能性があるpropscomputedと併用しましょう。

動作確認

組みあがったものの動作を確認したいときは次のコマンドを

$ yarn dev

StorybookでUIパーツを確認したいときは次のコマンドを使います(今頃かよ、という気がしますが)

$ yarn storybook

*Windowsの場合は C:\Windows\system32 を環境変数に追加しないと、ブラウザを開けずにStorybookが終了します。
https://qiita.com/maedadada/items/97c54b68d5825b60c393

storybook.png

おわりに

Atomic Designを実践し、WebアプリのUIをコンポーネントにすることで「この機能増やしてー」と頼まれたときにAtoms/Molecules/Organismsを流用して変更を素早く実現できています。

今のところは💦

ただし、その一方で

  • Atoms/Molecules/Organismsの3つにUIパーツが全て収まらない
  • Atomsを用いたOrganismsが出てきてしまう、など悩ましい状況が出てくる
  • コンポーネントを大量に作成したり、大量のpropsやeventsを設定する、となると時間がかかる
    そしてAdvent Calendarに間に合わなくなる🙇‍♂️

といった問題があります。
適切にAtomsなどに分割して仕様変更に対応しやすい構造を目指しましょう。

(後ほど使いまわしたい共用部分が発生して)手戻りを許容するのであれば、Atomsを作成してprops/ eventsを設定するコストを減らすためにMoleculesから作成するのもアリだと考えています。

Advent Calendar遅れてホント申し訳ない

最後に、このサンプルのソースコードはこちらです。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205

\次は @miyatay さんです/

参考

Atomic Design by Brad Frost
http://atomicdesign.bradfrost.com/

Atomic Design を分かったつもりになる | DeNA DESIGN BLOG
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/

Vue.js × Atomic Design - コンポーネント分割の指針 / Vue.js and Atomic Design - Guideline for components division
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-division

Nuxt.jsでStorybookを使用してみたメモ - WebTecNote
https://tenderfeel.xsrv.jp/javascript/4121/

Nuxt.jsへのStorybookの導入と、Sassの変数や共通CSSを読めるようにする設定 - tacamy--blog
http://tacamy.hatenablog.com/entry/2019/05/27/113131

Nuxt.js + Vuetifyのセットアップでcore-js関係の依存関係が見つからないと怒られた
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4

Vue Js - Loop via v-for X times (in a range)
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-range

ng s -oコマンド実行時の「spawn cmd ENOENT」エラー対処法
https://qiita.com/maedadada/items/97c54b68d5825b60c393

40
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?