无屁股网站的兴起

Avatar of Evan Payne
Evan Payne

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

似乎所有酷孩子都分成两个派系:一边是 **无头 CMS**,另一边是 **静态网站生成器**。虽然我承认这些都是很酷的团队名称,但我发现自己无法选择任何一方。用格劳乔·马克斯的话来说,“我不想加入任何会接纳我为成员的俱乐部。”

对于我自己的简单博客(目前令人尴尬地空着),静态网站生成器可能非常适合。像 HugoJekyll 这样的系统都得到了我爱戴和信任的开发人员的高度推荐,而且乍一看效果很好,但当我想要更改主题或在页面之间设置更复杂的 JavaScript 和交互时,我遇到了障碍。有方法可以解决这两个问题,但这并不是我想要的周末。

除此之外,我喜欢尝试新事物,制作新东西,而且目前我对 Vue 迷恋不已。使用无头 CMS 设置与前端分离的后端可能对我来说是一个很好的组合,但在使用 WordPress 进行 7 年以上的 PHP 开发之后,整个设置感觉对我来说有点过分了。

我真正想要的,是一个可以让我将博客作为一个更大的单页应用程序的一部分来编写的静态网站生成器,这样我就可以尝试新事物,同时仍然完全控制样式,而不需要数据库或任何后端。这是我告诉你我找到了自己要加入的俱乐部,但这个俱乐部有一个绝对不酷的名字。

准备好了吗……

无屁股网站

因为没有后端,明白吗? 😶

要实现无屁股,需要几个步骤

  1. 使用 Vue 设置单页应用程序
  2. 在构建时生成每个路由
  3. 创建博客和文章组件
  4. 集成 Webpack 来解析 Markdown 内容
  5. 使用插件扩展功能
  6. 盈利!

这一点必须是每个提议的一部分,对吧?

我知道这看起来很多步骤,但这并不像看起来那么难。让我们一起分解这些步骤。

使用 Vue 设置单页应用程序

让我们让 Vue 运行起来。我们需要 Webpack 来做到这一点。

我知道,即使你知道发生了什么,Webpack 也非常吓人。最好让别人做那些真正困难的工作,所以我们将使用 Vue 渐进式 Web 应用样板 作为我们的基础,并进行一些调整。

我们可以使用仓库中的默认设置,但即使在我写这篇文章的时候,那里也正在进行一些更改。为了避免出现问题,我们将 使用我创建的仓库 用于演示目的。该仓库为我们将在这篇文章中介绍的每个步骤都有一个分支,可以帮助您跟踪。

在 GitHub 上查看

克隆仓库并检出 step-1 分支

$ git clone https://github.com/evanfuture/vue-yes-blog.git step-1
$ cd vue-yes-blog
$ npm install
$ npm run dev

我最喜欢的现代开发部分之一是,只需要三十秒就可以让一个渐进式 Web 应用程序运行起来!

接下来,让我们使事情变得复杂起来。

在构建时生成每个路由

单页应用程序开箱即用只有一个入口点。换句话说,它只有一个 URL。在某些情况下这是有意义的,但我们希望我们的应用程序感觉像一个普通的网站。

我们需要在 Vue Router 文件 中使用历史模式来做到这一点。首先,我们将通过在 Router 对象的属性中添加 mode: 'history' 来打开它,如下所示

// src/router/index.js
Vue.use(Router);

