JavaScript 对模块化编程

前言

随着 Web 技术的蓬勃发展和依赖的基础设施日益完善,前端领域逐渐从浏览器扩展至服务端(Node.js),桌面端(PC、Android、iOS),乃至于物联网设备(IoT),其中 JavaScript 承载着这些应用程序的核心部分,随着其规模化和复杂度的成倍增长,其软件工程体系也随之建立起来(协同开发、单元测试、需求和缺陷管理等),模块化编程的需求日益迫切。

JavaScript 对模块化编程的支持尚未形成规范,难以堪此重任;一时间,江湖侠士挺身而出,一路披荆斩棘,从刀耕火种过渡到面向未来的模块化方案;

概念

模块化编程就是通过组合一些__相对独立可复用的模块__来进行功能的实现,其最核心的两部分是__定义模块__和__引入模块__;

  • 定义模块时,每个模块内部的执行逻辑是不被外部感知的,只是导出(暴露)出部分方法和数据;
  • 引入模块时,同步 / 异步去加载待引入的代码,执行并获取到其暴露的方法和数据;

刀耕火种

尽管 JavaScript 语言层面并未提供模块化的解决方案,但利用其可__面向对象__的语言特性,外加__设计模式__加持,能够实现一些简单的模块化的架构;经典的一个案例是利用单例模式模式去实现模块化,可以对模块进行较好的封装,只暴露部分信息给需要使用模块的地方;

// Define a module
var moduleA = (function ($, doc) { var methodA = function() {}; var dataA = {}; return { methodA: methodA, dataA: dataA  }; })(jQuery, document); // Use a module var result = moduleA.mehodA();

直观来看,通过立即执行函数(IIFE)来声明依赖以及导出数据,这与当下的模块化方案并无巨大的差异,可本质上却有千差万别,无法满足的一些重要的特性;

  • 定义模块时,声明的依赖不是强制自动引入的,即在定义该模块之前,必须手动引入依赖的模块代码;
  • 定义模块时,其代码就已经完成执行过程,无法实现按需加载;
  • 跨文件使用模块时,需要将模块挂载到全局变量(window)上;

AMD & CMD 二分天下

题外话:由于年代久远,这两种模块化方案逐渐淡出历史舞台,具体特性不再细聊;

为了解决”刀耕火种”时代存留的需求,AMD 和 CMD 模块化规范问世,解决了在浏览器端的异步模块化编程的需求,__其最核心的原理是通过动态加载 script 和事件监听的方式来异步加载模块;__

AMD 和 CMD 最具代表的两个作品分别对应 require.js 和 sea.js;其主要区别在于依赖声明和依赖加载的时机,其中 require.js 默认在声明时执行, sea.js 推崇懒加载和按需使用;另外值得一提的是,CMD 规范的写法和 CommonJS 极为相近,只需稍作修改,就能在 CommonJS 中使用。参考下面的 Case 更有助于理解;

