用 Nuxt.js 实现一个简单的论坛网站。
demo 的 github 地址:https://github.com/gothinkster/realworld

项目初始化

  1. 新建文件夹
  2. 初始化package.json
  3. 添加命令
  4. 添加nuxt模块
  5. 添加pages文件夹,新建index.vue文件
  6. 运行项目

下载模板

github 复制模板。

app.html

  1. <!DOCTYPE html>
  2. <html {{ HTML_ATTRS }}>
  3. <head {{ HEAD_ATTRS }}>
  4. {{ HEAD }}
  5. <!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
  6. <link
  7. href="https://cdn.jsdelivr.net/npm/ionicons@2.0.1/css/ionicons.min.css"
  8. rel="stylesheet"
  9. type="text/css"
  10. />
  11. <link
  12. href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
  13. rel="stylesheet"
  14. type="text/css"
  15. />
  16. <!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
  17. <!-- <link rel="stylesheet" href="//demo.productionready.io/main.css"> -->
  18. <link rel="stylesheet" href="/index.css" />
  19. </head>
  20. <body {{ BODY_ATTRS }}>
  21. {{ APP }}
  22. </body>
  23. </html>

布局组件

pages/layout/index.vue:

  1. <template>
  2. <div>
  3. <nav class="navbar navbar-light">
  4. <div class="container">
  5. <nuxt-link
  6. class="navbar-brand"
  7. to="/"
  8. >conduit</nuxt-link>
  9. <ul class="nav navbar-nav pull-xs-right">
  10. <li class="nav-item">
  11. <!-- Add "active" class when you're on that page" -->
  12. <nuxt-link
  13. class="nav-link"
  14. to="/"
  15. exact
  16. >Home</nuxt-link>
  17. </li>
  18. <li class="nav-item">
  19. <nuxt-link
  20. class="nav-link"
  21. to="/editor"
  22. >
  23. <i class="ion-compose"></i>&nbsp;New Post
  24. </nuxt-link>
  25. </li>
  26. <li class="nav-item">
  27. <nuxt-link
  28. class="nav-link"
  29. to="/settings"
  30. >
  31. <i class="ion-gear-a"></i>&nbsp;Settings
  32. </nuxt-link>
  33. </li>
  34. <li class="nav-item">
  35. <nuxt-link
  36. class="nav-link"
  37. to="/profile/lpz"
  38. >
  39. <img
  40. class="user-pic"
  41. src="http://toutiao.meiduo.site/FtNcS8sKFSYQbtBbd40eFTL6lAs_"
  42. />
  43. LPZ
  44. </nuxt-link>
  45. </li>
  46. <li class="nav-item">
  47. <nuxt-link
  48. class="nav-link"
  49. to="/login"
  50. >Sign in</nuxt-link>
  51. </li>
  52. <li class="nav-item">
  53. <nuxt-link
  54. class="nav-link"
  55. to="/register"
  56. >Sign up</nuxt-link>
  57. </li>
  58. </ul>
  59. </div>
  60. </nav>
  61. <nuxt-child />
  62. <footer>
  63. <div class="container">
  64. <nuxt-link
  65. class="logo-font"
  66. to="/"
  67. >conduit</nuxt-link>
  68. <span class="attribution">
  69. An interactive learning project from
  70. <a href="https://kaiwu.lagou.com/">lagou</a>. Code &amp; design licensed under MIT.
  71. </span>
  72. </div>
  73. </footer>
  74. </div>
  75. </template>

其中的 Home 链接去掉了原本的active样式 class,新增了exact属性,表示必须严格匹配到路径,这是为了处理 Home 链接的高亮。

页面组件

