nuxt 搭建个人博客系统 ——前端


最近看了下 xx联盟 后,也想在自己的网站中接入广告,但是我的网站是用 vue 开发的单页面应用,对 SEO 不太友好,自然,接入广告,也会有所损失。而且,对于一个博客系统来说,SEO 也是一项不可忽视的指标,因此,我把网站用 nuxt 重写了一边,改造成 ssr(服务端渲染)形式。

效果:https://zhangjinpei.cn/

github:https://github.com/love-peach/nuxt-ts-blog

初始化项目

通过 Nuxt.js 官方提供的脚手架工具 create-nuxt-app,初始化项目

运行

1
2
3
npx create-nuxt-app <项目名>
// 或
yarn create nuxt-app <项目名>

yarn create 参见 yarn create

1
2
3
4
yarn create nuxt-app <项目名> // 等价于下面

yarn global add create-nuxt-app
create-nuxt-app <项目名>

项目配置

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.
├── .editorconfig
├── .env
├── .eslintrc.js
├── .git/
├── .gitignore
├── .nuxt/  // Nuxt自动生成,临时的用于编辑的文件
├── .prettierrc
├── README.md
├── assets/  // 用于组织未编译的静态资源如 LESS、SASS 或 JavaScript
├── components/  // 用于组织应用的 Vue.js 组件。不会像页面组件那样有 asyncData 方法的特性
├── jsconfig.json
├── layouts/  // 用于组织应用的布局组件
├── middleware/ // 目录用于存放应用的中间件
├── nuxt.config.js  // 文件用于组织Nuxt.js 应用的个性化配置,以便覆盖默认配置。
├── package.json
├── pages/  // 用于组织应用的路由及视图,并自动生成对应的路由配置
├── plugins/  // 用于组织那些需要在 根vue.js应用 实例化之前需要运行的 Javascript 插件。
├── server/
├── static/  // 用于存放应用的静态文件,不会被构建编译处理,会映射至应用的根路径 /
├── store/  // 用于组织应用的 Vuex 状态树 文件
├── stylelint.config.js
├── tsconfig.json
└── yarn.lock

运行项目

1
yarn run dev

在浏览器中,打开 http://localhost:3000

搭建前端页面

项目启动后,我们就可以开发前端页面了。

添加页面

nuxt 中,大部分的路由都可以通过 pages/ 目录自动生成,例如:

1
2
3
4
5
6
7
pages/
└── article/
    ├── index.vue
    ├── _category/
    │   └── index.vue
    └── detail/
        └── _articleId.vue

将生成如下路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
router: {
  routes: [
    {
      name: 'article',
      path: '/article',
      component: 'pages/article/index.vue'
    },
    {
      name: "article-category"
      path: "/article/:category",
      component: 'pages/article/_category/index.vue',
    },
    {
      name: "article-detail-articleId"
      path: "/article/detail/:articleId",
      component: 'pages/article/detail/_articleId.vue'
    }
  ]
}

布局layout

因为我的博客有这几个部分,articleresourcemoiveebookadminuser,会有几种布局,
layout/ 目录下,添加相应的布局文件。草图如下:

从草图中,可以看到 defaultuser,又具有相同的结构,header,footer,content, 因此,将其提取成组件 AppLayout,下面是 defaultuser 布局的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
  <AppLayout>
    <nuxt />
  </AppLayout>
</template>

<script>
import AppLayout from '@/components/framework/app-layout/index.js';

