背景
前端近两年来发展迅速,随着 nodejs 的广泛使用,大批 npm 的框架/库层出不穷,npm 上 JavaScript 库的数量甚至超过了 Maven 中央仓库。
然而即便如此,仍然有很多公司固守在传统的前端切 UI,后端通过模板视图填充视图的技术。一方面固然是为了避免新技术踩坑,另一方面,居然有人在 deno 下说出了:求不要更新了,老子学不动了,并引发了大量讨论。
deno 是 nodejs 的作者开发的下一代 JavaScript 运行时。
现代前端
前端发展史
- 1996 年,样式表标准
CSS
第一版发布。- 2001 年,微软公司时隔 5 年之后,发布了 IE 浏览器的下一个版本
Internet Explorer 6
。这是当时最先进的浏览器,它后来统治了浏览器市场多年。- 2002 年,Mozilla 项目发布了它的浏览器的第一版,后来起名为
Firefox
。- 2003 年,苹果公司发布了
Safari
浏览器的第一版。- 2004 年,Google 公司发布了
Gmail
,促成了互联网应用程序(Web Application
)这个概念的诞生。由于 Gmail 是在 4 月 1 日发布的,很多人起初以为这只是一个玩笑。- 2004 年,
WHATWG
组织成立,致力于加速 HTML 语言的标准化进程。- 2005 年,
Ajax
方法(Asynchronous JavaScript and XML)正式诞生,Jesse James Garrett 发明了这个词汇。它开始流行的标志是,2 月份发布的 Google Maps 项目大量采用该方法。它几乎成了新一代网站的标准做法,促成了Web 2.0
时代的来临。- 2006 年,
jQuery
函数库诞生,作者为 John Resig。jQuery 为操作网页DOM
结构提供了非常强大易用的接口,成为了使用最广泛的函数库,并且让 JavaScript 语言的应用难度大大降低,推动了这种语言的流行。- 2008 年,
V8
编译器诞生.- 2009 年,
Node.js
项目诞生,创始人为 Ryan Dahl,它标志着 JavaScript 可以用于服务器端编程
,从此网站的前端和后端可以使用同一种语言开发。并且,Node.js 可以承受很大的并发流量,使得开发某些互联网大规模的实时应用变得容易。- 2013 年 5 月,Facebook 发布 UI 框架库
React
,引入了新的JSX
语法,使得 UI 层可以用组件开发。- 2015 年 3 月,Facebook 公司发布了
React Native
项目,将 React 框架移植到了手机端,可以用来开发手机App
。它会将 JavaScript 代码转为iOS
平台的Objective-C
代码,或者Android
平台的Java
代码,从而为 JavaScript 语言开发高性能的原生 App 打开了一条道路。- 2015 年
vuejs
发布 1.0 版本- 2016 年
vuejs2.x
版本发布- 新生事物仍在不断涌现...
上面就是前端的大概发展史,看完之后,不难发现,有一些关键的历史时刻,对前端开发产生了重大影响。例如 IE6 的发布(统治了浏览器市场很多年),JQuery 的诞生,Ajax 的流行。而现在,新的拐点出现了 -- nodejs 的流行。现代前端仍然在快速发展中,前后端分离,SSR,PWA 都是近两年才出现的概念。如果没有上车,后面就再难追上了。例如像十年前不使用 Spring 开发的应用,在现代 Java Web 后端的环境中,没有 Spring 简直寸步难行。
上面说了一些现代前端的历史,那么使用它具体有什么好处呢?
JavaScript 模块化
仔细想想,我们的 HTML, CSS 和 JavaScript 是如何结合使用的?
是的,我们按照规范分离了 HTML, CSS 和 JavaScript,并在 HTML 中使用 <link />
和 <scirpt></script>
标签引入 CSS 和 JavaScript。那么,不同的 JavaScript 之间如何交互呢?我们只能通过暴露顶级变量(window 作用域
)来进行交互。 是呀,稍有经验的 JavaScript 开发者都会 抽取函数,然而一个 JavaScript 中太多的函数仍然容易产生混乱。
例如下面这段代码,点击不同的按钮,显示不同的面板。
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>JavaScript 避免使用 if-else</title>
</head>
<body>
<main>
<div id="tab">
<label>
<input type="radio" data-index="1" name="form-tab-radio" />
第一个选项卡
</label>
<label>
<input type="radio" data-index="2" name="form-tab-radio" />
第二个选项卡
</label>
<label>
<input type="radio" data-index="3" name="form-tab-radio" />
第三个选项卡
</label>
</div>
<form id="extends-form"></form>
</main>
<script src="./js/if-else.js"></script>
</body>
</html>
// js/if-else.js
document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach((el) => {
el.addEventListener("click", () => {
const index = el.dataset.index
const header = el.parentElement.innerText.trim()
// 如果为 1 就添加一个文本表单
if (index === "1") {
document.querySelector("#extends-form").innerHTML = `
<header><h2>${header}</h2></header>
<div>
<label for="name">姓名</label>
<input type="text" name="name" id="name" />
</div>
<div>
<label for="age">年龄</label>
<input type="number" name="age" id="age" />
</div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
} else if (index === "2") {
document.querySelector("#extends-form").innerHTML = `
<header><h2>${header}</h2></header>
<div>
<label for="avatar">头像</label>
<input type="file" name="avatar" id="avatar" />
</div>
<div><img id="avatar-preview" src="" /></div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
function readLocalFile(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = (event) => {
resolve(event.target.result)
}
fr.onerror = (error) => {
reject(error)
}
fr.readAsDataURL(file)
})
}
document.querySelector("#avatar").addEventListener("change", (evnet) => {
const file = evnet.target.files[0]
if (!file) {
return
}
if (!file.type.includes("image")) {
return
}
readLocalFile(file).then((link) => {
document.querySelector("#avatar-preview").src = link
})
})
} else if (index === "3") {
const initData = new Array(100).fill(0).map((v, i) => `第 ${i} 项内容`)
document.querySelector("#extends-form").innerHTML = `
<header><h2>${header}</h2></header>
<div>
<label for="search-text">搜索文本</label>
<input type="text" name="search-text" id="search-text" />
<ul id="search-result"></ul>
</div>
`
document
.querySelector("#search-text")
.addEventListener("input", (evnet) => {
const searchText = event.target.value
document.querySelector("#search-result").innerHTML = initData
.filter((v) => v.includes(searchText))
.map((v) => `<li>${v}</li>`)
.join()
})
}
})
})
使用现代前端的 JavaScript 模块化重构如下
// common.js
/**
* 状态机
* 用于避免使用 if-else 的一种方式
*/
class StateMachine {
static getBuilder() {
const clazzMap = new Map()
/**
* 状态注册器
* 更好的有限状态机,分离子类与构建的关系,无论子类如何增删该都不影响基类及工厂类
*/
return new class Builder {
// noinspection JSMethodCanBeStatic
/**
* 注册一个 class,创建子类时调用,用于记录每一个 [状态 => 子类] 对应
* @param state 作为键的状态
* @param clazz 对应的子类型
* @returns {*} 返回 clazz 本身
*/
register(state, clazz) {
clazzMap.set(state, clazz)
return clazz
}
// noinspection JSMethodCanBeStatic
/**
* 获取一个标签子类对象
* @param {Number} state 状态索引
* @returns {QuestionType} 子类对象
*/
getInstance(state) {
const clazz = clazzMap.get(state)
if (!clazz) {
return null
}
//构造函数的参数
return new clazz(...Array.from(arguments).slice(1))
}
}()
}
}
export StateMachine
// TabBuilder.js
export default StateMachine.getBuilder()
// Tab.js
class Tab {
// 基类里面的初始化方法放一些通用的操作
init(header) {
const html = `
<header><h2>${header}</h2></header>
${this.initHTML()}
`
document.querySelector("#extends-form").innerHTML = html
}
// 给出一个方法让子类实现,以获得不同的 HTML 内容
initHTML() {}
}
export default Tab
// Tab1.js
import builder from "./TabBuilder.js"
import Tab from "./Tab"
const Tab1 = builder.register(
1,
class Tab1 extends Tab {
// 实现 initHTML,获得选项卡对应的 HTML
initHTML() {
return `
<div>
<label for="name">姓名</label>
<input type="text" name="name" id="name" />
</div>
<div>
<label for="age">年龄</label>
<input type="number" name="age" id="age" />
</div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}
}
)
// Tab2.js
import builder from "./TabBuilder.js"
import Tab from "./Tab"
const Tab2 = builder.register(
2,
class Tab2 extends Tab {
initHTML() {
return `
<div>
<label for="avatar">头像</label>
<input type="file" name="avatar" id="avatar" />
</div>
<div><img id="avatar-preview" src="" /></div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}
// 重写 init 初始化方法,并首先调用基类通用初始化的方法
init(header) {
super.init(header)
document.querySelector("#avatar").addEventListener("change", (evnet) => {
const file = evnet.target.files[0]
if (!file) {
return
}
if (!file.type.includes("image")) {
return
}
this.readLocalFile(file).then((link) => {
document.querySelector("#avatar-preview").src = link
})
})
}
// 子类独有方法
readLocalFile(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = (event) => {
resolve(event.target.result)
}
fr.onerror = (error) => {
reject(error)
}
fr.readAsDataURL(file)
})
}
}
)
// Tab2.js
import builder from "./TabBuilder.js"
import Tab from "./Tab"
const Tab3 = builder.register(
3,
class Tab3 extends Tab {
initHTML() {
return `
<div>
<label for="search-text">搜索文本</label>
<input type="text" name="search-text" id="search-text" />
<ul id="search-result" />
</div>
`
}
init(header) {
super.init(header)
const initData = new Array(100).fill(0).map((v, i) => `第 ${i} 项内容`)
document
.querySelector("#search-text")
.addEventListener("input", (evnet) => {
const searchText = event.target.value
document.querySelector("#search-result").innerHTML = initData
.filter((v) => v.includes(searchText))
.map((v) => `<li>${v}</li>`)
.join()
})
}
}
)
// main.js
import builder from "./TabBuilder.js"
import "./Tab1"
import "./Tab2"
import "./Tab3"
document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach((el) => {
el.addEventListener("click", () =>
// 调用方式不变
builder
.getInstance(Number.parseInt(el.dataset.index))
.init(el.parentElement.innerText.trim())
)
})
虽然看起来代码/文件变得更多了,然而实际上不同的状态区分更加明显,代码也更容易维护了。
兼容性
如果我们想要让传统前端项目兼容 IE11
,那么恐怕不得不使用 JQuery
以及 ES5
以前的语法(ES5 也支持的不完全)。如果想要使用 ES6/ES7/ES8
的话恐怕不仅在 IE11 上无法保证兼容性,既便 Web 标准的前沿实现者 Google Chrome,它的旧版本对新特性的支持恐怕也不算好(Google Chrome 开发团队的实力毋庸置疑,然而如果一个标准是在浏览器发布之后才出现的话,旧版本浏览器却是不可能兼容了)。
附: 最近两年 JavaScript 的标准几乎是一年一个版本,不过都没有再像
ES6
如此激进了
那么,如果使用现代前端就能解决这个问题了么?是的,它现代前端项目基本上都会引入的一个库 -- Babel。
Babel 官网首页用一句话说明了 Babel 的定位
Babel is a JavaScript compiler.
Use next generation JavaScript, today.
意为:
Babel 是一个 JavaScript 编译器。
立刻使用下一代 JavaScript。
是的,你没听错,Babel 给自身的定义是 JavaScript 编译器。众所周知,JavaScript 是运行在浏览器上(现在也可以运行在 NodeJS)的解释型弱类型的脚本语言,是没有编译器的。而 Babel 就是帮我们将 ES6 之后的 JavaScript 代码编译成 ES5 的代码,以兼容较旧版本的浏览器。
例如下面的代码
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
会被转换成
for (var i = 0; i < 3; i++) {
;((_i) => {
setTimeout(() => console.log(_i), 0)
i = _i
})(i)
}
当然,传统前端不能使用 Babel 了么?答案是可以的,然而因为是在浏览器端编译 JavaScript,所以速度比较慢,具体可以参考吾辈写的 在传统项目中使用 babel 编译 ES6。
MVVM
MVVM
(Model–view–viewmodel
)是一种软件架构模式。
MVVM 有助于将图形用户界面的开发与业务逻辑或后端逻辑(数据模型)的开发分离开来,这是通过置标语言或 GUI 代码实现的。MVVM 的视图模型是一个值转换器,这意味着视图模型负责从模型中暴露(转换)数据对象,以便轻松管理和呈现对象。在这方面,视图模型比视图做得更多,并且处理大部分视图的显示逻辑。视图模型可以实现中介者模式,组织对视图所支持的用例集的后端逻辑的访问。
说人话就是 MVVM 能让我们不再关心 DOM 的更改,专注于操作数据,DOM 会根据数据自动渲染,我们不再需要关心它。
事实上,我们的不同的代码虽然分离了,但逻辑上却不然,JavaScript 仍然需要操作 DOM 和 Style,而这项工作是非常繁琐而且易错的。
曾经我们使用 JQuery 来进行 DOM 交互,同时保证兼容性,以及更好的 Ajax 工具。现在,现代前端的很多框架就是为了解决数据与 DOM 同步的,不管是 React
的 JSX
,还是 VueJS
的 单文件组件
。
JSX
:React 的理念是 既然 JavaScript 能够操作 HTML/CSS,那就把所有的控制权交给 JavaScript 就好了,在 React JSX 中,一切都是 JavaScript,即便是 JSX 的 DSL 也只是一个看起来像 HTML 的 JavaScript 代码而已。像下面的代码,事实上就是 JavaScript,直接写 <div>Hello {this.props.name}</div>
只是语法糖,背后真正运行的还是 JavaScript。
class HelloMessage extends React.Component {
render() {
return <div>Hello {this.props.name}</div>
}
}
ReactDOM.render(<HelloMessage name="Taylor" />, mountNode)
假如使用 vuejs 的话写起来大概是这样
<template>
<div>Hello {name}</div>
</template>
<script>
export default {
name: 'HelloWord',
props: {
name: {
type: String
}
}
}
</script>
import Vue from "vue"
import HelloMessage from "./HelloMessage"
new Vue({
el: "#app",
components: { HelloMessage },
template: "<App/>",
})
它们之间的思想有许多共同之处,都推崇组件化开发,把 HTML/CSS/JavaScript
混合起来形成组件(类似于 Java 中将属性和函数封装为类),然后组合成更大的组件,形成组件树,并最终构成 WebApp。吾辈目前推荐先看 VueJS,毕竟是国人开发,中文文档最为完善,在三大前端框架中也属于最简单的一个(ReactJS 是最困难也是生态最好的一个)。
生态丰富
NPM 的生态相当丰富,现代前端几乎所有的库都通过 NPM 发布。至今,NPM 上已经有超过 70W+ 的包,在数量上甚至远超了 Maven 中央仓库。正是因为 NPM 发布包相当简单(吾辈都发布了几个),造成了如今无比繁荣的生态(想想 Maven 感觉都是泪。。。)
包管理器对比数据可以参考 http://www.modulecounts.com/
使用 NPM 安装和使用包相当简单,使用 npm i [package]
就能直接安装一个包,使用 ES6 import
语法就能在自己的 JavaScript 文件中快速引用一个包。
下面列出一些常用的 NPM 库
- yarn: Facebook 家的前端包管理器
- babel: 现代前端的 JQuery,解决兼容性
- vuejs: 华人开发的前端 MVVM 框架
- stylus: CSS 预处理器
- eslint: 前端代码规范检查
- webpack: 现代前端必备的打包工具
- rollup: JavaScript SDK 打包工具
- lodashjs: 流行的函数式工具库
- axios: 符合 ES6 Promise 风格的 Ajax 库
- vuetify: 基于 vuejs 的前端
material
风格的 UI 库 - js-xlsx: 前端 Excel 处理工具
- debug: debug 日志辅助工具
- uglifyjs: JavaScript 压缩工具
- http-server: 静态 http 服务器
- hexo: 现代前端开发的博客系统
- highcharts: 丰富强大的图表库
- masonry: 无限滚动瀑布流
- highlightjs: 代码高亮
- rx-util: 写 Greasemonkey 脚本时自定义的工具库
- 还有更多。。。
工程化
现代前端已经和后端类似,将原本混沌的 HTML/CSS/JavaScript 细分为了许多的内容。
├── dist // 打包后的静态文件
├── .editorconfig // 编辑器配置
├── .eslintrc.js // eslint 配置格式
├── .git // git 仓库
├── .gitignore // git 忽略文件
├── babel.config.js // babel 配置
├── LICENSE // 许可证
├── node_module // 项目依赖
├── package.json // npm 定义文件
├── public // 一些公共的资源
│ ├── favicon.ico
│ └── index.html
├── README.md // 项目说明
├── src // 源代码目录
│ ├── App.vue // 根组件
│ ├── main.js // 项目入口
│ ├── api // api 接口,和 views 中的文件夹对应
│ ├── components // 公共的组件
│ ├── plugins // vuejs 插件
│ │ └── vuetify.js
│ ├── router // vuejs 路由管理
│ │ └── index.js
│ ├── store // vuejs 状态管理
│ │ └── index.js
│ ├── utils // 工具函数
│ └── views // 各个页面
├── tests
│ └── unit // 单元测试
│ ├── .eslintrc.js
│ └── example.spec.js
├── vue.config.js // vuejs 的配置
└── yarn.lock // yarn 配置文件
当初第一次看到这个目录时真是被吓到了,使用 yarn
一下子 20000 个依赖文件就下载下来了。然而其实这只是将传统前端分的更细一点而已,对后期维护的好处也是不言而喻的。
总结
总而言之,现代前端流行之后,前后端分离已然是大势所趋,前端开发如果还仅仅是 切图仔 的话,迟早会因为跟不上时代而被淘汰。就吾辈而言,亦希望有更多人入坑现代前端,体会现代前端的强大!
附:吾辈个人而言认为现代前端主要的优势 模块化/工程化 和 MVVM。前者使大型 WebApp 的开发变成可能,后者则改变了数据与 DOM 之间的交互方式。