home

  1. <template>
  2. <div class="home-page">
  3. <div class="banner">
  4. <div class="container">
  5. <h1 class="logo-font">conduit</h1>
  6. <p>A place to share your knowledge.</p>
  7. </div>
  8. </div>
  9. <div class="container page">
  10. <div class="row">
  11. <div class="col-md-9">
  12. <div class="feed-toggle">
  13. <ul class="nav nav-pills outline-active">
  14. <li class="nav-item">
  15. <a
  16. class="nav-link disabled"
  17. href
  18. >Your Feed</a>
  19. </li>
  20. <li class="nav-item">
  21. <a
  22. class="nav-link active"
  23. href
  24. >Global Feed</a>
  25. </li>
  26. </ul>
  27. </div>
  28. <div class="article-preview">
  29. <div class="article-meta">
  30. <a href="profile.html">
  31. <img src="http://i.imgur.com/Qr71crq.jpg" />
  32. </a>
  33. <div class="info">
  34. <a
  35. class="author"
  36. href
  37. >Eric Simons</a>
  38. <span class="date">January 20th</span>
  39. </div>
  40. <button class="btn btn-outline-primary btn-sm pull-xs-right">
  41. <i class="ion-heart"></i> 29
  42. </button>
  43. </div>
  44. <a
  45. class="preview-link"
  46. href
  47. >
  48. <h1>How to build webapps that scale</h1>
  49. <p>This is the description for the post.</p>
  50. <span>Read more...</span>
  51. </a>
  52. </div>
  53. <div class="article-preview">
  54. <div class="article-meta">
  55. <a href="profile.html">
  56. <img src="http://i.imgur.com/N4VcUeJ.jpg" />
  57. </a>
  58. <div class="info">
  59. <a
  60. class="author"
  61. href
  62. >Albert Pai</a>
  63. <span class="date">January 20th</span>
  64. </div>
  65. <button class="btn btn-outline-primary btn-sm pull-xs-right">
  66. <i class="ion-heart"></i> 32
  67. </button>
  68. </div>
  69. <a
  70. class="preview-link"
  71. href
  72. >
  73. <h1>The song you won't ever stop singing. No matter how hard you try.</h1>
  74. <p>This is the description for the post.</p>
  75. <span>Read more...</span>
  76. </a>
  77. </div>
  78. </div>
  79. <div class="col-md-3">
  80. <div class="sidebar">
  81. <p>Popular Tags</p>
  82. <div class="tag-list">
  83. <a
  84. class="tag-pill tag-default"
  85. href
  86. >programming</a>
  87. <a
  88. class="tag-pill tag-default"
  89. href
  90. >javascript</a>
  91. <a
  92. class="tag-pill tag-default"
  93. href
  94. >emberjs</a>
  95. <a
  96. class="tag-pill tag-default"
  97. href
  98. >angularjs</a>
  99. <a
  100. class="tag-pill tag-default"
  101. href
  102. >react</a>
  103. <a
  104. class="tag-pill tag-default"
  105. href
  106. >mean</a>
  107. <a
  108. class="tag-pill tag-default"
  109. href
  110. >node</a>
  111. <a
  112. class="tag-pill tag-default"
  113. href
  114. >rails</a>
  115. </div>
  116. </div>
  117. </div>
  118. </div>
  119. </div>
  120. </div>
  121. </template>

login/register

  1. <template>
  2. <div class="auth-page">
  3. <div class="container page">
  4. <div class="row">
  5. <div class="col-md-6 offset-md-3 col-xs-12">
  6. <h1 class="text-xs-center">Sign up</h1>
  7. <p class="text-xs-center">
  8. <a href="">Have an account?</a>
  9. </p>
  10. <ul class="error-messages">
  11. <li>That email is already taken</li>
  12. </ul>
  13. <form>
  14. <fieldset class="form-group">
  15. <input class="form-control form-control-lg" type="text" placeholder="Your Name">
  16. </fieldset>
  17. <fieldset class="form-group">
  18. <input class="form-control form-control-lg" type="text" placeholder="Email">
  19. </fieldset>
  20. <fieldset class="form-group">
  21. <input class="form-control form-control-lg" type="password" placeholder="Password">
  22. </fieldset>
  23. <button class="btn btn-lg btn-primary pull-xs-right">
  24. Sign up
  25. </button>
  26. </form>
  27. </div>
  28. </div>
  29. </div>
  30. </div>
  31. </template>

profile

  1. <template>
  2. <div class="profile-page">
  3. <div class="user-info">
  4. <div class="container">
  5. <div class="row">
  6. <div class="col-xs-12 col-md-10 offset-md-1">
  7. <img
  8. class="user-img"
  9. src="http://i.imgur.com/Qr71crq.jpg"
  10. />
  11. <h4>Eric Simons</h4>
  12. <p>
  13. Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the
  14. Hunger Games
  15. </p>
  16. <button class="btn btn-sm btn-outline-secondary action-btn">
  17. <i class="ion-plus-round"></i>
  18. &nbsp;
  19. Follow Eric Simons
  20. </button>
  21. </div>
  22. </div>
  23. </div>
  24. </div>
  25. <div class="container">
  26. <div class="row">
  27. <div class="col-xs-12 col-md-10 offset-md-1">
  28. <div class="articles-toggle">
  29. <ul class="nav nav-pills outline-active">
  30. <li class="nav-item">
  31. <a
  32. class="nav-link active"
  33. href
  34. >My Articles</a>
  35. </li>
  36. <li class="nav-item">
  37. <a
  38. class="nav-link"
  39. href
  40. >Favorited Articles</a>
  41. </li>
  42. </ul>
  43. </div>
  44. <div class="article-preview">
  45. <div class="article-meta">
  46. <a href>
  47. <img src="http://i.imgur.com/Qr71crq.jpg" />
  48. </a>
  49. <div class="info">
  50. <a
  51. class="author"
  52. href
  53. >Eric Simons</a>
  54. <span class="date">January 20th</span>
  55. </div>
  56. <button class="btn btn-outline-primary btn-sm pull-xs-right">
  57. <i class="ion-heart"></i> 29
  58. </button>
  59. </div>
  60. <a
  61. class="preview-link"
  62. href
  63. >
  64. <h1>How to build webapps that scale</h1>
  65. <p>This is the description for the post.</p>
  66. <span>Read more...</span>
  67. </a>
  68. </div>
  69. <div class="article-preview">
  70. <div class="article-meta">
  71. <a href>
  72. <img src="http://i.imgur.com/N4VcUeJ.jpg" />
  73. </a>
  74. <div class="info">
  75. <a
  76. class="author"
  77. href
  78. >Albert Pai</a>
  79. <span class="date">January 20th</span>
  80. </div>
  81. <button class="btn btn-outline-primary btn-sm pull-xs-right">
  82. <i class="ion-heart"></i> 32
  83. </button>
  84. </div>
  85. <a
  86. class="preview-link"
  87. href
  88. >
  89. <h1>The song you won't ever stop singing. No matter how hard you try.</h1>
  90. <p>This is the description for the post.</p>
  91. <span>Read more...</span>
  92. <ul class="tag-list">
  93. <li class="tag-default tag-pill tag-outline">Music</li>
  94. <li class="tag-default tag-pill tag-outline">Song</li>
  95. </ul>
  96. </a>
  97. </div>
  98. </div>
  99. </div>
  100. </div>
  101. </div>
  102. </template>

