文章

前端开发整理(一): 前端工程化及准备

前端开发整理(一): 前端工程化及准备

前端工程化

前端工程化需要遵循的一些规范

前端工程化代码的常用结构:

1
2
3
4
5
src -> 目录
src/main.ts -> 入口文件
src/views -> 路由组件目录
src/components -> 子组件目录
src/router -> 路由目录

在写属性时,尽可能一个属性占一行;同时对多种正确的语法方式要确定一种作为标准

Vue.js与工程化

Vue 的编程方式被称为 “数据驱动” 编程,通过操作虚拟 DOM ( Virtual DOM ,简称 VDOM ),每一次数据更新都通过 Diff 算法找出需要更新的节点,只更新对应的虚拟 DOM ,再去映射到真实 DOM 上面渲染,以此避免频繁或大量的操作真实 DOM 。

DOM(Document Object Model,文档对象模型) DOM(文档对象模型,Document Object Model)是浏览器用来表示和操作网页内容的一种树状结构模型
DOM 把网页看作一个由节点组成的树状结构,页面上的每一个元素(如<div><p>、文本、属性等)都被看作是一个节点(Node),JavaScript 可以通过 DOM 来动态访问、修改网页结构、样式和内容。
这是一个html示例:

1
2
3
4
5
6
<html>  
  <body>  
    <h1>Hello</h1>  
    <p>World</p>  
  </body>  
</html>  

这是对应的css示例:

1
2
3
4
5
6
7
Document  
└── html  
    └── body  
        ├── h1  
           └── "Hello"  
        └── p  
            └── "World"  

进而则可以用javascript修改:

1
document.querySelector('h1').textContent = 'Hi!';  

虚拟 DOM 则是指将原本应该是真实 DOM 元素的 UI 界面,用数据结构来组织起完整的 DOM 结构,再同步给真实 DOM 渲染,减少浏览器的回流与重绘。

Vue 3.0 版本还引入了组合式 API 的概念,更符合软件工程 “高内聚,低耦合” 的思想,让开发者可以更灵活的管理自己的逻辑代码,更方便的进行抽离封装再复用。

现代化开发理念

MPA与SPA

MPA(Multi-Page Application,多页面应用)

MPA 多页面应用是最传统的网站体验,当一个网站有多个页面时,会对应有多个实际存在的 HTML 文件,访问每一个页面都需要经历一次完整的页面请求过程。

这种方法的优点:

  • 首屏加载速度快:MPA 页面源码写在 HTML 文件里, HTML 文件被访问成功,内容随即呈现
  • SEO友好:因为网页的内容也影响收录,而MPA除TKD外网页其它内容也在HTML内
  • 容易与服务端语言结合:传统的页面都由服务端直出,可用 PHP 、 JSP 、 ASP 、 Python 等非前端语言或技术栈来编写页面模板,最终输出 HTML 页面到浏览器访问

这种方法的缺点:

  • 页面之间的跳转访问速度慢:每一次页面访问都需要完整的经历一次渲染过程
  • 用户体验不够友好:如果网页上资源较多或网速不好,就会明显卡顿或布局错乱
  • 开发成本高:传统的多页面模式缺少前端工程化的很多优秀技术栈支持,无法做到前后端分离来利用好跨岗位协作

TKD
网页的 TKD 三要素是指一个网页的三个关键信息,含义如下:

  1. T ,指 Title ,网站的标题,即网页的 网站的标题 标签。
  2. K ,指 Keywords ,网站的关键词,即网页的 标签。
  3. D ,指 Description ,网站的描述,即网页的 标签。

SPA(Single-Page Application,单页面应用)

SPA 单页面应用是现代化的网站体验,不论站点内有多少个页面,在 SPA 项目实际上只有一个 HTML 文件,也就是 index.html 首页文件。它只有第一次访问的时候才需要经历一次完整的页面请求过程,之后的每个内部跳转或者数据更新操作,都是通过 AJAX 技术来获取需要呈现的内容并只更新指定的网页位置。

