创建单文件 Vue 组件

为快速实现效果,我们一直在使用单个 HTML 页面编写 Vue 应用。但在实际的 VueNuxt 开发项目中,我们不应采用这种写法:

const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

上述代码将两个 Vue 组件以 JavaScript 对象形式集中编写(例如在单个 HTML 页面中),更合理的做法是将它们分离到单独的 .js 文件中:

// components/foo.js
Vue.component('page-foo', {
  data: function () {
    return { message: 'foo' }
  },
  template: '<div>{{ count }}</div>'
})

这种方式适用于 HTML 结构简单的组件。但当涉及复杂 HTML 布局时,我们应避免在 JavaScript 文件中编写 HTML 标记。此时可以使用 .vue 扩展名的单文件组件:

<!-- index.vue -->
<template>
  <p>{{ message }}</p>
</template>

<script>
export default {
  data () {
    return { message: 'Hello World!' }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

注意:这类文件需要通过 webpackrollup 等构建工具编译后才能运行。本书将使用 webpack 进行构建。这意味着从现在开始不再使用 CDN 或单个 HTML 页面开发复杂 Vue 应用。改用 .vue.js 文件配合单个 .html 文件构建应用下文将指导如何使用 webpack 实现这一目标。

使用 webpack 编译单文件组件

要编译 .vue 组件,我们需要在 webpack 构建流程中安装 vue-loadervue-template-compiler。但在之前,必须在项目目录下创建 package.json 文件来列出项目依赖的 Node.js 包。您可以在 https://docs.npmjs.com/creating-a-package-json-file 查看 package.json 字段的详细信息。最基础且必需的字段是 nameversion。让我们开始操作:

  1. 在项目目录创建包含以下必需字段的 package.json 文件:

    package.json
    {
      "name": "vue-single-file-component",
      "version": "1.0.0"
    }
  2. 打开终端,进入项目目录并安装依赖:

    $ npm i vue-loader --save-dev
    $ npm i vue-template-compiler --save-dev

    安装时会看到警告提示,因为这些 Node.js 包还依赖其他包(最值得注意的是 webpack 包)。本书中我们已在 GitHub 仓库的 /chapter-5/vue/component-webpack/basic/ 目录配置了基础 webpack 构建流程,后续 Vue 应用将主要采用这个配置。我们将 webpack 配置文件拆分为三个部分:

    • webpack.common.js:包含开发和生产环境共享的通用插件和配置

    • webpack.dev.js:仅包含开发环境专用配置

    • webpack.prod.js:仅包含生产环境专用配置

    script 命令中的使用方式如下:

    package.json
    "scripts": {
      "start": "webpack-dev-server --open --config webpack.dev.js",
      "watch": "webpack --watch",
      "build": "webpack --config webpack.prod.js"
    }

    本书假设您已掌握 webpack 编译 JavaScript 模块的基础知识,初学者请参考 https://webpack.js.org/

  3. 安装完依赖后,需要在 webpack.common.js(若使用单一配置文件则是 webpack.config.js)中配置 module.rules

    // webpack.common.js
    const VueLoaderPlugin = require('vue-loader/lib/plugin')
    
    module.exports = {
      mode: 'development',
      module: {
        rules: [
          {
            test: /\.vue$/,
            loader: 'vue-loader'
          },
          {
            test: /\.js$/,
            loader: 'babel-loader'
          },
          {
            test: /\.css$/,
            use: [
              'vue-style-loader',
              'css-loader'
            ]
          }
        ]
      },
      plugins: [
        new VueLoaderPlugin()
      ]
    }
  4. 最后可以通过 package.json 中预设的命令运行应用:

    $ npm run start  # 在localhost:8080启用热重载开发服务
    $ npm run watch  # 在项目dist目录下开发构建
    $ npm run build  # 在项目dist目录下生产构建

至此,您已经建立了使用 webpack 开发 Vue 应用的基础构建流程。在后续更复杂的应用中,我们将编写单文件组件并采用这种方式进行编译。下一节我们将创建一个简单的 Vue 应用。

在单文件组件中传递数据和监听事件

目前为止,我们一直在使用单个 HTML 页面进行 "待办事项" 功能演示。这次我们将使用单文件组件构建一个简单的 "待办事项" 购物清单。让我们开始:

  1. 创建一个包含 Vue 实例挂载点(id 为 "todos" 的 div 元素)的 index.html 文件:

    <!doctype html>
    <html>
    <head>
        <title>待办购物清单应用(单文件组件)</title>
    </head>
    <body>
        <div id="todos"></div>
    </body>
    </html>
  2. 在项目根目录创建 /src/ 文件夹,并在其中创建 entry.js 文件作为 webpack 的入口文件。webpack 将使用该文件确定应用的内部依赖关系图:

    // src/entry.js
    'use strict'
    import Vue from 'vue/dist/vue.js'
    
    import App from './app.vue'
    new Vue({
        el: 'todos',
        template: '<App/>',
        components: {
            App
        }
    })
  3. 创建父组件,在 <script> 块中提供包含商品列表的模拟数据:

    <!-- src/app.vue -->
    <template>
        <div>
            <ol>
                <TodoItem
                    v-for="thing in groceryList"
                    v-bind:item="thing"
                    v-bind:key="item.id"
                    v-on:add-item="addItem"
                    v-on:delete-item="deleteItem"
                ></TodoItem>
            </ol>
            <p><span v-html="&pound;"></span>{{ total }}</p>
        </div>
    </template>
    
    <script>
    import TodoItem from './todo-item.vue'
    export default {
        data () {
            return {
                cart: [],
                total: 0,
                groceryList: [
                    { id: 0, text: '小扁豆', price: 2 },
                    //...
                ]
            }
        },
        components: {
            TodoItem
        }
    }
    </script>

    在前面的代码中,我们只是简单地将子组件 TodoItem 导入,然后使用 v-for 指令根据 groceryList 中的数据生成一个子组件列表。

  4. methods 对象中添加以下方法来实现商品增减功能,并在 computed 对象中添加计算购物车总金额的方法:

    methods: {
        addItem (item) {
            this.cart.push(item)
            this.total = this.shoppingCartTotal
        },
        deleteItem (item) {
            this.cart.splice(this.cart.findIndex(e => e === item), 1)
            this.total = this.shoppingCartTotal
        }
    },
    computed: {
        shoppingCartTotal () {
            let prices = this.cart.map(item => item.price)
            let sum = prices.reduce((accumulator, currentValue) =>
                accumulator + currentValue, 0)
            return sum
        }
    }
  5. 创建子组件来显示通过 props 从父组件传递的商品信息:

<!-- src/todo-item.vue -->
<template>
    <li>
        <input type="checkbox" :name="item.id" v-model="checked">
        {{ item.text }}
        <span v-html="&pound;"></span>{{ item.price }}
    </li>
</template>
<script>
export default {
    props: ['item'],
    data () {
        return { checked: false }
    },
    methods: {
        addToCart (item) {
            this.$emit('add-item', item)
        }
    },
    watch: {
        checked (boolean) {
            if (boolean === false) {
                return this.$emit('delete-item', this.item)
            }
            this.$emit('add-item', this.item)
        }
    }
}
</script>

在这个组件中,我们通过复选框按钮来触发 delete-itemadd-item 事件,将商品数据传递给父组件。现在如果运行 $npm run start 命令,应用将在 localhost:8080 加载显示。

干得漂亮!你已经成功使用 webpack 构建了基于单文件组件的 Vue 应用,而 Nuxt 在底层正是使用 webpack 来编译和构建应用的。了解底层运行机制总是很有帮助的。掌握 webpack 后,你可以将刚学到的 webpack 构建配置应用于各种 JavaScriptCSS 相关项目。

你可以在本书 GitHub 仓库的 /chapter-5/vue/componentwebpack/todo/ 目录找到这个示例。

下一节中,我们将把前面学到的知识应用到示例网站(位于本书 GitHub 仓库的 /chapter-5/nuxt-universal/local-components/sample-website/ 目录)。

在 Nuxt 中添加 Vue 组件

在这个示例网站中,只有两个 .vue 文件可以通过 Vue 组件进行优化: /layouts/default.vue/pages/work/index.vue。首先我们应该改进 /layouts/default.vue 文件。这个文件中只需要优化三个部分:导航栏、社交媒体链接和版权信息。

重构导航和社交链接

我们将从重构导航栏和社交媒体链接开始:

  1. /components/ 目录下创建导航组件:

    <!-- components/nav.vue -->
    <template>
      <li>
        <nuxt-link :to="item.link" v-html="item.name"></nuxt-link>
      </li>
    </template>
    
    <script>
    export default {
      props: ['item']
    }
    </script>
  2. 同样在 /components/ 目录下创建社交媒体链接组件:

    <!-- components/social.vue -->
    <template>
      <li>
        <a :href="item.link" target="_blank">
          <i :class="item.classes"></i>
        </a>
      </li>
    </template>
    <script>
    export default {
      props: ['item']
    }
    </script>
  3. 在布局文件的 <script> 块中导入这些组件:

    // layouts/default.vue
    import Nav from '~/components/nav.vue'
    import Social from '~/components/social.vue'
    components: {
      Nav,
      Social
    }

    注意:如果在 Nuxt 配置文件中已将 components 选项设为 true,可以跳过此步骤。

  4. <template> 块中移除原有的导航和社交媒体链接代码:

    <!-- 删除以下代码 -->
    <template v-for="item in nav">
      <li><nuxt-link :to="item.link" v-html="item.name"></nuxt-link></li>
    </template>
    <template v-for="item in social">
      <li>
        <a :href="item.link" target="_blank">
          <i :class="item.classes"></i>
        </a>
      </li>
    </template>
  5. 用导入的 NavSocial 组件替换原有代码:

    <!-- layouts/default.vue -->
    <Nav
      v-for="item in nav"
      v-bind:item="item"
      v-bind:key="item.slug"
    ></Nav>
    
    <Social
      v-for="item in social"
      v-bind:item="item"
      v-bind:key="item.name"
    ></Social>

至此,重构工作就完成了!

重构版权组件

现在我们将重构已存在于 /components/ 目录下的版权组件。让我们开始:

  1. /components/base-copyright.vue 文件的 <script> 块中移除 data 函数:

    // components/copyright.vue
    export default {
      data () {
        return { copyright: '&copy; Lau Tiam Kok' }
      }
    }
  2. props 属性替换上述 data 函数:

    // components/copyright.vue
    export default {
      props: ['copyright']
    }
  3. 将版权数据改为在 /layouts/default.vue<script> 块中定义:

    // layouts/default.vue
    data () {
      return {
        copyright: '&copy; Lau Tiam Kok',
      }
    }
  4. 移除 <template> 块中原有的 <Copyright /> 组件:

    // layouts/default.vue
    <!-- 删除此行 -->
    <Copyright />
  5. 添加新的 <Copyright /> 组件并绑定版权数据:

    // layouts/default.vue
    <Copyright v-bind:copyright="copyright" />

通过以上步骤,您已成功实现了从默认页面(父组件)向组件(子组件)的数据传递。做得很好!至此已完成 /layouts/default.vue 的优化工作。我们同样优化了作品(work)页面,相关代码已为您准备好,可在本书 GitHub 仓库的 /chapter-5/nuxt-universal/local-components/sample-website/ 目录找到。如果您在本地运行这个示例网站,将会看到我们最终完美应用的组件效果。

这个过程展示了:一旦理解 Vue 组件系统的工作原理,就能轻松将布局中的元素抽象为组件。但如何将数据传递回父组件呢?我们在 /chapter-5/nuxt-universal/local-components/emit-events/ 目录创建了示例应用,演示子组件通过事件向父组件传递数据,您可以在本书 GitHub 仓库找到。我们还添加了自定义输入框和复选框组件,以下是示例代码片段:

<!-- components/input-checkbox.vue -->
<input
  type="checkbox"
  v-bind:checked="checked"
  v-on:change="changed"
  name="subscribe"
  value="newsletter"
>
<script>
export default {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: { checked: Boolean },
  methods: {
    changed ($event) {
      this.$emit('change', $event.target.checked)
    }
  }
}
</script>

可以看到,我们在 Nuxt 应用中使用的组件代码与 Vue 应用中的写法完全一致。这类组件属于嵌套组件,通过 props 属性和 $emit 方法实现父子组件间的双向数据传递。从另一个角度看,Vue 组件可分为局部组件和全局组件。自 "什么是组件?" 章节以来,您一直在学习全局组件,但仅限于 Vue 应用中的用法。下一节我们将探讨如何在 Nuxt 应用中注册全局组件。不过在深入之前,让我们从全局视角重新审视 Vue 组件:全局组件与局部组件的区别与应用。