// AMD
define(['./a','./b'], function (moduleA, moduleB) {  // 依赖前置 moduleA.mehodA(); console.log(moduleB.dataB); // 导出数据 return {}; }); // CMD define(function (requie, exports, module) {  // 依赖就近  var moduleA = require('./a');   moduleA.mehodA(); // 按需加载  if (needModuleB) {    var moduleB = requie('./b');    moduleB.methodB();  } // 导出数据 exports = {}; });

CommonJS

2009 年 ry 发布 Node.js 的第一个版本,CommonJS 作为其中最核心的特性之一,适用于服务端下的场景;历年来的考察和时间的洗礼,以及前端工程化对其的充分支持,CommonJS 被广泛运用于 Node.js 和浏览器;

// Core Module
const cp = require('child_process');
// Npm Module const axios = require('axios'); // Custom Module const foo = require('./foo'); module.exports = { axios }; exports.foo = foo;

规范

  • module (Object): 模块本身
  • exports (*): 模块的导出部分,即暴露出来的内容
  • require (Function): 加载模块的函数,获得目标模块的导出值(基础类型为复制,引用类型为浅拷贝),可以加载内置模块、npm 模块和自定义模块

实现

1、模块定义

默认任意 .node .js .json 文件都是符合规范的模块;

2、引入模块

首先从缓存(require.cache)优先读取模块,如果未命中缓存,则进行路径分析,然后按照不同类型的模块处理:

  • 内置模块,直接从内存加载;
  • 外部模块,首先进行文件寻址定位,然后进行编译和执行,最终得到对应的导出值;

其中在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装,结果如下:

(function (exports, require, module, __filename, __dirname) {
    var circle = require('./circle.js'); console.log('The area of a circle of radius 4 is ' + circle.area(4)); });

特性总结

  • 同步执行模块声明和引入逻辑,分析一些复杂的依赖引用(如循环依赖)时需注意;
  • 缓存机制,性能更优,同时限制了内存占用;
  • Module 模块可供改造的灵活度高,可以实现一些定制需求(如热更新、任意文件类型模块支持);

ES Module(推荐使用)

ES Module 是语言层面的模块化方案,由 ES 2015 提出,其规范与 CommonJS 比之 ,导出的值都可以看成是一个具备多个属性或者方法的对象,可以实现互相兼容;但写法上 ES Module 更简洁,与 Python 接近;

import fs from 'fs';
import color from 'color'; import service, { getArticles } from '../service'; export default service; export const getArticles = getArticles;

主要差异在于:

  • ES Module 会对静态代码分析,即在代码编译时进行模块的加载,在运行时之前就已经确定了依赖关系(可解决循环引用的问题);
  • ES Module 关键字:import export 以及独有的 default 关键字,确定默认的导出值;
  • ES Module 中导出的值是一个 只读的值的引用 ,无论基础类型和复杂类型,而在 CommonJS 中 require 的是值的拷贝,其中复杂类型是值的浅拷贝;
// a.js
export let a = 1;
export function caculate() { a++; }; // b.js import { a, caculate } from 'a.js'; console.log(a); // 1 caculate(); console.log(a); // 2 a = 2; // Syntax Error: "a" is read-only

UMD

通过一层自执行函数来兼容各种模块化规范的写法,兼容 AMD / CMD / CommonJS 等模块化规范,贴上代码胜过千言万语,需要特别注意的是 ES Module 由于会对静态代码进行分析,故这种运行时的方案无法使用,此时通过 CommonJS 进行兼容;

(function (global, factory) {
  if (typeof exports === 'object') {    module.exports = factory(); } else if (typeof define === 'function' && define.amd) {    define(factory);  } else {    this.eventUtil = factory();  } })(this, function (exports) { ​ // Define Module  Object.defineProperty(exports, "__esModule", { value: true }); exports.default = 42; });

构建工具中的实现

为了在浏览器环境中运行模块化的代码,需要借助一些模块化打包的工具进行打包( 以 webpack 为例),定义了项目入口之后,会先快速地进行依赖的分析,然后将所有依赖的模块转换成浏览器兼容的对应模块化规范的实现;

模块化的基础

从上面的介绍中,我们已经对其规范和实现有了一定的了解;在浏览器中,要实现 CommonJS 规范,只需要实现 module / exports / require / global 这几个属性,由于浏览器中是无法访问文件系统的,因此 require 过程中的文件定位需要改造为加载对应的 JS 片段(webpack 采用的方式为通过函数传参实现依赖的引入)。具体实现可以参考:tiny-browser-require。

webpack 打包出来的代码快照如下,注意看注释中的时序;

(function (modules) {
  // The module cache
  var installedModules = {};  // The require function  function __webpack_require__(moduleId) {} return __webpack_require__(0); // ---> 0 }) ({  0: function (module, exports, __webpack_require__) {    // Define module A    var moduleB = __webpack_require__(1); // ---> 1  },  1: function (module, exports, __webpack_require__) {    // Define module B    exports = {}; // ---> 2

我们之前已经学习过二维纹理 gl.TEXTURE_2D,而且还使用它实现了各种效果。但还有一种立方体纹理 gl.TEXTURE_CUBE_MAP,它包含了6个纹理代表立方体的6个面。不像常规的纹理坐标有2个纬度,立方体纹理使用法向量,换句话说三维方向。本节实现的demo请看 

根据法向量的朝向选取立方体6个面中的一个,这个面的像素用来采样生成颜色。这六个面通过他们相对于立方体中心的方向被引用。它们是分别是

gl.TEXTURE_CUBE_MAP_POSITIVE_X//右
gl.TEXTURE_CUBE_MAP_NEGATIVE_X//左
gl.TEXTURE_CUBE_MAP_POSITIVE_Y//上
gl.TEXTURE_CUBE_MAP_NEGATIVE_Y//下
gl.TEXTURE_CUBE_MAP_POSITIVE_Z//后
gl.TEXTURE_CUBE_MAP_NEGATIVE_Z//前

环境贴图
其实我们更应该把cube map叫作纹理盒,通常纹理盒不是给立方体设置纹理用的,设置立方体纹理的标准用法其实是使用二维贴图,那么纹理盒用来做什么的呢?纹理盒最常见的用法是用来做环境贴图。在百度和google地图中的3D街景就是环境贴图应用的一个例子。

纹理
下面是6张红色峡谷图片


将以上尺寸为512x512的图片填充到立方体的每个面,以下就是纹理的创建加载过程

// 创建纹理。
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);

const faceInfos = [
{
target: gl.TEXTURE_CUBE_MAP_POSITIVE_X,
url: '/img/sorbin_rt.jpg',
},
{
target: gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
url: '/img/sorbin_lf.jpg',
},
{
target: gl.TEXTURE_CUBE_MAP_POSITIVE_Y,
url: '/img/sorbin_up.jpg',
},
{
target: gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
url: '/img/sorbin_dn.jpg',
},
{
target: gl.TEXTURE_CUBE_MAP_POSITIVE_Z,
url: '/img/sorbin_bk.jpg',
},
{
target: gl.TEXTURE_CUBE_MAP_NEGATIVE_Z,
url: '/img/sorbin_ft.jpg',
},
];
faceInfos.forEach((faceInfo) => {
const {target, url} = faceInfo;
// 上传画布到立方体贴图的每个面
const level = 0;
const format = gl.RGBA;
const width = 512;
const height = 512;
const type = gl.UNSIGNED_BYTE;
// 设置每个面,使其立即可渲染
gl.texImage2D(target, level, format, width, height, 0, format, type, null);

// 异步加载图片
const image = new Image();
image.src = url;
image.onload = function() {
// 图片加载完成将其拷贝到纹理
gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
gl.texImage2D(target, level, internalFormat, format, type, image);
gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
};
});
gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);

法向量
标准立方体法向量 和 纹理盒法向量的区别


3D立方体使用纹理盒有一个巨大的好处就是不需要额外指定纹理坐标。只要盒子是被放置在世界坐标系的原点,盒子本身的坐标就可以作为纹理坐标使用,因为在3D世界中位置本身就是一个向量,表示一个方向,我们要的就是这个方向。

所以顶点着色器非常简单

attribute vec4 a_position;
uniform mat4 u_vpMatrix;
varying vec3 v_normal;

void main() {
gl_Position = u_vpMatrix * a_position;
//因为位置是以几何中心为原点的,可以用顶点坐标作为法向量
v_normal = normalize(a_position.xyz);
}

片段着色器中我们需要用samplerCube 代替 sampler2D 用 textureCube代替texture2D。textureCube 需要vec3类型的向量。 法向量从顶点着色器传递过来经过了插值处理,需要重新单位化。

precision mediump float; // 从顶点着色器传入。
varying vec3 v_normal; // 纹理。
uniform samplerCube u_texture;

void main() {
gl_FragColor = textureCube(u_texture, normalize(v_normal));
}

实现
运行后得到如下的效果,很明显就能看出是个立方体,并不是我们想要的360度环绕的3D场景。


其实我们只需要将相机位置置于原点(0,0,0),同时lookAt向其中的一个面就可以了。但是在原点有个问题,如果要旋转查看场景怎么办?我们可以通过旋转相机的位置,这其实就相当于立方体旋转,同时我们不需要矩阵位移相关的信息,只需要方向相关的信息就好了。同时还可以禁止写入深度缓存,造成背景在很远的假象,让效果更加真实。

const viewPosition = new Vector3([0,0,1]);//相机位置
const lookAt = [0, 0, 0];//原点

//相机绕y轴旋转
cameraMatrix.rotate(0.2,0,1,0);
viewPoint = cameraMatrix.multiplyVector3(viewPosition);
vpMatrix.setPerspective( 30, canvas.width / canvas.height, 0.1, 5 );
vpMatrix.lookAt(...viewPoint.elements, ...lookAt, 0, 1, 0);

//重置位移
vpMatrix.elements[12] = 0;
vpMatrix.elements[13] = 0;
vpMatrix.elements[14] = 0;
With F being the Fresnel equation. Moving the Fresnel denominator to the BRDF gives us the following equivalent equation:

其中F代表菲涅耳方程。将菲涅耳分母移到BRDF,得到:

Substituting the right-most F with the Fresnel-Schlick approximation gives us:

用Fresnel-Schlick近似代替右边的F,得到:

Let's replace (1−ωo⋅h)5 by α to make it easier to solve for F0:

我们用α代替(1−ωo⋅h)5,从而对收拾F0简单点:

JsonConvert.DefaultSettings =
() => new JsonSerializerSettings() {
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = {new StringEnumConverter(www.tianscpt.com)}
};

// Serialized as: {"day":"Saturday"}
await PostSomeStuff(new { Day = DayOfWeek.Saturday });
因为默认设置是全局设置,它会影响你的整个应用。所以这里我们最好使用针对特定API使用独立的配置。当使用Refit生成一个接口对象的时候,你可以传入一个RefitSettings参数,这个参数可以指定你使用的JSON序列化配置。

Copy
var gitHubApi = RestService.For<IGitHubApi>("https://www.yunyouuyL.com .github.com",
new RefitSettings {
ContentSerializer = new JsonContentSerializer(
new JsonSerializerSettings {
ContractResolver = new SnakeCasePropertyNamesContractResolver()
}
)});

var otherApi = RestService.For<IOtherApi>www.hnxinhe.cn("https://www.chenhaiyulp.cn api.example.com",
new RefitSettings {
ContentSerializer = new JsonContentSerializer(
new JsonSerializerSettings {
ContractResolver = new CamelCasePropertyNamesContractResolver()
}
)});
针对自定义属性的序列化和反序列化,我们同样可以使用Json.NET的JsonProperty属性。

Copy
public class Foo
{
// Works like [AliasAs("b")] would in form posts (see below)
[JsonProperty(PropertyName="b"www.yunyouuyL.com )]
public string Bar { get; set; }
}
Xml内容#
针对XML请求和响应的序列化和反序列化,Refit使用了System.Xml.Serialization.XmlSerializer。默认情况下, Refit会使用JSON内容序列化器,如果想要使用XML内容序列化器,你需要将RefitSetting的ContentSerializer属性指定为XmlContentSerializer。

Copy
var gitHubApi = RestService.For<IXmlApi>("https://www.dayuzaixianyL.cn /XML",
new RefitSettings {
ContentSerializer = new XmlContentSerializer()
});
我们同样可以使用System.Xml.Serialization命名空间下的特性,自定义属性的序列化和反序列化。

Copy
public class Foo
{
[XmlElement(Namespace = "https://www.baihuiyulegw.com /XML")]
public string Bar { get; set; }
}
System.Xml.Serialization.XmlSerializer提供了多种序列化方式,你可以通过在XmlContentSerialier对象的构造函数中指定一个XmlContentSerializerSettings 对象类进行配置。

Copy
var gitHubApi = RestService.For<IXmlApi>("https://www.chenghgongs.com /XML",
new RefitSettings {
ContentSerializer = new XmlContentSerializer(
new XmlContentSerializerSettings
{
XmlReaderWriterSettings = new XmlReaderWriterSettings()
{
ReaderSettings = new XmlReaderSettings
{
// 禁止写入深度缓存,造成背景在很远的假象
gl.depthMask(false);
环境纹理映射
环境贴图还有个更通俗的叫法-天空盒。接着我们还要实现一个非常帅气的效果,在天空盒三维场景中,让其中的物体反射场景周围的着色。这个操作就叫做环境纹理映射(environment mapping)。

反射
如果物体的表面像光滑的镜子,那么我们就能看到物体反射出天空和周围的景色。反射的原理非常简单,那就是使用反射公式映射纹理盒对应的纹素:


相机位置(观察点)和 物体顶点的位置,顶点位置又包含着法线信息,通过GLSL的reflect函数就可以非常容易的计算反射向量R,进而确定看到的是哪一块表面的着色。

实现
我们就在天空盒下面增加一个镜面立方体,那就需要增加一对着色器,首先顶点着色器需要增加法线,mvp矩阵

attribute vec4 a_position;
attribute vec4 a_normal;
uniform mat4 u_vpMatrix;
uniform mat4 u_modelMatrix;
varying vec3 v_position;
varying vec3 v_normal;

void main() {
v_position = (u_modelMatrix * a_position).xyz;
v_normal = vec3(u_modelMatrix * a_normal);
gl_Position = u_vpMatrix * u_modelMatrix * a_position;
}

片元着色器则需要添加相机位置,纹理以及顶点着色器传递过来的法线和顶点位置

precision highp float;
varying vec3 v_position;
varying vec3 v_normal;
uniform samplerCube u_texture;
uniform vec3 u_viewPosition;

void main() {
vec3 normal = normalize(v_normal);
vec3 eyeToSurfaceDir = normalize(v_position - u_viewPosition);
vec3 direction = reflect(eyeToSurfaceDir,normal);
gl_FragColor = textureCube(u_texture, direction);
}

这样我们绘制的时候就要轮流切换着色器program

function draw(){
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

//天空盒
gl.useProgram(program.program);
//绘制天空盒
//...

//立方体
gl.useProgram(cProgram.program);
//绘制立方体
//...

requestAnimationFrame(draw);
}

 

实际上,ES Module 的处理同 CommonJS 相差无几,只是在定义模块和引入模块时会去处理 __esModule 标识,从而兼容其在语法上的差异。

异步和扩展

1、浏览器环境下,网络资源受到较大的限制,因此打包出来的文件如果体积巨大,对页面性能的损耗极大,因此需要对构建的目标文件进行拆分,同时模块也需要支持动态加载;

webpack 提供了两个方法 require.ensure() 和 import() (推荐使用)进行模块的动态加载,至于其中的原理,跟上面提及的 AMD & CMD 所见略同,import() 执行后返回一个 Promise 对象,其中所做的工作无非也是动态新增 script 标签,然后通过 onload / onerror 事件进一步处理。

2、由于 require 函数是完全自定义的,我们可以在模块化中实现更多的特性,比如通过修改 require.resolve 或 Module._extensions 扩展支持的文件类型,使得 css / .jsx / .vue / 图片等文件也能为模块化所使用;

附录1:特性一览表

模块化规范 加载方式 加载时机 运行环境 备注
AMD 异步 运行时 浏览器  
CMD 异步 运行时 浏览器 依赖基于静态分析,require 时已经 module ready
CommonJS 同步/异步 运行时 浏览器 / Node  
ES Module 同步/异步 编译阶段 浏览器 / Node 通过 import() 实现异步加载

猜你喜欢

转载自www.cnblogs.com/qwangxiao/p/10818957.html