export default {
  name: 'AppLayoutDefault',
  components: {
    AppLayout,
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<template>
  <AppLayout>
    <div class="z-container">
      <div class="user-page-wrap">
        <div class="user-page-side">
          <div class="hidden-xs">
            <UserBrief />
          </div>
          <Card :padding="0" style="margin-bottom: 0;">
            <UserMenu></UserMenu>
          </Card>
        </div>
        <div class="user-page-main">
          <nuxt />
        </div>
      </div>
    </div>
  </AppLayout>
</template>

<script lang="ts">
import AppLayout from '@/components/framework/app-layout/index.js';

import Card from '@/components/base/card/';
import UserMenu from '@/components/framework/user-menu.vue';
import UserBrief from '@/components/framework/user-brief.vue';

export default {
  name: 'AppLayoutUser',
  components: {
    AppLayout,
    Card,
    UserMenu,
    UserBrief,
  },
  head() {
    return {
      title: '用户中心',
    };
  },
};
</script>

注意 layout/ 目录下的文件,name 值不能重复,不然会报错。

使用布局

如果,没有向下面这样注明 layout 字段,将使用 default 布局。

1
2
3
export default Vue.extend({
  layout: 'admin',
});

组件,页面组件

公用的组件,我们知道是放在 conponents/ 目录下,但是页面中的组件放哪呢?放在 pages 目录下的对应文件夹中肯定是不行的,因为,它会将组件当初页面,会生成路由。

我建议是也放在 componets/ 目录下,然后通过目录来区分不同的组件,如下

1
2
3
4
5
6
7
8
9
components/
├── base    基本组件
├── framework    布局相关组件
└── page/    各个页面下的组件
    ├── admin
    ├── ebook
    ├── home
    ├── movie
    └── user

css js img 放哪?

一般放在 assets/ 目录下。

如果,不需要 Webpack 做构建编译处理,应该放在 static/ 下,会映射至应用的根路径 / 下。

全局样式

首先,全局样式的文件,可以放在 assets/ 目录下,然后在 nuxt.config.js 中引入

1
2
3
module.exports = {
  css: ['@/assets/css/grid.less', '@/assets/css/reset.less'],  
}

可以将,各个独立的样式,通过一个文件引入,然后,再配置下:

1
2
3
4
5
6
7
// assets/css/index.less
@import './variables.less';
@import "./clearfix.less";
@import './reset.less';
@import './animate.less';
@import "./grid.less";
@import './common.less';
1
2
3
module.exports = {
  css: ['@/assets/css/index.less'],  
}

less 全局变量

如果你用 less 进行样式的预处理,可能会用到变量,又不想每个页面中引入 变量文件,怎么做呢?

首先定义好变量文件 variables.less

1
2
3
4
@colorPrimary: #2d8cf0;
@colorPrimaryLight: lighten(@colorPrimary, 10%);
@colorPrimaryDark: darken(@colorPrimary, 10%);
@colorPrimaryFade: fade(@colorPrimary, 10%);

然后在 nuxt.config.js 中如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
  build: {
    // extend(config, ctx) {},
    loaders: {
      less: {
        lessOptions: {
          modifyVars: getLessVariables(resolve('assets/css/variables.less')),
          javascriptEnabled: true,
        },
      },
    },
  },
}

getLessVariables 函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const path = require('path');
const fs = require('fs');

function resolve(dir) {
  return path.join(__dirname, dir);
}

function getLessVariables(file) {
  const themeContent = fs.readFileSync(file, 'utf-8');
  const variables = {};
  themeContent.split('\n').forEach(function(item) {
    if (item.includes('//') || item.includes('/*')) {
      return;
    }
    const _pair = item.split(':');
    if (_pair.length < 2) return;
    const key = _pair[0].replace('\r', '').replace('@', '');
    if (!key) return;
    const value = _pair[1]
      .replace(';', '')
      .replace('\r', '')
      .replace(/^\s+|\s+$/g, '');
    variables[key] = value;
  });
  return variables;
}

全局过滤器

plugins/ 目录下,新建 filters.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Vue from 'vue';
import dayjs from 'dayjs';

export function dateFormatFilter(date, fmt) {
  if (!date) {
    return '-';
  } else {
    return dayjs(date).format(fmt);
  }
}
const filters = {
  dateFormatFilter,
};

Object.keys(filters).forEach(key => {
  Vue.filter(key, filters[key]);
});
export default filters;

然后,在 nuxt.config.js 中配置,

1
2
3
module.exports = {
    plugins: ['~/plugins/filters.js'],
}

自定义指令

跟定义全局过滤器一样,需要在 plugins 中操作。

plugins/directive/focus 目录下,添加 focus.js

1
2
3
4
5
6
7
import Vue from 'vue';
const focus = Vue.directive('focus', {
  inserted(el) {
    el.focus();
  },
});
export default focus;

然后,在 nuxt.config.js 中配置,

1
2
3
4
5
6
module.exports = {
  plugins: [
    '~/plugins/filters.js',
    { src: '~/plugins/directive/focus/index.js', ssr: false },
  ],
}

head 动态设置页面标题

我的博客中的 豆瓣电影ebook 栏目,用到了很多外部的图片,而他们做了 防止盗链 处理,不做配置的情况下,会出现 403 的情况,图片不允许访问。想到的办法是 添加 meta 标签,设置 referrer

之前单页面的时候,我是配置在,public/index.html 中,这样每个页面都有这个 标签了,我不想这样。

Nuxt.js 使用 vue-meta 来更新应用的 头部标签(Head) 和 html 属性。

我们可以通过在页面中配置这个属性,来修改页面的 title ,或者动态添加一些 meta 标签,在 head 中,可通过 this 关键字来获取组件的数据。

1
2
3
4
5
6
head() {
  return {
    title: `${this.blogResult.title} 详情页`,
    meta: [{ hid: 'ebook-home referrer', name: 'referrer', content: 'never' }],
  };
},

也可以,通过 nuxt.config.js 全局配置 head 信息,所以在配置 meta 标签的时候,最好提供一个 hid 唯一编号。

asyncData

因为采用的是 ssr,需要在浏览器请求页面的时候,就将页面中的数据渲染好,返回一个完整的 html 内容。

asyncData 方法会在组件(限于页面组件)每次加载之前被调用。它可以在服务端或路由更新之前被调用。在这个方法被调用的时候,第一个参数被设定为当前页面的上下文对象,你可以利用 asyncData 方法来获取数据并返回给当前组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
  data () {
    return { blogList: [] };
  },
  async asyncData({ app }: ctxProps) {
    const params = { page: 1, limit: 10 };
    const res = await app.$myApi.blogs.index(params);
    return {
      blogList: res.result.list,
      pageTotal: res.result.pages,
      itemTotal: res.result.total,
    };
  },
}

