服务端总结&单页面开发概念
一.服务端语言
服务端语言是什么?它是运行在服务器上,可用于读写数据库、编写服务逻辑的语言。
我并不想把这件事情描述的太复杂,因为归根结底它就是做这两件事。
至于服务端语言的选择,完全是根据业务和自身能力相结合。比如我是一个前端开发,我更倾向于选择 Node 作为服务端开发语言,因为 js 可以去写后端。
这里强调一下,
Node它本身不是一门语言,而是一个开发环境,基于谷歌的 V8 引擎,能让JavaScript可以运行在服务器的一个环境。赋予JavaScript更多除了浏览器脚本意外的能力,如IO操作等。
二.数据库
数据库的作用,是用于数据的持久化存储。
本项目选择 MySQL 作为数据库,是因为它作为老牌数据库,网上解决方案比较多,若是在开发过程中遇到问题,可以在搜索引擎上轻松找到自己遇到的问题的解决方案,对于新手而言,项目开发的流畅度是关键,完成一整套项目的开发之后,当然可以尝试去选择其他更有意思的数据库,这里不展开描述。
本教程采用 egg-mysql 插件进行数据库的操作。官方推荐使用 Sequelize 来应对较为复杂的项目。初衷是希望大家能快速入门,不希望引入过多的复杂概念,这里如果在后续的学习过程中有需要应对更为复杂的项目时,可以考虑引入 Sequelize 。
三.项目分析
整个项目是基于 Egg.js 作为上层架构,通过 Egg.js 提供的一些预定好的开发形式,轻松地完成了下列十余个接口的开发:
项目不大,但是本项目包含了几个关键的知识点:
- 多用户鉴权
- 一套增删改查
- 表数据的二次处理(图表)
- 列表分页的制作
- 文件数据上传
用“麻雀虽小、五脏俱全”来形容,哈哈哈。
如果想要在此基础上拓展一些新的功能,如朋友圈、笔记、博客等,都是可以基于该项目进行二次开发的,因为多用户鉴权的架子已经搭建好,后续要做的就是添加表,以及一些业务逻辑上的处理。
当然,如果想做更为复杂的项目,比如一个商城。那涉及到的内容可能就更多了,单论商品模块的后台编辑,前台展示,就涉及到 sku、购物车、库存的处理、多图上传、详情编辑、类型筛选等等。这就很考验知识储备和解决问题的能力了。
四.前端框架单页面概念
服务端的内容基本上已经结束,从这开始,带进入前端部分的实战环节。本项目前端部分使用 React 作为前端框架,所以先理解什么是单页面,它是怎样实现页面组件之间的切换,路由的原理等知识点。
- 传统页面 DOM 直出
- 单页面原理
- 前端路由实现
1.单页面
前端自从进入三大框架时代以来,传统的多页面开发已渐渐淡出人们的视线。为了更好的了解现在,先要去知道它的过去。
2.传统页面
这里不纠结叫法,凡是整个项目都是 DOM 直出的页面,都称它为“传统页面”(SSR 属于首屏直出,这里我不认为是传统页面的范畴)。那么什么是 DOM 直出呢?简单说就是在浏览器输入网址后发起请求,返回来的 HTML 页面是最终呈现的效果,那就是 DOM 直出。并且每次点击页面跳转,都会重新请求 HTML 资源。耳听为虚,眼见为实。以这个地址为例,验证以下上述说法。
腚眼一看,就能明白上图在描述什么。没错,博客园就是一个传统页面搭建而成的网站,每次加载页面,都会返回 HTML 资源以及里面的 CSS 等静态资源,组合成一个新的页面。打开后如下所示:
网页上能看到什么图片或文字,你能在上述图片中找到相应的 HTML 结构,那也属于传统页面,也就是 DOM 直出。
3.单页面
时代在进步,科技在发展,面对日益增长的网页需求,网页开始走向模块化、组件化的道路。随之而来的是代码的难以维护、不可控、迭代艰难等现象。面临这种情况,催生出不少优秀的现代前端框架,首当其冲的便是 React 、 Vue 、 Angular 等著名单页面应用框架。而这些框架有一个共同的特点,便是“通过 JS 渲染页面”。 举个例子,以前直出 DOM ,而现在运用这些单页面框架之后, HTML 页面基本上只有一个 DOM 入口,大致如下所示:
所有的页面组件,都是通过运行上图底部的 app.js 脚本,挂载到 <div id="root"></div> 这个节点下面。用一个极其简单的 JS 展示挂载这一个步骤:
<body>
<div id="root"></div>
<script>
const root = document.getElementById('root') // 获取根节点
const divNode = document.createElement('div') // 创建 div 节点
divNode.innerText = 'Hello,World!' // 插入内容
root.appendChild(divNode) // 插入根节点
</script>
</body>
所有的节点都通过 createElement 方法创建,最终通过 appendChild 的形式插入到 root 根节点。
那么问题来了,如果有十几个页面需要这样的操作咋整?
这时候「前端路由」应运而生,它的出现就是为了解决单页面网站多个页面组件切换。通过切换浏览器地址路径,来匹配相对应的页面组件。通过一张丑陋的图片来理解这个过程:
「前端路由」会根据浏览器地址栏 pathname 的变化,去匹配相应的页面组件。然后将其通过创建 DOM 节点的形式,塞入根节点
。这就达到了无刷新页面切换的效果,从侧面也能说明正因为无刷新,所以 React 、 Vue 、 Angular 等现代框架在创建页面组件的时候,每个组件都有自己的「生命周期」。
4.路由实现原理
「前端路由」插件比较火的俩框架对应的就是 Vue-Router 和 React-Router ,但是它们的逻辑,归根结底还是一样的,用殊途同归四个字,再合适不过。
通过分析「哈希模式」和「历史模式」的实现原理,对前端路由的原理有一个更深刻的理解。
哈希模式(hash)
a 标签锚点大家应该不陌生,而浏览器地址上 # 后面的变化,是可以被监听的,浏览器提供了原生监听事件 hashchange,它可以监听到如下的变化:
- 点击
a标签,改变了浏览器地址。 - 浏览器的前进后退行为。
- 通过
window.location方法,改变浏览器地址。
接下来利用这些特点,去实现一个 hash 模式的简易路由: 在线运行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hash 模式</title>
</head>
<body>
<div>
<ul>
<li><a href="#/page1">page1</a></li>
<li><a href="#/page2">page2</a></li>
</ul>
<!--渲染对应组件的地方-->
<div id="route-view"></div>
</div>
<script type="text/javascript">
// 第一次加载的时候,不会执行 hashchange 监听事件,默认执行一次
// DOMContentLoaded 为浏览器 DOM 加载完成时触发
window.addEventListener('DOMContentLoaded', Load)
window.addEventListener('hashchange', HashChange)
// 展示页面组件的节点
var routeView = null
function Load() {
routeView = document.getElementById('route-view')
HashChange()
}
function HashChange() {
// 每次触发 hashchange 事件,通过 location.hash 拿到当前浏览器地址的 hash 值
// 根据不同的路径展示不同的内容
switch(location.hash) {
case '#/page1':
routeView.innerHTML = 'page1'
return
case '#/page2':
routeView.innerHTML = 'page2'
return
default:
routeView.innerHTML = 'page1'
return
}
}
</script>
</body>
</html>
当然,这是很简单的实现,真正的 hash 模式,还要考虑到很多复杂的情况,大家有兴趣就去看看源码。
历史模式(history)
history 模式会比 hash 模式稍麻烦一些,因为 history 模式依赖的是原生事件 popstate,下面是来自 MDN 的解释:
小知识:pushState 和 replaceState 都是 HTML5 的新 API,他们的作用很强大,可以做到改变浏览器地址却不刷新页面。这是实现改变地址栏却不刷新页面的重要方法。
包括 a 标签的点击事件也是不会被 popstate 监听。需要想个办法解决这个问题,才能实现 history 模式。
解决思路:可以通过遍历页面上的所有 a 标签,阻止 a 标签的默认事件的同时,加上点击事件的回调函数,在回调函数内获取 a 标签的 href 属性值,再通过 pushState 去改变浏览器的 location.pathname 属性值。然后手动执行 popstate 事件的回调函数,去匹配相应的路由。逻辑上可能有些饶,用代码来解释一下:在线地址
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>History 模式</title>
</head>
<body>
<div>
<ul>
<li><a href="/page1">page1</a></li>
<li><a href="/page2">page2</a></li>
</ul>
<div id="route-view"></div>
</div>
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', Load)
window.addEventListener('popstate', PopChange)
var routeView = null
function Load() {
routeView = document.getElementById('route-view')
// 默认执行一次 popstate 的回调函数,匹配一次页面组件
PopChange()
// 获取所有带 href 属性的 a 标签节点
var aList = document.querySelectorAll('a[href]')
// 遍历 a 标签节点数组,阻止默认事件,添加点击事件回调函数
aList.forEach(aNode => aNode.addEventListener('click', function(e) {
e.preventDefault() //阻止a标签的默认事件
var href = aNode.getAttribute('href')
// 手动修改浏览器的地址栏
history.pushState(null, '', href)
// 通过 history.pushState 手动修改地址栏,
// popstate 是监听不到地址栏的变化,所以此处需要手动执行回调函数 PopChange
PopChange()
}))
}
function PopChange() {
console.log('location', location)
switch(location.pathname) {
case '/page1':
routeView.innerHTML = 'page1'
return
case '/page2':
routeView.innerHTML = 'page2'
return
default:
routeView.innerHTML = 'page1'
return
}
}
</script>
</body>
</html>
这里注意,不能在浏览器直接打开静态文件,需要通过 web 服务,启动端口去浏览网址。默认打开的协议是 file 协议,它是不会被
popstate监听的。
五.总结
上述内容,基本上对前端单页面的实现原理有了一个大概的雏形,更深入的学习还是需要再对框架的源码进行分析。