settings

  1. <template>
  2. <div class="settings-page">
  3. <div class="container page">
  4. <div class="row">
  5. <div class="col-md-6 offset-md-3 col-xs-12">
  6. <h1 class="text-xs-center">Your Settings</h1>
  7. <form>
  8. <fieldset>
  9. <fieldset class="form-group">
  10. <input
  11. class="form-control"
  12. placeholder="URL of profile picture"
  13. type="text"
  14. />
  15. </fieldset>
  16. <fieldset class="form-group">
  17. <input
  18. class="form-control form-control-lg"
  19. placeholder="Your Name"
  20. type="text"
  21. />
  22. </fieldset>
  23. <fieldset class="form-group">
  24. <textarea
  25. class="form-control form-control-lg"
  26. placeholder="Short bio about you"
  27. rows="8"
  28. ></textarea>
  29. </fieldset>
  30. <fieldset class="form-group">
  31. <input
  32. class="form-control form-control-lg"
  33. placeholder="Email"
  34. type="text"
  35. />
  36. </fieldset>
  37. <fieldset class="form-group">
  38. <input
  39. class="form-control form-control-lg"
  40. placeholder="Password"
  41. type="password"
  42. />
  43. </fieldset>
  44. <button class="btn btn-lg btn-primary pull-xs-right">Update Settings</button>
  45. </fieldset>
  46. </form>
  47. </div>
  48. </div>
  49. </div>
  50. </div>
  51. </template>

create/edit article

  1. <template>
  2. <div class="editor-page">
  3. <div class="container page">
  4. <div class="row">
  5. <div class="col-md-10 offset-md-1 col-xs-12">
  6. <form>
  7. <fieldset>
  8. <fieldset class="form-group">
  9. <input
  10. class="form-control form-control-lg"
  11. placeholder="Article Title"
  12. type="text"
  13. />
  14. </fieldset>
  15. <fieldset class="form-group">
  16. <input
  17. class="form-control"
  18. placeholder="What's this article about?"
  19. type="text"
  20. />
  21. </fieldset>
  22. <fieldset class="form-group">
  23. <textarea
  24. class="form-control"
  25. placeholder="Write your article (in markdown)"
  26. rows="8"
  27. ></textarea>
  28. </fieldset>
  29. <fieldset class="form-group">
  30. <input
  31. class="form-control"
  32. placeholder="Enter tags"
  33. type="text"
  34. />
  35. <div class="tag-list"></div>
  36. </fieldset>
  37. <button
  38. class="btn btn-lg pull-xs-right btn-primary"
  39. type="button"
  40. >Publish Article</button>
  41. </fieldset>
  42. </form>
  43. </div>
  44. </div>
  45. </div>
  46. </div>
  47. </template>