fetch

有些时候,我们只是想在页面进来的时候,拉取最新数据而已,不是修改组件中的值,那么,这个时候,可以使用 fetch 方法。

如果页面组件设置了 fetch 方法,它会在组件每次加载前被调用(在服务端或切换至目标路由之前)。
fetch 是在组件初始化之前被调用。

asyncData 方法类似,不同的是它不会设置组件的数据。

1
2
3
async fetch({ store }: ctxProps) {
  await store.dispatch('common/requestCategoryList');
},

解耦 api

建议接口采用 RESTFUL 风格,这样,一个接口基本就是这样

1
2
3
4
5
6
7
8
import request from '@/utils/request';

export default {
  GetCategory: (params, options) => request.get('/categories', params, options),
  PostCategory: (params, options) => request.post('/categories', params, options),
  PutCategory: (params, options) => request.put(`/categories/${params.categoryId}`, params, options),
  DeleteCategory: (params, options) => request.delete(`/categories/${params.categoryId}`, params, options),
}

但是这样,每次都需要映入 请求方法,例如 axios,封装过的 request。

可以参考这篇文章,可以让你的 api 优雅的解耦:组织并解耦你在 NuxtJs 中调用的 api

proxy 代理

请求接口,难免会用到代理,在 nuxt 中配置起来也很方便。

首先,安装依赖(好像不用安装,如果不行,你在安装)

1
yarn add @nuxtjs/proxy

然后,在 nuxt.config.js 中添加 modules,并配置,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module.exports = {
  modules: ['@nuxtjs/proxy'],
  proxy: {
    '/api/': {
      target: process.env.NODE_ENV === 'production' ? 'http://localhost:3000/' : 'http://localhost:3000/',
      // target: process.env.NODE_ENV === 'production' ? 'http://zhangjinpei.cn' : 'http://localhost:3000/',
      changeOrigin: true,
    },
    '/douban/': {
      target: 'http://api.douban.com/v2',
      changeOrigin: true,
      pathRewrite: {
        '^/douban': '',
      },
    },
    '/ebookSearch/': {
      target: 'http://www.shuquge.com/search.php',
      changeOrigin: true,
      pathRewrite: {
        '^/ebookSearch/': '',
      },
    },
  },
};

之前,在 单页面 的模式下,本地开发,配上类似上面的代理,上线后还需要配置,nginx 代理。但是在 nuxt 中,不需要再做接口相关的代理。

我线上的 ng 代理如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
upstream server_host {
  server localhost:3000;
}

server {
  listen             80;
  server_name        zhangjinpei.cn;
  rewrite ^(.*)$ https://$host$1 permanent;
  gzip               on;
  gzip_http_version  1.0;
  gzip_proxied       any;
  gzip_types         text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png application/vnd.ms-fontobject font/ttf font/opentype font/xwoff image/svg+xml;
}

