【第2139期】Web应用程序如何工作:客户端开发和部署
前言
基础普及,有兴趣的就了解看看。今日前端早读课文章由@飘飘翻译投稿,@acemarke授权分享。
正文从这开始~~
JavaScript开发和构建过程
随着时间的推移,理解JavaScript的设计和使用的演变是很重要的,这样才能理解为什么以及如何使用它。
这句话很好地总结了这一点。
JavaScript的设计目的是当你把鼠标移到猴子身上时,它会跳舞。脚本通常只有一行。我们认为十行脚本是很正常的,百行脚本是巨大的,千行的脚本是闻所未闻的。这门语言绝对不是为大型编程而设计的,我们的实现决策、性能目标等等都是基于这个假设的。
微软前IE/JS开发人员Eric Lippert。 http://programmers.stackexchange.com/a/221658/214387
今天,应用程序通常由数十万行JavaScript组成。这为开发人员如何编写、构建和交付客户端应用程序引入了一套完全不同的约束。这些限制导致了与早期截然不同的开发方法。现在的 web 客户端使用复杂的构建工具链,直接等价于 c + + 或 Java 等语言的编译器,而不是编写几行 JS 并将它们内联到 HTML 页面中。
JavaScript模块格式
几乎所有的语言都有内置的语法来声明封装的 "模块 "或 "包"。例如,一个Java文件可以声明它是包com.my.project的一部分,然后用import some.other.project.SomeClass;声明对另一个包的依赖。C#、Python、Go和Swift都有自己的包定义和导入/导出语法。
JavaScript不是这些语言中的一种。
与其他所有这些语言不同,JavaScript最初没有内置模块格式语法。许多年来,JS代码都是直接以内联 <script>
标签的形式写在HTML中的,或者是以带有一些共享全局变量的小型.js文件的形式写的。
随着开发人员开始编写更大的应用程序代码库,社区最终开始发明自己的模块格式来帮助提供结构和封装。每一种格式的发明都是为了解决不同的用例。
注意:本节中的代码片段纯粹是为了说明各种格式之间的语法差异--实际的代码并不打算运行或做任何有用的事情。
遗留模块格式
在一个页面中添加许多脚本标签有几个问题。要确定不同脚本文件之间的依赖关系,并以正确的顺序加载它们可能非常困难。另外,由于所有的顶层变量都占据了相同的全局命名空间,所以很容易出现同名变量相互覆盖的意外。
<script src="jquery.min.js"></script>
<script src="jquery.someplugin.js"></script>
<script src="./components/dropdown.js"></script>
<script src="./components/modal.js"></script>
<script src="./application.js"></script>
// dropdown.js
var delay = 2000; // in ms
// modal.js
var delay = 4000; // in ms
// application.js
// Oops - is it 2000 or 4000?
console.log(delay)
立即调用函数表达式(IIFEs)是一种依赖于JS变量作用域到最近函数的模式。IIFE涉及定义一个新的函数,然后立即调用它来获得一个结果。这提供了封装,并被用作 "揭示模块 "模式的基础,其中IIFE返回一个定义其公共API的对象(相当于工厂函数或类构造函数)。
// dropdown.js
(function(){
var delay = 2000; // in ms
APP.dropdown.delay = delay;
}());
// modal.js
const modalAPI = (function(){
// No name clash - encapsulated in the IIFE
var delay = 4000; // in ms
APP.modal.delay = delay;
$("#myModal").show();
function hideModal() {
$("#myModal").hide()
}
// return a "public API" for the modal by exposing methods
return {
hideModal : hideModal
}
}());
异步模块定义格式(AMD)是专门为浏览器设计的。专门的AMD加载器库首先创建一个全局定义函数。然后AMD模块调用define(),并传入一个它们所依赖的模块名数组,以及一个作为模块主体的函数。模块主体函数接收所有请求的依赖项作为参数,并可以返回任何一个值作为它的 "出口"。然后,加载器库检查是否所有请求的依赖关系都已被注册和加载。如果没有,它将以瀑布式的方式递归下载其它的依赖关系,并沿着依赖关系链向上工作,用它的依赖关系初始化每个模块函数。
// moduleA.js
// Loader library adds a global `define()` function
define(["jquery", "myOtherModule"],
function($, myOtherModule) {
// Body of the function is the module definition
const a = 42;
const b = 123;
function someFunction() { }
// Return value is the "exports" of the module
// Can do "named exports" by returning object with many values
return {a : a, publicName : b, someFunction : someFunction}
});
// moduleB.js
define(["backbone"],
function(Backbone) {
const MyModel = Backbone.Model.extend({});
// Can do a "default" export by just returning one thing
// instead of an object with multiple things inside
return MyModel;
});
IIFEs和AMD模块已经不再积极用于新的开发,但使用这些模式的代码仍然存在。
CommonJS Modules
CommonJS模块格式是专门为Node.js运行时(在浏览器外运行的JS解释器)而开发的。由于Node可以访问文件系统,CommonJS格式被设计成在模块被导入后立即从磁盘同步加载。
Node.js解释器定义了一个全局的require函数,它、可以接受相对路径、绝对路径或库名。然后,Node会按照一个复杂的查找公式来查找与请求的路径/名称相匹配的文件,如果找到了,就会立即读取并加载请求的文件。
CommonJS模块没有任何外包装功能。解释器还定义了一个全局的module.export变量,模块通过赋值给该变量来定义其导出值。
// moduleA.js
// Node runtime system adds `require()` function and infrastructure
const $ = require("jquery");
const myOtherModule = require("myOtherModule");
// The entire file is the module definition
const a = 42;
const b = 123;
function someFunction() { }
// Node runtime adds a `module.exports` keyword to define exports
// Can do "named exports" by assigning an object with many values
module.exports = {
a : a,
publicName : b,
someFunction : someFunction
}
// moduleB.js
const Backbone = require("backbone");
const MyModel = Backbone.Model.extend({});
// Can do a "default" export by just assigning
// one value to `module.exports`
module.exports = MyModel;
CommonJS模块允许随时动态导入其他模块,而且导入可以有条件地进行。
反过来说,CommonJS模块不能在浏览器中原封不动地使用,需要某种适配器或重新包装。
通用模块定义
有些库需要能够在多种环境下使用同一个构建工件:浏览器中的普通全局 <script>
标签,浏览器中的AMD模块,或者Node下的CommonJS文件。社区发明了一个看起来很奇怪的黑客技巧,通过特征检测功能,让一个模块在这三种环境下都能正常工作,这被称为通用模块定义(UMD)格式。如今,这仍然被半通用地用作一些库的构建输出目标。
// File log.js
(function (global, factory) {
if (typeof define === "function" && define.amd) {
define(["exports"], factory);
} else if (typeof exports !== "undefined") {
factory(exports);
} else {
var mod = {
exports: {}
};
factory(mod.exports);
global.log = mod.exports;
}
})(this, function (exports) {
"use strict";
function log() {
console.log("Example of UMD module system");
}
// expose log to other modules
exports.log = log;
});
ES模块
ES2015语言规范终于为JS语言增加了一个官方模块语法,现在被称为ES模块或 "ESM"。它提供了用于定义命名和默认导入和导出的语法。然而,由于浏览器和Node.js之间的差异,该规范并没有定义模块究竟如何被解释器加载,也没有定义导入字符串指的是什么,而是让不同的环境去想办法适当地加载模块。现代浏览器都已经实现了基于URL作为导入字符串来加载ES模块。Node由于依赖CommonJS模块作为默认格式,在确定前进的道路上遇到的麻烦明显更多。从Node 15开始,Node对加载ES模块有了一定的支持,但在确定CommonJS和ES模块文件应该如何互操作方面仍然存在困难。
ES Modules被设计成可以静态分析的。缺点是你不能做动态或有条件的导入--所有的导入和导出必须在文件的顶层。
// moduleA.js
// ES6 language spec defines import/export keywords
import $ from "jquery";
// Can do "default" imports - no curly braces around the variable name
import myOtherModule from "myOtherModule";
// Define "named exports" by adding `export` in front of a variable
export const a = 42;
export const b = 123;
export {b as publicName};
export function someFunction() { }
// moduleB.js
// Can do "named imports" from other modules
import {Model} from "backbone";
const MyModel = Model.extend({});
// Can do a "default" export with the `export default` keyword
export default MyModel;
编译
多年来,JS语言规范增加了很多额外的语法。尤其是ES2015规范,有效地将语言中的语法量增加了一倍。
每当一个浏览器发布一个新的版本时,该版本对JS语言的某个子集都有固定的理解。由于该浏览器版本可能会在许多年内保持广泛使用,因此开发人员需要只使用他们打算支持的一组浏览器版本所支持的语法来发布代码。然而,开发人员也希望能够使用最新和最优秀的语法来编写和开发代码。
这意味着,开发人员需要将他们编写的原始 "current"JS源代码编译成只使用旧语法的等效版本。
Babel JS编译器是用于将JS代码交叉编译成不同变化的JS的标准工具。它有一个广泛的插件,支持将特定的较新的JS语言语法编译成其旧的等价形式。
举个例子,这个ES2015兼容的片段使用了ES模块语法、箭头函数、const/let变量声明关键字和速记对象声明语法。
export const myFunc = () => {
let longVariableName = 1;
return {longVariableName};
}
当Babel JS在启用ES2015插件并针对CommonJS模块格式进行编译时,就会变成:
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.myFunc = void 0;
var myFunc = function myFunc() {
var longVariableName = 1;
return {
longVariableName: longVariableName
};
};
exports.myFunc = myFunc;
此外,业内还有很多 "compile-to-JS "的语言在使用。有些是默默无闻的小众语言,有些流行了几年,后来就没落了(CoffeeScript)。目前最常用的compile-to-TS语言是TypeScript,它是微软创建的JS静态类型化超集。TypeScript编译器本身在编译时剥离出类型注释,输出纯JS。与Babel类似,它也可以将新的语法编译成不同的旧语言版本。
// Input: TypeScript syntax is JS with type annotations
const add2 = (x: number, y: number) => {
return x + y;
};
// Output: plain JS, no type annotations
const add2 = (x, y) => {
return x + y;
};
Bundling
有多种原因导致原始JS源码无法按原样交付给浏览器。
以CommonJS格式编写的代码不能被浏览器加载。
以ES模块格式编写的代码可以,但需要仔细工作,以使所有文件和路径URL正确排列。
用户使用的目标浏览器可能不支持所有的现代语法。
代码库可能由数千个独立的JS文件组成,单独下载每个文件会花费太长的时间来加载。
原创源码中包含注释、空格和较长的变量名,开发者需要尽量减少发送给浏览器的字节数,让页面加载速度更快。
像TypeScript这样的语言是不被JS解释器支持的--原始源码必须被编译成纯JS语法。
正因为如此,JS代码也被捆绑起来,为在浏览器中使用做准备。这既要在开发环境中进行,也要在生产环境中进行。
捆绑过程从一组入口点文件(如src/index.js)开始追踪导入和依赖关系的树。然后,任何导入的文件都会被添加到要处理的文件列表中。捆绑工具解析所有请求的导入文件,确定必要的加载顺序,并输出被包裹在一些脚手架中的模块源,这些脚手架会在加载捆绑文件时初始化应用程序。
在捆绑过程中,捆绑工具通常还支持多个额外的处理步骤。特别是,捆绑程序通常会被配置为:
在所有JS/TS源文件上运行像Babel或TypeScript这样的编译器
如果使用TS,请使用TS编译器进行类型检查,以验证代码的实际编译情况。
可以导入和处理额外的资产,如CSS和图像。
优化输出的大小,使其尽可能的小,通过最小化(也称为丑化)。
对JS源码进行最小化涉及到尽可能地缩减代码,通过去掉空白和注释,用较短的名字代替长的变量名,并使用尽可能短的语法版本。最后,minifiers可以检测死代码并将其删除,JS代码经常用if (process.env.NODE_ENV ! == 'production')这样的标志来添加只用于开发的检查,这些检查将在生产构建中被删除。
上面同样的Babel编译后的输出,经过最小化后是这样的。
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.myFunc=void 0;var myFunc=function(){return{longVariableName:1}};exports.myFunc=myFunc;
Webpack是使用最广泛的JS捆绑器。其他工具,如Parcel、Snowpack和ESBuild,也能完成同样的角色,但目标和约束不同。
Source Maps
由于这些转换,浏览器加载的代码已经被篡改成完全无法识别的形式,使得实际调试时无法按原样进行。为了解决这个问题,开发工具也会写出Source Maps,将输出文件的段子映射回原来的源码行。这样,浏览器调试器就可以显示原始的源代码,即使是浏览器中JS解释器实际上不支持的语言。浏览器将在其各个文件中显示 "原始源码",并允许开发人员通过设置断点和查看变量内容来调试该 "原始源码"。
开发环境和工具
Node.js
Node.js是一个在浏览器环境之外执行JS的运行时,它相当于Java的JRE、.NET框架SDK或Python运行时。它相当于Java的JRE、.NET Framework SDK或Python运行时。它由Chrome浏览器的V8 JS引擎组成,该引擎被重新打包为独立的可执行文件,同时还有一个标准的API库,用于与文件系统交互、创建套接字和服务器等。
NPM
"NPM "有三层含义。
NPM是公开托管的JS包注册表,托管社区发布的第三方JS库和包。
npm是一个开源的CLI客户端,用于从该注册表中安装包。
NPM是一家运行注册表和开发CLI客户端的公司(最近被微软收购)。
从NPM上安装的库和包会被放到一个./nodemodules文件夹中。因此,npm install redux会从NPM注册服务器下载一个发布的存档,并将其内容提取到一个新的./nodemodules/redux文件夹中。
节点构建工具
由于大多数JS构建工具都是JS开发者为JS开发者编写的,所以构建工具本身通常都是用JS编写的。其中包括Babel、Webpack、ESLint等广泛使用的工具。因此,要运行它们,你必须在开发环境中安装Node.js。(如下文所述,你不需要在服务器机器上安装Node.js,只是为了在浏览器中运行你的客户端代码,除非你自己也用JS编写了服务器应用程序)。
最近有一种用Rust或Go编写替代JS构建工具的趋势,目标是通过使用本地代码和并行性使编译和捆绑变得更快。这些工具中的大多数还没有成为主流,但潜在的速度提升已经足够大,这些工具很可能会被使用。
开发服务器
因为当开发人员在本地进行修改时,需要对源码进行反复的重新编译和重新捆绑,典型的开发过程包括启动一个开发服务器,这是一个单独的进程,它可以检测到对原始源码文件的编辑,并根据更改重建客户端代码。
开发服务器进程通常作为HTTP代理,将数据和资源的请求转发到实际的应用服务器上。
一个典型的例子可能是这样的
应用服务器进程监听端口8080
开发服务器进程监听端口3000
然后开发者会浏览到http://localhost:3000,加载页面。位于3000端口的服务器接收到这个请求后,从内存中加载HTML主机页面和捆绑的JS源码,并将其返回。当浏览器请求http://localhost:3000/images/avatar.png时,开发服务器会将其转发到应用服务器http://localhost:8080/images/avatar.png。 同样的,浏览器请求GET http://localhost:3000/items的数据,也会被转发到位于http://localhost:8080/items的应用服务器,而响应则通过开发服务器传回给浏览器。
Webpack有一个预建的开发服务器包,其他工具如Create-React-App通常会围绕Webpack开发服务器提供额外的配置和功能。
热更新
通常情况下,重新编译一个Web应用程序需要完全重新加载页面才能看到更改后的代码运行。当页面被刷新时,这将抹去任何加载到应用程序中的状态。
像Webpack这样的工具提供了一个 "热更新"的能力。当一个文件被编辑后,开发服务器会带着变化重新编译,然后在浏览器中向客户端代码推送一个通知。然后,应用程序代码可以订阅 "一些文件改变了 "的通知,重新导入新版本的代码,并在应用程序仍在运行时将旧代码换成新代码。
然后,其他工具,比如React的 "快速刷新 "模式,就可以利用这种重载能力来交换应用的特定部分,比如实时替换单个React组件。
部署
服务建设输出
正常的bundler构建过程的输出是一个充满静态JS、HTML、CSS和图片文件的文件夹。下面是一个典型的React应用的输出结构。
/my-project/build
- index.html
/static
/css
- main.34928ada.chunk.css
- 2.7110e618.chunk.css
/js
- 2.e5df1c81.chunk.js
- 2.e5df1c81.chunk.js.map
- main.caa84d88.chunk.js
- main.caa84d88.chunk.js.map
- runtime-main.d653cc00.js
- runtime-main.d653cc00.js.map
/media
- image1.png
- image2.jpg
- fancy-font.woff2
这些是简单的静态文件,可以由任何Web服务器提供。
要部署这些文件,只需要将它们上传到托管服务器应用程序的机器上的适当位置。这通常使用SFTP等文件传输协议来完成。
Polyfills
有许多浏览器API在旧版浏览器中不存在,但不能通过向后编译语法来处理。这包括内置函数、类和数据类型。几个例子是String.padStart()方法和Map数据结构。
然而,其中一些仍然可以通过开发者提供的实现来进行多重填充。Polyfills是额外的代码,它在加载应用程序时执行,检测当前环境中是否存在运行时给定的功能,并动态地添加一个人工但等价的实现。举个例子,String.padStart()的polyfill可能是这样的。
if (!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength,padString) {
// actual logic here
}
}
代码分割
即使进行了最小化,JS捆绑包也会变得太大了(250K,1MB,甚至更大)。这通常是由于使用了许多第三方库来实现额外的功能。
代码分割允许捆绑者将非常大的捆绑包分解成更小的块。额外的小块要么作为额外的 <script>
标签添加到主机的HTML页面中,要么在应用程序运行时动态下载。
有些chunks可能只包含第三方代码,因此 "vendor chunks "可以被浏览器缓存,只有在用户第一次访问网站时才会下载。或者,chunks可能是一个应用程序的多个部分之间共享的共同逻辑,例如面向用户的主页面和管理页面所使用的通用工具。
有些块可能只有在用户激活某个功能时才会被 "懒加载"。例如,一个丰富的文本编辑器实现可能会给捆绑程序增加一个额外的500K,但如果它只在一个特定的模态对话框中使用,对话框代码可以动态导入文本编辑器库。捆绑程序就会检测到这种动态导入,将编辑器代码分割成一个独立的块,只有当用户打开那个模态时才会下载这个块。
关于本文 译者:飘飘 作者:@acemarke 原文:https://blog.isquaredsoftware.com/2020/11/how-web-apps-work-client-dev-deployment/
为你推荐
【第2107期】在Ant Design 4.0里,我们如何追求快乐的工作?
欢迎自荐投稿,前端早读课等你来