0%

先上个结论,目前的Web前端体系就是:以HTML + CSS + JavaScript三项技术为基础而构建的图形界面交互系统。
HTML是文字,CSS是颜料,JavaScript是笔和橡皮。

接下来我将从起源、演进、实战、扩张四个部分来介绍这个体系。

一、起源

当我踏入世界的时候,它已经是一片花园了。可是这一切是怎么形成的呢?

追本溯源的有趣之处在于发现这个错综复杂,包罗万象,甚至被称为“宇宙”的系统,原来只是因为50多年前那只“蝴蝶”笨拙的煽动了一下翅膀。

1.1 万维网

冷战时期(1947—1991),美国国防部建设了一个军用网,叫做“阿帕网”(ARPAnet),阿帕网于1969年正式启用,这就是Internet(因特网)的前身。53年过去,现在它已发展成为一个基于TCP/IP协议,覆盖全球大部分国家的开放型全球计算机网络系统。

1990年,在因特网的基础上,欧洲粒子物理实验室(CERN)的Tim Berners-Lee和Robert Cailliau为了方便交流科学论文和数据,开发了超文本服务器程序,Tim Berners-Lee把他设计的超文本标记语言文件所构成的系统称为WWW(World Wide Web / Wan Wei Wang)。

万维网层级.png

1.2 HTML

HTML全称为超文本标记语言(Hyper Text Markup Language),是一种标记语言。它包括一系列标签,通过这些标签可以将网络上的文档格式统一,使分散在Internet上的资源连接为一个逻辑整体。

在WWW的使用中,两项重要的创造发挥了关键的作用。这两项技术是 超文本(hyper text)图形用户界面(GUI) 。超文本是一种组织信息的方式,它通过超级链接方法将文本中的文字、图表与其他信息媒体相关联。

其格式如下:

1
2
3
4
5
6
7
<html>
<head></head>
<body>
<h1>标题</h1>
<p>段落</p>
</body>
</html>

浏览器

1990年Tim Berners-Lee在发明HTML的同时也发明了世界上第一款浏览器Nexus。

浏览器是用来检索、展示以及传递Web信息资源的应用程序。它使用统一资源标识符( Uniform Resource Identifier,URI)来标记Web信息资源。通过浏览器的图形用户界面,可以把HTML文件以一种易读的方式展示出来。

1993年NCSA(美国国家超级电脑应用中心)推出了世界上第一款能显示图片的浏览器NCSAMosaic,它是后来IE浏览器的基础,成为了点燃因特网热潮的火种之一。后来这款浏览器的核心开发者成立了网景公司(Netscape),又开发了网景导航者浏览器(Netscape Navigator)以替代NCSAMosaic,之后Netscape Navigator与IE浏览器在市场上分庭抗礼。

同时前端最常用的开发语言 JavaScript 也于1995年在Netscape Navigator上首次设计实现,由Netscape公司的Brendan Eich发明,他后来是火狐浏览器的联合创始人。

如今,最流行的浏览器是Google Chrome,它基于开源的浏览器引擎WebKit开发(2008)。

1.3 JavaScript

1995 年,当时就职于网景公司的 Brendan Eich 迫于公司的压力,只花了十天就设计了 JS 的最初版本,并命名为 Mocha。后来Netscape与Sun合作,改名为JavaScript。JavaScript是一种解释型的脚本语言,C、C++等语言是先编译后执行,而JavaScript是在程序的运行过程中逐行进行解释。

完整的JavaScript实现包含三个部分:ECMAScript,文档对象模型(DOM),浏览器对象模型(BOM)。打开chrome控制台,即可编写JS代码:

1
document.body.style.background = 'red';

1.4 CSS

层叠样式表(英文全称:Cascading Style Sheets)是一种用来表现HTML或XML等文件样式的计算机语言。1994年由哈坤·利提出了CSS的最初建议。1996年底,CSS初稿已经完成,同年12月,层叠样式表的第一份正式标准(Cascading style Sheets Level 1)完成,成为W3C的推荐标准。其代码格式如下:

1
2
3
4
5
6
7
8
9
10
span {
display: inline-block;
width: 100px;
height: 48px;
line-height: 48px;
font-size: 28px;
color: #fff;
text-align: center;
cursor: pointer;
}

到此,以HTML为骨架,CSS为外表,JavaScript作为控制,Web前端的3大重量级嘉宾就出场完毕了。

Web前端体系基础.png

浏览器页面的渲染流程如下:

浏览器页面的渲染机制.png

那么之后,从1990年开始发展到现在如此丰富的Web前端世界,其间经历了哪些阶段呢?

二、演进

2.1 只有一种程序员

这个阶段还没有Web前端这个细分工种,开发者以使用的后端语言来划分,比如说做Java的,做C++的。

静态页

静态页的阶段不长,自1990年HTML发明开始至1994年W3C成立,之后很快就出现了动态开发HTML的语言。

世界上第一个Web网页(被恢复的副本):http://info.cern.ch/ ,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<html>
<head></head>
<body>
<header>
<title>http://info.cern.ch</title>
</header>

<h1>http://info.cern.ch - home of the first website</h1>
<p>From here you can:</p>
<ul>
<li><a href="http://info.cern.ch/hypertext/WWW/TheProject.html">Browse the first website</a></li>
<li><a href="http://line-mode.cern.ch/www/hypertext/WWW/TheProject.html">Browse the first website using the line-mode browser simulator</a></li>
<li><a href="http://home.web.cern.ch/topics/birth-web">Learn about the birth of the web</a></li>
<li><a href="http://home.web.cern.ch/about">Learn about CERN, the physics laboratory where the web was born</a></li>
</ul>
</body>
</html>

可以想像如果按这样的方式把大量的内容转移到网络上,是多么笨拙和费力的事情。那么为了实现数据的灵活拼装动态加载数据库中的数据增加用户交互 ,就出现了动态页面。

动态页

这是后端写Web页面的时代,运行在服务端用于编写动态页面的语言包括PHP、ASP、JSP。这一部分如果勾起了你的回忆,你可能暴露年龄了。

PHP

1994年由Rasmus Lerdorf 创建的开源项目:Personal Home Page,后正式更名为:PHP: Hypertext Preprocessor,即“超文本预处理器”,是在服务器端执行的脚本语言,尤其适用于Web开发并可嵌入HTML中。

根据W3Techs2019年12月6号发布的统计数据,PHP在WEB网站服务器端使用的编程语言所占份额高达78.9%。在内容管理系统的网站中,有58.7%的网站使用WordPress(PHP开发的CMS系统),这占所有网站的25.0%。

开始使用:
下载一个XAMPP,通过它启动一个本地的Apache服务,新增一个.php后缀的文件,即可编写一个简单的helloworld程序。页面代码如下:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<body>

<?php
echo "Hello World 大清!";
?>

</body>
</html>

phpdemo.png

ASP

1996年微软推出的ASP(Active Server Pages/动态服务器页面)简单、易于维护 , 是小型页面应用程序的选择。2002推出的ASP.NET,ASP.NET 是一个免费的 Web 开发框架,用于通过 HTML、CSS、JavaScript 以及服务器脚本来构建网页和网站,它通过 IIS (Internet Information Server,基于Windows的互联网信息服务)解析执行后可以得到动态页面。

开始使用:
安装.net SDK,安装完成后,根据命令新建一个ASP.NET Web应用。

1
2
3
dotnet new webapp -o aspnetcoreapp
cd aspnetcoreapp
dotnet watch run

修改项目中Pages文件下Index.cshtml文件,代码如下:

1
2
3
4
5
6
7
8
9
10
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}

<div class="text-start">
<h1 class="display-4">Welcome 大清</h1>
<p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>

asp.net.png

JSP

1997年Servlet技术的产生以及紧接着JSP的产生,为Java对抗PHP、ASP等服务器端语言带来了筹码。JSP将Java代码和特定变动内容嵌入到静态的页面中,实现以静态页面为模板,动态生成其中的部分内容。2003年11月24日发布了J2EE(Java 2 平台企业版)1.4, 该版本中包含了JSP2.0,JSP 2.0支持表达语言(expression language)。

开始使用:
安装java开发环境 JDK,配置JAVA_HOME环境变量,安装Tomcat 服务器(配置环境变量CATALINA_HOME为tomcat安装目录),在tomcat安装目录\webapps\ROOT下添加 test.jsp。

下面是使用java代码实现的日期格式化和99乘法表,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*,java.util.*" %>
<%@ page import="javax.servlet.*,java.text.*" %>

<html>
<head>
<title>JSP页面</title>
</head>
<body>
<%!
String nineTable() {
StringBuilder builder = new StringBuilder();
for(int i=1;i<=9;i++){
for(int j=1;j<=i;j++){
builder.append(i + "*" + j + "=" + i*j + "&nbsp;&nbsp;&nbsp;&nbsp;");
}
builder.append("<br/>");
}
return builder.toString();
}
%>
<%
Date dNow = new Date( );
SimpleDateFormat ft = new SimpleDateFormat ("yyyy-MM-dd HH:mm:ss");
%>
<div style="padding-left: 40px;">
<p>Hello 大清!现在时间是:<%= ft.format(dNow) %></p>
<p>
<%
String sum1;
sum1=nineTable();
out.print(sum1);
%>
</p>
</div>
</body>
</html>

jspdemo.png

在这个阶段前端和后端还没有清晰的界限,那么我们所说的前后端分离出现在什么时候呢?

2.2 前后端分离

我们可以参考下国内什么时候出现前端开发的职位。我在百度上以前端和招聘两个关键字搜索到最早的 前端招聘信息 在2008年左右。

当时对于Web前端开发的职位要求:

1.熟悉 ActionScript 面向对象的编程,能独立完成 Flash 前台脚本及后台动作实现互动功能编程;

2.熟悉 Flash 与ASP.NET之间的数据传递;

3.熟悉javascript,能够应用第三方的开源js库,比如prototype,jquery等;

4.有三年以上相关工作经验;

5.具有高超的艺术修养,美术或设计专业大专以上学历,有良好的美术功底和优秀的创意、实现能力;

6.熟练使用设计及网页制作工具,如photoshop、fireworks、dreamweaver、flash等,有良好的设计能力并具备熟练网页制作技巧,熟悉HTML/css/javascript等并能熟练手工编辑修改HTML源代码,能熟练美化ASP/PHP/JSP等程序动态页面;

7.熟悉网站建设的流程和网页设计制作流程,能独立完成大、中型网站页面设计,有成功的网页设计案例;

8.热爱网站工作,喜欢从事网站设计、策划、制作;

9.具有高深的flash制作功底者优先考虑;

10.对dhtml,javascript,xml等有充分的了解,能熟练使用javascript进行客户端编程。

2010年各大厂招聘前端开发的职位描述,包括阿里、腾讯、网易等。可以大概看出当时对于前端的定义和技能要求:
这段时期属于一个承上启下的阶段,要会使用ASP/PHP/JSP等动态语言,也要会使用js库(jQuery),甚至还要会使用Photoshop和通过ActionScript制作Flash动画。

我认为职责的分离还是为了业务的需要,一方面用户产生了日益增长的对交互体验提升的需要,另一方面后端产生了日益增长的业务量压力的技术需要,前后端分离后,各自专注于解决不同的业务痛点。

对于前端来说这段时期有2项技术举足轻重,分别是jQuery和Ajax,jQuery可以让开发者可以专注于前端交互,Ajax可以高效的与后端进行数据通信。

jQuery

2006年1月由John Resig发布的jQuery是一个快速、简洁的JavaScript框架,它的设计宗旨是:Write Less,Do More。jQuery是一个里程碑,它易于上手,使得开发者可以更加语义化的编写前端交互逻辑,在此之后的10年里一直占据着前端开发框架的统治地位。

Ajax

