storybook与Nuxt.js一起使用的综合指南


对于那些不知道的人,Storybook 是一个独立开发 UI 组件的开源工具。查看其用例

将 Storybook 与普通的 Vue.js 一起使用并不麻烦,但它是 Nuxt 的另一个story,因为它不能直接使用。那里的信息是分散的,我不得不挖掘其他人的回购和例子,让它与 Nuxt 合作,包括我通常如何使用Store。

我以为我会把它写下来并为那些想要开始使用带有 Nuxt 的 Storyboook 的其他人创建一个更强大的示例存储库。

我通常的项目设置包括使用Vuex storeNuxt Axios模块TailwindCSS自定义SCSS

这大致是我希望看到与 Storybook 一起工作,而不必过多地改变我对 Nuxt 的使用方式。

在本示例的最后,我们将有一个 List 组件,可以从JSONPlaceholder外部加载数据。

看看它在这里会是什么样子。

这是一个广泛的指南,所以请随意跳到您正在寻找的部分。你可以在这里获取所有这些代码。

内容

  • Setup initial Nuxt project - 使用 npx 定期安装 Nuxt;
  • Adding Storybook to Nuxt  - Storybook 的基本安装和设置;
  • Creating a Story - 我们的第一个例子 Story;
  • Adding a Store to the project - 添加 Vuex 并使用其他组件增强示例组件并进行 API 调用(尚未使用 Store with Storybook);
  • Handling Storybook Issues
  1. 别名
  2. CSS/SCSS
  3. <nuxt-link>标签
  • Using Vuex with Storybook - 将 Vuex 添加到我们的Story并重新使用
  • Deploying it to Netlify - 分享给您的团队和客户;

初始设置

因为本指南是从头开始的,所以我们首先使用create-nuxt-app 创建一个新的 Nuxt 项目:

npx create-nuxt-app nuxt-storybook

此外,我们将更新 Nuxt 到它的最新稳定版本 2.5.1:

npm rm nuxt && npm i -S nuxt

构建错误?

在撰写本文时,升级到 Nuxt 2.5 会导致构建时出错:

ERROR  Failed to compile with 1 errors
friendly-errors 13:29:07
[...]
Module parse failed: Unexpected token (7:24)
friendly-errors 13:29:07
[...]
|
| var _0c687956 = function _0c687956() {
>   return interopDefault(import('../pages/index.vue'
|   /* webpackChunkName: "pages/index" */
|   ));

如果仍然如此,基于此解决方法,以下内容适用于我的机器™(macOS):

rm -rf node_modules package-lock.json
npm i -D webpack@4.28.4
npm i

运行npm run dev现在应该显示默认的Nuxt欢迎页面

添加 Storybook

我们将根据他们的Vue指南手动安装Storybook和必要的依赖项。由于Nuxt,大多数依赖项已经存在,babel-preset-vue是唯一缺失的。

// Add Storybook & dependencies
npm i -D @storybook/vue babel-preset-vue

现在创建一个名为.storybook的文件夹,并在其中添加文件config.js

Config.js用作“入口点”,告诉Storybook在哪里查找和加载Story,以及导入和使用其他必要的插件或插件与Story一起使用。

遵循Vue准则config.js最初看起来像这样:

// /.storybook/config.js
import { configure } from '@storybook/vue';
function loadStories() {
  const req = require.context('../stories', true, /\.stories\.js$/);
  req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);

它正在做的是迭代文件中Story以.stories.js结尾的每个文件。因为我喜欢将我的Story放在我的组件附近而不是全部放在Story文件夹中,我只需将文件夹更改为组件,然后让该函数遍历其中的每个文件夹。

我们稍后会回到config.js。现在让我们确保Story能够加载一个简单的Story并显示它。

添加第一个 Story

在组件目录中,创建一个名为list的新文件夹,并在其中创建一个名为List.vue的文件,其中包含以下代码。我们将使用它来构建我们的最终组件。

// /components/list/List.vue
<template>
  <div class="list">
    I'm a list
  </div>
</template>
<script>
  export default {
    name: 'List'
  }
</script>
<style scoped>
  .list {
    background: #CCC;
  }
</style>

没什么,只是让我们的Story显示的东西。现在在同一文件夹中添加一个名为List.stories.js的文件,其中包含以下代码:

// /components/list/List.stories.js
import Vue from 'vue'
import { storiesOf } from '@storybook/vue'
import List from './List'
storiesOf('List', module)
  .add('As a component', () => ({
    components: { List },
    template: '<List />'
  }))
  .add('I don\'t work', () => '<List />')

现在要启动Storybook,我们需要将运行脚本添加到package.json(在不同的端口上运行它,添加-p <port-number>)

“storybook”: “start-storybook”

在终端输入 npm run storybook,浏览器将打开一个新选项卡:

Our first stories! But only one works?

这是Story的运行。而且因为它使用热重新加载,您将能够立即看到您的更改。

注意第二个Story不起作用?那是因为我们没有告诉Storybook使用我们的List组件来实现第一个Story(打开浏览器控制台会显示这些错误)。

我们可以将List作为一个全局组件注册,就像我们使用Nuxt的插件注册它们一样,只在config.js中,所以最终看起来像:

// /.storybook/config.js
import { configure } from '@storybook/vue';
import Vue from 'vue'
import List from '../components/list/List.vue'
Vue.component('List', List)
function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/);
  req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);