export default new Router({
  mode: 'history',
  routes: [
// ...

我们的入门应用程序有两个路由。除了 Hello 之外,我们还有一个名为 Banana 的第二个视图组件,它位于路由 /banana 上。如果没有历史模式,该页面的 URL 将是 http://localhost:1982/#/banana。历史模式将其清理为 http://localhost:1982/banana。更优雅!

所有这些在开发模式(npm run dev)下运行得很好,但让我们看看它在生产中的样子。以下是我们编译所有内容的方式

$ npm run build

该命令会将您的 Vue 网站生成到 ./dist 文件夹中。要查看它,在您的 Mac 上有一个方便的命令可以启动一个超级简单的 HTTP 服务器

$ cd dist
$ python -m SimpleHTTPServer

抱歉,Windows 用户,我不知道等效的命令!

现在在浏览器中访问 localhost:8000。您将看到您的网站在生产环境中的样子。单击 Banana 链接,一切正常。

刷新页面。糟糕!这揭示了单页应用程序的第一个问题:在构建时只生成一个 HTML 文件,因此浏览器无法知道 /banana 应该指向主应用程序页面并在没有花哨的 Apache 风格重定向的情况下加载路由!

当然,有一个应用程序可以解决这个问题。或者,至少有一个插件。基本用法在 Vue 渐进式 Web 应用样板 文档 中有说明。以下是它告诉我们可以启动插件的方式

$ npm install -D prerender-spa-plugin

让我们将路由添加到 Webpack 生产配置文件

// ./build/webpack.prod.conf.js
// ...
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin')
const PrerenderSpaPlugin = require('prerender-spa-plugin')
const loadMinified = require('./load-minified')
// ...
const webpackConfig = merge(baseWebpackConfig, {
  // ...
  plugins: [
  // ...
    new SWPrecacheWebpackPlugin({
      // ...
      minify: true,
      stripPrefix: 'dist/'
    }),
    // prerender app
    new PrerenderSpaPlugin(
      // Path to compiled app
      path.join(__dirname, '../dist'),
      // List of endpoints you wish to prerender
      [ '/', '/banana' ]
    )
  ]
})

就是这样。现在,当您运行新的构建时,数组中的每个路由都将被渲染为应用程序的新入口点。恭喜,我们基本上刚刚启用了静态网站生成!

创建博客和文章组件

如果您跳过了前面的内容,我们现在已经来到了我的演示仓库的 step-2 分支。请继续检出它

$ git checkout step-2

这一步非常简单。我们将创建两个新的组件,并将它们连接在一起。

博客组件

让我们注册博客组件。我们将在 /src/components 目录中创建一个名为 YesBlog.vue 的新文件,并将视图的标记放入其中

// ./src/components/YesBlog.vue
<template>
  <div class="blog">
    <h1>Blog</h1>
    <router-link to="/">Home</router-link>
    <hr/>
    <article v-for="article in articles" :key="article.slug" class="article">
      <router-link class="article__link" :to="`/blog/${ article.slug }`">
        <h2 class="article__title">{{ article.title }}</h2>
        <p class="article__description">{{article.description}}</p>
      </router-link>
    </article>
  </div>
</template>

<script>
export default {
  name: 'blog',
  computed: {
    articles() {
      return [
        {
          slug: 'first-article',
          title: 'Article One',
          description: 'This is article one\'s description',
        },
        {
          slug: 'second-article',
          title: 'Article Two',
          description: 'This is article two\'s description',
        },
      ];
    },
  },
};
</script>

我们在这里真正做的只是创建一个占位符数组(articles),该数组将填充文章对象。该数组创建了我们的文章列表,并使用 slug 参数作为文章 ID。titledescription 参数填写文章详细信息。目前,它们都是硬编码的,在我们准备就绪时,我们将把其余的代码添加到其中。

文章组件

文章组件是一个类似的过程。我们将创建一个名为 YesArticle.vue 的新文件,并为视图建立标记

// ./src/components/YesArticle.vue
<template>
  <div class="article">
    <h1 class="blog__title">{{article.title}}</h1>
    <router-link to="/blog">Back</router-link>
    <hr/>
    <div class="article__body" v-html="article.body"></div>
  </div>
</template>

<script>
  export default {
    name: 'YesArticle',
    props: {
      id: {
        type: String,
        required: true,
      },
    },
    data() {
      return {
        article: {
          title: this.id,
          body: '<h2>Testing</h2><p>Ok, let\'s do more now!</p>',
        },
      };
    },
  };
</script>

我们将使用路由传递的道具来了解我们正在处理的文章 ID。目前,我们将只用它作为文章标题,并将正文硬编码。

路由

在我们将新的视图添加到路由之前,我们无法继续。这将确保我们的 URL 有效,并允许我们的导航正常运行。以下是 路由文件 的全部内容

// ./src/router/index.js
import Router from 'vue-router';
import Hello from '@/components/Hello';
import Banana from '@/components/Banana';
import YesBlog from '@/components/YesBlog';
import YesArticle from '@/components/YesArticle';

Vue.use(Router);

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Hello',
      component: Hello,
    },
    {
      path: '/banana',
      name: 'Banana',
      component: Banana,
    },
    {
      path: '/blog',
      name: 'YesBlog',
      component: YesBlog,
    },
    {
      path: '/blog/:id',
      name: 'YesArticle',
      props: true,
      component: YesArticle,
    },
  ],
});