article

  1. <template>
  2. <div class="article-page">
  3. <div class="banner">
  4. <div class="container">
  5. <h1>How to build webapps that scale</h1>
  6. <div class="article-meta">
  7. <a href>
  8. <img src="http://i.imgur.com/Qr71crq.jpg" />
  9. </a>
  10. <div class="info">
  11. <a
  12. class="author"
  13. href
  14. >Eric Simons</a>
  15. <span class="date">January 20th</span>
  16. </div>
  17. <button class="btn btn-sm btn-outline-secondary">
  18. <i class="ion-plus-round"></i>
  19. &nbsp;
  20. Follow Eric Simons
  21. <span class="counter">(10)</span>
  22. </button>
  23. &nbsp;&nbsp;
  24. <button class="btn btn-sm btn-outline-primary">
  25. <i class="ion-heart"></i>
  26. &nbsp;
  27. Favorite Post
  28. <span class="counter">(29)</span>
  29. </button>
  30. </div>
  31. </div>
  32. </div>
  33. <div class="container page">
  34. <div class="row article-content">
  35. <div class="col-md-12">
  36. <p>Web development technologies have evolved at an incredible clip over the past few years.</p>
  37. <h2 id="introducing-ionic">Introducing RealWorld.</h2>
  38. <p>It's a great solution for learning how other frameworks work.</p>
  39. </div>
  40. </div>
  41. <hr />
  42. <div class="article-actions">
  43. <div class="article-meta">
  44. <a href="profile.html">
  45. <img src="http://i.imgur.com/Qr71crq.jpg" />
  46. </a>
  47. <div class="info">
  48. <a
  49. class="author"
  50. href
  51. >Eric Simons</a>
  52. <span class="date">January 20th</span>
  53. </div>
  54. <button class="btn btn-sm btn-outline-secondary">
  55. <i class="ion-plus-round"></i>
  56. &nbsp;
  57. Follow Eric Simons
  58. </button>
  59. &nbsp;
  60. <button class="btn btn-sm btn-outline-primary">
  61. <i class="ion-heart"></i>
  62. &nbsp;
  63. Favorite Post
  64. <span class="counter">(29)</span>
  65. </button>
  66. </div>
  67. </div>
  68. <div class="row">
  69. <div class="col-xs-12 col-md-8 offset-md-2">
  70. <form class="card comment-form">
  71. <div class="card-block">
  72. <textarea
  73. class="form-control"
  74. placeholder="Write a comment..."
  75. rows="3"
  76. ></textarea>
  77. </div>
  78. <div class="card-footer">
  79. <img
  80. class="comment-author-img"
  81. src="http://i.imgur.com/Qr71crq.jpg"
  82. />
  83. <button class="btn btn-sm btn-primary">Post Comment</button>
  84. </div>
  85. </form>
  86. <div class="card">
  87. <div class="card-block">
  88. <p class="card-text">With supporting text below as a natural lead-in to additional content.</p>
  89. </div>
  90. <div class="card-footer">
  91. <a
  92. class="comment-author"
  93. href
  94. >
  95. <img
  96. class="comment-author-img"
  97. src="http://i.imgur.com/Qr71crq.jpg"
  98. />
  99. </a>
  100. &nbsp;
  101. <a
  102. class="comment-author"
  103. href
  104. >Jacob Schmidt</a>
  105. <span class="date-posted">Dec 29th</span>
  106. </div>
  107. </div>
  108. <div class="card">
  109. <div class="card-block">
  110. <p class="card-text">With supporting text below as a natural lead-in to additional content.</p>
  111. </div>
  112. <div class="card-footer">
  113. <a
  114. class="comment-author"
  115. href
  116. >
  117. <img
  118. class="comment-author-img"
  119. src="http://i.imgur.com/Qr71crq.jpg"
  120. />
  121. </a>
  122. &nbsp;
  123. <a
  124. class="comment-author"
  125. href
  126. >Jacob Schmidt</a>
  127. <span class="date-posted">Dec 29th</span>
  128. <span class="mod-options">
  129. <i class="ion-edit"></i>
  130. <i class="ion-trash-a"></i>
  131. </span>
  132. </div>
  133. </div>
  134. </div>
  135. </div>
  136. </div>
  137. </div>
  138. </template>

导航链接高亮

  1. module.exports = {
  2. router: {
  3. linkActiveClass: 'active', // 用于精准激活的 RouterLink 的默认类,这里指定激活的link 类是 active
  4. }
  5. }

exact

<router-link>标签的 exact 属性,用来指示“是否激活”默认类名的依据是包含匹配。 举个例子,如果当前的路径是 /a 开头的,那么 也会被设置 CSS 类名。
按照这个规则,每个路由都会激活 !想要链接使用“精确匹配模式”,则使用 exact 属性。

封装请求模块

新建 utils 文件夹,在文件夹新建request.js,封装 axios 模块。

  1. /**
  2. * 基于 axios 封装的请求模块
  3. */
  4. import axios from 'axios'
  5. const requset = axios.create({
  6. baseURL: 'https://api.realworld.io/api',
  7. })
  8. export default requset

基础登录功能

api

新建 api 文件夹,在文件夹新建user.js,这里封装了用户登录/注册的接口。

  1. import requset from "../utils/request"
  2. export const login = data => {
  3. return requset({
  4. method: 'POST',
  5. url: '/users/login',
  6. data
  7. })
  8. }
  9. export const register = data => {
  10. return requset({
  11. method: 'POST',
  12. url: '/users',
  13. data
  14. })
  15. }