SPA 在页面跳转的时候,地址栏也会发生变化,主要有以下两种方式:

  • 过修改 Location:hash 修改 URLHash 值(也就是 # 号后面部分),例如从 https://example.com/#/foo 变成 https://example.com/#/bar
  • 通过 History APIpushState 方法更新 URL ,例如从 https://example.com/foo 变成 https://example.com/bar

SPA 的优点:

  • 只有一次完全请求的等待时间(首屏加载)
  • 用户体验好,内部跳转的时候可以实现 “无刷切换”
  • 不需要重新请求整个页面,切换页面的时候速度更快
  • 没有脱离当前页面,“页” 与 “页” 之间在切换过程中支持动画效果
  • 脱离了页面跳页面的框架,整个网站形成一个 Web App ,更接近原生 App 访问体验
  • 开发效率高,前后端分离,后端做 API 接口,前端做界面和联调,同步进行缩短工期

SPA 的缺点:

  • 首屏加载相对较慢:由于 SPA 应用的路由是由前端控制, SPA 在打开首页后,还要根据当前的路由再执行一次内容渲染
  • 不利于 SEO 优化:对搜索引擎来说,网站的内容再丰富,依然只是一个 “空壳” ,无法让搜索引擎进行内容爬取

CSR与SSR

CSR(Client-Side Rendering,客户端渲染)

一种利用 AJAX 的技术,把渲染工作从服务端转移到客户端完成,不仅客户端的用户体验更好,前后端分离的开发模式更加高效;但其具有SPA首屏加载较慢、不利于 SEO 优化等缺点。

SSR(Server-Side Rendering,服务端渲染)

现代前端工程化里的 SSR 通常是指使用 Node.js 作为服务端技术栈;SSR 服务端渲染交给前端开发者来维护,利用 Node 提供的能力进行同构渲染,本身前后端都使用 JavaScript 编写,维护成本也大大的降低。

SSR 技术利用的同构渲染方案( Isomorphic Rendering ),指的是一套代码不仅可以在客户端运行,也可以在服务端运行,在一些合适的时机先由服务端完成渲染( Server-Side Rendering )再直出给客户端激活( Client-Side Hydration ),其优势在于:

  • 更好的 SEO 支持,解决了 SPA 单页面应用的痛点
  • 更快的首屏加载速度,保持了 MPA 多页面应用的优点
  • 和 SPA 一样支持前后端分离,开发效率依然很高
  • 更好的客户端体验,用户完全打开页面,本地访问过程中也可保持 SPA 单页面应用体验
  • 统一的心智模型,由于支持同构,因此没有额外的心智负担

Pre-Rendering 与 SSG

Pre-Rendering(预渲染)

预渲染只在构建的时候就完成了页面内容的输出(发生在用户请求前),因此构建后不论用户何时访问, HTML 文件里的内容都是构建的时候的那份内容,所以预渲染适合一些简单的、有一定的 SEO 要求但对内容更新频率没有太高要求、内容多为静态展示的页面;例如企业用于宣传的官网页面、营销活动的推广落地页都非常适合使用预渲染技术,现代的构建工具都提供了预渲染的内置实现。

SSG(Static-Site Generation, 静态站点生成)

基于预渲染技术,通过开放简单的 API 和配置文件,让开发者实现一个预渲染静态站点的技术方案,作为一些开箱即用的技术产品来简化开发过程中的繁琐步骤。

常见的 SSG 静态站点生成器有:基于 Vue 技术的 VuePress 和 VitePress ,自带了 Vue 组件的支持,还有基于 React 的 Docusaurus ,以及很多各有特色的生成器,例如 Jekyll 、 Hugo 等等。

ISR 与 DPR

ISR 增量式的网站渲染,通过区分 “关键页面” 和 “非关键页面” 进行构建,优先预渲染 “关键页面” 以保证内容的最新和正确,同时缓存到 CDN ,而 “非关键页面” 则交给用户访问的时候再执行 CSR 客户端渲染,并触发异步的预渲染缓存到 CDN;DPR 分布式的持续渲染则是为了解决 ISR 方案下可能访问到旧内容的问题。

与Web前端关联的工程化开发

服务端开发

传统的认知里,如果一个前端工程师想自己搭建一个服务端项目,需要学习 Java 、 PHP 、 Go 等后端语言,还需要学习 Nginx 、 Apache 等 Web Server 程序的使用,并使用这些技术来开发并部署一个项目的服务端;现在的前端工程师可以利用 Node.js ,单纯使用 JavaScript 或者 TypeScript 来开发一个基于 Node 的服务端项目

在 GitHub 开源社区也诞生了很多更方便的、开箱即用、功能全面的服务端框架,根据它们的特点,可以简单归类如下:

  • 以 Express 、 Koa 、 Fastify 为代表的轻量级服务端框架, “短平快” ,对于服务端需求不高,但可能出现服务端搭的很乱以至于难以维护的情况
  • 以 Nest (底层基于 Express ,可切换为 Fastify )、 Egg (基于 Koa )为代表的基于 MVC 架构的企业级服务端框架,基于底层服务进行了更进一步的架构设计并实现了代码分层,还自带了很多开箱即用的 Building Blocks ,开箱即用,对大型项目的开发更加友好

学习注意
Node.js 所做的事情是解决服务端程序部分的工作,如果涉及到数据存储的需求,学习 MySQL 和 Redis 的技术知识还是必不可少的。

App 开发

Hybrid App 的出现,使得前端开发者也可以使用 JavaScript / TypeScript 来编写混合 App ,只需要了解简单的打包知识,就可以参与到一个 App 的开发工作中;在 App 开发完毕后,使用 Hybrid 框架提供的 CLI 工具编译出 App 资源包,再根据框架提供的原生基座打包教程去完成 Android / iOS 的安装包构建,这个环节会涉及到原生开发的知识。

桌面程序开发

放在以前要开发一个 Windows 桌面程序,需要用上 QT / WPF / WinForm 等技术栈,还要学习 C++ / C# 之类的语言,对于只想在业余写几个小工具的开发者来说,上手难度和学习成本都很高,但在前端工程化的时代里,使用 JavaScript 或 TypeScript 也可以满足程序开发的需要。

这得益于 Electron / Tauri 等技术栈的出现,其中 Electron 的成熟度最高、生态最完善、最被广泛使用,除了可以构建 Windows 平台支持的 .exe 文件之外,对 macOS 和 Linux 平台也提供了对应的文件构建支持。

Electron 的底层是基于 Chromium 和 Node.js ,它提供了两个进程供开发者使用:

  • 主进程:它是整个应用的入口点,主进程运行在 Node 环境中,可以使用所有的 Node API ,程序也因此具备了和系统进行交互的能力,例如文件的读写操作
  • 渲染进程:负责与用户交互的 GUI 界面,基于 Chromium 运行,所以开发者得以使用 HTML / CSS / JavaScript 像编写网页一样来编写程序的 GUI 界面

一个程序应用只会有一个主进程,而渲染进程则可以根据实际需求创建多个,渲染进程如果需要和系统交互,则必须与主进程通信,借助主进程的能力来实现。

在构建的时候, Electron 会把 Node 和 Chromium 一起打包为一个诸如 .exe 这样的安装文件(或者是包含了两者的免安装版本),这样用户不需要 Node 环境也可以运行桌面程序。

应用脚本开发

构建一种拥有可视化 GUI 界面的程序,但有时候并不需要复杂的 GUI ,可能只想提供一个双击运行的脚本类程序给用户,现在的前端工程化也支持使用 JavaScript 构建一个无界面的应用脚本。

这里推荐一个工具 Pkg ,它可以把 Node 项目打包为一个可执行文件,支持 Windows 、 macOS 、 Linux 等多个平台,它的打包机制和 Electron 打包的思路类似,也是通过把 Node 一起打包,让用户可以在不安装 Node 环境的情况下也可以直接运行脚本程序。

实践工程化的流程与工具

实践工程化的流程

基于 Vue 3 的项目,目前广泛采用的方案包括:

常用方案 Runtime 构建工具 前端框架
方案一 Node Webpack Vue
方案二 Node Vite Vue

方案一是比较传统并且过去项目使用最多的方案组合,但从 2021 年初随着 Vite 2.0 的发布,伴随着更快的开发体验和日渐丰富的社区生态,新项目很多都开始迁移到方案二。

Node.js介绍

Node.js(简称 Node)是一个基于 Chrome V8 引擎构建的 JS 运行时(JavaScript Runtime);它让 JavaScript 代码不再局限于网页上,还可以跑在客户端、服务端等场景。

Node 的巨大优势在于,使用一种语言就可以编写所有东西(前端和后端),不再花费很多精力去学习各种各样的开发语言。

Runtime的解释
Runtime ,可以叫它 “运行时” 或者 “运行时环境” ,这个概念是指,项目的代码在哪里运行,哪里就是运行时。传统的 JavaScript 只能跑在浏览器上,每个浏览器都为 JS 提供了一个运行时环境,可以简单地把浏览器当成一个 Runtime ,Node 就是一个让 JS 可以脱离浏览器运行的环境。

工程化的构建工具

目前已经有很多流行的构建工具,例如: Grunt 、 Gulp 、 Webpack 、 Snowpack 、 Parcel 、 Rollup 、 Vite … 每一个工具都有自己的特色。构建工具通常集 “语言转换 / 编译” 、 “资源解析” 、 “代码分析” 、 “错误检查” 、 “任务队列” 等非常多的功能于一身。

实际的项目里,要用到的 JavaScript 原生方法非常多,不可能手动去维护每一个方法的兼容性,所以这部分工作,通常会让构建工具来自动化完成,常见的方案就有 Babel 。在实际的开发中,构建工具可以更好地提高开发效率、提供自动化的代码检查、规避上线后的生产风险。

开发环境和生产环境

对构建工具而言,会有 “开发环境(development)” 和 “生产环境(production)” 之分。生产环境和开发环境最大的区别就是稳定:除非再次打包发布,否则不会影响到已部署的代码。

工程化的前期准备

命令行工具

在前端工程化开发过程中,已经离不开各种命令行操作,例如:管理项目依赖、本地服务启动、打包构建,还有拉取代码 / 提交代码这些 Git 操作等等。

命令行界面( Command-line Interface ,缩写 CLI ),是一种通过命令行来实现人机交互的工具,需要提前准备好命令行界面工具。

在Windows端,可以使用CMD或Windows PowerShell,推荐使用Windows Terminal或CMDer

名称 简介 下载
Windows Terminal 由微软推出的强大且高效的 Windows 终端 Windows Terminal下载
CMDer 一款体验非常好的 Windows 控制台模拟器 MDer下载

Node.js

Node.js的安装

在 Node.js 官网提供了安装包的下载,直接下载安装包并运行即可安装到的电脑里,就可以用来开发项目。在官网下载完后,则可以通过在命令行中输入node -v验证是否安装成功。

基础 Node 项目构建的知识

项目初始化

让一个项目称为Node项目,可以采取下述方案:

1
2
3
4
5
6
7
# 进入项目所在目录
cd <your-reposity-path>
# 执行初始化命令
npm init
# 按照情况填写项目信息,出现的(demo)为Node推荐的回应
# 也可以直接加上-y参数,通过Node快速生成项目信息
npm init -y

结果案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "name": "hello-node",
  "version": "1.0.0",
  "description": "demo about Node.js.",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "XXX",
  "license": "MIT"
}