请注意,我们已将/:id附加到YesArtcle组件的路径,并将它的 props 设置为true。这些至关重要,因为它们建立了我们在组件文件中组件的 props 数组中设置的动态路由。

最后,我们可以添加一个指向博客的首页链接。这是我们在 Hello.vue 文件 中添加的内容,以使其生效。

<router-link to="/blog">Blog</router-link>

预渲染

到目前为止,我们已经做了很多工作,但如果我们不预先渲染路由,这些工作都不会起作用。预渲染是一种花哨的说法,表示我们告诉应用程序哪些路由存在,以及将正确的标记转储到正确的路由中。我们之前为此添加了一个 Webpack 插件,因此这里是我们可以在 Webpack 生产配置 文件 中添加的内容。

// ./build/webpack.prod.conf.js
// ...
  // List of endpoints you wish to prerender
  [ '/', '/banana', '/blog', '/blog/first-article', '/blog/second-article' ]
// ...

我必须承认,这个过程可能很繁琐且令人讨厌。我的意思是,谁想为了创建一个 URL 触碰多个文件?!值得庆幸的是,我们可以自动化这个过程,我们将在后面进一步介绍。

集成 Webpack 以解析 Markdown 内容

我们现在已经到了step-3分支。如果你正在代码中跟随,请查看它。

$ git checkout step-3

文章

我们将使用 Markdown 来编写我们的文章,并使用一些 FrontMatter 来创建元数据功能。

posts目录中创建一个新文件,以创建我们的第一篇文章。

// ./src/posts/first-article.md
---
title: Article One from MD
description: In which the hero starts fresh
created: 2017-10-01T08:01:50+02
updated:
status: publish
---
Here is the text of the article.  It's pretty great, isn't it?



// ./src/posts/second-article.md
---
title: Article Two from MD
description: This is another article
created: 2017-10-01T08:01:50+02
updated:
status: publish
---
## Let's start with an H2
And then some text
And then some code:
```html
<div class="container">
  <div class="main">
    <div class="article insert-wp-tags-here">
      <h1>Title</h1>
      <div class="article-content">
        <p class="intro">Intro Text</p>
        <p></p>
      </div>
      <div class="article-meta"></div>
    </div>
  </div>
</div>
```

动态路由

目前,一个令人讨厌的事情是,我们需要为预渲染插件硬编码我们的路由。幸运的是,用一些 Node 魔术使它动态化并不复杂。首先,我们将在 实用程序文件 中创建一个助手,以查找文件。

// ./build/utils.js
// ...
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const fs = require('fs')

exports.filesToRoutes = function (directory, extension, routePrefix = '') {
  function findFilesInDir(startPath, filter){
    let results = []
    if (!fs.existsSync(startPath)) {
      console.log("no dir ", startPath)
      return
    }
    const files = fs.readdirSync(startPath)
    for (let i = 0; i < files.length; i++) {
      const filename = path.join(startPath, files[i])
      const stat = fs.lstatSync(filename)
      if (stat.isDirectory()) {
        results = results.concat(findFilesInDir(filename, filter)) //recurse
      } else if (filename.indexOf(filter) >= 0) {
        results.push(filename)
      }
    }
    return results
  }

  return findFilesInDir(path.join(__dirname, directory), extension)
    .map((filename) => {
      return filename
        .replace(path.join(__dirname, directory), routePrefix)
        .replace(extension, '')
      })
}

