论博客的进化与前端发展史

创建于更新于文集:普通文章

概述

这次的标题十分标题党了,这篇文章实际上想要聊聊我的个人博客的技术栈变更与我感受中的前端技术的发展。

事实上,博客这个词,对我来说似乎是上个世纪的东西,在我小时候微博已经流行起来,我是在微博之后才知道博客这个东西的。大约在2017年左右吧,突然想找个输出文字的地方,做一些记录,当时注册了一个微信公众号,直到19年才开始搭建了这个个人博客。其实搭建博客可以说是醉翁之意不在酒,成熟的博客生成应用挺多的,没必要自己折腾,但是当时趁着促销买了个阿里云服务器,正好又想学习一些前端技术,这个博客就应运而生了。

有时为了实现一些功能,了解到一些新的技术,有时又正好相反,将一些新东西应用到博客上,致使博客成了个完全的“冗余工程”,我的博客的技术栈改变,也就恰好变成了我个人的技术栈成长历程,,正好在这里记录一下,顺带要记录一下NextJS的使用体验。

模板渲染、Session与jQuery

一开始,做为一名主要使用Python的后端程序员,我尝试使用Django来开发博客。Django是一个遵循MVC模式的Web框架,Python相关的应用在性能上一般都不强,所以Django主打的卖点也是快速交付,内置的用户系统、管理后台、模型迁移等,最初版本的博客没花多久就做好了。

数据库采用MySQL,在Django中使用内置的模板引擎处理前端页面:

{% if latest_question_list %}
    <ul>
    {% for question in latest_question_list %}
        <li><a href="/polls/{{ question.id }}/">{{ question.question_text }}</a></li>
    {% endfor %}
    </ul>
{% else %}
    <p>No polls are available.</p>
{% endif %}

在约定的文件夹内放置静态文件,使用nginx代理。UI样式主要是通过BootStrap,毕竟我对CSS实在是不熟悉,对于一些页面动效也是通过框架或者一些搜索到的jQuery代码来解决,这里参考了不少杜塞的博客

Django内置了用户与组模型,管理后台也是内置的,登录验证也通过默认的Session中间件,利用第三方库实现了OAuth登录,在这些方面基本上没花时间,一开始博客开放了登录注册与评论功能,可以通过Github登录,但后来觉得没必要又去掉了。这里UI与业务代码是完全紧密联系在一起的。

RESTful、Docker与TLS

因为工作的原因,我开始向全栈的方向发展,开始学习JavaScript,在这之前就常常听到前端三大框架的名字了,因为偶然间加了一个React群,于是开始学习React。在这期间我先把博客的后端部分提取了出来,借助Django REST framework这个库快速地完成了这部分工作,尽量使接口符合RESTful规范,毕竟应用简单,没什么需要妥协而违反规范的地方,评论功能已经移除,但是登录接口仍然保留着,所以做了个JWT登录认证的接口,后来还是删除了。剩下的Django部分不再关心如何呈现用户界面,仅仅根据请求将需要的数据通过JSON返回给前端,也并不关心前端是什么。

我的服务器操作系统是Ubuntu 18.04,在最初的部署过程中,需要考虑Python版本,以及nginxMySQL的安装问题,未来如果服务器要迁移(毕竟阿里云学生机活动只有两年),也比较麻烦,于是用上了Docker,写好镜像文件后,运行容器即可。

在安全问题与钱包的权衡中,我选择了Let's Encrypt的免费证书实现HTTPS,安装很方便,每三个月自动续签一次。

SPA与SSR

顺利地在项目中使用了React做了管理后台之后,开始考虑在博客中用上。但是平时开发的都是SPA单页应用,怎么解决SEO问题呢?

之前我使用Django的模板引擎来做服务端渲染,也就是在返回响应之前,已经将文章内容等数据插入HTML文件中,最终用户在浏览器得到的都是静态文件,如果用户请求了另一个页面,那么他得到的是完全不同的静态文件。

而用React写的SPA则是在客户端渲染,使用虚拟DOM,整个应用往往只有单个HTML文件,切换页面也不再重新请求新的页面,而是更换需要改变的组件

那么这两者的优缺点也就很明显了,传统的服务端渲染(SSR),首页打开是较快的,因为不会一次加载过多内容,对搜索引擎也是友好的,毕竟爬虫可以直接获取到静态的、包含数据HTML文件,但是切换页面则需要更换整个页面重新渲染,并且前后端,表现层与业务层紧耦合;单页应用在客户端渲染,页面切换快,异步获取数据,前后端分离,但是由于首次访问就要获取整个应用资源,因此首屏加载慢,并且经由JS在客户端操作渲染,爬虫难以获得需要的数据,对SEO不利。

