这是个个人项目,并非公司项目。先上项目地址:https://www.veryideas.com。
目录
一、使用到的外部项目/素材
开源程序:fantasticit/wipi。
图片素材:px。
诗词文章素材:chinese-poetry/chinese-poetry和chinese-poetry/huajianji。
二、为何不从零开始开发
本项目并非一个从零开始开发的项目,因为从零开始开发一个项目对从业比较久的程序员而言性价比有点太低了(要会科学地懒),把重点花在更容易有成长性的地方是一个人节省精力的持续成长必备的能力,否则只能是大部分时候都在原地踏步。
fantasticit/wipi项目是我在逛v2ex时偶然看到其作者在推广这个项目。看了下项目README.md并体验了下在线demo后,发现挺适合做CMS内容管理系统的,并且简单浏览了下代码发现用这个项目二次开发一个简单的小站自己是完全能hold住的,但是如果要自己从零开始开发这个系统,还是要耗费不少时间的,对自己来说与其说是技术活不如说是体力活。取舍之后决定采用基于开源项目进行二次开发的方式开发本项目。
初步查看下的时候,除了后台架构用next.js感觉不妥之外,还有就是next.js的版本有点旧,但是这个项目从时间上看其实是挺新的,估计是从别的项目里去掉业务代码重构出来的项目。不过版本也不是太旧,问题不大。
三、二次开发过程中遇到的问题汇总
下面是汇总到的一些二次开发时碰到的问题,思路上有一些是其他基于开源项目进行二次开发时也容易碰到的,可以借鉴。
3.1、项目架构问题
这个开源项目前台和后台用的都是next.js,服务端用的是nest.js,是react+node系产品。作为一个诗词类内容站,前台部分需要考虑到SEO,所以用next.js没有什么问题。服务端用nest.js也没有什么问题。
不过后台项目用nest.js就有点问题了,后台项目不仅不需要做SEO,而且是应当避免做SEO,直接一个纯前端项目即可,所以不应该上next.js。另外从项目稳定性、部署方便度、性能、体验、定制难易程度等方面来考虑,需要启动的next.js项目都是没法和纯前端项目比的。
- 项目稳定性:纯前端项目只要有个类似nginx的服务配合下就完事了,服务几乎不会挂。
- 部署方便度:纯前端项目把静态文件扔到服务器上就好了,非纯前端项目多了一个启动服务的步骤(还需要设置开机重启,服务挂了重启等)。
- 性能:nginx这种支持的并发量很高,其他的服务一加,并发量的瓶颈就在其他服务上了。
- 体验:next项目涉及到服务端逻辑,出页面速度会慢一些。
- 定制难易程度:next是一个已经比较深度定制了的框架,所以要再定制一些比较底层的东西是比较麻烦的。
不过这个项目主要是做数据入库和前台展示,后台项目的架构问题现阶段优先级不高,所以重构的事情暂时搁置。只处理了不能二级目录部署的问题,解决方案就是升级下next.js到9.5版本,然后使用新版本提供的basePath配置即可,因为把next.js从9.2升级到9.5属于小版本更新,一般不会有大的破坏性代码出现,事实上升级时也确实很顺利。
3.2、SEO问题
在作者罗列的该项目的功能点中提到了SEO,我们一般用next.js也主要是为了这个点(其次大部分是为了解决首页白屏问题的)。但是实际上这个开源项目的SEO是有问题的。在项目github主页上作者有提供在线demo,打开这个网页,在浏览器开发者工具中的Element里你的确是可以看到有填充了内容的title标签和SEO相关的meta标签(name为keyword、description的meta标签),但这不能说明这些标签是在服务端渲染的还是在客户端渲染出来的。我们需要在网页上右键选择查看网页源代码,或者拿其他比如postman之类的直接请求页面地址,才能看到没被客户端js处理过的网页内容。通过查看页面源代码,我们可以发现title标签其实都没有出来。
修改方案是把该项目中用到的react-helmet的Helmet组件换成next自带的Head组件。如下图所示:
除此之外,在demo上如果到文章/页面详情页手动刷新下页面,会发现其实内容不是一开始就出现的,极有可能其内容也是客户端渲染出来的。如果文章/页面内容都是从客户端渲染出来的,我们做SEO的目的显然是达不成的。
该项目的详情页里正文部分是用一个封装了的MarkdownReader组件渲染出来的:
import React, { useRef, useEffect } from 'react'; import hljs from 'highlight.js'; import { copy } from '@/utils'; import './index.module.scss'; export const MarkdownReader = ({ content }) => { const ref = useRef<HTMLDivElement>(); useEffect(() => { if (!content) { return; } const el = ref.current; const range = document.createRange(); const slot = range.createContextualFragment(content); el.innerHTML = ''; el.appendChild(slot); }, [content]); // 高亮 useEffect(() => { if (!ref.current) { return; } setTimeout(() => { const blocks = ref.current.querySelectorAll('pre code'); blocks.forEach((block: HTMLElement) => { const span = document.createElement('span'); span.classList.add('copy-code-btn'); span.innerText = '复制'; span.onclick = () => copy(block.innerText); block.parentNode.insertBefore(span, block); hljs.highlightBlock(block); }); }, 0); }, [content]); return <div ref={ref} className={'markdown'}></div>; };
妥妥的客户端代码,看着也不太好改的样子。我们换个思路,我们其实并不需要在服务端考虑代码高亮的事情,因为SEO的内容是给网页蜘蛛看的。所以解决方案就是直接把最后return的内容改成下面这样。
return <div ref={ref} className={'markdown'} dangerouslySetInnerHTML={{ __html: content }}></div>;
3.3、网页静态资源加载速度优化
我有两台服务器,一台大陆的只能放备案的网站。这个项目域名尚未备案,所以放到了香港服务器上,访问速度和稳定性上不如大陆主机。这里不讲压缩文件、合并请求这些方案。大部分公司里前端、后端、运维等等分工比较明细,所以网上的各种优化方案基本上都是各自站在各自角度去考虑的方案,所以前端知道要减少并行请求量的时候主要是往合并文件的角度考虑,这无可厚非。其实还有个操作成本很小就可以直接提速的方案——同时使用多个域名来放静态资源,这样,在用户网速不是瓶颈的时候(大部分时候网速并不是瓶颈)请求域名数量个资源和请求1个同等大小的资源的用时会相差无几。需要注意的是多个域名就会多一个域名DNS解析的过程,这个是需要权衡的点。
通过next.js的assetPrefix配置属性就可以另外加一个域名。只是填一个域名就可以,所以不截图了。
这里有个地方要注意下,在文章详情页,左侧有个浮动的分享按钮,点击后会有一个弹框,可以将文章内容截图保存到本地方便分享,这里保存的时候如果有文章封面图是跨域的地址的话,会保存不了的,估计底层是用了canvas啥的,canvas就是可以显示跨域图片但不允许你通过canvas的api把图片导出来。可能可以通过在图片请求的响应头里设置允许跨域的方式避开(没试过),但是因为我现在的图都是随便弄的,没有配图的必要,所以我直接把分享的图去掉了。如果后面需要分享配图然后设置响应头的方式不生效的话,要注意下图片资源的域名问题,不要跨域。
3.4、项目部署
项目的部署,服务器上我用的是常规的pm2来控制项目的常规启动、挂掉后自动重启、重启电脑后自动启动。这个开源项目自带了部署脚本,不过不太符合我的使用习惯,而且这些项目的编译是一个CPU密集型任务,编译操作在服务器上进行的话对我那个只有2G运行内存的服务器而言有点强人所难(服务器上还部署了其他很多小服务),所以我的部署方案是本地开发机上进行build操作(编译出待启动的编译产物),然后将需要部署到服务器上的文件部署到服务器之后再在服务器上进行start操作(启动项目)。当然这写操作都是一个脚本直接实现的。这里用到了2个npm包。
- ssh2-sftp-client:可以用来将本地的文件部署到远程服务器上(用于部署本地build出的待启动项目代码)。
- node-ssh:用来连接到远程服务器后执行指定的命令(用来在部署完文件后启动项目)。
在部署文件到服务器时,不需要把所有源码都部署上去(很多人是这么干的),当然这个项目光部署编译产物和package.json、package-lock.json、.npmrc这些涉及依赖安装的文件是不行的,需要把next.config.js文件也部署上去,然后next.config.js里依赖到的相关文件也都需要部署,除此以外的文件是不需要部署的。如果是自家服务可以不用太关注这点,如果是需要部署到别人家的服务,可以通过这种方式尽可能的保护源码(额外多制造了一些麻烦——没有绝对的安全,所谓的安全就是尽可能设置更多的障碍)。
还有一个点需要注意,ssh2-sftp-client创建的实例的fastPut方法部署文件时不能往不存在的路径下部署文件,可以先通过uploadDir来创建一个空目录,像下面这样:
client.uploadDir(join('/src/theme'), '/www/examplepath/client/src/theme', /\.nothingButGenerateSrcThemeFolder/),
上例里第三个参数里的正则是用来过滤出需要上传的文件的,这里传一个啥文件都过滤不出来的正则就可以实现创建一个空目录的目的。
3.5、数据录入
人工维护一个像这个博客一样基本都是自己原创内容的网站是很耗费精力的,常常不能持久。所以我当时想到的是网上找一些没有版权问题的诗词类数据。第一个念头是写爬虫,因为正好前一段时间里在某个项目里写过爬虫。不过再一想完全没必要,因为这种明显是很多人都爬过有现成数据可以拿的,然后去github上一搜,好家伙,不仅有,还挺多的。
然后为了方便数据的录入以及后续方便恢复数据库(因为这个开源程序在数据量较大时响应很卡需要各种尝试),我是直接用node来写要执行的sql命令去执行的。剪一段代码示例如下:
/** * 插入文章 * @param taskName * @param data * @param categoryId * @returns {Promise<void>} */ exports.insertArticles = async (taskName, data, categoryId) => { const queryParams = [] for (let i = 0, len = data.length; i < len; i++) { const item = data[i] item.paragraphs = item.paragraphs.map((d) => toLiteral(d)) const itemParams = [ item.title, item.author, (item.notes && item.notes.length > 0) ? item.paragraphs.concat(['备注:']).concat(item.notes).join('\n') : item.paragraphs.join('\n'), item.paragraphs.join(''), (item.notes && item.notes.length > 0) ? `<p>${item.paragraphs.concat(['备注:']).concat(item.notes).join('</p><p>')}</p>` : `<p>${item.paragraphs.join('</p><p>')}</p>`, 'publish', 'https://cdn.orzzone.com/kindle-paperwhite.jpg', categoryId, generateRandomViewsNum(), ] queryParams.push(`("${itemParams.join('","')}")`) } const queryString = ` INSERT IGNORE INTO article (title, author, content, summary, html, status, cover, categoryId, views) VALUES ${queryParams.join(',')};` try { const res = await sql({ query: queryString, params: [], }) console.log(`[${taskName}]文件数据插入情况:${res.message}`) } catch (err) { console.log(err) } finally { console.log() } }
这里需要注意的是数据不要一条一条的插入,那样太慢了,要把多条数据拼接成一条sql后一次性插入。这里我举个前端的例子各位一听就懂了,比如我们需要往页面里插入一个列表时,我们都知道不能一条一条的插入到dom里,那样性能开销很大,需要先拼接出完整的html片段字符串再一次性插入dom中。
插入数据时注意下引号的问题,可以用下面的方法先处理下待插入的文案:
function toLiteral(str) { var dict = { '\b': 'b', '\t': 't', '\n': 'n', '\v': 'v', '\f': 'f', '\r': 'r' }; return str.replace(/([\\'"\b\t\n\v\f\r])/g, function($0, $1) { return '\\' + (dict[$1] || $1); }); }
另外,这个开源项目自带的数据表格式稍微有点不满足我的需求场景,文章表里没有作者信息,为了方便,我直接加了author列(没加authorId列)。
3.6、sql性能优化
1、该开源项目中库表主键的id用的是uuid,将其改为自增id,方便一些特殊场景下的性能优化。比如不附加其他查询条件,只需要返回第3页的10篇文章时,如果是自增id且id都是连续的话,我们就可以先计算出对应的文章id,然后查这些文章就会很快了。如果文章数量很大的时候,offset limit的那种会遍历很多记录的方式是很费时的。这是一个拿约定换效率的方式。
2、建索引。原项目主键建了索引,我把其他常用的搜索条件也建了索引,这对查询速度的提升是很明显的,对数据插入的速度会有一些影响,因为要建索引。总体而言,这是一个拿空间换时间的方式。
3、提早限定范围。如果多表关联时,有个表的搜索结果可以将范围缩小很多,要优先查这个表,再关联其他表。
4、redis缓存搜索结果。有些常用的搜索结果可以直接缓存到redis里,那查询sql作为key,搜索结果作为value。因为这个诗词站的内容是相关固定的,所以文章列表页第一页查出来的文章是哪些、第二页查出来的文章是哪些,在一个时间段内都是固定不变的,但是也总会有变化的时候的。所以这里的方案,是有请求进来的时候去查询结果存进redis缓存里,但是接口不等待这个查询结果,直接返回当前redis里缓存了的结果(如果已经有缓存结果的话)。
3.7、白天黑夜模式
这个开源项目带了白天和黑夜模式,可以通过点击按钮直接切换主题样式,非常好用。默认是白天模式。但是有个问题,如果你切换成黑夜模式,然后去刷新页面,会先出现白天模式的样式,然后才切换成黑夜模式,这个白天模式的临时样式显示的时间还挺久的,对用户的体验不是很好。
这里主要的问题是判断白天黑夜模式的代码逻辑是在切换白天黑夜模式的按钮所在的组件里写的,而页面从开始加载默认样式到执行到到这个判断白天黑夜模式的代码,中间需要经历较长的时间。
对很多新人来说,js都建议放到页面body结束标签的前面,这样可以晚点去加载,让页面主要内容更快的显示出来。这里其实是要分具体情况的。
一些会明显影响到页面样式和布局的js代码需要尽可能放到head标签里,早一点去加载。不然用户就会看到页面先显示了一个样子然后又变成了另一个样子。有时候还不只是颜色变了下的问题,有些代码晚执行还会导致大量已显示元素被reflow(css里的部分属性如width等会导致页面reflow),这会对性能有一定影响,这可以用移动端常用的rem布局来举例,rem是基于文档根元素的字体大小而按比例变化的,把设置文档根元素字体大小的js逻辑代码放到head里就可以避免页面里各个块大小的变动。此外,还有错误上报等代码,也是建议放在业务代码之前加载(这个倒是不一定要放在head里)。
所以这里解决黑夜模式前会短暂显示白天模式的方案,就是把判断是哪种模式的逻辑代码提前到head标签里。思路是这样,不过这个逻辑代码需要取body元素进行操作,所以不能放head里,那咱们就放到body开始标签的后面,像下面这样(新建一个_document.tsx):
import Document, { Html, Head, Main, NextScript } from 'next/document' class MyDocument extends Document { static async getInitialProps(ctx) { const initialProps = await Document.getInitialProps(ctx) return { ...initialProps } } render() { return ( <Html> <Head> <link rel="shortcut icon" href="https://www.orzzone.com/projects/cdn/favicon.ico"/> </Head> <body> <script dangerouslySetInnerHTML={{ __html: `if (window.localStorage.getItem('dark') === "1") { document.body.classList.add('dark'); }` }} /> <Main /> <NextScript /> </body> </Html> ) } } export default MyDocument
3.7、文章详情字体问题
估计这个开源项目之前markdown解析组件主要是用来显示代码的,所以放中文进去看起来字体很奇怪,要换一下。
3.8、wap端顶部导航栏改为水平防线展示
如题。
3.9、文章列表页侧栏不需要显示推荐文章
因为主栏头部已经显示了推荐文章了。
3.10、分页模式由滑动加载更多改为按页码分页
个人理解,滑动加载更多的方式更适合用来阅读适合快速阅读的内容,比如抖音短视频之类的滑动加载更多视频这种,而适合细读的内容站不适合这种加载方式。难道我看到了第三页的一个标题觉得感兴趣点进去到详情页再返回出来还得重新从第一页开始翻过去吗?所以改成了按页面分页的传统方式。
3.11、有个请求的路径为null
原系统里,在页面上刷一下,会看到有个路径为null的请求,直觉觉得是因为后台我没配favicon的地址导致的,这里可以优化下在jsx里加个判断——没这个值的时候不添加对应的link标签。
3.12、外部统计功能bug
原系统里,如果你配了百度统计,只会在刷新页面之后有统计请求(用的是图片类型的请求,应该是用的img元素的src属性实现的,具体没看是不是这样弄的)。作为一个使用next.js的项目,一大特点就是同时支持服务端渲染和客户端渲染,再不刷新浏览器的情况下就是个SPA(单页应用),这种情况下切换页面也需要有对应的统计才行。下面有个偷懒的方案,切换页面时重新加载百度统计js脚本(还要删除一个挂在window对象上一个格式如“_bdhm_loaded_百度统计id”的key)。具体如下图所示(修改src/components/Analytics.tsx文件)。
import { useEffect, useContext } from 'react'; import { GlobalContext } from '@/context/global'; import Router from "next/router" export const Analytics = (props) => { const { setting } = useContext(GlobalContext); useEffect(() => { const googleAnalyticsId = setting.googleAnalyticsId; if (!googleAnalyticsId) { return; } // @ts-ignore window.dataLayer = window.dataLayer || []; function gtag() { // @ts-ignore window.dataLayer.push(arguments); // eslint-disable-line prefer-rest-params } // @ts-ignore gtag('js', new Date()); // @ts-ignore gtag('config', googleAnalyticsId); const script = document.createElement('script'); script.src = `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsId}`; script.async = true; if (document.body) { document.body.appendChild(script); } }, [setting.googleAnalyticsId]); const startBaiduStatistics = () => { const baiduAnalyticsId = setting.baiduAnalyticsId; if (!baiduAnalyticsId) { return; } const hm = document.createElement('script'); hm.id = 'baiduStatistics' hm.src = `https://hm.baidu.com/hm.js?${baiduAnalyticsId}`; const s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(hm, s); } const resetBaiduStatistics = () => { const elemBaiduStatistics = document.getElementById('baiduStatistics') if (elemBaiduStatistics) { elemBaiduStatistics.parentNode.removeChild(elemBaiduStatistics) } const key = `_bdhm_loaded_${setting.baiduAnalyticsId}` // 删掉这个属性的话,重新加载百度统计js代码会触发新的统计 if (window[key]) { delete window[key] } // 重新加载对应js(有之前的缓存在,加载很快的,而且请求的也是百度的服务器,不会对自由服务造成压力) startBaiduStatistics() } useEffect(() => { resetBaiduStatistics() Router.events.on('routeChangeComplete', () => { resetBaiduStatistics() }) }, [setting.baiduAnalyticsId]); return props.children || null; };
四、总结
差不多就是上面这些了,有些早一点的改动都不记得了,上面列的都是还有些印象的。用好开源项目,可以让我们事半功倍。最后,对作者的开源项目表示感谢。