exports.assetsPath = function (_path) {
// ...

这实际上可以复制粘贴,但我们在这里所做的是创建一个名为filesToRoutes()的实用程序方法,它将接收一个directoryextension和一个可选的routePrefix,并返回一个基于该目录中递归文件搜索的路由数组。

为了使我们的博客文章路由动态化,我们所要做的就是将这个新数组合并到我们的PrerenderSpaPlugin路由中。ES6 的强大功能使这变得非常简单。

// ./build/webpack.prod.conf.js
// ...
new PrerenderSpaPlugin(
  // Path to compiled app
  path.join(__dirname, '../dist'),
  // List of endpoints you wish to prerender
  [
    '/',
    '/banana',
    '/blog',
    ...utils.filesToRoutes('../src/posts', '.md', '/blog')
  ]
)

由于我们在文件顶部已经为其他目的导入了utils,因此我们可以简单地使用扩展运算符...将新的动态路由数组合并到此数组中,我们就完成了。现在我们的预渲染是完全动态的,只取决于我们添加一个新文件!

Webpack 加载器

我们现在已经到了step-4分支。

$ git checkout step-4

为了将我们的 Markdown 文件实际转换为可解析的内容,我们需要一些 Webpack 加载器。同样,其他人已经为我们完成了所有工作,因此我们只需要安装它们并将它们添加到我们的配置中。

2018 年 9 月更新:Seif Sayed 写信说包‘markdown-it-front-matter-loader’ 已弃用。本文的其余部分仍然使用它,但你可能应该使用 markdown-with-front-matter-loader 替代,并且它还具有你不需要使用 json-loader 的额外好处。

$ npm install -D json-loader markdown-it-front-matter-loader markdown-it highlight.js yaml-front-matter

我们只会从 Webpack 配置中调用json-loadermarkdown-it-front-matter-loader,但后者具有markdown-ithighlight.js的同级依赖项,因此我们将在同一时间安装它们。此外,没有警告我们,但yaml-front-matter也是必需的,因此上面的命令也添加了它。

为了使用这些新奇的加载器,我们将向 Webpack 基础配置 添加一个块。

// ./build/webpack.base.conf.js
// ...
module.exports = {
  // ...
  module: {
    rules: [
  // ...
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
        }
      },
      {
        test: /\.md$/,
        loaders: ['json-loader', 'markdown-it-front-matter-loader'],
      },
    ]
  }
}

现在,每当 Webpack 遇到带有.md扩展名的 require 语句时,它将使用front-matter-loader(它将正确地从我们的文章中解析元数据块以及代码块),并将输出 JSON 通过json-loader运行。这样,我们就知道我们最终得到了一个类似于这样的对象,用于每篇文章。

// first-article.md [Object]
{
  body: "<p>Here is the text of the article. It's pretty great, isn't it?</p>\n"
  created: "2017-10-01T06:01:50.000Z"
  description: "In which the hero starts fresh"
  raw: "\n\nHere is the text of the article. It's pretty great, isn't it?\n"
  slug: "first-article"
  status: "publish"
  title: "Article One from MD"
  updated: null
}

这正是我们需要的,并且如果需要,可以很容易地用其他元数据扩展它。但到目前为止,这还没有做任何事情!我们需要在我们的一个组件中require它们,以便 Webpack 可以找到并加载它们。

我们可以简单地写

require('../posts/first-article.md')

…但是然后我们必须为我们创建的每篇文章都这样做,并且随着我们博客的增长,这将不会很有趣。我们需要一种动态地 require 所有 Markdown 文件的方法。

动态 Require

幸运的是,Webpack 已经可以做到这一点!找到它的文档并不容易,但 这里就是。有一个名为require.context()的方法,我们可以使用它来实现我们想要的功能。我们将在YesBlog组件的脚本部分添加它。

// ./src/components/YesBlog.vue
// ...
<script>
  const posts = {};
  const req = require.context('../posts/', false, /\.md$/);
  req.keys().forEach((key) => {
    posts[key] = req(key);
  });

  export default {
    name: 'blog',
    computed: {
      articles() {
        const articleArray = [];
        Object.keys(posts).forEach((key) => {
          const article = posts[key];
          article.slug = key.replace('./', '').replace('.md', '');
          articleArray.push(article);
        });
        return articleArray;
      },
    },
  };
</script>
// ...

这里发生了什么?我们正在创建一个posts对象,我们首先用文章填充它,然后在组件中使用它。由于我们预先渲染了所有内容,因此此对象将立即可用。