NextJS:pages、route与定制app

通过ReactSEO这两个关键字,我发现了NextJS这个React脚手架,在其官网我又看到了关键词SSR服务端渲染,难道前端经过多年发展又绕回去了吗?当然不是,历史的发展总是螺旋上升的,这里的SSR与传统的方式已经不同了,准确点说,这种技术应该叫SSG静态站点生成或Jamstack

NextJS采用约定式路由,在pages目录下的文件名,如about.js,则对应/about这个URL,其中默认导出的组件就是页面组件。特殊的是index.js对应的是/。除此之外还有动态路由,要求文件名用方括号括起来,如[tag].js。可以匹配到/a/someThing/?tag=crumb等。pages目录下默认是页面级组件,共享组件则放到src/component中。

框架提供了useRoute这个Hook让我们便捷地使用路由api,这里我主要希望在页面切换的时候监听切换事件,改变loading状态,改善用户弱网环境下体验(实际上很难看到这个切换过程,原因后面再说)。监听路由切换是挺方便的,但是如果每个页面都需要注册一次监听,组件卸载时取消监听,重复代码未免太多了。

NextJS提供了修改容器组件的功能,在pages文件夹下新建_app.js

// _app.tsx节选
function MyApp({ Component, pageProps }: AppProps) {
  const [loading, setLoading] = useState(false)
  const router = useRouter()

  const startLoading = () => {
    console.log('route change start')
    setLoading(true)
  }

  const stopLoading = () => {
    console.log('route change complete')
    setLoading(false)
  }

  useEffect(() => {
    router.events.on('routeChangeStart', startLoading)
    router.events.on('routeChangeComplete', stopLoading)
    router.events.on('routeChangeError', stopLoading)

    return () => {
      router.events.off('routeChangeStart', startLoading)
      router.events.off('routeChangeComplete', stopLoading)
      router.events.on('routeChangeError', stopLoading)
    }

  }, [])

  return (
    <Fragment>
      <Header />
        <Component loading={loading} {...pageProps} />
      <Footer />
      <BackTop />
      <style jsx global>{`
        body {
          background-color: #f6f6f6
        };
      `}</style>
    </Fragment>
  )
}

上面代码中使用了内置的css-in-component,一种内联式的样式写法。当时我使用的React版本已经有了Hooks,整个博客代码里我全部使用的函数式组件,事实上这里用类组件也是可以的。

Data Fetch

前面提到NextJS可以让搜索引擎获取到预渲染的,拥有数据的静态页面,那么Next中具体怎么获取数据呢?

  • SSR:服务端渲染,通过在页面级组件中导出getServerSideProps函数,在这个函数内访问API,最后返回一个{props: {...}}对象,返回值的props将被注入到页面组件的props中,这个方法的运行时机是每次客户端请求时(这种形式下NextJS会默认用户使用Node做服务器,但仅限于UI层,仍然是前后端分离的),适合页面数据变化多的情况。

    • 可以渐进加载,数据完全获取之前用户首先得到较少的、轻量的页面
    • 前端需要一个Node服务做一个中间层,前后端分离
    • 每次请求都需要向后端获取一次数据
  • SSG:静态站点生成,在页面中导出getStaticProps方法,这个方法只会在build时运行一次,不会出现在客户端,所以甚至可以在这里访问数据库和文件系统,如果所有页面都是SSG,构建之后的应用可以直接以Serverless方式部署,只需一个CDN就可以部署项目。同一个页面下SSRSSG是互斥的,但不同页面可以根据需要来做。对于[id].js这样的动态路由,则可以配合getStaticPaths这个方法,返回所有可能的路径,Next会自动生成所有页面。

    • 部署便捷
    • 仅在构建时在服务端调用数据获取函数
  • ISG:增长式静态再生成,前面说了对于页面数据变化频繁的应用,可以使用getServerSideProps,但是较新版本的NextJS变得更加强大,对于getStaticProps,可以在返回的对象中加上revalidate属性,值以秒为单位,Next会在有新请求进入后的固定时间后验证后端数据,如果确实有新的更改,将先返回由新数据组成的页面,在这之后再自动重新build。而getStaticPaths则可以通过在返回值中设置fallback属性为true,这样例如posts/[uuid].js上次build后有24篇文章,那么访问/posts/25将可以不用直接返回404,而是重新验证请求是否有新文章(详情查看文档)。这两种方式可以配合使用。

    • 增长式构建
    • 快速响应,结合了传统服务端渲染与SPA的优点
  • 客户端请求:在以上任何一种形式下,我们都仍然可以在客户端执行请求,并且Next提供了一个非常优秀的基于Hooks的请求库swr