Is this OK? (yes)

了解package.json

完成项目的初始化后,项目根目录下出现了名为 package.json 的 JSON 文件。

打开文件后可以得到初始化过程中得到的那些答案,如:

1
2
3
4
5
6
7
8
9
10
11
{
  "name": "hello-node",
  "version": "1.0.0",
  "description": "A demo about Node.js.",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "chengpeiquan",
  "license": "MIT"
}

package.json 的字段并非全部必填,唯一的要求就是,必须是一个 JSON 文件,所以也可以仅仅写入{}。但在实际的项目中,往往需要填写更完善的项目信息,除了手动维护这些信息之外,在安装 npm 包等操作时, Node 也会写入数据到这个文件里,以下是一些常用字段的含义:

字段名 含义
name 项目名称,如果打算发布成 npm 包,它将作为包的名称
version 项目版本号,如果打算发布成 npm 包,这个字段是必须的,遵循 语义化版本号 的要求
description 项目的描述
keywords 关键词,用于在 npm 网站上进行搜索
homepage 项目的官网 URL
main 项目的入口文件
scripts 指定运行脚本的命令缩写,常见的如 npm run build 等命令就在这里配置,详见 脚本命令的配置
author 作者信息
license 许可证信息,可以选择适当的许可证进行开源
dependencies 记录当前项目的生产依赖,安装 npm 包时会自动生成,详见:依赖包和插件
devDependencies 记录当前项目的开发依赖,安装 npm 包时会自动生成,详见:依赖包和插件
type 配置 Node 对 CJS 和 ESM 的支持

