背景

前端近两年来发展迅速,随着 nodejsopen in new window 的广泛使用,大批 npmopen in new window 的框架/库层出不穷,npm 上 JavaScript 库的数量甚至超过了 Maven 中央仓库open in new window
然而即便如此,仍然有很多公司固守在传统的前端切 UI,后端通过模板视图填充视图的技术。一方面固然是为了避免新技术踩坑,另一方面,居然有人在 denoopen in new window 下说出了:求不要更新了,老子学不动了open in new window,并引发了大量讨论。

deno 是 nodejs 的作者开发的下一代 JavaScript 运行时。

现代前端

前端发展史

  1. 1996 年,样式表标准 CSS 第一版发布。
  2. 2001 年,微软公司时隔 5 年之后,发布了 IE 浏览器的下一个版本 Internet Explorer 6。这是当时最先进的浏览器,它后来统治了浏览器市场多年。
  3. 2002 年,Mozilla 项目发布了它的浏览器的第一版,后来起名为 Firefox
  4. 2003 年,苹果公司发布了 Safari 浏览器的第一版。
  5. 2004 年,Google 公司发布了 Gmail,促成了互联网应用程序(Web Application)这个概念的诞生。由于 Gmail 是在 4 月 1 日发布的,很多人起初以为这只是一个玩笑。
  6. 2004 年,WHATWG 组织成立,致力于加速 HTML 语言的标准化进程。
  7. 2005 年,Ajax 方法(Asynchronous JavaScript and XML)正式诞生,Jesse James Garrett 发明了这个词汇。它开始流行的标志是,2 月份发布的 Google Maps 项目大量采用该方法。它几乎成了新一代网站的标准做法,促成了 Web 2.0 时代的来临。
  8. 2006 年,jQuery 函数库诞生,作者为 John Resig。jQuery 为操作网页 DOM 结构提供了非常强大易用的接口,成为了使用最广泛的函数库,并且让 JavaScript 语言的应用难度大大降低,推动了这种语言的流行。
  9. 2008 年,V8 编译器诞生.
  10. 2009 年,Node.js 项目诞生,创始人为 Ryan Dahl,它标志着 JavaScript 可以用于 服务器端编程,从此网站的前端和后端可以使用同一种语言开发。并且,Node.js 可以承受很大的并发流量,使得开发某些互联网大规模的实时应用变得容易。
  11. 2013 年 5 月,Facebook 发布 UI 框架库 React,引入了新的 JSX 语法,使得 UI 层可以用组件开发。
  12. 2015 年 3 月,Facebook 公司发布了 React Native 项目,将 React 框架移植到了手机端,可以用来开发手机 App。它会将 JavaScript 代码转为 iOS 平台的 Objective-C 代码,或者 Android 平台的 Java 代码,从而为 JavaScript 语言开发高性能的原生 App 打开了一条道路。
  13. 2015 年 vuejs 发布 1.0 版本
  14. 2016 年 vuejs2.x 版本发布
  15. 新生事物仍在不断涌现...

上面就是前端的大概发展史,看完之后,不难发现,有一些关键的历史时刻,对前端开发产生了重大影响。例如 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 如此激进了

那么,如果使用现代前端就能解决这个问题了么?是的,它现代前端项目基本上都会引入的一个库 -- Babelopen in new window

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

Wikiopen in new window

MVVMModel–view–viewmodel)是一种软件架构模式。

MVVM 有助于将图形用户界面的开发与业务逻辑或后端逻辑(数据模型)的开发分离开来,这是通过置标语言或 GUI 代码实现的。MVVM 的视图模型是一个值转换器,这意味着视图模型负责从模型中暴露(转换)数据对象,以便轻松管理和呈现对象。在这方面,视图模型比视图做得更多,并且处理大部分视图的显示逻辑。视图模型可以实现中介者模式,组织对视图所支持的用例集的后端逻辑的访问。

说人话就是 MVVM 能让我们不再关心 DOM 的更改,专注于操作数据,DOM 会根据数据自动渲染,我们不再需要关心它。

事实上,我们的不同的代码虽然分离了,但逻辑上却不然,JavaScript 仍然需要操作 DOM 和 Style,而这项工作是非常繁琐而且易错的。
曾经我们使用 JQuery 来进行 DOM 交互,同时保证兼容性,以及更好的 Ajax 工具。现在,现代前端的很多框架就是为了解决数据与 DOM 同步的,不管是 ReactJSX,还是 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/open in new window

使用 NPM 安装和使用包相当简单,使用 npm i [package] 就能直接安装一个包,使用 ES6 import 语法就能在自己的 JavaScript 文件中快速引用一个包。

下面列出一些常用的 NPM 库

工程化

现代前端已经和后端类似,将原本混沌的 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 之间的交互方式。