登录/注册的组件要进行修改。输入框加入表单验证,处理按钮点击事件,登录和注册的功能分开,通过 api 请求登录数据,捕获异常,登录成功后跳转到首页。

  1. <template>
  2. <div class="auth-page">
  3. <div class="container page">
  4. <div class="row">
  5. <div class="col-md-6 offset-md-3 col-xs-12">
  6. <h1 class="text-xs-center">{{ isLogin ? 'Sign in' : 'Sign up'}}</h1>
  7. <p class="text-xs-center">
  8. <!-- <a href>Have an account?</a> -->
  9. <nuxt-link
  10. to="/register"
  11. v-if="isLogin"
  12. >Need an account?</nuxt-link>
  13. <nuxt-link
  14. to="/login"
  15. v-else
  16. >Have an account?</nuxt-link>
  17. </p>
  18. <ul class="error-messages">
  19. <template v-for="(messages, field) in errors">
  20. <li
  21. :key="index"
  22. v-for="(message, index) in messages"
  23. >{{ field }}{{ message }}</li>
  24. </template>
  25. </ul>
  26. <form @submit.prevent="onSubmit">
  27. <fieldset
  28. class="form-group"
  29. v-if="!isLogin"
  30. >
  31. <input
  32. class="form-control form-control-lg"
  33. placeholder="Your Name"
  34. type="text"
  35. v-model="user.username"
  36. />
  37. </fieldset>
  38. <fieldset class="form-group">
  39. <input
  40. class="form-control form-control-lg"
  41. placeholder="Email"
  42. required
  43. type="email"
  44. v-model="user.email"
  45. />
  46. </fieldset>
  47. <fieldset class="form-group">
  48. <input
  49. class="form-control form-control-lg"
  50. minlength="8"
  51. placeholder="Password"
  52. required
  53. type="password"
  54. v-model="user.password"
  55. />
  56. </fieldset>
  57. <button class="btn btn-lg btn-primary pull-xs-right">{{ isLogin ? 'Sign in' : 'Sign up'}}</button>
  58. </form>
  59. </div>
  60. </div>
  61. </div>
  62. </div>
  63. </template>
  64. <script>
  65. import { login, register } from '@/api/user'
  66. export default {
  67. name: 'LoginIndex',
  68. computed: {
  69. isLogin() {
  70. return this.$route.name === 'login'
  71. }
  72. },
  73. data() {
  74. return {
  75. user: {
  76. username: '',
  77. email: '',
  78. password: ''
  79. },
  80. errors: {}
  81. }
  82. },
  83. methods: {
  84. async onSubmit() {
  85. try {
  86. const { data } = this.isLogin ?
  87. await login({
  88. user: this.user
  89. }) :
  90. await register({
  91. user: this.user
  92. })
  93. console.log(data)
  94. this.$router.push('/')
  95. } catch (err) {
  96. this.errors = err.response.data.errors
  97. }
  98. }
  99. },
  100. }
  101. </script>
  102. <style scoped>
  103. </style>

数据持久化

由于服务端和客户端都需要验证用户信息,所以这里通过操作 cookie 来实现。
新建 store 文件夹,它的功能相当于 Vuex,新建index.js

  1. const cookieparser = process.server ? require('cookieparser') : undefined
  2. export const state = () => {
  3. return {
  4. user: null
  5. }
  6. }
  7. export const mutations = {
  8. setUser (state, data) {
  9. state.user = data
  10. }
  11. }
  12. export const actions = {
  13. nuxtServerInit ({ commit }, { req }) {
  14. let user = null
  15. if (req.headers.cookie) {
  16. const parsed = cookieparser.parse(req.headers.cookie)
  17. try {
  18. user = JSON.parse(parsed.user)
  19. } catch (err) {
  20. // No valid cookie found
  21. }
  22. }
  23. commit('setUser', user)
  24. }
  25. }

登录按钮事件修改:

  1. // 仅在客户端加载 js-cookie
  2. const Cookie = process.client ? require('js-cookie') : undefined
  3. async onSubmit() {
  4. try {
  5. const { data } = this.isLogin ?
  6. await login({
  7. user: this.user
  8. }) :
  9. await register({
  10. user: this.user
  11. })
  12. this.$store.commit('setUser', data.user)
  13. Cookie.set('user', data.user)
  14. this.$router.push('/')
  15. } catch (err) {
  16. this.errors = err.response.data.errors
  17. }
  18. }

页面访问权限

一些页面在未登录的状态是不应该被允许访问到的。NuxtJS 通过中间件来实现路由拦截。
新建middleware文件夹,新建一个 js 文件,导出一个函数,该函数接收一个上下文对象作为参数。
写一个未登录就跳到登录页面的中间件authenticated.js

  1. export default function ({ store, redirect }) {
  2. if (!store.state.user) {
  3. return redirect('/login')
  4. }
  5. }

在页面组件中配置 middleware 属性,和中间件的文件名一致,就可以开启拦截。

  1. <script>
  2. export default {
  3. name: 'EditorIndex',
  4. middleware: 'authenticated'
  5. }
  6. </script>

登录/注册页面在登录之后就不能再被访问到。
写一个登录了就跳转到首页的中间件:

  1. export default function ({ store, redirect }) {
  2. if (store.state.user) {
  3. return redirect('/')
  4. }
  5. }

首页

获取公共文章列表

封装 api:

  1. import request from '../utils/request'
  2. export const getArticles = params => {
  3. return request({
  4. method: 'GET',
  5. url: '/articles',
  6. params
  7. })
  8. }