最后的 type 字段是涉及到模块规范的支持,它有两个可选值:

  • commonjs:当不设置或者设置为 commonjs 时,扩展名为 .js 和 .cjs 的文件都是 CommonJS 规范的模块,如果要使用 ES Module 规范,需要使用 .mjs 扩展名
  • module:当设置为 module 时,扩展名为 .js.mjs 的文件都是 ES Module 规范的模块,如果要使用 CommonJS 规范,需要使用 .cjs 扩展名

项目名称规则

如果计划发布为npm包,则包名可以为普通包名,也可以为范围包包名。发布前可以用npm view <package-name>查看包名是否已存在。包名的类型和数学规则如下:

类型 释义 例子
范围包 具备 @scope/project-name 格式,一般有一系列相关的开发依赖之间会以相同的 scope 进行命名 如 @vue/cli 、 @vue/cli-service 就是一系列相关的范围包
普通包 其他命名都属于普通包 如 vue 、 vue-router
  • 名称必须保持在 1 ~ 214 个字符之间(包括范围包的 @scope/ 部分)
  • 只允许使用小写字母、下划线、短横线、数字、小数点(并且只有范围包可以以点或下划线开头)
  • 包名最终成为 URL 、命令行参数或者文件夹名称的一部分,所以名称不能包含任何非 URL 安全字符

语义化版本号管理

Node项目和Vue都遵循了语义化版本号的发布规则。版本号的格式为: Major.Minor.Patch (简称 X.Y.Z ),它们的含义和升级规则如下:

英文 中文 含义
Major 主版本号 当项目作了大量的变更,与旧版本存在一定的不兼容问题
Minor 次版本号 做了向下兼容的功能改动或者少量功能更新
Patch 修订号 修复上一个版本的少量 BUG

一般情况下,三者均为正整数,并且从 0 开始,遵循这三条注意事项:

  • 当主版本号升级时,次版本号和修订号归零
  • 当次版本号升级时,修订号归零,主版本号保持不变
  • 当修订号升级时,主版本号和次版本号保持不变

脚本命令的配置

在工作中,会频繁接触到 npm run dev 启动开发环境、 npm run build 构建打包等操作,这些操作其实是对命令行的一种别名。它在 package.json 里是存放于 scripts 字段,以 [key: string]: string 为格式的键值对存放数据( key: value )。

其中:

  • key 是命令的缩写,也就是 npm run xxx 里的 xxx ,如果一个单词不足以表达,可以用冒号 : 拼接多个单词,例如 mock:list 、 mock:detail 等等
  • value 是完整的执行命令内容,多个命令操作用 && 连接,例如 git add . && git commit

以 Vue CLI 创建的项目为例,它的项目 package.json 文件里就会包括了这样的命令,这里的名字也是可以自定义的;如果 value 部分包含了双引号 " ,必须使用转义符 \ 来避免格式问题:

1
2
3
4
5
6
{
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
    }
}

案例:Hello Node流程设计

可以参照如下方案创建Hello Node的实验型项目:

  1. 创建index.js文件并书写javascript代码console.log(‘Hello World’)
  2. 打开 package.json 文件,修改 scripts 部分,配置 “dev”: “node index” 命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 首先创建并进入到目录
mkdir hello-node
cd hello-node
# 然后执行项目初始化
npm init -y
# 创建index.js文件并书写javascript代码;然后修改scripts部分(如下)
# 最后在命令行执行命令
npm run dev

# 从而得到如下输出
npm run dev

> demo@1.0.0 dev
> node index