现在第二个Story有效。这只是为了让您了解某些组件可能会使用其他组件。为了避免总是导入这些,我们可以像我们一样全局定义它们(剩下的例子不会使用它,所以你可以删除它)。

你现在有一个使用Nuxt的Storybook的香草设置。但这还不是一个Story。

增强我们的列表组件和添加Store

首先,我们将为我们的列表添加一些复杂性,并担心Storybook稍后会引发错误。

清单应该:

  • 安装后 - 使用JSONPlaceholder获取假用户或虚假评论;
  • 迭代每个用户/注释并使用ListItem组件呈现它;
  • 利用Vuex发送我们的API调用;
  • 看起来更漂亮,使用TailwindCSS和一些自定义样式;

样式

对于样式,我们将使用一些TailwindCSS实用程序类以及一些自定义样式来举例说明它与Storybook的用法。我使用SCSS,所以我们需要添加通常的node-sasssass-loader

 npm i -D node-sass sass-loader

List将接受prop源,因此它知道我们想要获取哪个数据源。我们还准备好在我们构建这些调用之后调用足够的Store操作来执行API调用。

// /components/list/List.vue
<template>
  <div class="list p-5 rounded">
    I'm a {{ source }} list
  </div>
</template>
<script>
  export default {
    name: 'List',
    props: {
      source: {
        type: String,
        default: 'users'
      }
    },
    data() {
      return {
        entities: []
      }
    },
    mounted() {
      switch (this.source) {
        default:
        case 'users':
          this.loadUsers()
          break
        case 'comments':
          this.loadComments()
          break
      }
    },
    methods: {
      loadUsers() {
        //  Will call store action
        console.log('load users')
      },
      loadComments() {
        //  Will call store action
        console.log('load comments')
      },
    }
  }
</script>
<style lang="scss" scoped>
  $background: #EFF8FF;
  .list {
    background: $background;
  }
</style>

添加Store和API调用

我通常会在Store的操作中保留我的API调用,因此我可以使用来轻松调用它们。this.$store.dispatch

.env:我们将端点保存在.env文件中,因此为了获得这些值,我们将安装@nuxtjs/dotenv模块npm i -S @nuxtjs/dotenv并将其添加到nuxt.config.js中模块。

在项目的根文件中创建.env并添加:

USERS_ENDPOINT=https://jsonplaceholder.typicode.com/users
COMMENTS_ENDPOINT=https://jsonplaceholder.typicode.com/comments

添加Store操作以检索用户和注释。使用以下代码在现有商店目录下添加actions.js文件:

// /store/actions.js
export default {
  async GET_USERS({ }) {
    return await this.$axios.$get(`${ process.env.USERS_ENDPOINT }`)
  },
  async GET_COMMENTS({ }) {
    return await this.$axios.$get(`${ process.env.COMMENTS_ENDPOINT }`)
  },
}

我们现在可以修改List组件的方法,以便在挂载时调用这些操作,最终看起来像:

// /components/list/List.vue
<template>
  <div class="list p-5 rounded">
    I'm a {{ source }} list
  </div>
</template>
<script>
  export default {
    name: 'List',
    props: {
      source: {
        type: String,
        default: 'users'
      }
    },
    data() {
      return {
        entities: []
      }
    },
    mounted() {
      switch (this.source) {
        default:
        case 'users':
          this.loadUsers()
          break
        case 'comments':
          this.loadUsers()
          break
      }
    },
    methods: {
      loadUsers() {
        this.$store.dispatch('GET_USERS')
        .then(res => {
          console.log(res)
        })
        .catch(err => {
          console.log('API error')
          console.log(err)
        })
      },
      loadComments() {
        this.$store.dispatch('GET_COMMENTS')
        .then(res => {
          console.log(res)
        })
        .catch(err => {
          console.log('API error')
          console.log(err)
        })
      },
    }
  }