home 组件文章部分:

  1. <template>
  2. <!-- 文章列表部分 -->
  3. <div
  4. :key="article.slug"
  5. class="article-preview"
  6. v-for="article in articles"
  7. >
  8. <div class="article-meta">
  9. <nuxt-link :to="{ name: 'profile', params: { username: article.author.username }}">
  10. <img :src="article.author.image" />
  11. </nuxt-link>
  12. <div class="info">
  13. <nuxt-link
  14. :to="{ name: 'profile', params: { username: article.author.username }}"
  15. class="author"
  16. >{{ article.author.username }}</nuxt-link>
  17. <span class="date">{{ article.createdAt }}</span>
  18. </div>
  19. <button
  20. :class="{
  21. active: article.favorited
  22. }"
  23. class="btn btn-outline-primary btn-sm pull-xs-right"
  24. >
  25. <i class="ion-heart"></i>
  26. {{ article.favoritesCount }}
  27. </button>
  28. </div>
  29. <nuxt-link
  30. :to="{
  31. name: 'article',
  32. params: {
  33. slug: article.slug
  34. }
  35. }"
  36. class="preview-link"
  37. >
  38. <h1>{{ article.title }}</h1>
  39. <p>{{ article.description}}</p>
  40. <span>Read more...</span>
  41. </nuxt-link>
  42. </div>
  43. </template>

分页

  1. <script>
  2. import { getArticles } from '@/api/article'
  3. export default {
  4. name: 'HomeIndex',
  5. async asyncData({ query }) {
  6. const page = Number.parseInt(query.page || 1)
  7. const limit = 1
  8. const { data } = await getArticles({
  9. limit,
  10. offset: (page - 1) * limit,
  11. })
  12. return {
  13. articles: data.articles, // 文章列表
  14. articlesCount: data.articlesCount, // 文章总数
  15. limit, // 每页大小
  16. page, // 页码
  17. }
  18. },
  19. watchQuery: ['page'],
  20. computed: {
  21. totalPage() {
  22. return Math.ceil(this.articlesCount / this.limit)
  23. }
  24. },
  25. }
  26. </script>

home 组件分页部分:

  1. <template>
  2. <div>
  3. <!-- 分页列表 -->
  4. <nav>
  5. <ul class="pagination">
  6. <li
  7. :class="{
  8. active: item === page
  9. }"
  10. :key="item"
  11. class="page-item"
  12. v-for="item in totalPage"
  13. >
  14. <nuxt-link
  15. :to="{
  16. name: 'home',
  17. query: {
  18. page: item,
  19. }
  20. }"
  21. class="page-link"
  22. >{{ item }}</nuxt-link>
  23. </li>
  24. </ul>
  25. </nav>
  26. <!-- /分页列表 -->
  27. </div>
  28. </template>

标签

获取标签列表 api:

  1. import request from '../utils/request'
  2. export const getTags = params => {
  3. return request({
  4. method: 'GET',
  5. url: '/tags',
  6. params
  7. })
  8. }

异步获取数据重构:

  1. <script>
  2. export default {
  3. name: 'HomeIndex',
  4. async asyncData({ query }) {
  5. const page = Number.parseInt(query.page || 1)
  6. const limit = 1
  7. const tag = query.tag
  8. const [articleRes, tagRes] = await Promise.all([
  9. getArticles({
  10. limit,
  11. offset: (page - 1) * limit,
  12. tag
  13. }),
  14. getTags()
  15. ])
  16. const { articles, articlesCount } = articleRes.data
  17. const { tags } = tagRes.data
  18. return {
  19. articles, // 文章列表
  20. articlesCount, // 文章总数
  21. tags, // 标签列表
  22. limit, // 每页大小
  23. page, // 页码
  24. tag,
  25. }
  26. },
  27. watchQuery: ['page', 'tag'],
  28. computed: {
  29. totalPage() {
  30. return Math.ceil(this.articlesCount / this.limit)
  31. }
  32. },
  33. }
  34. </script>

home 组件标签部分:

  1. <template>
  2. <div class="col-md-3">
  3. <div class="sidebar">
  4. <p>Popular Tags</p>
  5. <div class="tag-list">
  6. <nuxt-link
  7. :key="tag"
  8. :to="{
  9. name: 'home',
  10. query: {
  11. tag: tag,
  12. }
  13. }"
  14. class="tag-pill tag-default"
  15. v-for="tag in tags"
  16. >{{ tag }}</nuxt-link>
  17. </div>
  18. </div>
  19. </div>
  20. </template>

导航栏