Hello World
1
console.log('Hello World')
1
2
3
4
5
6
7
8
9
10
11
12
{
  "name": "hello-node",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "node index"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

模块化设计要求

模块和包是 Node 开发最重要的组成部分,不管是全部自己实现一个项目,还是依赖各种第三方轮子来协助开发,项目的构成都离不开这两者。而模块化的要求就是遵循“单一职责”原则,按照职责去划分好代码块再进行组合,形成一个 “高内聚,低耦合” 的工程设计。

在前端工程里,每个单一职责的代码块,就叫做模块( Module ) ,模块有自己的作用域,功能与业务解耦,非常方便复用和移植。

目前两种最主流的模块化机制是:CJS(CommonJS,适用Node端)和ESM(ES Module,适用Node 端和浏览器)。

CommonJS设计模块

  1. 针对目录结构,需要调整为如下格式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
     hello-node
     │ # 源码文件夹
     ├─src
     │ │ # 业务文件夹
     │ └─cjs
     │   │ # 入口文件
     │   ├─index.cjs
     │   │ # 模块文件
     │   └─module.cjs
     │ # 项目清单
     └─package.json
    
  2. 然后修改package.json中的scripts部分:

    1
    2
    3
    4
    5
    
     {
         "scripts": {
             "dev:cjs": "node src/cjs/index.cjs"
         }
     }
    
  3. 基本语法:CJS 使用 module.exports 语法导出模块,可以导出任意合法的 JavaScript 类型;默认导出的意思是,一个模块只包含一个值;导入默认值,则指导入时声明的变量是对应模块的值,如:

    1
    2
    3
    4
    5
    
     // src/cjs/module.cjs
     module.exports = 'Hello World'
     // ssrc/cjs/index.cjs
     const m = require('./module.cjs')
     console.log(m)
    

    然后在命令行输入npm run dev:cjs则可成功输出Hello World信息:

    1
    2
    3
    4
    5
    6
    
     npm run dev:cjs
    
     > demo@1.0.0 dev:cjs
     > node src/cjs/index.cjs
    
     Hello World
    

    这里可以看到,导入模块时声明的变量m拿到的值,即为整个模块内容,可直接使用;同理也可以将导入和导出改为函数,发现结果一致,只是打印行为在模块中定义

    1
    2
    3
    4
    5
    6
    7
    
     // src/cjs/module.cjs
     module.exports = function foo() {
     console.log('Hello World')
     }
     // ssrc/cjs/index.cjs
     const m = require('./module.cjs')
     m()
    

    默认导出的时候,一个模块只包含一个值,有时候如果想把很多相同分类的函数进行模块化集中管理,就可以用到命名导出,这样既可以导出多个数据,又可以统一在一个文件里维护管理;命名导出是先声明多个变量,然后通过 {} 对象的形式导出。需要通过 m.foo()m.bar 的形式才可以拿到值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
     // src/cjs/module.cjs
     function foo() {
     console.log('Hello World from foo.')
     }
    
     const bar = 'Hello World from bar.'
    
     module.exports = {
     foo,
     bar,
     }
     // src/cjs/index.cjs
     const m = require('./module.cjs')
     console.log(m)
    

    从而得到输出:

    1
    2
    3
    4
    5
    6
    
     npm run dev:cjs
    
     demo@1.0.0 dev:cjs
     node src/cjs/index.cjs
    
     { foo: [Function: foo], bar: 'Hello World from bar.' }
    

    有时候不同的模块之间也会存在相同命名导出的情况,则需要导入时重命名;该过程在导入模块进行实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
     // src/cjs/index.cjs
     const {
     foo: foo2,  // 这里进行了重命名
     bar,
     } = require('./module.cjs')
    
     // 就不会造成变量冲突
     const foo = 1
     console.log(foo)
    
     // 用新的命名来调用模块里的方法
     foo2()
    
     // 这个不冲突就可以不必处理
     console.log(bar)
    

    从而可以得到以下结果:

    1
    2
    3
    4
    5
    6
    7
    8
    
     npm run dev:cjs
    
     > demo@1.0.0 dev:cjs
     > node src/cjs/index.cjs
    
     1
     Hello World from foo.
     Hello World from bar.
    

ES Module设计模块

ES Module(ESM) 是新一代的模块化标准,它是在 ES6( ECMAScript 2015 )版本推出的,是原生 JavaScript 的一部分。因为历史原因,如果要直接在浏览器里使用该方案,在不同的浏览器里会有一定的兼容问题,需要通过 Babel 等方案进行代码的版本转换。

  1. 目录结构设置,案例如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
     hello-node
     │ # 源码文件夹
     ├─esm
     │   │ # 入口文件
     │   ├─index.mjs
     │   │ # 模块文件
     │   └─module.mjs
     │
     │ # 项目清单
     └─package.json
    
  2. 修改package.json里的scripts部分,增加ESM版本的script,案例如下:

    1
    2
    3
    4
    5
    
     {
         "scripts": {
             "dev:esm": "node src/esm/index.mjs"
         }
     }
    
  3. 基本语法:ESM 使用 export default (默认导出)和 export (命名导出)这两个语法导出模块,可以导出任意合法的 JavaScript 类型;使用 import ... from ... 导入模块,导入时,如果文件扩展名是 .js 则可省略文件名后缀,否则需要把扩展名也完整写出来。

    ESM 的默认导出也是一个模块只包含一个值,导入时声明的变量名,它对应的数据就是对应模块的值。

    1
    2
    3
    4
    5
    
     // src/esm/module.mjs
     export default 'Hello World'
     // src/esm/index.mjs
     import m from './module.mjs'
     console.log(m)
    

    ESM 的但命名导出和CJS完全不同;方式是通过 export 对数据进行命名导出,先将 src/esm/module.mjs 文件修改成如下代码,然后通过 export 命名导出的方式,使用大括号将它们进行命名导入,或通过调用对象属性的方式 使用这些模块:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
     // src/esm/module.mjs
     export function foo() {
     console.log('Hello World from foo.')
     }
     export const bar = 'Hello World from bar.'
    
     // src/esm/index.mjs
     import { foo, bar } from './module.mjs'
     foo()
     console.log(bar)
    
     //另外一种方式,将所有的命名导出都挂在了 m 变量上
     import * as m from './module.mjs'
    
     console.log(typeof m)
     console.log(Object.keys(m))
    
     m.foo()
     console.log(m.bar)
    

    面对相同命名导出的问题,ESM 的重命名方式和 CJS 是完全不同的,它是使用 as 关键字来操作,语法为 <old-name> as <new-name>,如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
     // src/esm/index.mjs
     import {
     foo as foo2,  // 这里进行了重命名
     bar
     } from './module.mjs'
    
     // 就不会造成变量冲突
     const foo = 1
     console.log(foo)
    
     // 用新的命名来调用模块里的方法
     foo2()
     console.log(bar)
    
  4. 在浏览器访问ESM:ES Module 除了支持在 Node 环境使用,还可以和普通的 JavaScript 代码一样在浏览器里运行。要在浏览器里体验 ESM ,需要使用现代的主流浏览器(如 Chrome ),并注意其访问限制。因为浏览器对 JavaScript 的安全性要求,会触发 CORS 错误,本地开发不能直接通过 file:// 协议在浏览器里访问本地 HTML 内引用的 JS 文件,所以需要启动本地服务并通过 http:// 协议访问。

    CORS
    CORS (全称 Cross-Origin Resource Sharing) 是指跨源资源共享,可以决定浏览器是否需要阻止 JavaScript 获取跨域请求的响应。
    现代浏览器默认使用 “同源安全策略” ,这里的 “源” 指 URL 的 origin 部分,例如网页可以通过 window.location.origin 获取到如 https://example.com 这样格式的数据,就是网页的 origin 。
    默认情况下,非同源的请求会被浏览器拦截,最常见的场景是通过 XHR 或者 Fetch 请求 API 接口,需要网页和接口都部署在同一个域名才可以请求成功,否则就会触发跨域限制。
    如果网页和接口不在同一个域名,例如网页部署在 https://web.example.com ,接口部署在 https://api.example.com ,此时需要在 https://api.example.com 的 API 服务端程序里,配置 Access-Control-Allow-Origin: * 允许跨域请求( * 代表允许任意外域访问,也可以指定具体的域名作为白名单列表)。

ESM添加服务端程序

服务端添加

在 hello-node 项目的根目录下创建名为 server 的文件夹(与 src 目录同级),并添加 index.js 文件,同时,需要在服务端响应文件内容时,将其 MIME Type 设置为 和 JavaScript 文件一样,并且需要注意传递给 readFileSync API 的文件路径是否与真实存在的文件路径匹配。例如:

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
// server/index.js
const { readFileSync } = require('fs')
const { resolve } = require('path')
const { createServer } = require('http')

/**
 * 判断是否 ESM 文件
 */
function isESM(url) {
  return String(url).endsWith('mjs')
}

/**
 * 获取 MIME Type 信息
 * @tips `.mjs` 和 `.js` 一样,都使用 JavaScript 的 MIME Type
 */
function mimeType(url) {
  return isESM(url) ? 'application/javascript' : 'text/html'
}

/**
 * 获取入口文件
 * @returns 存放在本地的文件路径
 */
function entryFile(url) {
  const file = isESM(url) ? `../src/esm${url}` : './index.html'
  return resolve(__dirname, file)
}

/**
 * 创建 HTTP 服务
 */
const app = createServer((request, response) => {
  // 获取请求时的相对路径,如网页路径、网页里的 JS 文件路径等
  const { url } = request

  // 转换成对应的本地文件路径并读取其内容
  const entry = entryFile(url)
  const data = readFileSync(entry, 'utf-8')

  // 需要设置正确的响应头信息,浏览器才可以正确响应
  response.writeHead(200, { 'Content-Type': mimeType(url) })
  response.end(data)
})

/**
 * 在指定的端口号启动本地服务
 */
const port = 8080
app.listen(port, '0.0.0.0', () => {
  console.log(`Server running at:`)
  console.log()
  console.log(`  ➜  Local:  http://localhost:${port}/`)
  console.log()
})
添加入口页面

继续在 server 目录下添加一个 index.html 并写入以下 HTML 代码,它将作为网站的首页文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- server/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ESM run in browser</title>
  </head>
  <body>
    <script type="module" src="./index.mjs"></script>
  </body>
</html>

<script /> 标签这一句代码上,比平时多了一个 type="module" 属性,这代表这个 script 是使用了 ESM 模块,而 src 属性则对应指向了上文在 src/esm 目录下的入口文件名

启动服务并访问

打开 package.json 文件,在 scripts 字段追加一个 serve 命令如下:

1
2
3
4
5
6
{
  "scripts": {
    "dev:esm": "node src/esm/index.mjs",
    "serve": "node server/index.js"
  }
}

然后运行npm run serve后在浏览器访问https://localhost:8000/地址访问本地服务。上述代码中无需使用 ../src/esm/index.mjs 显式的指向真实目录,是因为在添加服务端程序时,已通过服务端代码里的 entryFile 方法重新指向了文件所在的真实路径,所以在 HTML 文件里可以使用 ./ 简化文件路径。

内联 ESM 代码

移除 <script /> 标签的 src 属性,并在标签内写入 src/esm/index.mjs 文件里的代码,现在该 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
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ESM run in browser</title>
  </head>
  <body>
    <!-- 标签内的代码就是 src/esm/index.mjs 的代码 -->
    <script type="module">
      import {
        foo as foo2, // 这里进行了重命名
        bar,
      } from './module.mjs'

      // 就不会造成变量冲突
      const foo = 1
      console.log(foo)

      // 用新的命名来调用模块里的方法
      foo2()

      // 这个不冲突就可以不必处理
      console.log(bar)
    </script>
  </body>
</html>

ESM的模块导入限制

不论是通过 <script type="module" /> 标签还是通过 import 语句导入,模块的路径都必须是以 /./ 或者是 ../ 开头,因此无法直接通过 npm 包名进行导入。正确的导入方法为:借助另外一个 script 类型: importmap ,在 server/index.html 里追加 <script type="importmap" /> 这一段代码,再次刷新页面即可成功。总的来说,现阶段在浏览器使用 ES Module 并不是一个很好的选择,建议开发者还是使用构建工具来开发,工具可以抹平这些浏览器差异化问题,降低开发成本。

组件化设计

一个页面由 HTML + CSS + JS 三部分组成, JS 代码可以按照不同的功能、需求划分成模块,那么页面也可以,这就叫组件化。

描述文字

模块化属于 JavaScript 的概念,把代码块的职责单一化,一个函数、一个类都可以独立成一个模块。组件就是把一些可复用的 HTML 结构和 CSS 样式再做一层抽离,然后再放置到需要展示的位置

常见的组件有:页头、页脚、导航栏、侧边栏… 甚至小到一个用户头像也可以抽离成组件,因为头像可能只是尺寸、圆角不同而已。

Vue 通过 Single-File Component (简称 SFC , .vue 单文件组件)来实现组件化开发。一个 Vue 组件是由三部分组成的:

1
2
3
4
5
6
7
8
9
10
11
<template>
  <!-- HTML 代码 -->
</template>

<script>
// JavaScript 代码
</script>

<style scoped>
/* CSS 代码 */
</style>

依赖包和插件

插件在 Node 项目里的体现是一个又一个的依赖包;虽然也可以把插件的代码文件手动放到的源码文件夹里引入,但并不是一个最佳的选择,本节内容将带了解 Node 的依赖包。

Node 项目里,包可以简单理解为模块的集合,一个包可以只提供一个模块的功能,也可以作为多个模块的集合集中管理。

包通常发布在官方的包管理平台 npmjs 上,开发者需要使用的时候,可以通过包管理器安装到项目里,并在的代码里引入,开箱即用。使用 npm 包可以减少在项目中重复造轮子,提高项目的开发效率,也可以极大的缩小项目源码的体积。

node_modules

node_modules 是 Node 项目下用于存放已安装的依赖包的目录,如果不存在,会自动创建。如果是本地依赖,会存在于项目根目录下,如果是全局依赖,会存在于环境变量关联的路径下。

包管理器

包管理器( Package Manager )是用来管理依赖包的工具,比如:发布、安装、更新、卸载等等。Node 默认提供了一个包管理器 npm ,在安装 Node.js 的时候,默认会一起安装 npm 包管理器,可以通过命令npm -v查看它是否正常。

依赖包管理

以 npm 作为默认的包管理器,在项目里管理依赖包需要学会下述内容:

  1. 配置镜像源

    1
    2
    3
    4
    5
    6
    7
    
     npm config get registry
     # 输出 https://registry.npmjs.org/
     npm config set registry https://registry.npmmirror.com
     npm config get registry
     # 输出 https://registry.npmmirror.com/
     # 删除镜像源
     npm config rm registry
    
  2. 本地安装

    项目的依赖建议优先选择本地安装,这是因为本地安装可以把依赖列表记录到 package.json 里,多人协作的时候可以减少很多问题出现,特别是当本地依赖与全局依赖版本号不一致的时候。

  3. 生产依赖

    执行 npm install 的时候,添加 --save 或者 -S 选项可以将依赖安装到本地,并列为生产依赖;注意安装前需要提前在命令行 cd 到的项目目录。另外, --save 或者 -S 选项在实际使用的时候可以省略,因为它是默认选项。项目在上线后仍需用到的包,就需要安装到生产依赖里,比如 Vue 的路由 vue-router 就需要以这个方式安装。

    1
    2
    
     # 安装方法
     npm install --save <package-name>
    
    1
    2
    3
    4
    5
    6
    
     {
         "dependencies": {
             {"包名": "版本号"}
             "vue-router": "^4.0.14"
         }
     }
    
  4. 开发依赖

    执行 npm install 的时候,如果添加 --save-dev 或者 -D 选项,可以将依赖安装到本地,并写入开发依赖里。

    1
    2
    
     # 安装方法
     npm install --save-dev <package-name>
    
    1
    2
    3
    4
    5
    
     {
         "devDependencies": {
             "eslint": "^8.6.0"
         }
     }
    

    开发依赖包也是会被安装到项目根目录下的 node_modules 目录里。和生产依赖包不同的点在于,只在开发环境生效,构建部署到生产环境时可能会被抛弃,一些只在开发环境下使用的包,就可以安装到开发依赖里,比如检查代码是否正确的 ESLint 就可以用这个方式安装。

  5. 全局安装

    执行 npm install 的时候,如果添加 --global 或者 -g 选项,可以将依赖安装到全局,它们将被安装在 配置环境变量 里配置的全局资源路径里。

    一般情况下,类似于 @vue/cli 之类的脚手架会提供全局安装的服务,安装后,可以使用 vue create xxx 等命令直接创建 Vue 项目。但不是每个 npm 包在全局安装后都可以正常使用,请阅读 npm 包的主页介绍和使用说明。

    1
    2
    
     # 全局安装方法
     npm install --global <package-name>
    
  6. 版本控制、版本升级与卸载

    这部分的常见命令包括:

    1
    2
    3
    4
    5
    6
    7
    8
    
     # 指定版本包的安装
     npm install <package-name>@<version | tag>
     # 全局更新与指定更新
     npm update
     npm update <package-name>
     # 删除和全局删除
     npm uninstall <package-name>
     npm uninstall --global <package-name>
    

使用包

首先在 命令行工具 通过 cd 命令进入项目所在的目录,用本地安装的方式来把需要的包添加到生产依赖,然后可以在package.json中看到安装的包的信息,在package-lock.json中看到安装的包所需的依赖包信息,node_modules 文件夹下也可以看到以这几个包名为命名的文件夹。

包的导入和在 ES Module设计模块 一节了解到的模块导入用法是一样的,只是把 from 后面的文件路径换成了包名。然后按语法规则调用包名即可。

控制编译代码的兼容性

为了保证程序可以正确的在不同版本浏览器之间运行,就需要根据产品要支持的目标浏览器范围,去选择兼容性最好的编程方案。在 Web 开发有一个网站非常知名:Can I use ,只要搜索 API 的名称,它会以图表的形式展示该 API 在不同浏览器的不同版本之间的支持情况,支持 HTML 标签、 CSS 属性、 JavaScript API 等内容的查询。在工作中,工程师无需关注每一个 API 的具体支持范围,这些工作可以交给工具来处理,这个工具就是Babel。

Babel的使用和配置

Babel 是一个 JavaScript 编译器,它可以让开发者仅需维护一份简单的 JSON 配置文件,即可调动一系列工具链将源代码编译为目标浏览器指定版本所支持的语法。

  1. 安装Babel

    首先需要安装相关依赖:

    1
    
     npm i -D @babel/core @babel/cli @babel/preset-env
    

    此时在 package.json 的 devDependencies 可以看到有了如下三个依赖:

    1
    2
    3
    4
    5
    6
    7
    
     {
         "devDependencies": {
             "@babel/cli": "^7.19.3",
             "@babel/core": "^7.19.3",
             "@babel/preset-env": "^7.19.3"
         }
     }
    

    它们的作用分别如下:

    依赖 作用 文档
    @babel/cli 安装后可以从命令行使用 Babel 编译文件 @babel/cli文档
    @babel/core Babel 的核心功能包 @babel/core文档
    @babel/preset-env 智能预设,可以通过它的选项控制代码要转换的支持版本 @babel/preset-env文档

    在使用 Babel 时,建议在项目下进行本地安装,尽量不选择全局安装,这是因为不同项目可能依赖于不同版本的 Babel ,全局依赖和可能会出现使用上的异常。

  2. 添加Babel配置

    接下来在项目的根目录下创建一个名为 babel.config.json 的文件,这是 Babel 的配置文件,详细配置方法可查看配置文件文档,写入以下内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
     {
         "presets": [
             [
                 "@babel/preset-env",
                 {
                     "targets": {
                     "chrome": "41(浏览器目标版本号)"
                     },
                     "modules": false,
                     "useBuiltIns": "usage",
                     "corejs": "3.6.5"
                 }
             ]
         ]
     }
    
  3. 使用 Babel 编译代码

    在当前项目的 src 目录下添加一个 babel 文件夹,并在该文件夹下创建一个 index.js 文件,写入代码;案例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
     // src/babel/index.js
     export class Hello {
         constructor(name) {
             this.name = name
         }
    
         say() {
             return `Hello ${this.name}`
         }
     }
    

    根据上一步的 Babel 配置,在这里使用 class 语法作为测试代码。接下来再打开 package.json 文件,添加一个 compile script。这条命令的含义是,使用 Babel 处理 src/babel 目录下的文件,并输出到根目录下的 compiled 文件夹。如下:

    1
    2
    3
    4
    5
    6
    7
    
     {
         "scripts": {
             "dev:esm": "node src/esm/index.mjs",
             "compile": "babel src/babel --out-dir compiled", // 添加的新内容
             "serve": "node server/index.js"
         }
     }
    
  4. 在命令行运行命令npm run compile。可以看到当前项目的根目录下多了一个 compiled 文件夹,里面有一个和源码相同命名的 index.js 文件,但是其中的语法相比于原来的 index.js 存在大量为转换兼容进行的修改。

本文由作者按照 CC BY 4.0 进行授权