</script>
<style lang="scss" scoped>
  // Pointless. Just for the sake of the example
  $background: #EFF8FF;
  .list {
    background: $background;
  }
</style>

我们现在从每个端点返回一组数据。让我们来展示它们。

添加ListItem组件

根据我们是否列出用户或评论,我们将显示ListItem组件的变体。每个变体也都有自己的组件。

在名为items的列表下创建一个文件夹,并创建一个名为ListItem.vue的文件。这是要添加到其中的代码:

// /components/list/items/ListItem.vue
<template>
  <div class="list-item rounded bg-blue-light px-5 py-3">
    <div v-if="itemType === 'users'">
      A user item
    </div>
    <div v-else>
      A comment item
    </div>
  </div>
</template>
<script>
  export default {
    name: 'ListItem',
    props: {
      itemType: {
        type: String,
        default: 'user'
      },
      data: {
        type: Object,
        default: () => {
          return {}
        }
      }
    }
  }
</script>

现在没什么,我们马上就会改变它。与此同时,我设置了主页的样式,以便我们可以并排看到两个列表:

主页显示2个并排列表

现在让我们实际使用我们的ListItem组件来迭代我们的API返回的每个实体并相应地设置它。

添加用户和评论组件

我们将根据以下数据结构为每个实体创建一个组件:

// User
{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  },
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
}
// Comment
{
  "postId": 1,
  "id": 1,
  "name": "id labore ex et quam laborum",
  "email": "Eliseo@gardner.biz",
  "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
}

使用以下代码在/components/list/items/中添加Comment.vue文件:

// /components/list/items/Comment.vue
<template>
  <div>
    <b>{{ name }}</b>
    <p>{{ body }}</p>
  </div>
</template>
<script>
  export default {
    name: 'Comment',
    props: {
      name: {
        type: String,
        default: ''
      },
      body: {
        type: String,
        default: ''
      }
    }
  }
</script>

使用以下代码在/components/list/items/中添加User.vue文件:

// /components/list/items/User.vue
<template>
  <div>
   <nuxt-link
      :to="{ name:'user' }"
      class="text-lg"
    >
      {{ name }} - "{{ username }}"
    </nuxt-link>
    <div class="flex flex-wrap justify-start my-2">
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">Email</span>
        <p class="p-0 m-0">{{ email }}</p>
      </div>
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">Phone</span>
        <p class="p-0 m-0">{{ phone }}</p>
      </div>
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">City</span>
        <p class="p-0 m-0">{{ address.city }}</p>
      </div>
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">Company</span>
        <p class="p-0 m-0">{{ company.name }}</p>
      </div>
    </div>
  </div>
</template>
<script>
  export default {
    name: 'User',
    props: {
      name: {
        type: String,
        default: ''
      },
      username: {
        type: String,
        default: ''
      },
      email: {
        type: String,
        default: ''
      },
      phone: {
        type: String,
        default: ''
      },
      address: {
        type: Object,
        default: () => {
          return {}
        }
      },
      company: {
        type: Object,
        default: () => {
          return {}
        }
      }
    }
  }
</script>

注意:为了示例,我添加了一个nuxt-link。为此,我们还添加了相应的页面/pages/user/index.vue。它没有任何东西,只是为了链接到某处的nuxt-link

让我们更改ListItem组件以使用这些新组件:

// /components/list/items/ListItem.vue
<template>
  <div class="list-item rounded bg-indigo-lightest shadow px-5 py-3 mb-3">
    <div v-if="itemType === 'users'">
      <User
        :name="data.name"
        :username="data.username"
        :email="data.email"
        :phone="data.phone"
        :address="data.address"
        :company="data.company"
      />
    </div>
    <div v-else>
      <Comment
        :name="data.name"
        :body="data.body"
      />
    </div>
  </div>
</template>
<script>
  import User from '@/components/list/items/User'
  import Comment from '@/components/list/items/Comment'
export default {
    name: 'ListItem',
    components: {
      User,
      Comment
    },
    props: {
      itemType: {
        type: String,
        default: 'user'
      },
      data: {
        type: Object,
        default: () => {
          return {}
        }
      }
    }
  }