导航栏最多有三个标签页,只有登录状态下显示的Your Feed,一直显示的Global Feed,还有点击标签产生的标签页。
home 组件导航栏部分:

  1. <ul class="nav nav-pills outline-active">
  2. <li class="nav-item">
  3. <nuxt-link
  4. :class="{
  5. active: tab === 'your_feed'
  6. }"
  7. :to="{
  8. name: 'home',
  9. query: {
  10. tab: 'your_feed'
  11. }
  12. }"
  13. class="nav-link"
  14. exact
  15. v-if="user"
  16. >Your Feed</nuxt-link
  17. >
  18. </li>
  19. <li class="nav-item">
  20. <nuxt-link
  21. :class="{
  22. active: tab === 'global_feed'
  23. }"
  24. :to="{
  25. name: 'home',
  26. query: {
  27. tab: 'global_feed'
  28. }
  29. }"
  30. class="nav-link"
  31. exact
  32. >Global Feed</nuxt-link
  33. >
  34. </li>
  35. <li class="nav-item" v-if="tag">
  36. <nuxt-link
  37. :class="{
  38. active: tab === 'tag'
  39. }"
  40. :to="{
  41. name: 'home',
  42. query: {
  43. tab: 'tag',
  44. tag: tag
  45. }
  46. }"
  47. class="nav-link"
  48. >{{ '#' + tag }}</nuxt-link
  49. >
  50. </li>
  51. </ul>

Token

一些接口需要获取用户信息。所以最好统一给所有接口的请求头设置 Token。

NuxtJS 允许在运行应用程序前执行 js 插件,这在需要使用自己的库或第三方模块时特别有用。
一个典型的例子,使用 axios 发送请求。

  1. <template>
  2. <h1>{{ title }}</h1>
  3. </template>
  4. <script>
  5. import axios from 'axios'
  6. export default {
  7. async asyncData({ params }) {
  8. let { data } = await axios.get(`https://my-api/posts/${params.id}`)
  9. return { title: data.title }
  10. }
  11. }
  12. </script>

在独立的 js 模块文件中,插件必须作为默认导出成员导出。并且需要在nuxt.config.jsplugins数组选项中声明。
NuxtJS 会为插件注入 context,从 context 中可以解构出 route、store、params、query 等经常需要用到的数据。
基于 NuxtJS 的插件机制和 axios 的请求拦截器,可以实现把用户的 token 设置到请求头。
把案例的请求模块request.js放到plugins文件夹下,从注入插件的 context 对象解构出 store,获取到 user。

  1. /**
  2. * 基于 axios 封装的请求模块
  3. */
  4. import axios from 'axios'
  5. // 创建请求对象
  6. export const request = axios.create({
  7. baseURL: 'https://conduit.productionready.io'
  8. })
  9. // 通过插件机制获取到上下文对象(query、params、req、res、app、store...)
  10. // 插件导出函数必须作为 default 成员
  11. export default ({ store }) => {
  12. // 请求拦截器
  13. // Add a request interceptor
  14. // 任何请求都要经过请求拦截器
  15. // 我们可以在请求拦截器中做一些公共的业务处理,例如统一设置 token
  16. request.interceptors.request.use(function (config) {
  17. // Do something before request is sent
  18. // 请求就会经过这里
  19. const { user } = store.state
  20. if (user && user.token) {
  21. config.headers.Authorization = `Token ${user.token}`
  22. }
  23. // 返回 config 请求配置对象
  24. return config
  25. }, function (error) {
  26. // 如果请求失败(此时请求还没有发出去)就会进入这里
  27. // Do something with request error
  28. return Promise.reject(error)
  29. })
  30. }

nuxt.config.js 声明插件:

  1. module.exports = {
  2. plugins: [
  3. '~/plugins/request.js'
  4. ]
  5. }

由于request.js默认导出的是插件函数,所有之前导入request的地方需要把request解构出来。当导入request.js的时候,插件函数会自动执行。

  1. import { request } from '../plugins/request'

日期格式化

日期格式化有多种方式,这里通过 NuxtJS 插件和 Vue 的过滤器来实现。
安装dayjs模块,新建一个插件文件,导入Vuedayjs

  1. import Vue from 'vue'
  2. import dayjs from 'dayjs'
  3. Vue.filter('date', (value, format = 'YYYY-MM-DD HH:mm:ss') => {
  4. return dayjs(value).format(format)
  5. })

nuxt.config.js中声明插件。

文章发表时间格式化:

  1. // 过滤器用法:{{ 表达式 | 过滤器 }}
  2. <span class="date">{{ article.createdAt | date('MMM DD, YYYY') }}</span>

点赞功能

点赞功能实现点击一次心心点赞,再点一次取消点赞。而且把点赞请求传给服务器期间,不允许再点击心心。

  1. <button
  2. :class="{
  3. active: article.favorited
  4. }"
  5. :disabled="article.favoriteDisabled"
  6. class="btn btn-outline-primary btn-sm pull-xs-right"
  7. @click="onFavorite(article)"
  8. >
  9. <i class="ion-heart"></i>
  10. {{ article.favoritesCount }}
  11. </button>
  12. <script>
  13. articles.forEach(article => {
  14. article.favoriteDisabled = false
  15. })
  16. async onFavorite (article) {
  17. article.favoriteDisabled = true
  18. if (article.favorited) {
  19. await deleteFavorite(article.slug)
  20. article.favorited = false
  21. article.favoritesCount -= 1
  22. } else {
  23. await addFavorite(article.slug)
  24. article.favorited = true
  25. article.favoritesCount += 1
  26. }
  27. article.favoriteDisabled = false
  28. }
  29. </script>