require.context()方法接受三个参数。

  • 它将搜索的目录
  • 是否包含子目录
  • 一个用于返回文件的正则表达式过滤器

在我们的例子中,我们只想要 posts 目录中的 Markdown 文件,因此

require.context('../posts/', false, /\.md$/);

这将给我们一个奇怪的新函数/对象,我们需要解析它才能使用它。这就是req.keys()将给我们一个每个文件相对路径的数组的地方。如果我们调用req(key),这将返回我们想要的文章对象,因此我们可以将该值分配给posts对象中的匹配键。

最后,在计算的articles()方法中,我们将通过向每个帖子添加一个slug键,并将该键的值设置为没有路径或扩展名的文件名,来自动生成我们的 slug。如果我们愿意,这可以修改为允许我们在 Markdown 本身中设置 slug,并且只回退到自动生成。同时,我们将文章对象推送到一个数组中,因此我们在组件中可以轻松地迭代它们。

额外奖励

如果你使用这种方法,你可能希望立即做两件事。首先是按日期排序,其次是按文章状态(例如草稿和已发布)过滤。由于我们已经有一个数组,因此可以在一行中完成此操作,在return articleArray之前添加它。

articleArray.filter(post => post.status === 'publish').sort((a, b) => a.created < b.created);

最后一步

现在要做的最后一件事,就是指示我们的YesArticle组件使用我们收到的新数据以及路由更改。

// ./src/components/YesArticle.vue
// ...
data() {
  return {
    article: require(`../posts/${this.id}.md`), // eslint-disable-line global-require, import/no-dynamic-require
  };
},

由于我们知道我们的组件将被预先渲染,因此我们可以禁用ESLint规则,这些规则禁止动态和全局 require,并 require 与id参数匹配的帖子的路径。这将触发我们的 Webpack Markdown 加载器,我们就完成了!

我的天!

继续测试一下。

$ npm run build && cd dist && python -m SimpleHTTPServer

访问localhost:8000,在页面之间导航并刷新页面以从新的入口点加载整个应用程序。它可以工作!

我想强调一下这有多酷。我们已经将一个 Markdown 文件夹转换为一个对象数组,我们可以像我们希望的那样在网站上的任何地方使用它。天空才是极限!

如果你只是想看看它是如何工作的,你可以查看最终分支。

$ git checkout step-complete

通过插件扩展功能

我最喜欢这种技术的一点是,它的一切都是可扩展和可替换的。

有人创建了一个更好的 Markdown 处理器吗?太好了,换掉加载器吧!需要控制网站的 SEO 吗?有一个插件可以做到。需要添加评论系统吗?添加这个插件也行。

我喜欢关注这两个代码库,寻找灵感和创意。

盈利!

你认为这一步是开玩笑吗?

现在我们要做的最后一件事,就是从我们创建的简洁性中获利,并获取一些免费的托管服务。由于您的网站现在是在您的 Git 仓库中生成的,您只需要将更改推送到 Github、Bitbucket、Gitlab 或您使用的任何代码仓库。我选择了 Gitlab,因为私有仓库是免费的,我不想让我的草稿公开,即使是以仓库形式。

设置完这些之后,您需要找到一个主机。您真正需要的是一个提供持续集成和部署的主机,这样将代码合并到主分支就会触发 `npm run build` 命令并重新生成您的网站。

我最初使用 Gitlab 自带的 CI 工具,持续了几个月。我发现设置很简单,但排查问题很困难。我最近切换到Netlify,它有出色的免费计划,以及一些内置的强大的 CLI 工具。

在这两种情况下,您都可以将自己的域名指向他们的服务器,甚至可以设置 SSL 证书 以支持 HTTPS——如果想要尝试像getUserMedia API 这样的功能,或者创建一个商店进行销售,那么这一点非常重要。

完成了所有这些设置后,您现在就成为“无屁股网站”俱乐部的成员了。恭喜您,欢迎加入,朋友们!希望您会发现这是一种简单的方式,可以替代复杂的网站内容管理系统,创建您自己的个人网站,并且可以让您轻松地进行实验。如果您在过程中遇到任何问题,请在评论区告诉我…或者如果您取得了超出您梦想的成就,也请告诉我。😉