2005年Jesse James Garrett提出了Ajax(Asynchronous Javascript And XML),它是一种Web数据交互方式。Ajax 在浏览器与 Web 服务器之间使用XMLHttpRequest对象进行异步数据传输(HTTP 请求)。使用Ajax的最大优点,就是能在不更新整个页面的前提下维护数据。这使得Web应用程序更为迅捷地回应用户操作,并避免了在网络上传输那些没有改变的额外信息。

开始使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<!DOCTYPE html>
<html>
<head>
<!-- 直接使用网络上的jQuery包,也可以下载jQuery包到本地 -->
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<script type="text/javascript">
// 这里使用 Vercel 部署了一个网易云音乐的接口服务 https://netease-cloud-music-api-ruby-mu.vercel.app
var apiServer = 'https://netease-cloud-music-api-ruby-mu.vercel.app'
$(document).ready(function () {
// 页面事件:点击搜索按钮
$('#search-btn').on('click', function () {
handleSearchSong()
})

// 页面事件:输入框敲击回车
$('#search-ipt').on('keyup', function (e) {
if (e.keyCode === 13) {
handleSearchSong()
}
})

// 从后端查询数据 并 显示在页面
function handleSearchSong() {
var keyword = $('#search-ipt').val()
if (!keyword) return
$.get(`${apiServer}/search?keywords=${keyword}`, function (data, status) {
var songList = data.result.songs
addSongToPanel(songList)
})
}

// 显示歌曲列表到页面上
function addSongToPanel(songList) {
$('#song-list').empty()
songList.forEach((item) => {
var imgStl = 'style="display: inline-block;width: 40px;height: 40px;margin-right: 20px;"'
var diabled = item.fee === 1 ? '' : 'disabled' // 是否有权限播放
$('#song-list').append(
`<div><img ${imgStl} src=${item.artists[0].img1v1Url} /><span>${item.name}</span><span>歌手:${item.artists[0].name}</span><button id=${item.id} ${diabled} class="play-btn">播放</button></div>`
)
})
}

// 页面事件:点击播放按钮
$('#song-list').on('click', '.play-btn', function (e) {
var id = e.target.id
$.get(`${apiServer}/song/url?id=${id}`, function (data, status) {
var audioUrl = (data.data[0] || {}).url
console.log('audioUrl', audioUrl)
if (audioUrl) {
$('#song-player').empty()
$('#song-player').append(`
<audio controls>
<source src=${audioUrl} type="audio/ogg">
您的浏览器不支持 audio 元素。
</audio>
`)
}
})
})
})
</script>
</head>
<body>
<div class="page">
<div class="header">
<input id="search-ipt" type="text" placeholder="请输入关键字搜索歌曲" />
<button id="search-btn">搜索</button>
</div>

<div class="body">
<div id="song-list"></div>
<div id="song-player"></div>
</div>
</div>
</body>
</html>

jquery.png

加入一点简单的css样式,在<head>...</head>中加上如下style标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<style type="text/css">
button {
border: none;
cursor: pointer;
}
.page {
width: 400px;
}
.page .header {
display: flex;
justify-content: space-around;
background: rgb(210, 0, 1);
padding: 12px;
}
.page .header #search-ipt {
flex: 1;
border: none;
}
.page .header #search-btn {
border: none;
}
.body {
position: relative;
}
#song-list {
height: 600px;
overflow-y: auto;
}
#song-list > div {
height: 60px;
display: flex;
align-items: center;
padding: 0 12px;
background: rgb(243, 244, 246);
border-bottom: 1px solid rgb(223, 224, 226);
}
#song-list > div > span {
width: 130px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body #song-player {
position: absolute;
text-align: center;
width: 100%;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.6);
}
</style>

jq_ajax.png

说明:
这里需要利用 Node.js 启动一个http服务,Node.js安装完成之后在项目根目录下命令行执行:
npm install http-server -g
然后执行:
http-server,项目就运行起来啦。

2.3 工程化框架和构建生态

主要指 MVC和MVVM开发框架。

目前Web前端岗位主流市场正处于这个阶段,也可以说是这个阶段如日中天的时候。最大的特点是开发框架从开发者手中接管了对DOM操作(DOM节点的CRUD)的工作,并且还进行了优化,开发者的精力可以更加倾注于业务逻辑的编写。

MVC由模型M、视图V、控制器C组成;MVVM由模型M、视图V、视图模型VM组成,类似于Java的springMVC,其中比较有代表性的有3个框架:

名称 类型 维护者 时间 开源 衍生框架 关注度
Angular MVVM Google 2009 开源 不详
React MVC Facebook 2013 开源 Next.js / RN
Vue MVVM 尤雨溪 2014 开源 Nuxt.js / uni-app

与开发框架齐头并进的,还有一个庞大的前端构建生态。开发中节省下来的复杂度需要由构建工具来承担。

React 官方文档中对构建生态的概括非常准确,一组 JavaScript 构建工具链通常由这些组成:

一个 package 管理器,比如 Yarn 或 npm。它能让你充分利用庞大的第三方 package 的生态系统,并且轻松地安装或更新它们。

一个打包器,比如 webpack 或 Parcel。它能让你编写模块化代码,并将它们组合在一起成为小的 package,以优化加载时间。

一个编译器,例如 Babel。它能让你编写的新版本 JavaScript 代码,在旧版浏览器中依然能够工作。

构建流程.png

React

下面我们用React来重写上面的音乐播放页。

开始使用:
根据 React官方 提供的脚手架生成一个新项目。这个例子使用的是React 17版本,目前React已经更新到18了。前端框架的更迭真可谓是日新月异啊。

Node.js 版本要求: Node >= 14.0.0 和 npm >= 5.6,实际上我们唯一需要依赖的环境就只有Node.js,你可以非常轻松的安装到你的电脑上。

初始化项目

1
2
3
npx create-react-app my-app
cd my-app
npm run start

编写页面

将App.js文件后缀改为 .jsx,jsx 同时兼容 html 和 js 写法的模板文件,npm i axios --save下载一个依赖库。

修改App.jsx文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import React from 'react'
import axios from 'axios'
import './App.css'

const apiServer = 'https://netease-cloud-music-api-ruby-mu.vercel.app'
export default class App extends React.Component {
constructor(props) {
super(props)
this.state = {
keyword: '',
songList: [],
audioUrl: '',
}
}
handleSearch() {
if (!this.state.keyword) return
axios.get(`${apiServer}/search?keywords=${this.state.keyword}`).then((res) => {
this.setState({ songList: res.data.result.songs })
})
}
play(id) {
axios.get(`${apiServer}/song/url?id=${id}`).then((res) => {
this.setState({ audioUrl: (res.data.data[0] || {}).url })
})
}
handleKeyUp(e) {
if (e.keyCode === 13) {
this.handleSearch()
}
}
handleInput(e) {
this.setState({ keyword: e.target.value })
}
handleClick(letter) {
this.setState({ justClicked: letter })
}
render() {
return (
<div id="counter" className="page">
<div className="header">
<input
id="search-ipt"
type="text"
placeholder="请输入关键字搜索歌曲"
onChange={(e) => {
this.handleInput(e)
}}
onKeyUp={(e) => {
this.handleKeyUp(e)
}}
/>
<button
id="search-btn"
onClick={() => {
this.handleSearch()
}}
>
搜索
</button>
</div>
<div className="body">
<div id="song-list">
{this.state.songList.map((song) => (
<div key={song.id}>
<img
style={{ display: 'inline-block', width: '40px', height: '40px', marginRight: '20px' }}
src={song.artists[0].img1v1Url}
alt=""
/>
<span>{song.name}</span>
<span>歌手:{song.artists[0].name}</span>
<button
id="{song.id}"
disabled={song.fee !== 1}
className="play-btn"
onClick={() => {
this.play(song.id)
}}
>
播放
</button>
</div>
))}
</div>
<div id="song-player">
{this.state.audioUrl && (
<audio controls>
<source src="{audioUrl}" type="audio/ogg" />
您的浏览器不支持 audio 元素。
</audio>
)}
</div>
</div>
</div>
)
}
}

对比jQuery的例子可以发现,DOM的empty()append()等操作没有了,取而代之的是setState()方法。也就是我们只需要控制数据就可以实现页面的变更了,不再需要对具体的DOM节点进行增删改。

最后,将之前一个例子中的CSS样式代码拷贝到App.css中即可。

当然这只是一个学习的例子,完整的项目还需要更加工程化的结构,下面我们来搭建一个相对完善的前端系统。

三、实战

前端的前辈们在上面各种开发框架的基础上又叠加出更加全面的生态,我们可以方便的使用脚手架快速的搭建起一个标准的项目。在这里我们需要一个包管理工具 npm ,它类似于Java生态中的Maven,npm会随着Node.js的安装顺带一起安装到你的电脑上。你可以在命令行通过 npm -v 查看版本信息。

目前跟React比较,Vue.js 提供了更加集成化的搭建项目的脚手架。这里我们使用vue-cli,来搭建一个《上下班调研系统》。

它是一个 SPA(单页面应用),即所有前端页面资源会一次性加载到客户端;与之对应的是多页应用,即每次只返回多个页面之中被请求的子页面资源(Js、HTML、CSS)。

Node.js环境和vue-cli版本:@vue/cli 4.5.12,Vue CLI 4.x 需要 Node.js >= v8.9
下载脚手架:npm install -g @vue/cli

3.1 初始化项目

命令行执行:vue create working-hours

选项配置:

vue-cli-option.png

在项目根目录下打开命令行,执行 npm run serve,浏览器打开控制台显示的地址,就可以看到项目初始的页面了。

3.2 修改首页

修改文件App.vue和Home.vue,同时添加页面入口。

App.vue代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div id="app">
<router-view></router-view>
</div>
</template>

<style lang="less">
html,
body,
#app {
height: 100%;
margin: 0;
}
</style>

Home.vue代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div class="entry">
<router-link to="/working-hours">下班了吗?</router-link>
</div>
</template>

<style lang="less">
.entry {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
& > a {
font-size: 48px;
font-weight: bold;
background-image: linear-gradient(90deg, #007cf0, #ff7875);
background-clip: text;
color: transparent;
letter-spacing: 8px;
}
}
</style>

3.3 添加子页面

在项目的views文件夹下新建3个页面,分别是 WorkingHours.vueRecord.vueStatistics.vue

WorkingHours.vue代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<template>
<div>
<div class="menu">
<router-link to="/">首页</router-link>
<router-link to="/working-hours/record">上报数据</router-link>
<router-link to="/working-hours/statistics">统计</router-link>
</div>
<div class="body">
<router-view />
</div>
</div>
</template>
<style lang="less">
.menu {
padding: 16px;
border-bottom: 1px solid #eee;
a {
font-weight: bold;
color: #2c3e50;
margin-right: 16px;
&.router-link-exact-active {
color: #42b983;
}
}
}
.body {
padding: 16px;
}
</style>

Record.vue代码如下:

1
2
3
<template>
<div>page record.</div>
</template>

Statistics.vue代码如下:

1
2
3
<template>
<div>page statistics.</div>
</template>

3.4 添加路由

也就是声明浏览器输入的链接地址和页面文件之间的匹配关系。

修改 router/index.js 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
},
{
path: '/working-hours',
name: 'WorkingHours',
component: () => import('../views/WorkingHours.vue'),
redirect: '/working-hours/record',
children: [
{
path: 'record',
name: 'Record',
component: () => import('../views/Record.vue'),
},
{
path: 'statistics',
name: 'Statistics',
component: () => import('../views/Statistics.vue'),
},
],
},
]

const router = new VueRouter({
routes,
})

export default router

这时我们点击“上报数据”和“统计”两个超链接,可以看到路由切换的效果。

image.png

3.5 添加业务代码

为了更加快速的实现业务功能,我们可以引入生态中开源的UI组件库,组件库中包含了按钮、表格、表单等常用基础组件。

组件库在前端开发生态中位置:
前端体系层级.png