文章详情页

文章详情页最重要的几个步骤:获取文章的详情展示到页面,把文章的 markdown 形式的内容转成 html,设置页面 meta 优化 seo。

  1. <template>
  2. <div class="article-page">
  3. <div class="banner">
  4. <div class="container" v-if="article">
  5. <h1>{{ article.title }}</h1>
  6. <article-meta v-if="article" :article="article"></article-meta>
  7. </div>
  8. </div>
  9. <div class="container page" v-if="article">
  10. <div class="row article-content">
  11. <div
  12. class="col-md-12"
  13. v-html="article.body"
  14. ></div>
  15. </div>
  16. <hr />
  17. <div class="article-actions" v-if="article">
  18. <article-meta :article="article"></article-meta>
  19. </div>
  20. <article-comment v-if="article" :article="article"></article-comment>
  21. </div>
  22. </div>
  23. </template>
  24. <script>
  25. import { getArticle } from '@/api/article'
  26. import Markdownit from "markdown-it"
  27. import ArticleMeta from './components/article-meta.vue'
  28. import ArticleComment from './components/article-comment.vue'
  29. export default {
  30. name: 'ArticleIndex',
  31. components: {
  32. ArticleMeta,
  33. ArticleComment,
  34. },
  35. async asyncData({ params }) {
  36. try {
  37. const { data } = await getArticle(params.slug)
  38. const { article } = data
  39. const md = new Markdownit()
  40. article.body = md.render(article.body)
  41. return {
  42. article: article
  43. }
  44. } catch (error) {
  45. return {
  46. article: null
  47. }
  48. }
  49. },
  50. head() {
  51. return {
  52. title: `${this.article ? this.article.title + '-' : ''}RealWord`,
  53. meta: [
  54. {
  55. hid: 'description',
  56. name: 'description',
  57. content: this.article ? this.article.description : ''
  58. }
  59. ]
  60. }
  61. }
  62. }
  63. </script>
  64. <style scoped>
  65. </style>

自动部署

使用 Github Actions 来实现项目在提交到 GitHub 仓库后自动打包,部署到服务器。

生成 token

打开个人 settings,选择 Developer settings -> Personal access tokens,点击Generate new token,选择 token,输入信息,选择 token 的使用期限,使用范围。
image.png

Actions secrets

打开项目仓库,选择仓库的 Settings,点击 Secrets -> Actions,生成一些仓库秘密,包括服务器地址,远程端口,登录的用户密码,以及生成的 token。
image.png

配置脚本

在项目根目录下,新建 ./github/workflows/main.yml 文件。

  1. name: Publish And Deploy Demo
  2. on:
  3. push:
  4. tags:
  5. - 'v*'
  6. jobs:
  7. build-and-deploy:
  8. runs-on: ubuntu-latest
  9. steps:
  10. # 下载源码
  11. - name: Checkout
  12. uses: actions/checkout@master
  13. # 打包构建
  14. - name: Build
  15. uses: actions/setup-node@master
  16. - run: npm install
  17. - run: npm run build
  18. - run: tar -zcvf release.tgz .nuxt static nuxt.config.js package.json package-lock.json pm2.config.json
  19. # 发布 Release
  20. - name: Create Release
  21. id: create_release
  22. uses: actions/create-release@master
  23. env:
  24. GITHUB_TOKEN: ${{ secrets.token }}
  25. with:
  26. tag_name: ${{ github.ref }}
  27. release_name: Release ${{ github.ref }}
  28. draft: false
  29. prerelease: false
  30. # 上传构建结果到 Release
  31. - name: Upload Release Asset
  32. id: upload-release-asset
  33. uses: actions/upload-release-asset@master
  34. env:
  35. GITHUB_TOKEN: ${{ secrets.token }}
  36. with:
  37. upload_url: ${{ steps.create_release.outputs.upload_url }}
  38. asset_path: ./release.tgz
  39. asset_name: release.tgz
  40. asset_content_type: application/x-tgz
  41. # 部署到服务器
  42. - name: Deploy
  43. uses: appleboy/ssh-action@master
  44. with:
  45. host: ${{ secrets.HOST }}
  46. username: ${{ secrets.USERNAME }}
  47. password: ${{ secrets.PASSWORD }}
  48. port: ${{ secrets.PORT }}
  49. script: |
  50. cd /root/realworld-nuxtjs
  51. wget https://github.com/yanlinchan/realworld-nuxtjs/releases/latest/download/release.tgz -O release.tgz
  52. tar zxvf release.tgz
  53. npm install --production
  54. pm2 reload pm2.config.json

推送标签

上面的文件中,编写了一个推送以 v 开头的标签到 GitHub 仓库,就会触发自动部署的任务。
所以在本地先用git tag创建一个标签,比如git tag v0.1.0,之后把这个标签推送到 GitHub,git push origin v0.1.0,就会触发 GitHub 自动部署到服务器的任务。
image.png