#server {
#  listen 80;
#  server_name ssr.zhangjinpei.cn;
#  location / {
#    proxy_set_header X-Real-IP $remote_addr;
#    proxy_set_header Host $http_host;
#    proxy_pass http://127.0.0.1:8000;
#  }
#}

server {
  listen 443 ssl;
  server_name zhangjinpei.cn;
  root /root/zhangjinpei/nuxt-ts-blog/;
  index index.html index.htm;
  ssl_certificate /xxx/xx;
  ssl_certificate_key /xxx/xx;
  ssl_session_timeout 5m;
  ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
  ssl_prefer_server_ciphers on;
 
  #location / {
  #  root /root/zhangjinpei/blog-front/dist;
  #  try_files $uri /index.html;
  #}
 
  location / {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Nginx-Proxy true;
    proxy_cache_bypass $http_upgrade;
    proxy_pass http://127.0.0.1:8000;
  }
  #location /api/ {
  #  proxy_pass http://server_host/api/;
  #  poxy_set_header Host $host;
  #  proxy_set_header X-Real-IP $remote_addr;
  #  proxy_set_header X-Forwarded-For $remote_addr;
  #  proxy_set_header X-Forwarded-Host $host;
  #  proxy_set_header X-Forwarded-Server $host;
  #}
  #location /douban/ {
  #  proxy_pass http://api.douban.com/v2/;
  #}
  #location /douban/movie/ {
  #  proxy_pass http://api.douban.com/v2/movie/;
  #}
  #location /doubanOld/ {
  #  proxy_pass https://movie.douban.com/;
  #}
}

store

nuxt 中使用 vuex 来管理状态,十分方便,只需要在 store/ 目录下创建文件即可。

store 目录下的每个 .js 文件会被转换成为状态树指定命名的子模块 (当然,index 是根模块)。

nuxt 中,有两种方式使用 store。

一种是,模块模式,直接将 stategettersmutationsactions导出,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const state = () => ({
    userInfo: null,
});

const getters = {
  getUserInfo: state => state.userInfo,
}

const mutations = {
  setUserInfo(state, data) {
    state.userInfo = data;
  },
}

const actions = {
  async requestUserInfo({ commit }) {
    const res = await axios.get('xx');
    commit('setUserInfo', res.data);
  },
}
export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions,
};

一种是 store/index.js 返回创建Vuex.Store实例的方法(不推荐,官网更推荐前一种)。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default new Vuex.Store({
  state: () => ({
    counter: 0
  }),,
  mutations: {
    ...mutations,
  },
  actions: {
    ...actions,
  },
  modules: {
    common,
  },
});

注意

无论使用那种模式,您的 state 的值应该始终是 function,为了避免返回引用类型,会导致多个实例相互影响。

我们甚至可以,将模块文件分解成单独的 js 文件,如:state.js, actions.js, mutations.jsgetters.js

注意

在使用拆分文件模块时,必须记住使用箭头函数功能, this 在词法上可用。词法范围 this 意味着它总是指向引用箭头函数的所有者。如果未包含箭头函数,那么 this 将是未定义的 (undefined)。解决方案是使用 "normal" 功能,该功能会将 this 指向自己的作用域,因此可以使用。

构建 部署

执行构建命令,然后启动

1
2
yarn run build
yarn run start

但是这样启动,就一直得开着后台,我用的 pm2 启动

pm2 启动

package.jsonscripts 配置命令:

1
2
3
4
5
{
  "scripts": {
    "pm2start": "cross-env NODE_ENV=production pm2 start ./server/pm2.config.json",  
  }
}

然后,在 server/ 目录下,新建 pm2.config.json 文件

1
2
3
4
5
6
7
8
9
10
11
{
  "apps": [
    {
      "name": "blog-front-ssr",
      "script": "./server/index.js",
      "instances": 0,
      "watch": false,
      "exec_mode": "cluster_mode"
    }
  ]
}

./server/index.js 这个文件,在创建项目是就有了。

最后,启动:

1
npm run pm2start

总结

OK,到目前为止,我把自己从完全没用过 nuxt ,到网站正式上线所遇到的问题都记录在这了,希望对对你也有帮助。最后,这是我用 nuxt 做的个人博客 zhangjinpei.cn

参考链接

Vue SSR 指南、
Nuxt 官网、
TypeScript、
Nuxt Typescript、
restfule api
阿里云图片处理