这里我们使用饿了么的组件库element-ui@2.15.7,在项目根目录下运行如下命令,下载组件库代码包:
npm i element-ui -S

修改 main.js 引入组件库,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

// 全局引入饿了么组件库
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);

Vue.config.productionTip = false

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

修改“上报数据”页面Record.vue,代码如下:
首先下载一个用于发起http请求的js库axios:npm i axios --save

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
<template>
<div>
<div class="btn-wrap">
<el-button type="primary" size="small" @click="handleAdd">上报</el-button>
</div>
<div class="table-wrap">
<el-table v-loading="loading" :data="tableData" style="width: 100%" stripe>
<el-table-column prop="startTime" label="上班时间"> </el-table-column>
<el-table-column prop="endTime" label="下班时间"> </el-table-column>
<el-table-column prop="job" label="职业"> </el-table-column>
<el-table-column prop="company" label="公司" show-overflow-tooltip> </el-table-column>
<el-table-column prop="gender" label="性别"> </el-table-column>
<el-table-column prop="age" label="年龄段"> </el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip> </el-table-column>
<el-table-column fixed="right" label="操作" width="120">
<template slot-scope="scope" v-if="scope.row.isSelf">
<el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="small" @click="handleDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="page-wrap">
<el-pagination layout="prev, pager, next" background :total="page.total" @current-change="handlePageChg"> </el-pagination>
</div>

<el-dialog :title="ruleForm.id ? '修改' : '新增'" :close-on-click-modal="false" :visible.sync="dialogVisible" width="600px" :before-close="handleClose">
<el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px">
<el-form-item label="上班时间" prop="startTime">
<el-time-select v-model="ruleForm.startTime" :picker-options="{ ...pickerOption, maxTime: ruleForm.endTime }"> </el-time-select>
</el-form-item>
<el-form-item label="下班时间" prop="endTime">
<el-time-select v-model="ruleForm.endTime" :picker-options="{ ...pickerOption, minTime: ruleForm.startTime }"> </el-time-select>
</el-form-item>
<el-form-item label="职业" prop="job">
<el-select v-model="ruleForm.job" placeholder="请选择职业">
<el-option v-for="item in jobList" :key="item" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
<el-form-item label="公司" prop="company">
<el-input :maxlength="16" v-model="ruleForm.company" placeholder="16个字以内"></el-input>
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-select v-model="ruleForm.gender">
<el-option label="男" value="男"></el-option>
<el-option label="女" value="女"></el-option>
</el-select>
</el-form-item>
<el-form-item label="年龄段" prop="age">
<el-select v-model="ruleForm.age">
<el-option label="00后" value="00后"></el-option>
<el-option label="90后" value="90后"></el-option>
<el-option label="80后" value="80后"></el-option>
<el-option label="70后" value="70后"></el-option>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input type="textarea" :maxlength="140" v-model="ruleForm.remark" placeholder="140个字以内"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')" :loading="saveLoading">确定</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>

<script>
import axios from 'axios'

const serverApi = 'https://vercel-egg.vercel.app/api' // 后端服务地址
const defaultForm = {
id: '',
startTime: '',
endTime: '',
job: '',
company: '',
gender: '',
age: '',
remark: '',
}
export default {
data() {
return {
tableData: [],
loading: false,
saveLoading: false,
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
dialogVisible: false,
pickerOption: {
start: '00:00',
step: '00:30',
end: '24:00',
},
ruleForm: Object.assign({}, defaultForm),
rules: {
startTime: [{ required: true, message: '请输入上班时间', trigger: 'blur' }],
endTime: [{ required: true, message: '请输入下班时间', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'blur' }],
company: [{ max: 16, message: '公司名称不能超过16个字', trigger: 'blur' }],
remark: [{ max: 140, message: '备注不能超过140个字', trigger: 'blur' }],
},
jobList: ['IT互联网技术', '电子/通信/半导体技术', '产品', '设计', '运营', '市场', '人事/行政/法务', '财务', '高级管理', '金融', '销售', '传媒'],
}
},
methods: {
getRecord() {
this.loading = true
axios
.get(`${serverApi}/workingHour/retrieve?pageNo=${this.page.currentPage}`)
.then((res) => {
this.tableData = res.data.list
this.page.total = res.data.page.total
})
.finally(() => {
this.loading = false
})
},
handlePageChg(cpage) {
this.page.currentPage = cpage
this.getRecord()
},
handleAdd() {
this.ruleForm = Object.assign({}, defaultForm)
this.dialogVisible = true
},
handleClose() {
this.dialogVisible = false
},
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.saveRecord()
} else {
return false
}
})
},
saveRecord() {
this.saveLoading = true
const createOrUpdate = this.ruleForm.id ? 'update' : 'create'
axios
.get(`${serverApi}/workingHour/${createOrUpdate}`, { params: this.ruleForm })
.then(() => {
this.$message.success('保存成功')
this.dialogVisible = false
this.getRecord()
})
.catch((err) => {
this.$message.warning(err?.response?.data)
})
.finally(() => {
this.saveLoading = false
})
},
resetForm(formName) {
this.$refs[formName].resetFields()
},
handleEdit(row) {
this.ruleForm = Object.assign({}, row)
this.dialogVisible = true
},
handleDel(row) {
this.$confirm('确认删除吗?', '提示').then(() => {
axios
.get(`${serverApi}/workingHour/delete?id=${row.id}`)
.then(() => {
this.$message.success('删除成功')
this.getRecord()
})
.catch((err) => {
this.$message.warning(err?.response?.data)
})
})
},
},
created() {
this.getRecord()
},
}
</script>
<style lang="less" scoped>
.btn-wrap {
text-align: right;
border-bottom: 1px solid #eee;
padding-bottom: 16px;
}
.page-wrap {
padding-top: 12px;
text-align: right;
}
</style>

修改“统计”页面Statistics.vue,代码如下:
首先下载一个用于实现图表的js库echarts:npm install echarts@5.3.2 --save

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
<div class="container" id="chart-box1" v-loading="loading"></div>
</template>

<script>
import axios from 'axios'
import * as echarts from 'echarts'

const serverApi = 'https://vercel-egg.vercel.app/api' // 后端服务地址
export default {
data() {
return {
loading: false,
}
},
methods: {
async getPieData() {
this.loading = true
const res = await axios.get(`${serverApi}/workingHour/pie`)
this.loading = false
let seriesData = []
for (let key in res.data) {
seriesData.push({
name: key,
value: res.data[key],
})
}
this.renderChart(seriesData)
},
renderChart(seriesData) {
let myChart = echarts.init(document.getElementById('chart-box1'))

myChart.setOption({
title: {
text: '下班时间统计',
left: 'center',
},
legend: {
top: 'bottom',
},
series: [
{
name: 'Nightingale Chart',
type: 'pie',
radius: [50, 170],
center: ['50%', '50%'],
roseType: 'area',
itemStyle: {
borderRadius: 8,
},
label: {
show: true,
formatter: '{b}\n{d}%',
},
data: seriesData,
},
],
})
},
},
mounted() {
this.getPieData()
},
}
</script>

<style lang="less" scoped>
.container {
width: 500px;
height: 500px;
}
</style>

这样,一个简单的应用就编码完成了。
接下来可以命令行运行npm run build,把代码编译、压缩成静态文件(根目录下会多出一个dist文件夹),那么怎么把这个页面部署到公网环境,让其他人可以访问到呢?

说明:上面代码中用到的后端服务,通过 Node.js + mongoDB 实现,并通过 Vercel 发布到公网。

3.6 发布到公共网络

将项目部署到公共网络,有以下4种方式。

云服务器

如果你是商用,复杂,定制的应用服务,最好还是花钱买一个服务器。云服务器厂商包括:Azure、AWS、阿里云、腾讯云、华为云等。在拥有一台服务器后,就可以开始部署了,用于部署服务的Web 应用服务器包括:Tomcat、Nginx、IIS(Windows)等。
这里以nginx为例:

  • 安装nginx,网上有很多在线、离线安装的教程
  • 配置服务入口
    首先上传上面项目中打包生成的dist文件夹到服务器上,比如上传到服务器上的这个位置:/opt/web/dist,然后根据你安装的nginx目录,找到nginx服务配置文件,比如:/usr/local/nginx/conf/nginx.conf,在文件http{…}代码块内加入如下代码:

    server {
    listen 8081;
    server_name localhost;
    location / {
    root /opt/web/dist;
    index index.html;
    }
    }
    保存后,重启nginx:/usr/local/nginx/sbin/nginx -s reload
  • 开放端口(防火墙)
    云服务器提供商一般有配置页面可以直接操作。开放端口.png

这样就可以通过IP:8081访问你的页面了,当然完善的流程后面还有域名申请,域名绑定IP,网站ICP备案,https认证(SSL证书)等

第三方平台托管

在web1.0的时代,Web服务几乎是广播式的。那个blog盛行的年代,大家通过个人主页来发布信息,展示自我。
第三方托管平台有GithubGiteeWordPress等。Github 提供了 GitHub Pages服务来发布博客页,国内的Gitee类似。这些服务通常还会推荐你使用一些博客模板来生成内容,比如WorkPressHexo等。
根据W3Techs2019年12月6号发布的统计数据,使用WordPress(PHP语言)开发的网站占所有网站的1/4。我这里使用 Hexo 及它衍生的 hexo-theme-next 主题,半小时即可完成一个博客站点:https://hexo-one-dun.vercel.app/

Serverless 提供商

Serverless平台包括:小程序平台、Vercel等。
上面《上下班调研系统》就通过Vercel部署到了公网,访问地址:https://working-hours-tau.vercel.app/

CDN服务提供商

可以通过七牛云、阿里云OSS、腾讯云OSS等进行静态页面托管,只需要给文件资源地址绑定一个域名即可。

四、扩张

随着数字化进程的开展,政府和企业侧需求激增,带来很多的前端就业岗位。再加上Web前端行业的从业门槛不高、终端设备的普遍支持、开发生态的完善等原因,涌现出很多的前端开发人员,前端代码也像苔藓般蔓延到更多的领域。
如果Web前端向自动化、智能化发展的话,还是需要高精尖的设计和开发人员铺路,普通从业人员只做一些打磨和修补的工作。
正如吴军老师在《硅谷来信2016》中提到的:

在未来的智能时代,真正受益于技术进步的个人可以不超过人口的2%。坦率地讲,仅仅会写几行JavaScript的人不属于我说的2%的行列,这些人恰恰在未来是要被计算机淘汰的。……如果有些人就满足于五年(正常工作大约10000小时)坚持不懈地写JavaScript,非常糟糕,因为这是低水平重复,即便五年后你把它练熟了,可能JavaScript已经过时了,或者是由计算机来写了。

BFF

Backends For Frontends是一种专门为前端设计的后端API服务,虽然有人认为它能解决一些问题,比如:

  1. api接口频繁变动的问题
  2. 前后端多次请求的性能问题
  3. 字段的冗余和一致性问题

但是我认为它主要提供了一种便捷的解决方案,以及承载溢出的劳动力。

开始使用:
如果你已经安装了Node.js,新建一个server.js文件,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const http = require('http')

const hostname = '127.0.0.1'
const port = 3000

const server = http.createServer((req, res) => {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('Hello World\n')
})

server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})

然后在文件目录命令行执行:node ./server.js 即可启动一个最简单的api服务。最后浏览器打开控制台上显示的链接即可查看运行结果。

Egg.js