CI/CD、Serverless与GraphQL

一开始我打算利用Github Actions来做自动部署,不过恰好碰上Next更新,Next所属的Vercel云服务更加好用了,于是我就选择了官方推荐的方式,只要提供一个git仓库的链接,Vercel会在每次push后自动以Serverless的形式部署,并且提供域名与HTTPS证书(其实就是Let's Encypt的证书)。

这时候我想把后端也改造成Serverless应用,顺带发现了hasura,可以提供免费的GraphQL服务,只要有一个postgresql数据库就可以生成一个基于GraphQL的后端,简单的CRUD是完全没问题的,所以也没有继续折腾了。但对于前端来说,我仅仅是要在构建时获取数据,Apollo对我来说太重了,没必要,于是我找到了graphql-request这个库,基本上只是对fetch的简单封装,足够使用了。

// 示例
const response = await request(GraphQLEndpoint, query, variables)

Link、Shallow Routing与筛选分页

前面提到服务端渲染在切换页面的速度上有缺陷,因为请求新页面需要返回完整的新页面的静态文件,哪怕页面大部分布局都没变。Next提供的Link组件,在默认情况下,会在闲置时自动请求JSON数据,这样等到用户点击链接时,就可以做到快速更换内容,渲染新页面,也是因为这个在非弱网环境下我看不到页面loading效果。

Network

在我的博客中,为文章模型设置了不少外键,像文章栏目、标签这些,还有分页,想要为这些设置页面,Next提供了一种不用重新抓取数据更新页面的方式Shallow Routing

// 代码节选
<Button
	onClick={() => route.push(`/posts?column=${item.column.name}`, undefined, { shallow: true })}>{item.column.name}</Button>

const route = useRoute()

useEffect(() => {
    if (column) {
      setArticles(articles => sourceArticles.filter(article => article.column.name === route.query.column))
    }
    console.log(articles)
  }, [route.query.column])

useEffect这个Hook中根据route.query.column的变化决定是否更新文章数据源,就可以做到筛选,并且不需要重新获取数据,页面只有部分更新。

但是我非常贪心,既想使用静态模式,又想每次筛选只拿到筛选所需的数据,而不是一次取得所有数据,在客户端筛选,这可以借助动态路由来做(这里我使用的TypeScript,Next全面支持TS):

pages
├── about.tsx
├── _app.tsx
├── posts
│   └── [id].tsx
├── series.tsx
└── [column]
│   └── [page].tsx
├── [tag]
│   └── [page].tsx
└── [page].tsx

但是这种形式,实质上对于栏目、标签、页数的筛选,其实都是首页列表页,这造成仅仅只是getStaticPaths函数不同,剩下全是重复代码,这是令人无法接受的,并且这只能接受**/[column]/[page这样的路由,而不能是/column=Python&page=2**这样的query形式。

那么有没有办法一次接受所有的动态路由呢?实际上是有的,[...slug]这种命名的页面组件就可以,但是参数只能是一个数组,例如['a', 'b']对应/a/b,这样我没法分辨columntag,并且与前面说的一样,没法以query参数的形式访问。

关于这方面,有一个issue,有可能会在某一个版本实现getStaticParams这样的API,对于博客这种数据量小的应用使用Shallow Routing完全没问题,但是对于如知乎这样的大型平台,筛选、搜索、分页功能都是必不可少的。

下一步

现在在我的博客中最初的Django部分已经完全废弃了,自然Django提供的管理后台也就不能用了,在上一篇文章中介绍了Blazor,接下来预计会花费几个周末来搭建一个SPA的后台,毕竟后台应用不需要SEOSPA会更合适,用CSharp来写前端,过去JS向桌面端、移动端渗透,反过来,静态语言也开始染指Web前端了。

总的来说,这个博客的不断重新构建的过程,也是我学习一些前端技能的试验过程,在这个过程中倒有一种经历了前端技术发展变迁的感觉,从“切图仔”,慢慢地工程化、体系化,随着Node以及一些框架的发展,前端开发体验也在不断提高。博客本身成了一个实验室,各种东西轮番体验了一遍,这个过程暂时还不会停止,毕竟业余时间写写代码还是挺有意思的。博客已开源

Copyright © 2020-2021 公子政的宅日常