</script>

最后,我们需要更改List.vue,以便我们实际将API调用的响应作为props传递,而不是仅仅记录它。更改方法,使其看起来像:

// /components/list/List.vue
[...]
methods: {
  loadUsers() {
    this.$store.dispatch('GET_USERS')
    .then(res => {
      this.entities = res.data
    })
    .catch(err => {
      console.log('API error')
      console.log(err)
    })
  },
  loadComments() {
    this.$store.dispatch('GET_COMMENTS')
    .then(res => {
      this.entities = res.data
    })
    .catch(err => {
      console.log('API error')
      console.log(err)
    })
  },
}
[...]

经过一些小的风格调整,现在应该是这样的:

Amazing Design®

我们现在准备转到故事书,看看会发生什么。

解决Story的问题

我们现在在运行Storybook时解决每个引发的问题,第一个是:

找不到模块

Error: Can’t resolve ‘@/components/list/items/ListItem’

如果你看一下Storybook的例子,你会看到它使用相对路径引用组件。对于我们使用Nuxt来说这是一个问题,因为框架使用了@alias。

我们现在需要在所有地方使用相对路径吗?幸运的是没有。还记得先前我们安装了babel-preset-vue吗?使用webpack的别名加上我们可以解决这个问题。

首先,使用以下命令在名为.babelrc的.storybook文件夹中创建一个文件:

// /.storybook/.babelrc
{
  "presets": [
    "@babel/preset-env",
    "babel-preset-vue"
  ]
}

使用以下命令在.storybook文件夹中创建另一个名为webpack.config.js的文件:

// /.storybook/.webpack.config.js
const path = require('path')
module.exports = {
  resolve: {
    alias: {
      '@': path.dirname(path.resolve(__dirname))
    }
  }
}

您现在应该能够继续使用@alias 来导入组件。

为了获得简洁的代码,我们现在可以更改从import List from './List'import List from '@/components/list/List的方式。

模块解析失败:处理SCSS

Storybook现在抛出:

Module parse failed: Unexpected character ‘#’ (69:13)
您可能需要适当的加载程序来处理此文件类型。

那是因为我们还没有指定如何加载这些。我们可以通过向webpack添加CSS/SCSS的模块规则来解决这个问题,使我们的文件现在看起来像这样:

// /.storybook/.webpack.config.js
const path = require('path')
module.exports = {
  module: {
    rules: [
      {
        test: /\.s?css$/,
        loaders: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
        include: path.resolve(__dirname, '../')
      }
    ]
  },
  resolve: {
    alias: {
      '@': path.dirname(path.resolve(__dirname))
    }
  }
}

我们还必须将import'@/assets/css/tailwind.css添加到.storybook/config.js,以便我们可以使用Tailwind的实用程序类。

再次启动Storybook,这次你应该让浏览器打开一个最好的新选项卡:

我们仍然需要配置Store!

将Vuex与Storybook结合使用

如果您在本演练之前已经遵循了Storybook的Vue指南,那么您应该已经在config.js中导入和使用Vuex。

如果没有,现在应该如何看待它:

// /.storybook/config.js
import Vue from 'vue'
import Vuex from 'vuex'
import { configure } from '@storybook/vue'
import '@/assets/css/tailwind.css'
Vue.use(Vuex)
function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/)
  req.keys().forEach(filename => req(filename))
}
configure(loadStories, module)

但这只是不会削减它。

Nuxt组件引用Store作为这个。$store和我们的Story不知道,因此我们需要创建一个新的Store并将其传递给我们的组件。

但我们需要重新创建整个商店吗?谢天谢地。我们确实会创建一个商店,但重用现有商店所拥有的所有现有操作,getter,突变或状态。

为此,我们将使用以下代码在.storybook文件夹中创建名为store.js的文件:

// /.storybook/store.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
// You can do the same for getters, mutations and states
import actions from '@/store/actions'
let store = new Vuex.Store({
  actions: actions
})
/**
  Bind Axios to Store as we don't have access to Nuxt's $axios instance here. See caveat below.
**/
store.$axios = axios
export default store

我们现在可以导入并将此store传递给我们的Stories。

目前我们只有一个故事列出用户,作为默认来源。让我们添加另一个故事来列出评论并重命名:

// /components/list/List.stories.js
import Vue from 'vue'
import { storiesOf } from '@storybook/vue'
import List from '@/components/list/List'
import store from '@/.storybook/store'
storiesOf('Lists', module)
  .add('Users', () => ({
    components: { List },
    store: store,
    template: '<List />'
  }))
  .add('Comments', () => ({
    components: { List },
    store: store,
    template: `<List :source="'comments'" />`
  }))

@nuxtjs/axios module caveat
store.$axios.$axios = axios hack允许我们将Axios传递到我们的新商店实例,但请记住它不是Nuxt的模块,因此您将无法访问其所有功能。
为了使用需要Vuex的Stories,您可能需要对如何使用$ axios进行更改,以便您的Stories和app都能正常工作但要小心。
在Thoro的情况下,我需要改变它的用法。$axios.$get 到 this.$axios.get。
无法想办法访问应用程序的实际商店,所以如果您知道更好的方法,请在评论中分享!

按照上面的步骤后,我们现在应该看到列表组件的两个Stories:

两个List故事都使用Vuex操作从API加载数据。 😌

最后我们可以看到一些东西但是我们的链接丢失了..

图2

如果你在Storybook的选项卡上打开浏览器控制台,你会发现它不知道nuxt-link是什么(如果事情不能正常工作,你也可以随时查看潜在的错误)。

需要对Storybook进行最终调整才能使这些工作和功能正常。

为此,我们需要安装@storybook/addon-actions依赖项:npm i -D @ storybook/addon-actions,并通过在.storybook目录中使用以下行创建文件addons.js将它们添加到Storybook:

// /.storybook/addons.js
import '@storybook/addon-actions'
import '@storybook/addon-actions/register'

最后,我们需要从config.js中的import {action} from @storybook/addon-actions ,并将调整后的nuxt-link组件注册到Vue。我们的config.js文件现在应该如下所示:

// /.storybook/config.js
import Vue from 'vue'
import Vuex from 'vuex'
import { configure } from '@storybook/vue'
import { action } from '@storybook/addon-actions'
import '@/assets/css/tailwind.css'
Vue.use(Vuex)
Vue.component('nuxt-link', {
  props:   ['to'],
  methods: {
    log() {
      action('link target')(this.to)
    },
  },
  template: '<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>',
})
function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/)
  req.keys().forEach(filename => req(filename))
}
configure(loadStories, module)

这将使用常规锚元素替换<nuxt-link>的所有实例,并在单击时设置显示lint路径的日志方法。

之后,我们不应再在浏览器的控制台上看到任何错误,并且在用户名上有实际的可点击链接:

链接现在正在运行。

使用Nuxt的Storybook!

花了一段时间,但我们已经设法让故事书与Nuxt.js项目中的Vue.js组件很好地配合。

这不是一个完全成熟的指南,因为我们缺少Nuxt的测试和关键方面,例如<no-ssr>标签(我也想知道asyncData和Storybook可能最终如何协同工作)。

Bonus:将Storybook部署到Netlify

运行Storybook时,您可以获得可以与本地网络中的其他人共享的IP,如果您使用的是同一个WiFi,那就太酷了。但是,如果您想将它分享给您的客户,以便他们可以就上周的迭代给您反馈,该怎么办?

在这种情况下,将它托管在Netlify上。只需将下面的脚本添加到package.json文件中,该文件将在storybook-static目录中生成一个静态Storybook:

"build-storybook": "build-storybook -c .storybook" 

通常的Netlify构建设置。

然后访问Netlify并选择您的存储库。将构建命令定义为npm run build-storybook,将发布目录定义为storybook-static

然后,每次推送/合并到主分支时,都应该实时更新Storybook。看看这个

访问环境变量

很可能您将在整个store或组件中使用环境变量。

为了使用这些变量构建Storybook,首先需要将它们提供给Netlify,然后使用webpack的DefinePlugin通过Storybook的webpack.config.js公开它们,如下所示:

// /.storybook/webpack.config.js
const webpack = require('webpack')
module.exports = async ({ config, mode }) => {
  config.plugins.push(new webpack.DefinePlugin({
    'process.env': {
      YOUR_VARIABLE: JSON.stringify(process.env.YOUR_VARIABLE)
    }
  }))
  return config
}

回购和资源

随意获取Github上的代码(https://github.com/mstrlaw/nuxt-storybook并查看这些阅读材料和其他有助于构建本指南的回购:

最后

我希望你发现这篇文章有用!你可以关注我的博客。请在下面的评论中留下任何问题。我很乐意帮忙!


文章作者: 左智文
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 左智文 !
评论
  目录