Egg.js是一个阿里团队基于Node.js和Koa打造的一个企业级后端服务框架,它是开源的。下面我们使用它来写两个连接mongo数据库的api接口。
前提:npm版本 >=6.1.0,准备一个本地或者远程能链接的 mongoDB数据库

  1. 初始化项目
    1
    2
    3
    4
    5
    6
    mkdir egg-example && cd egg-example
    npm init egg --type=simple
    npm i

    // 启动项目
    npm run dev
  2. 安装mongoDB JavaScript工具库: npm i egg-mongoose --save
  3. 在config/plugin.js中注册egg-mongoose
    内容如下:
    1
    2
    3
    4
    5
    6
    module.exports = {
    mongoose: {
    enable: true,
    package: 'egg-mongoose',
    },
    }
  4. 修改config/config.default.js,配置mongoDB链接
    内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    module.exports = (appInfo) => {
    const config = (exports = {})

    config.keys = appInfo.name + '_1650010446983_6736'

    config.middleware = []

    const userConfig = {}

    config.mongoose = {
    url: 'mongodb://127.0.0.1:27017/local', // local 是数据库名
    options: {
    useUnifiedTopology: true,
    },
    }

    return {
    ...config,
    ...userConfig,
    }
    }
  5. 在app文件夹下新建一个model目录,并在目录下新建一个student.js
    内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    module.exports = (app) => {
    const mongoose = app.mongoose
    const Schema = mongoose.Schema

    const StudentSchema = new Schema({
    name: { type: String },
    age: { type: Number },
    gender: { type: String, enum: ['男', '女'] },
    })

    return mongoose.model('Student', StudentSchema, 'student')
    }
  6. 在app文件夹下新建一个service目录,并在目录下新建一个student.js
    内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const Service = require('egg').Service

    class StudentService extends Service {
    // 查询学生
    async list() {
    return this.ctx.model.Student.find()
    }
    // 新增学生
    async add(student) {
    return this.ctx.model.Student.create(student)
    }
    }

    module.exports = StudentService
  7. 在app/controller目录下新建student.js
    内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    const Controller = require('egg').Controller

    class HomeController extends Controller {
    // 查询学生
    async list() {
    const { ctx } = this
    let studentList = await ctx.service.student.list()
    ctx.body = studentList
    }
    // 新增学生
    async add() {
    const { ctx } = this
    const student = ctx.request.query
    const result = await ctx.service.student.add(student)
    ctx.body = result
    }
    }

    module.exports = HomeController
  8. 修改路由文件app/router.js
    内容如下:
    1
    2
    3
    4
    5
    6
    module.exports = (app) => {
    const { router, controller } = app
    router.get('/', controller.home.index)
    router.get('/student/list', controller.student.list)
    router.get('/student/add', controller.student.add)
    }
  9. 浏览器或者Postman验证结果
    1
    2
    http://127.0.0.1:7001/student/add?name=wang&age=18&gender=男
    http://127.0.0.1:7001/student/list

手机APP&桌面应用

手机APP
手机APP运行在IOS和Andriod两大平台,开发方式目前有三种:

  1. native app,使用原生语言开发
  2. hybrid app,使用原生语言+Web语言混合开发
  3. web app,使用Web语言开发

相比原生开发或者使用FLutter(Dart语言),Web语言通过容器和转译的方式,使得APP开发更加便捷和容易,虽然牺牲了性能和体验。
目前比较成熟的Web跨端开发的框架包括:React Native、uni-app、taro。

桌面应用
思路都是使用HTML + CSS + JS的方式来实现桌面端应用,目前比较流行的框架包括Electron 和 Tauri,其中Electron的技术方案是Chromium + Nodejs,Tauri是采用Webview + Rust语言来实现。

WebGL

WebGL是一种在浏览器里展示3D场景和模型的技术。随着硬件设备以及浏览器性能的提升,数字孪生、沉浸式体验等方面的需求。它得到了越来越多的应用。直接使用WebGL进行开发,还是有一定门槛,需要数学、图形学、着色器编程语言等比较专业的知识。同样在这个领域,也已经存在一片“花园”了,我们可以采用框架来使用WebGL,比如 three.js

three.js

开始使用:
首先下载 three.js 库文件,新建一个html文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html>
<body>
<script src="./three.js"></script>
<script>
const scene = new THREE.Scene() // 场景
const color = new THREE.Color(0x7298a5)
scene.background = color
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 1000) // 视角
camera.position.z = 5

// 渲染器
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

// 物体模型
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshNormalMaterial()
const cube = new THREE.Mesh(geometry, material)
cube.castShadow = true
scene.add(cube)

// 动画
function animate() {
requestAnimationFrame(animate)
cube.rotation.z += 0.01
cube.rotation.y += 0.01
renderer.render(scene, camera)
}
animate()
</script>
</body>
</html>

three-3d.gif

CSS 3D

利用CSS3的transform属性,也能实现简单的3D交互效果。
只需设置容器变换方式为:transform-style: preserve-3d,让其维持3D显示的特性,然后设置其子元素为:transform: rotateY(xxdeg) translateZ(xxpx),使其在3D空间内偏移即可。全部代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>CSS 3D示例</title>
<style>
:root {
--item-height: 150px;
}
html,
body {
height: 100%;
}
body {
perspective: 1000px;
background: #7298a5;
overflow: hidden;
}
.wrap {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 800px;
height: var(--item-height);
margin: auto;
transform-style: preserve-3d;
animation: carousel 15s linear 0s infinite;
}
.list {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
list-style: none;
transition: 0.5s;
}
.item {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 300px;
height: var(--item-height);
margin: auto;
line-height: var(--item-height);
text-align: center;
font-size: 50px;
color: #fff;
background: #ffd591;
border: 1px solid #eee;
}
.item:hover {
cursor: pointer;
color: #ff4d4f;
}
@keyframes carousel {
0% {
transform: rotateX(10deg) rotateY(0deg);
}
100% {
transform: rotateX(10deg) rotateY(360deg);
}
}
</style>
</head>
<body>
<div class="wrap">
<ul class="list">
<li class="item">1</li>
<li class="item">2</li>
<li class="item">3</li>
<li class="item">4</li>
<li class="item">5</li>
<li class="item">6</li>
<li class="item">7</li>
<li class="item">8</li>
<li class="item">9</li>
<li class="item">10</li>
</ul>
</div>

<script>
let list = document.querySelector('.list')
let items = list.querySelectorAll('.item')
let itemWidth = items[0].offsetWidth //单个图片的宽度
let perimeter = items.length * itemWidth //算出所有图片的宽度(周长)
let radius = perimeter / (2 * Math.PI) //算出半径
let angle = 360 / items.length //计算每张图片旋转的角度

Array.from(items).forEach((item, index) => {
//把图片通过位移和旋转拼成圆环
item.style.transform = `rotateY(${angle * index}deg) translateZ(${radius}px)`
})
</script>
</body>
</html>

css3-3d.gif

CSS3中与3D相关的属性:
transform: 变换
perspective: 摄像机距离屏幕的距离
perspective-origin: 摄像机在X轴和Y轴的坐标
transform-style: 变换方式
backface-visibility: 背面是否显示
matrix3d:变换矩阵

WSAM

在Node.js中文官网教程中提供了如下信息:
WebAssembly 是一种高性能的类汇编语言,可以从包括 C/C++、Rust 和 AssemblyScript 在内的无数语言进行编译。 目前,Chrome、Firefox、Safari、Edge 和 Node.js 都支持它!

有多种方法可用于生成 WebAssembly 二进制文件,包括:

  • 手工编写 WebAssembly(.wat)并使用 wabt 等工具转换为二进制格式
  • 在 C/C++ 应用程序中使用 emscripten
  • 在 Rust 应用程序中使用 wasm-pack
  • 如果你喜欢类似 TypeScript 的体验,则使用 AssemblyScript

这意味着某些原来只能运行在操作系统上的程序,也能在浏览器或者Node.js环境运行,它们原来是用C++或者Rust等语言编写。

总结

文章结束了,一段通过搜索引擎对信息的重新排列,个人感觉挺有意思的,像一只小青蛙在一片片带着画面的时间荷叶间跳跃前进,从1969年跳到了当下,回过头拍了一张剪影,紧接着又跳入无限。

概述

Vite是新一代的前端构建工具,类似于 Webpack+ Webpack-dev-server。
在开发环境利用浏览器的 ESM,完全跳过打包环节,按需加载;生产环境则利用 Rollup 打包。

特点:

  • 快速冷启动,No Bundle + esbuild
  • 模块热更新,基于 ESM 的 HMR,同时利用浏览器缓存
  • 按需加载,基于 ESM

ESM

ESM 是 JavaScript 提出的官方标准化模块系统,作为 ECMA 标准,已经有绝大部分浏览器支持。

ESM的执行可以分为三个步骤:

  • 构建,查找模块
  • 实例化,创建实例
  • 运行,内存关联实例

从上面实例化的过程可以看出,ESM 使用实时绑定的模式,导出和导入的模块都指向相同的内存地址,也就是值引用。而 CommonJS 采用的是值拷贝,即所有导出值都是拷贝值。

Esbuild

Vite底层使用Esbuild实现对 .ts、.jsx、.js 代码文件的转化。

Esbuild 是由 Figma 的 CTO 「Evan Wallace」基于 Golang 开发的一款打包工具,相比传统的打包工具,主打性能优势,在构建速度上可以快10~100 倍。

它的优势来源于 GO 语言的能力:

  • 编译运行 vs 解释运行
  • 多线程 vs 单线程

目前他支持以下的功能:

  • 加载器
  • 压缩
  • 打包
  • Tree Shaking
  • Source Map

Esbuild总共提供了四个函数:transform、build、buildSync、Service。有兴趣的可以移步官方文档了解。

Rollup

在生产环境下,Vite 使用 Rollup 来进行打包。

Rollup是基于ESM的JavaScript打包工具。
能针对源码进行 Tree Shaking。
Scope Hoisting 减小输出文件大小。

核心原理

ES6 的模块加载:

1
<script type="module" src="/src/main.js"></script>

当声明一个模块时,浏览器会解析地址并在当前域名发起一个 GET 请求 main.js 文件。并在文件内部递归发起 import 的文件请求。

Vite核心原理:

  • 利用 ES6 的 import, 遇到 import 就发送一个 HTTP 请求加载文件
  • Vite 启动一个 Koa 服务拦截发送的请求,在Koa服务中对文件进行简单的分解 与 整合
  • 以 ESM 格式返回给浏览器

按需加载

只加载当前页面用到的模块,而且不需要解析依赖、打包 等过程。

热更新

webpack:通过 WebSocket 建立浏览器和服务器的连接,监听到文件变化,则重新加载模块。

Vite:

  • 创建 WebSocket
  • chokidar 监听文件变更
  • 通知变更
  • 获取更新文件

利用浏览器的缓存

因为是利用 HTTP 发起的文件请求,所以可以利用浏览器的缓存机制。

基于esbuild的预编译

Vite预编译之后,将文件缓存在 node_modules/.vite/

预编译的作用:

  • 支持CommonJS,转化成 ESM 模块并缓存入 node_modules/.vite
  • 减少请求数量
    例如:lodash-es 这种包会有几百个子模块,当代码中出现 import { debounce } from ‘lodash-es’ 会发出几百个 HTTP 请求, Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

文章来源:《Vue.js设计与实现》—霍春阳

官网简介

Vue is a JavaScript framework for building user interfaces. It builds on top of standard HTML, CSS and JavaScript, and provides a declarative and component-based programming model that helps you efficiently develop user interfaces, be it simple or complex.

渐进式的UI框架,提供声明式及基于组件的编程模型。

权衡的艺术

命令式 vs 声明式

声明式关注过程,命令式关注结果。
Vue.js封装了命令式的过程,帮助我们实现了声明式的结果。

1
<div @click="alert('ok')">hello world</div>

性能 vs 可维护性

性能上声明式代码不优于命令式代码,Vue.js框架要做的就是减少性能上的损失。

1
div.textContent = 'hello other'

上面的命令式写法是性能最优的,如果是声明式往往做不到有的放矢,指哪打哪。毕竟Vue.js本身就是封装了命令式代码才实现了声明式编程。
那Vue.js为什么要采用声明式呢?为了开发的便捷和代码的可维护性。

虚拟DOM vs 真实DOM

innerHTML(字符串拼接)虚拟DOM原生JavaScript
心智负担-中心智负担-小心智负担-大
性能-差性能-良好性能-高
可维护性-高可维护性-差
不知道是不是幸存者偏差,总之虚拟DOM表现还不错。

操作虚拟DOM的性能消耗 = Diff + 原生操作的性能消耗。

运行时 vs 编译时

纯运行时
1
2
3
4
5
6
7
8
9
const obj = {
tag: 'div',
children: [
{tag: 'span', children: 'hello world' }
]
}

// 渲染成真实的dom
Render(obj, document.body)
运行时 + 编译时
1
2
3
4
5
6
7
8
9
10
const html = `
<div>
<span>hello world</span>
</div>
`

// 编译成上面的obj对象
const obj = Compiler(html)
// 渲染成真实的dom
Render(obj, document.body)
纯编译时
1
2
3
4
5
6
7
8
const html = `
<div>
<span>hello world</span>
</div>
`

// 把上面的html模板直接渲染成真实的DOM
RenderRealDOM(html)

Svelte就是纯编译时框架。
Vue.js是一个编译时+运行时的框架。

框架设计的核心要素

  • 框架应该给用户提供哪些构建产物?
  • 产物的模块格式如何?
  • 怎么保证用户的开发体验?
  • 开发版本和生产版本如何区别构建?
  • 如何减少资源打包体积?

开发体验

衡量一个框架是否足够优秀的指标之一就是看它的开发体验如何?

  1. Vue.js在错误捕获上做了封装,以便更准确的定位问题。
  2. 通过DevTools勾选”Console - Enable custom formatters”,可以优化proxy的输出内容,比如打印count.value。

框架资源包体积

Vue.js通过rollup的打包常量控制,不同场景的产物。

1
2
3
4
// __DEV__可以根据需要修改true or false
if (__DEV__ && !res) {
warn('Failed to mount app: mount target selector "${container}" returned null.')
}

良好的Tree-Shaking
什么是Tree-Shaking呢?
在前端领域,这个概念因rollup.js而普及。简单地说,Tree-Shaking指的就是消除哪些永远不会被执行的代码,也就是排除 dead code。

1
2
3
4
5
6
7
8
9
10
// utils.js
export function foo(obj) {
obj && obj.foo
}
export function bar(obj) {
obj && obj.bar
}

// input.js
import { foo } from './utils.js'

utils中的bar函数没有被用到,就不会被导出。
想要实现Tree-Shaking,只有以ESM模块打包的方式才有效。为了实现更加精炼的Tree-Shaking可以通过/*#__PURE__*/来标识代码。

构建产物

  1. 在HMTL的<script>标签中直接引入的IIFE格式的产物。
  2. 用于ESM打包方式的vue.esm-browser.jsvue.runtime.esm-bundle.js工程化引入的产物,比如npm、yarn。
  3. 用于Node.js的服务端渲染的CommonJS的产物。

特性开关

Vue.js可以通过打包的时候开启或关闭某个特性开关,来控制输出产物的内容。类似于前面的__DEV__,这样的机制保证了框架的灵活性,可以便捷的添加和删除特性,其中包括对Vue.js 2.0版本的兼容。

1
2
3
4
5
6
7
8
// support for 2.x options
if (__FEATURE_OPTIONS_API__) {
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}

错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// utils.js
let handleError = null
export default {
for(fn) {
callWithErrorHandling(fn)
},
// 用户可以调用该函数注册统一的错误处理函数
registerErrorHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
// 将捕获到的错误传递给用户的错误处理程序
handleError(e)
}
}

TypeScript支持

因为TS的好处,对TS类型的支持是否完善也成为评价一个框架的重要指标。
衡量TS支持是否友好,并非只是使用了TS就可以,还必须真正利用到TS的有用特性。
Vue.js源码中的runtime-cor/src/apiDefineComponent.ts文件,整个文件里真正会在浏览器中运行的代码其实只有3行,但是全部的代码接近200行,其实这些代码都是在为类型支持服务。

设计思路

一个项目就算再大,也是存在一条核心思路的,并围绕核心展开。
Vue.js是一个声明式的UI框架。

graph LR
    A[编译器] -->B[Virtual DOM] -->C[渲染器] -->D[真实DOM]
1
2
3
4
5
6
7
import { h } from 'vue'

export default {
render() {
return h('h1', { onClick: handler })
}
}

h 函数就是一个辅助创建虚拟DOM的工具函数。
组件就是一组DOM元素的封装。

编译器把模板编译成Virtual DOM,渲染函数在把Virtual DOM转化成渲染树,最后通过渲染器生成真实的DOM。

graph LR
    A[HTML template] -->B[Virtual DOM] -->D[真实DOM]

文章来源:《Vue.js设计与实现》—霍春阳

响应系统的作用与实现

我们知道Vue.js 3 采用Proxy实现响应式数据,这涉及语言规范层面的知识。这部分内容包括如何根据语言规范实现对数据对象的代理,以及其中的一些重要细节。

响应式数据 & 副作用函数

副作用函数:effect 函数的执行会直接或间接影响其他函数的执行。

1
2
3
4
5
6
7
8
9
10
11
12
function effect1() {
document.body.innerText = 'hello vue 3'
}

function effect2() {
// 全局变量
let val = 1

function effect() {
val = 2 // 修改全局变量,产生副作用
}
}

响应式数据:当一个数据发生改变时,我们希望引用到它的副作用函数自动重新执行,这样的数据被称为响应式数据。

1
2
3
4
5
6
7
const obj = { text: 'hello world' }
function effect() {
document.body.innerText = obj.text
}

// 修改obj.text的值,同时希望副作用函数会重新执行
obj.text = 'hello other'

响应式数据基本实现

  1. 在数据赋值时,进行拦截,并保存对应的副作用函数。
  2. 在数据取值时,进行拦截,重新执行副作用函数。

具体手段:在ES2015前采用Object.defineProperty实现拦截,在ES2015后,采用代理对象Proxy实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const bucket = new Set()

const obj = { text: 'hello world' }

const reactiveObj = new Proxy(obj, {
get(target, key) {
bucket.add(effect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})

完善的响应式系统

graph LR

A((obj))
B((obj.A))
C((obj.B))
D((effect1))
E((effect2))
A --> B
A --> C
B --> D
B --> E

复杂一些的情况:对目标对象的多个属性添加多个副作用函数。
因此用于存储副作用函数的集合的数据结构变成:WeakMap - Map - Set。
其中WeakMap的键是原始对象obj,WeakMap的值是一个Map实例,而Map的键是原始对象obj的key,Map的值是一个由副作用函数组成的Set。

WeakMap 和 Map的主要区别:

1
2
3
4
5
6
7
8
9
10
const map = new Map()
const weakMap = new WeakMap()

(function() {
const foo = {foo:1}
const bar = {bar:2}

map.set(foo, 1)
weakMap.set(bar, 2)
})()

WeakMap是弱引用,当表达式执行完毕,会被垃圾回收。所以WeakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息。

然后把副作用收集的逻辑封装到track(追踪)函数中,把触发副作用函数的逻辑封装到 trigger函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})

function track(target, key) {
// activeEffect 用来注册副作用函数的全局变量
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMaps.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}

function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}

副作用函数的依赖收集

为了避免副作用函数中的条件依赖问题:

1
2
3
4
5
6
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })

effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})

分支切换导致冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。

嵌套effect与effect栈

避免无限递归循环

调度执行

能够控制副作用函数的执行时机,次数,方式;这对于computed和watch的实现由重要意义。

计算属性computed 与 lazy

1
2
3
4
5
6
7
8
9
10
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)

当options.lazy为true时,则不立即执行副作用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function computed(getter) {
// 把getter作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})

const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}

return obj
}

并且可以通过 value(缓存值)、dirty、scheduler实现缓存。也就是多次访问不用重新计算。

watch

通过 effect 和 scheduler实现。immediate可以实现watch的立即调用。
过期的副作用,多次watch中的异步请求,通过设置watch回调函数的第3个参数onInvalidate将上一次副作用标记为expired过期,避免竞态问题。

非原始值的响应式方案

实现响应式数据要比想象中难很多,并不是像上一章讲述的那样,单纯地拦截get/set操作即可。

Proxy 和 Reflect

Proxy可以创建一个代理对象,它能够实现对其他对象的代理。
Proxy只能代理对象,无法代理非对象值,例如字符串、布尔值等。
Proxy可以对一个对象的基本语义进行代理,包括getset,也包括一些基本操作,也即非复合操作

在JavaScript的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const fn = (name) => {
console.log('我是:', name)
}

// 调用函数是对对象的基本操作
fn()

const p2 = new Proxy(fn, {
// 使用apply 拦截函数调用
apply(target, thisArg, argArray) {
target,call(thisArg, ...argArray)
}
})

ps('hcy') // 输出:'我是:hcy'

复合操作:

1
2
3
obj.fn()
// 由2个操作组成
get obj.fn + 执行fn

Reflect 是一个全局对象,其下有许多方法:

1
2
3
4
Reflect.get()
Reflect.set()
Reflect.apply()
// ...

Reflect.get 函数还能接收第三个参数,即指定receiver,你可以理解为函数调用过程中的this,例如:

1
2
const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', {foo: 2})) // 输出 2

为什么要时使用Reflect

1
2
3
4
5
6
7
8
9
10
11
const obj = {
foo: 1,
get bar() {
return this.foo
}
}

effect(() => {
// p 是一个通过Proxy代理的对象
console.log(p.bar)
})

上面调用访问器bar时,没有触发响应式。

1
2
3
4
5
6
7
8
// 改造后
const p = new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
// target[key] 替换掉
return Reflect.get(target, key, receiver)
}
})

JavaScript对象 & Proxy工作原理

根据ECMAScript规范,JavaScript对象分为2种:

graph LR
A[Object]
B[常规对象ordinary object]
C[异质对象exotic object]
A --> B
A --> C

那么如何区分普通对象和函数呢?在JS中,对象的实际语义是由 内部方法(internal method) 指定的:

[[Get]][[Set]][[Delete]][[OwnPropertyKeys]][[GetPrototypeOf]][[SetPrototypeOf]][[IsExtensible]][[PreventExtensions]][[GetOwnProperty]][[DefineOwnProperty]][[HasProperty]],除了这11个内部方法,还有另外2个额外的内部方法:[[Call]][[Construct]]

而 **函数对象会部署内部方法[[Call]]**,普通对象不会。

内部方法具有多态性,也就是每个方法可以定义不同的实现。
常规对象满足的条件

  • 11个内部方法按照ECMA 10.1.x规范实现
  • [[Call]]按照ECMA 10.2.1规范实现
  • [[Construct]]按照ECMA 10.2.2规范实现

其他的则都为异质对象,例如Proxy对象的[[Get]]方法没有使用ECMA 10.1.8实现,所以它是异质对象。

Proxy的接口函数

内部方法实现函数
[[Get]]get
[[Set]]set
[[Delete]]deleteProperty
[[OwnPropertyKeys]]ownKeys
[[GetPrototypeOf]]getPrototypeOf
[[SetPrototypeOf]]setPrototypeOf
[[IsExtensible]]isExtensible
[[PreventExtensions]]preventExtensions
[[GetOwnProperty]]getOwnPropertypeDescriptor
[[DefineOwnProperty]]defineProperty
[[HasProperty]]has
[[Call]]apply
[[Construct]]construct

[[Call]][[Construct]]只有当对象是函数和构造函数时才会部署。

如何代理 Object

对象上所有的读取操作:

  • obj.foo
  • key in obj
  • for (const key in obj) {}

in对应的Proxy拦截方法时has,for…in对应的Proxy拦截方法是ownKeys。

合理触发响应

  • 当值没有改变时,不触发。
    1
    NaN === NaN // false
  • 屏蔽由原型引起的更新
  • 当key时Symbol类型时,不触发

深响应 & 浅响应

reactive是深响应、shallowReactive是浅响应。
reactive 是 Proxy的上层封装

1
2
3
function reactive(obj) {
return new Proxy(...)
}

然而,并非所有场景我们都希望触发深响应,例如:

1
obj.foo.bar

当我们不希望调用第二层属性.bar时触发响应,这时我们就可以用 shallowReactive代理obj。

代理数组

数组是一个异质对象,其[[DefineOwnProperty]]的内部方法实现有所不同。

数组的读取操作:

  • arr[0]
  • arr.length
  • for…in
  • for…of
  • 原型方法:concat / join / every / some / find / findIndex / includes 等

数组的赋值操作:

  • arr[0] = 1
  • arr.length = 0
  • 栈方法:push / pop / unshift / shift
  • 改变原数组的方法:splice / fill / sort 等

遍历数组

因为数组也是对象,所以也可以用for...in遍历,但是不推荐使用。

可迭代对象:实现了Symbol.iterator方法,ECMAScript用来指定迭代器的Symbol值,它执行后会默认返回一个迭代器。

Proxy代理后会导致includes、indexOf、lastIndexOf方法异常,所以需要重写这些方法。

隐式改变数组length属性,会导致循环触发副作用函数。比如arr.push(1),所以需要重写push、pop、shift、unshift、splice 等方法。

代理 Set 和 Map

为了支持代理,需要重写Set和Map的迭代器方法

原始值的响应式方案

除Object外的其他7中类型都是原始值:Boolean / Number / BigInt / String / Symbol / undefined / null。
在JavaScript中原始值是按值传递的,而非按引用传递。形参与实参之间没有引用关系,形参的修改不会影响实参。另外,JavaScript中Proxy不提供原始类型的代理。
所以需要对原始值进行一层包裹,这就时 ref

1
2
3
4
5
6
7
8
9
// 封装一个 ref 函数
function ref(val) {
// 创建包裹对象
const wrapper = {
value: val
}
// 给包裹对象添加响应式
return reactive(wrapper)
}

这就是为什么ref包裹对象需要通过.value来取值的原因。
通过Object.defineProperty定义一个__v_isRef来区别包裹对象和普通对象。

响应丢失问题

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<p>{{ foo }} / {{ bar }}</p>
</template>

<script>
export default {
setup() {
const obj = reactive({foo: 1, bar: 2})
return { ...obj }
}
}
</script>

上面的问题我们可以通过将各个属性通过ref包装的方式解决:

1
return { ...toRefs(obj) }

自动去ref

为了用户在模板中调用时不使用foo.value,减轻用户的心智负担,可以直接用{{ foo }}调用。设计者对访问对象的get 方法做了处理:

1
2
3
4
get (target, key, receiver) {
const value = Reflect.get(target, key, receiver)
return value.__v_ifRef ? value.value : value
}

文章来源:《Vue.js设计与实现》—霍春阳

渲染器Renderer

Vue.js中包含了两大核心模块:编译器、渲染器。
渲染器是框架性能的核心,它的实现直接影响框架的性能。Vue.js 3 的渲染器不仅包含传统的Diff算法,还独创快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。

graph LR

A[Vue.js]
R[Renderer]
C[Compiler]
R1((Diff))
R2((快捷路径))
A-->R
A-->C
R-->R1
R-->R2

渲染器是用来渲染真是DOM元素的,除此之外为了支持框架跨平台的能力,还应该考虑其支持自定义的能力。

1
2
3
function renderer(domString, container) {
container.innerHTML = domString
}

与响应式系统的结合

1
2
3
4
5
6
7
8
9
10
11
12
<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

const { effect, ref } = VueReactivity

function renderer(domString, container) {
container.innerHTML = domString
}
const count = ref(1)

effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})

基本概念

名词:

  • render - 渲染
  • renderer - 渲染器
  • vdom - virtual DOM 虚拟DOM
  • vnode - virtual node,树状的虚拟DOM
  • mount - 挂载,渲染器把虚拟DOM渲染为真实DOM的过程
  • container - 容器,挂载到该DOM节点
1
2
3
4
5
6
7
8
9
10
11
12
13
function createRenderer() {
function render(vnode, container) {}
// 服务端渲染
function hydrate(vnode, container) {}
return {
render,
hydrate
}
}

const renderer = createRenderer()
// 首次渲染
renderer.render(vnode, document.querySelector('#app'))

mount & patch

根据render的执行阶段,第一次渲染叫做挂载(mount);第二次渲染叫做打补丁(patch)。
根据传入null的 vnode 还可以执行卸载操作(unmount)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createRenderer() {
function render(vnode, container) {
if (vnode) {
// 新的vnode存在,将其与旧的 vnode 对比,进行打补丁
patch(container._vnode, vnode, container)
} else {
// 旧的 vnode 存在,新的 vnode 不存在,说明是 unmount 操作
// 清空 container 内的 DOM 即可
if (container._vnode) {
container.innerHTML = ''
}
}
// 缓存本次 vnode
container._vnode = vnode
}
}

patch函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑。

1
2
3
function patch(oldVnode, newVnode, container) {
// ...
}

自定义渲染器

1
2
3
4
5
6
7
8
9
10
11
12
function createRenderer(options) {
// 通过options自定义render过程
const {
createElement,
insert,
setElementText
} = options

function mountElement(vnode, container) {}
function patch(n1, n2, container) {}
function render(vnode, container) {}
}

自定义渲染器,可以保证浏览器和Node.js环境可以根据平台切换使用。

HTML标签属性 & DOM对象属性

HTML标签属性:

1
<input class="cls" id="my-input" type="text" value="foo" />
HTML标签属性DOM对象属性
classclassName
idid
typetype
aria-valuenow
valuevalue
......
1
2
3
el.getAttribute('value') // foo
el.value // bar
el.defaultValue // foo

一个HTML标签属性可能关联多个DOM对象属性。
HTML标签属性的作用是设置与之对应的DOM对象属性的初始值。

1
2
3
4
5
6
7
8
9
10
11
12
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}

class的设置

浏览器为一个元素设置class有三种方式:

  • className
  • setAttribute
  • classList

其中el.className性能最优。

Vue.js允许对象类型的值作为class是为了方便开发者,在底层实现上,必然需要对值进行正常化后再使用。而正常化是有代价的,大量正常化操作会消耗更多的性能。

卸载unmount

container.innerHTML = null

这样暴力卸载有几个缺点:

  • 不会触发钩子函数,包括组件和指令的
  • 不会移除元素绑定的事件

所以需要封装一个unmount函数。

区分 vnode 的类型

事件的处理

伪造一个invoker,invoker.value保存上次绑定的事件,这样在更新事件时就不用先调用removeEventListener了。

事件冒泡

通过invoker.attached属性,用来存储事件处理函数被绑定的时间(高精时间performance.now),通过invoker.attached 与 e.timeStamp(事件触发的时间)比较来屏蔽所有绑定时间晚于事件触发时间的回调函数的执行。

更新子节点

子节点的数据结构有3种情况:

  • null
  • 文本
  • 数组(一个或多个子节点)

文本节点和注释节点

这两种节点不具有标签名称,所以我们需要人为创造一些唯一标识,并将其作为注释节点和文本节点的type属性值:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 文本节点的 type 标识
const Text = Symbol()
const newVnode = {
type: Text,
children: '文本'
}

// 注释节点的 type 标识
const Comment = Symbol()
const newVnode = {
type: Comment,
children: '注释'
}

Fragment

在Vue.js 2 中不允许组件模板存在多个根节点,比如:

1
2
3
4
5
<template>
<li></li>
<li></li>
<li></li>
</template>

在 Vue.js 3 中通过 Fragment,可以使用多根节点模板:

1
2
3
4
5
6
7
8
9
const Fragment = Symbol()
const vnode = {
type: Fragment,
children: [
{type: 'li', children: 'text 1'},
{type: 'li', children: 'text 2'},
{type: 'li', children: 'text 3'}
]
}

与文本和注释节点类似,Fragment也没有所谓的标签名称,因此我们也需要创建唯一标识。
Fragment本身不渲染任何内容,只会渲染Fragment的子节点。

简单的Diff算法

我们知道,操作DOM的性能开销通常比较大,而渲染器的核心Diff算法就是为了解决这个问题诞生的。

减小DOM操作性能开销

原始流程:卸载旧的子节点,再挂载新的子节点。
改进流程:先取新旧两组子节点长度较短的一组,对比更新修改,再判断删除或新增子节点。

DOM复用 & key 的作用

1
2
3
4
5
6
7
8
9
10
11
12
13
// old children
[
{type: 'p'},
{type: 'div'},
{type: 'span'}
]

// new children
[
{type: 'span'},
{type: 'p'},
{type: 'div'}
]

上面的情况仍然需要6次更新。但是经过判断,我们发现只要移动节点位置,就可以减小性能开销。
为了精确的确认新旧两组子节点中,存在相同的节点,我们采用给每个节点定义key的方式,key 属性就像虚拟节点的“身份证”号一样。

双端 Diff 算法

将新旧两组的头和尾进行比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
while (oldStartIdx <= oldEndIdx && newStartIdx <=newEndIdx) {
if (oldStartVnode.key === newStartVnode.key) {
// 调用 patch 函数再 oldStartVnode 与 newStartVnode 之间打补丁
patch(oldStartVnode, newStartVnode, container)
// 更新相关索引,指向下一个位置
oldStartVnode = oldChidren[++oldStartIdx]
newStartVnode = newChidren[++newStartIdx]
} else if (oldEndVnode.key === newEndVnode.key) {
patch(oldEndVnode, newEndVnode, container)
oldEndVnode = oldChildren[--oldEndIdx]
newEndVnode = newChildren[--newEndIdx]
} else if (oldStartVnode.key === newEndVnode.key) {
patch(oldStartVnode, newEndVnode, container)
insert(oldStartVnode.el, container, oldEndVnode.el.nextSibling)

oldStartVnode = oldChildren[++oldStartIdx]
newEndVnode = newChildren[--newEndIdx]
} else if (oldEndVnode.key === newStartVnode.key) {
patch(oldEndVnode, newStartVnode, container)
insert(oldEndVnode.el, container, oldStartVnode.el)

lodEndVnode = oldChildren[--oldEndIdx]
newStartVnode = newChildren[++newStartIdx]
}
}

使用双端Diff算法,可以进一步减少DOM操作的次数。

快速 Diff 算法

快捷路径:如果两段文本全等,那么就无须进入核心Diff算法了。

1
if (text1 === text2) return

文本的前缀和后缀

1
2
I use vue for app development
I use react fro app development

快速 Diff 算法借鉴了纯文本 Diff 算法中预处理的步骤。

graph LR
A[p-1]
B[p-1]
A --> B
B --> A
A1[p-4]
B1[p-2]
A1 --> B1
A2[p-2]
B2[p-3]
A2 --> B2
A3[p-3]

步骤一:对比前置节点
从新旧组第一项开始,直到遇到不同的节点为止。

1
2
3
4
5
6
7
8
j = 0
while(a.key === b.key) {
// 更新节点
patch(a, b, container)
j++
a = o_list[j]
b = n_list[j]
}

步骤二:对比后置节点
由于新旧两组length不同,所以需要两个索引oldEnd、newEnd。

1
2
3
4
5
6
7
8
9
oldEnd = o_list.legnth - 1
newEnd = n_list.length - 1
while(a.key === b.key) {
patch(a, b, container)
oldEnd--
newEnd--
a = o_list[oldEnd]
b = n_list[newEnd]
}

步骤三:找出新增节点
case 1:oldEnd < j
case 2:newEnd >= j

那么 newEnd >= x >= j,这之间的就是新增节点。
以新子节点组的最后一个相同的元素(n_list[newEnd + 1])为锚点,挂载 j 到 newEnd间的新增节点

1
2
3
4
5
6
7
if (j > oldEnd && j <= newEnd) {
const anchorIndex = newEnd + 1
const anchor = anchorIndex < n_list.length ? n_list[anchorIndex].el : null
while(j <= newEnd) {
patch(null, n_list[j++], container, anchor)
}
}

步骤四:找出删除节点

case 1:j > newEnd
case 2:j <= oldEnd

1
2
3
4
5
6
7
8
if (j > oldEnd && j <= newEnd) {
// 步骤四中的代码
} else if (j > newEnd && j <= oldEnd) {
// 卸载 j -> oldEnd 之间的节点
while(j <= oldEnd) {
unmount(o_list[j++])
}
}

最后一步:处理其他情况

1
2
3
4
5
6
7
if (j > oldEnd && j <= newEnd) {
// 步骤四中的代码
} else if (j > newEnd && j <= oldEnd) {
// 步骤五中的代码
} else {
// 其他情况
}

关于剩余情况的处理,值得注意的是,
简单 Diff、双端 Diff、快速 Diff 都遵循同样的处理规则:

  • 优先移动节点
  • 添加 或 删除 节点

TODO

文章来源:《Vue.js设计与实现》—霍春阳

组件的实现原理

当页面模块变得越来越大,这时我们就需要组件化的能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}

const { type } = n2

if (typeof type === 'string') {
// 普通标签元素
} else if (type === Text) {
// 文本
} else if (type === Fragment) {
// 片段
} else if (type === 'object') {
if (!n1) {
// 挂载组件
mountComponent(n2, container, anchor)
} else {
// 更新组件
patchComponent(n1, n2, anchor)
}
}
}

组件实例

通过缓存组件实例,防止每次都重新渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
function mountComponet(vnode, container, anchor) {
const instance = {
state,
isMounted: false,
subTree: null
}

if (!instance.isMounted) {
patch(null, subTree, container, anchor)
} else {
patch(instance.subTree, subTree, container, anchor)
}
}

setup

组件的setup函数时Vue.js 3 的新增组件选项。主要用于配合composition API,它用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等场景。在组件的整个生命周期中,setup只会被执行一次。

setup的返回值有两种:
1)返回函数,作为render 函数,不能和template模板共用。
2)返回对象,该对象暴露给template模板使用。

1
2
3
4
5
6
7
8
9
const Comp = {
props: {
foo: string
}
setup(props, setupContext) {
props.foo
const { slots, emit, attrs, expose } = setupContext
}
}

slots: 组件接受到的插槽
emit:一个函数,用来发射事件
attrs:那些没有显式声明的props属性
expose:一个函数,著书时该API还在设计讨论中

emit

本质上就是根据事件名称去props数据对象中寻找对应事件处理函数并执行。

1
2
3
4
5
6
7
8
9
function emit(event, ...payload) {
const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
const handler = instance.props[eventName]
if (handler) {
handler(...payload)
} else {
console.error('事件不存在')
}
}

slot插槽的工作原理

顾名思义,组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入。

插槽定义:

1
2
3
4
5
6
7
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>

使用组件时:

1
2
3
4
5
6
7
8
9
10
11
<MyComponent>
<template #header>
<h1>标题</h1>
</template>
<template #body>
<section>内容</section>
</template>
<template #footer>
<p>底部</p>
</template>
</MyComponent>

组件模板中的插槽内容会被编译成插槽函数,返回值就是具体的插槽内容。

异步组件 & 函数式组件

异步组件

其实用户可以自行实现异步组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<CompA />
<component :is="asyncComp" />
</template>

<script>
import { shallowRef } from 'vue'
import CompA from 'CompA.vue'

export default {
components: { CompA },
setup() {
const asyncComp = shallowRef(null)

// 异步加载 CompB 组件
import('CompB.vue').then(CompB => asyncComp.value = CompB)
return {
asyncComp
}
}
}
</script>

但是出于加载失败、占位内容、Loading、重试等考虑,Vue.js 3 封装了 defineAsyncComponent来异步加载组件,它是一个高阶组件,它返回值是一个包装组件。

函数式组件

函数式组件本质上就是一个普通函数,返回值是虚拟DOM。
函数式组件本身没有自身状态,但它仍然可以接收由外部传入的props:

1
2
3
4
5
6
7
function MyFuncComp(props) {
return { type: 'h1', children: props.tile }
}
// 定义 props
MyFuncComp.props = {
title: String
}

在有状态组件的基础上,就可以复用mountComponent函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
function mountComponent(vnode, container, anchor) {
// 检查是否是函数式组件
const isFunctional = typeof vnode.type === 'function'

let componentOptions = vnode.type
if (isFunctional) {
// 如果是函数式组件,则将 vnode.type 作为渲染函数
componentOptions = {
render: vnode.type,
props: vnode.type.props
}
}
}

内建组件 & 模块

Vue.js 中有几个非常重要的内建组件,例如 KeepAlive、Teleport、Transition 组件等,它们都需要渲染器级别的底层支持。理解他们的原理有助于更好的使用他们。

KeepAlive

KeepAlive 借鉴于HTTP协议,HTTP中KeepAlive 可以避免连接频繁的销毁、创建。与HTTP中相似,Vue.js 内建的 KeepAlive 可以避免一个组件被频繁的销毁、创建。
其实 KeepAlive 的本质就是缓存管理,再加上特殊的挂载/卸载逻辑。

1
2
3
4
5
<template>
<Tab v-if="currentTab === 1"></Tab>
<Tab v-if="currentTab === 2"></Tab>
<Tab v-if="currentTab === 3"></Tab>
</template>

KeepAlive 使用:

1
2
3
4
5
6
7
<template>
<KeepAlive>
<Tab v-if="currentTab === 1"></Tab>
<Tab v-if="currentTab === 2"></Tab>
<Tab v-if="currentTab === 3"></Tab>
</KeepAlive>
</template>

使用 KeepAlive 包裹的组件,在激活和失活时会分别触发 activated、deactivated 两个生命周期。

_deactivated 失活的本质是将组件所渲染后的内容移动到隐藏容器中,而activated 激活的本质是将组件的渲染后内容从隐藏容器中搬运回来。这其中有两个关键点:渲染后的内容、移动。
KeepAlive 还提供了 include、exclude 来更细粒度的控制缓存。

当缓存容量满时,Vue.js 采用“最新一次访问”的策略。

Teleport

Teleport 是 Vue.js 3 新增的内建组件。
通常情况,虚拟DOM的层级关系与真实渲染出来的真实DOM一致:

1
2
3
4
5
<template>
<div id="parent-box">
<OverLay />
</div>
</template>

其中 Overlay 渲染的DOM一定在 id="parent-box" 的 div 下。无法跨越层级。
假如有这样一个场景,将无法实现:Overlay 是一个蒙层,我们希望它遮罩在所有元素之上,但此时无论我们 z-index 设置多大都无法实现。

利用 Teleport 重新实现 Overlay:

1
2
3
4
5
6
7
8
9
10
11
<template>
<Teleport to="body">
<div class="overlay"></div>
</Teleport>
</template>

<style scoped>
.overlay {
z-index: 9999;
}
</style>

该组件会直接把它的插槽内容渲染到 body 下。

Tansition

  • 当DOM元素挂载时,将动效附加到该DOM元素上。
  • 当DOM元素卸载时,不要立即卸载,先等到DOM元素的动画执行完成后再卸载。

文章来源:《Vue.js设计与实现》—霍春阳

编译器的核心技术原理

编译技术是一门博大精深的技术,不同用途的编译技术的难度和深度都不一样。如果你要实现诸如 C、JavaScript 这类通用用途语言,那么就需要掌握较底层编译技术知识。
Vue.js 的模板和JSX都属于领域特定语言(DSL),它们的实现难度属于中、低级别,只要掌握基本的编译技术理论即可实现这些功能。

模板 DSL 编译器

编译器其实就是一段程序,它将“一种语言A” 翻译成 “另一种语言B”,也就是源代码翻译成目标代码,中间的翻译过程就叫 编译
完整的编译过程包括:词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成 等步骤。

Vue.js 模板编译过程:

graph LR
A[源代码]
B((Vue.js 编译器))
C[目标代码]
A --> B --> C
graph LR
A((模板))
A1[模板AST]
O((Transformer))
B1[JavaScript AST]
B((渲染函数))
A -->|词法分析/语法分析| A1 --> O --> B1 -->|代码生成| B

AST 是 abstract syntax tree,抽象语法树。

1
2
3
<div>
<h1 v-if="ok">Vue Template</h1>
</div>

它的AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const ast = {
// 根节点
type: 'Root',
children: [
{
// div 标签节点
type: 'Element',
tag: 'div',
children: [
// h1 标签节点
type: 'Element',
tag: 'h1',
props: [
// v-if 指令
type: 'Directive',
name: 'if',
exp: {
// 指令的表达式
type: 'Expression',
content: 'ok'
}
]
]
}
]
}
graph LR
A((模板))
A1[模板AST]
O((Transformer))
B1[JavaScript AST]
B((渲染函数))
A -->|parser| A1 --> O --> B1 -->|generator| B

parser:解析器,将模板字符串解析成模板AST。
transformer:转换器,模板AST 转换成 JavaScript AST。
generator:生成器,JavaScript AST 生成 渲染函数。

模板 转换成 AST

对于通用用途语言(GPL)来说,例如 JavaScript 这样的脚本语言,想要为其构造 AST,较常用的一种算法叫做 递归下降算法,这里面需要解决 GPL 层面才会遇到的很多问题,例如最基本的运算符优先级问题。

Vue.js 的模板构造 AST 是一件很简单的事,因为 HTML 的格式非常固定,标签之间是树形结构的,这样的结构和AST是“同构”的。

1
2
3
4
<div>
<p>Vue</p>
<p>Template</p>
</div>
1
2
3
4
5
6
7
8
9
10
const ast = {
tag: Root
childre: {
tag: div,
children: [
{ tag: p },
{ tag: p }
]
}
}

构建 AST 的过程:

graph LR
A[Tokens] --> B[Element Stack] --> C[AST]
  • 顺序遍历 parser 解析的 tokens
  • 遇到开始token,将标签 压入 标签栈
  • 添加 AST 子节点
  • 遇到结束token,标签栈 弹出 标签
  • 返回根节点
parser
1
<p>Vue</p>

解析器会把这段字符串切割为三个 Token。

  • <p>
  • Vue
  • </p>
    解析器是如何进行切割的呢?有限状态自动机
    它的工作流程是:从第一个字符开始,随着读取字符向后推移,状态机会进入不同的状态。经过一系列状态迁移的过程之后,最终得到相应的 Token。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // 定义状态机的状态
    const state = {
    initial: 1, // 初始状态
    tagOpen: 2, // 标签开始状态
    tagName: 3, // 标签名称状态
    text: 4, // 文本状态
    tagEnd: 5, // 结束标签状态
    tagEndName: 6 // 结束标签名状态
    }

    // 对模板字符串 标记化
    tokenized(str) {
    let currentState = State.initial
    const tokens = []
    const chars = []

    while(str) {
    const char = str[0]
    // 标记过程
    // ...
    str = str.slice(1)
    }

    return tokens
    }
    我们可以 通过 正则表达式 来优化 tokenized 函数的代码,实际上 正则表达式 的本质就是有限状态自动机。

解析 HTML 并构造 Token 的过程是有规范可循的。在 WHATWG 发布的关于浏览器解析 HTML 的规范中,详细阐述了状态迁移过程。

节点的访问

采用深度优先遍历算法,访问 AST 的每一个节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function transform(ast) {
const context = {
nodeTransforms: [
transformElement, // 转换标签工具函数
transformText // 转换文本工具函数
]
}
// 遍历节点
traverseNode(ast, context)
// 输出 ast 信息
console.log(dump(ast))
}

function transformElement(node) {
if (node.type === 'Element' && node.tag === 'p') {
node.tag = 'h1'
}
}

function transformText(node) {
if (node.type === 'Text') {
node.content = node.content.repeat(2)
}
}
转换上下文

Context:

  • React.createContext,允许组件树件访问该上下文
  • Vue.js 通过 provide / inject 能力,向一整棵树提供数据
  • Koa 的中间件函数通过上下文来访问相同的数据

可以看出,上下文其实就是在 某个范围内可访问的“公有变量”

转化上下文中可以保存如下数据:

  • 当前转换的节点
  • 当前转换节点的父节点
  • 当前转换节点是第几个?

模板 AST 转换为 JavaScript AST

1
2
3
4
5
6
7
8
9
10
const ast = {
tag: Root
childre: {
tag: div,
children: [
{ tag: p },
{ tag: p }
]
}
}

最终目标:

1
2
3
4
5
6
function render() {
return h('div', [
h('p', 'Vue'),
h('p', 'Template')
])
}

前面我们把模板转换成了模板 AST,然后我们需要把模板 AST转换成JavaScript AST,最后由 JavaScript AST 生成最终目标(渲染函数)

JavaScript AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const FunctionDeclareNode = {
type: 'FunctionDeclare',
id: {
type: 'Identifier',
name: 'render'
},
param: [],
body: [
{
type: 'ReturnStatement',
return: null
}
]
}

目标函数中,h函数的调用可以表示为:

1
2
3
4
5
6
7
8
9
const callExp = {
type: 'CallExpresstion',
callee: {
type: 'Identifier',
name: 'h'
},
// 参数
arguments: []
}

而它有两个参数,第一个是字符串,第二个是一个数组

1
2
3
function render() {
return h('div', [/*...*/])
}

字符串可表示为:

1
2
3
4
const Str = {
type: 'StringLiteral',
value: 'div'
}

数组可表示为:

1
2
3
4
const Arr = {
type: 'ArrayExpression',
elements: []
}

JavaScript AST 生成渲染函数代码

1
2
3
4
5
6
7
8
9
10
function compile(tempate) {
// 模板
const ast = parse(template)
// 模板 AST -> JavaScript AST
transform(ast)
// 代码生成
const code = generate(ast.jsNode)

return code
}
1
2
3
function render() {
return h('div', [h('p', 'Vue'), h('p', 'Template')])
}

解析器 Parser

  • 浏览器是如何对 HTML 进行解析的?
  • 一些特殊的状态,例如DATA、CDATA、RCDATA、RAWTEXT 等,是什么含义?
  • Vue.js 模板解析器如何处理 HTML 实体?

DATA、CDATA、RCDATA、RAWTEXT

解析器的初始模式是 DATA 模式,Vue.js 模板中不允许出现<script> 标签,因此遇到<script> 会切换到 RAWTEXT 模式。
遇到 <title><textarea> 会切换到 RCDATA 模式。
遇到 <style><xmp><iframe><noembed><noframes>noscript 会切换到 RAWTEXT 模式。
遇到 <![CDATA[ 字符串时,会切换到 CDATA 模式。该标签内部的内容不作解析,保持原样。

递归下降算法构造模板AST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAWTEXT: 'RAWTEXT',
CDATA: 'CDATA'
}

function parse(str) {
// 解析器的初始模式是 DATA 模式
const context = {
source: str,
mode: TextModes.DATA
}
// param1: 上下文, param2: 父节点构成的栈
const nodes = parseChildren(context, [])

return {
type: 'Root',
children: nodes
}
}

元素的子节点可以是以下几种:

  • 标签,如 <div>
  • 插值文本, 如 {{ val }}
  • 文本,如 text
  • 注释,如 <!-- -->
  • CDATA 节点, 如 <![CDATA[ xxx ]]

状态机

parseChildren函数本质上是一个状态机,它会开启一个 while 循环是的状态机自动运行:

1
2
3
4
5
6
7
8
9
10
function parseChildren(context, ancestors) {
let nodes = []
const { mode } = context
// 运行状态机
while(!isEnd(context, ancestors)) {
// ...
}

return nodes
}

停止状态机:

  • 模板内容解析完毕
  • 解析到顶栈节点时结束
    1
    2
    3
    4
    5
    6
    7
    8
    function isEnd(context, ancestors) {
    if (!context.source) return true

    const parent = ancestors[ancestors.length - 1]
    if (parent && context.source.startWith(`</${parent.tag}`)) {
    return true
    }
    }

解析标签节点

解析属性

解析文本

解码命名字符引用

1
2
3
4
5
6
7
8
9
10
<div>A$lt;B</div>

// namedCharacterReference,共2000+
{
'GT': '>',
'gt': '>',
'LT': '<',
'lt': '<',
// ...
}

解码数字字符引用

数字字符引用的格式:前缀 + Unicode 码点。

1
2
3
4
const CCR_REPLACEMENTS = {
0x80: 0x20ac,
// ...
}

解析插值 & 注释

1
2
3
4
5
6
7
8
9
10
11
12
13
// 插值节点
{
type: 'Interpolation',
content: [
type: 'Expression',
content: ' bar '
]
}
// 注释节点
{
type: 'Comment',
content: ' commnet content '
}

编译优化

优化的一般方向:尽可能地区分动态内容和静态内容,并针对不同的内容采用不同的策略。

在渲染阶段,虚拟DOM是为了减少操作真实DOM产生的性能开销,那么如何进一步减少虚拟DOM产生的性能开销?
虚拟DOM会进行Diff算法来优化操作DOM的次数,但是有的情况并没有必要进行Diff比较,而这些 不需要Diff 的情况,可以在编译阶段标记,并传递给渲染器 Renderer。

Block & patchFlag

1
2
3
4
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>

理想情况下,我们只需要更新 p 标签的文本节点即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const vnode ={
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
// 通过 patchFlag 标识这是动态节点
{ tag: 'p', children: ctx.bar, patchFlag: 1 }
]
}

const PATCH_FLAGS = {
TEXT: 1, // 文本会动态变化
CLASS: 2, // class会动态变化
STYLE: 3, // style会动态变化
// ...
}

将动态子节点存到 dynamicChildren 数组内:

1
2
3
4
5
6
7
8
9
10
11
12
const vnode ={
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
// 通过 patchFlag 标识这是动态节点
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlag.TEXT }
],
dynamicChildren: [
// Block 可以收集所有动态子节点
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlag.TEXT }
]
}

收集动态节点

1
2
3
4
5
6
7
8
9
10
11
render() {
return createVNode('div', {}, [
createVNode('div', {}, [
createVNode('div', {}, [
createVNode('div', {}, [
createVNode('div', { class: 'bar' }, text, PatchFlags.TEXT)
])
])
])
])
}

当外层createVNode 函数执行时,内层的 createVNode 函数已经执行完毕。因此,为了让外层 Block 节点能收集到内层动态节点,就需要一个栈结构的数据来临时存储内层的动态节点:

1
2
3
4
5
6
7
8
const dynamicChildrenStack = []
let currentDynamicChildren = null
function openBlock() {
dynamickChildrenStack.push(currentDynamicCHildren = [])
}
function closeBlock() {
currentDynamicCHildren = dynamicChildrenStack.pop()
}

渲染器的运行时支持

有了动态节点集合 vnode.dynamicChildrenpathFlag,就可以在渲染器中实现按需更新了。

静态提升

将纯静态节点的创建提升到渲染函数外,这样渲染函数内就是静态节点的引用,当响应式数据变化重新渲染时,就不会重新创建静态节点,从而避免额外的性能开销。

1
2
3
4
5
6
7
8
9
// 静态节点创建提升
const staticNode = createVNode('p', null, 'text')

function render() {
return (openBlock(), createBlock('div', null, [
staticNode,
createVNode('p', null, ctx,title, PatchFlags.TEXT)
]))
}
预字符串化

大量的静态节点,又可以进一步将其序列化为一个字符串,通过innerHTML进行设置。

1
2
3
4
5
6
7
<div>
<p></p>
<p></p>
<p></p>
// ...
<p></p>
</div>
1
2
3
4
5
const staticHTML = createStaticVNode('<p></p><p></p><p></p><p></p>')

render() {
return (openBlock(), createBlock('div', null, [ staticHTML ]))
}

这么做的优势:

  • 大块静态内容通过 innerHTML 设置,减少性能消耗
  • 减少创建虚拟节点的开销
  • 减少内存开销

缓存内联事件处理函数

v-once

绑定v-once 缓存组件的创建,从而提升更新性能。

文章来源:《Vue.js设计与实现》—霍春阳

同构渲染

Vue.js 可以在浏览器运行,也可以在 Node.js 环境中运行。
Vue.js 作为现代前端框架,不仅能够独立地进行CSR 或 SSR,还能够两者结合,形成所谓的同构渲染(isomorphic rendering)。

SSR

graph LR
A[浏览器] --> B((服务器)) --> C[(数据库)]
C --> B --> A

CSR

有别于服务端渲染的服务器直接返回 生成好的HTML 内容,客户端渲染则只从服务器获取css/js 文件资源,然后在客户端(浏览器)执行文件,在 浏览器生成HTML 页面内容。

同构渲染

那么,我们能否融合 SSR 与 CSR 两者的优点于一身呢?答案是:“可以的”。
同构 一词的含义是:同样一套代码即可在服务端运行,也可以在客户端运行。

首次渲染:整个页面的内容是在服务端完成渲染的,不同的是同构渲染会把 数据静态HTML页面 一并发送给客户端。

在解析到静态HTML页面中 <link><script> 后,浏览器会从服务端或者 CDN 获取资源,这一步与 CSR 一致。在 JavaScript 资源加载完毕后,会进行激活操作。

在 Vue.js 中,这包含2步:

  • 已经渲染的DOM 与 虚拟DOM建立关联
  • 提取服务端序列化后的数据,用以初始化整个 Vue.js 应用

激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。

将虚拟DOM 渲染为 HTML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ElementVNode = {
type: 'div',
props: {
id: 'foo'
},
children: [
{ type: 'p', children: 'hello' }
]
}

function renderElementVNode(vnode) {
// 返回渲染后的 HTML 字符串
// <html><div> text </div></html>
}
将组件渲染为 HTML
1
2
3
4
5
6
function renderComponentVNode(vnode) {
let { type: { setup } } = vnode
const render = setup()
const subTree = render()
return renderElementVNode(subTree)
}
客户端激活

对于同构渲染来说,组件代码会在服务端和客户端分别执行一次。
当组件代码的客户端执行时,会再次创建 DOM 元素吗?答案是“不会”。
这时客户端会做2件事:

  • 建立页面中 DOM 元素与虚拟节点对象的关联
  • 绑定 DOM 事件

具体实现:真实 DOM 元素与虚拟 DOM 对象都是树形结构,并且节点之间存在一一对应的关系,因此,我们可以认为它们是“同构”的。

1
renderer.hydrate(vnode, container)
1
2
3
4
5
6
const html = renderComponentVNode(compVNode)

const container = document.querySelector('#app')
container.innerHTML = html

renderer.hydrate(compVNode, container)
1
2
3
function hydrate(vnode, container) {
hydrateNode(container.firstChild, vnode)
}
1
2
3
4
5
6
7
8
function hydrateNode(node, vnode) {
const { type } = vnode
// 让vnode.el 引用 真实 DOM
vnode.el = node

// 返回下一个兄弟节点,以便继续激活
return node.nextSibling
}
编写同构代码

因为需要代码能在客户端和服务端都能运行,所以应该额外注意不同运行环境中代码实现的差异。

区分环境

1
2
3
4
5
6
7
created() {
if (!import.meta.env.SSR) {
this.timer = setInterval(() => {
// ...
}, 1000)
}
}

使用跨平台的API

1
2
3
if (!import.meta.env.SSR) {
window.xxx
}

只在某一端引入模块

避免多用户请求的状态污染

<ClientOnly>组件