Skip to main content

3 posts tagged with "promise"

View All Tags

· 12 min read

autopilot 系列更新到第五篇了,这一篇分享些跟自车相关的优化。关于 threejs 的模型加载,可以用 promise 封装一下,然后提供 abort 方法中断模型加载避免重叠问题,其次是模型加载速度的一些优化,比如 draco 压缩、缓存策略等,然后还有提供相机视角的切换,然后可以做个车灯光源提高下视觉效果

模型相关

自车用的是 gltf 模型,所以下面主要围绕这个格式来说

模型加载进度

默认模型加载器都有内置一个 DefaultLoadingManager,可以参考 GLTFLoader

// ...
loadEgoCar() {
gltfLoader.load(
carModel,
(gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
},
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
},
function (error) {
console.log(error);
}
);
}

THREE.LoadingManager 可以定制模型加载器

const manager = new THREE.LoadingManager();
manager.onLoad = () => {
console.log("===Loading complete!");
};
manager.onProgress = (url, loaded, total) => {
console.log("===loading", url, loaded, total);
};
const gltfLoader = new GLTFLoader(manager);

对于 GLTFLoader,它可能会加载多个资源(如网格、纹理、材质等),并且这些资源的加载可能是异步的,LoadingManageronProgress 回调中的 total 值可能只代表当前正在处理或已处理的特定资源的一部分或全部的总数据量,所以这里简单用 loaded/total 是不太准确的

加载函数

因为模型加载默认是回调函数,这里可以做个 promise 的封装,实现异步加载和渐进式显示

// src/helper/index.ts
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

export function loadGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}
// src/renderer/index.ts
// ...
loadEgoCar() {
loadGLTFWithPromise(carModel)
.then((gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
})
.catch((err) => console.log(err));
}

加载贴图

贴图也是异步加载的,这里和模型的加载可能存在顺序问题,可以用 Promise.allSettled 确保都模型和对应贴图加载完再更新。如果此时任一方加载失败了,那也可以在这里做个兜底处理,比如贴图换占位图或者提示加载模型失败。当然,如果贴图确实加载的很慢,那还是分开请求避免阻塞模型加载,更新贴图的逻辑其实类似的

// src/helper/index.ts
export function loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
resolve(texture);
},
undefined,
(error) => {
reject(error);
}
);
});
}
// ...
// 在没渲染之前,threejs默认会用占位图替代,避免报错
import testModal from "@/assets/models/xxx.glb";
import testTextures from "@/assets/textures/xxx.png";
Promise.allSettled([
loadGLTFWithPromise(testModal),
loadTexture(testTextures),
]).then((results) => {
const modelResult = results[0];
const textureResult = results[1];
if (modelResult.status === "fulfilled") {
if (textureResult.status === "fulfilled") {
modelResult.value.material.map = textureResult.value;
modelResult.value.material.needsUpdate = true;
}
return;
}
// 其他异常情况可以自行补充,比如提示模型加载失败或者贴图加载失败
// 实际可能有多个模型需要贴图
// model.traverse((child) => {
// if (child.isMesh) {
// model.material.map = texture;
// // 在下一帧应用更新
// model.material.needsUpdate = true;
// }
// });
});

中断异步加载

需要提供中断异步加载的功能,避免模型没加载完时用户又换成到其他模型,这种场景在有多个自车模型的时候会碰到,如果不中断可能造成模型重叠。因为之前用 promise 封装了,所以这里其实就等同于实现一个 promise 的 abort 方法

// 中断promise的辅助函数
export function abortWrapper(p1: Promise<any>) {
let abort;
const p2 = new Promise((resolve, reject) => (abort = reject));
const p = Promise.race([p1, p2]);
p.abort = abort;
return p;
}
// 比如可以调整下自车的加载逻辑
// ...
(this.egoCarLoader = abortWrapper(loadGLTFWithPromise(carModel))).then(
(gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
}
);
// ...
// 可能某个信号触发中断,直接调用abort, 这里用setTimeout模拟中断效果
setTimeout(() => {
this.egoCarLoader.abort();
}, 10);

但其实还不是很彻底,因为请求还是会正常进行和接收,那怎么中断请求?这一步倒不是很必要,也能做,但是得自行写一个模型加载器,然后通过 xhr 对象的 abort 方法或者 AbortController 来取消请求。可以跟踪下 threejs 的这个 issue,目前还是 open 状态,似乎有希望在未来版本中支持 abort

压缩

自车模型有 9M 左右,还是比较大,可以考虑压缩下模型,GLTF 模型可以通过高效的 Draco 算法来压缩几何数据,并且借助了 webAssembly 来加速计算,但是肯定有额外的解压耗时,一般都是值得的,当然你可以做下耗时对比

npm 全局安装 gltf-pipeline,然后到对应模型的目录下执行 gltf-pipeline -i xxx.glb -o xxx.glb -d。或者找个在线压缩 gltf 的地址快速压缩,比如 这个。如果经常要压缩一些模型的话,每次都这么手动处理肯定很麻烦,可以尝试用 node 写个批处理脚本

压缩后 9M 降到了 3.4M,体积减小还是很明显的。得到压缩后的文件后,那就该准备 DRACOLoader了。新增一个针对压缩后模型的加载函数

export function loadDracoGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
// 设置解压相关文件的路径
dracoLoader.setDecoderPath(
// 将 three/examples/jsm/libs/draco/gltf/ 拷贝到 public 目录来用
"./draco-gltf/"
// 或者也可以直接用这个
// "https://threejs.org/examples/jsm/libs/draco/gltf/"
);
// 使用js方式解压
// dracoLoader.setDecoderConfig({ type: "js" });
// 初始化 initDecoder 解码器
dracoLoader.preload();
// 设置GLTFLoader使用的压缩器
loader.setDRACOLoader(dracoLoader);
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}

缓存

一般场景里用到的模型,比如行人、障碍物、路标等都是经常会出现的,这个时候不能做重复加载,而是要尽可能复用。这里其实做个 map 对象,在接口数据里约定 type 来映射模型就行,可以在初始化界面的时候发起并行请求(Promise.all)获取模型,并保存到 map 对象上,后面可以直接通过 clone复用模型

因为有些识别出来的物体是会连续存在于多帧里的,可以用id唯一标识,这类非新增的物体就可以通过 id 找到模型并更新位置、大小、颜色等属性

其他关于模型的优化还有:

  • 纹理压缩
  • 建模的时候尽量减少顶点和面的数量,借助建模软件做网格简化和合并,从而实现减面
  • LOD 技术(Level of Detail),其实就是根据物体与相机的距离,动态选择渲染不同精度的模型。其实可以配合上述的纹理压缩和减面来做
  • CDN/......

有机会实践后再分享下吧 ~

相机优化

Threejs 相机有透视相机和正交相机。透视相机能模拟我们人眼所看到的景象,它是 web3d 中使用得最普遍的

camera分类

视角切换

因为 autopilot 这个应用主要是用于智驾算法调试,所以肯定涉及到视角切换,比如跟车视角、俯视横向纵向、又或者用户希望能自定义视角,那就需要设计这个视角切换的功能

先设计这几种视角:跟车、俯视横向、俯视纵向,其实实现很简单,就是改变相机的位置和方向。这里需要增加一个悬浮层,放一些交互按钮,注意悬浮层要加一个pointer-events: none来阻止鼠标行为,包括点击、悬停等交互,避免和 OrbitControls 交互冲突

// ...
switchCameraView(view: EViewType) {
this.cameraView = view;
switch (view) {
case EViewType.FollowCar: {
this.camera.up.set(0, 0, 1);
this.camera.position.set(-0.4, 4, 1.4);
break;
}
case EViewType.Overlook: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(0, -1, 0);
break;
}
case EViewType.OverlookVertical: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(1, 0, 0);
break;
}
default:
break;
}
}

俯视视角

上面这个还只是简单的调了下位置,这里 OrbitControls 在俯视视角用起来还有点问题,后面得优化下

车灯

我们之前其实接触过光源,比如整体场景的环境光 AmbientLight 还有投向自车的直行光 DirectionLight,其他光源还有 SpotLight 聚光灯

光源分类

车灯主要就是借助聚光灯光源来模拟,如果自车模型包含灯模型,通过灯的位置来设置光源位置就更准确了。但如果自车位置会变的话,还需要结合自车位置和转向来计算光源目标

// ...
const target1 = new THREE.Object3D();
target1.position.set(0.1, -0.2, 0.3);
const light1 = new THREE.SpotLight("#fff", 1.2, 3, Math.PI / 6, 0.1);
light1.position.set(0.1, 0.2, 0.3);
light1.castShadow = true;
light1.target = target1;
this.scene.add(target1);
this.scene.add(light1);
const target2 = new THREE.Object3D();
target2.position.set(-0.1, -0.2, 0.3);
const light2 = new THREE.SpotLight("#fff", 1.2, 2, Math.PI / 6, 0.1);
light2.position.set(-0.1, 0.2, 0.3);
light2.castShadow = true;
light2.target = target2;
this.scene.add(target2);
this.scene.add(light2);

车灯效果

最后

· 8 min read

Promise 是异步编程的一种解决方案。Promise 本质上是一个绑定了回调的对象,而不是将回调传进函数内部,从它可以获取异步操作的信息

人物介绍

Promise 异步操作有三种状态:

  • pending(进行中)
  • fulfilled(已成功)-- resolve
  • rejected(已失败)-- reject

Promise 对象只有两种状态转换:

  • pending => fulfilled
  • pending => rejected

从形式上看,Promise API 就是 Promise、resolve、reject 三个元件

  • Promise
let promise = new Promise((resolve, reject) => {});
  • resolve -- 进入 fulfilled 状态
// Promise.resolve(value)的返回值是一个 promise 对象
// 可以对返回值进行.then调用
Promise.resolve(1).then(function (value) {
console.log(value); // 打印出1
});
  • reject -- 进入 rejected 状态

Promise.then(链式调用)

当连续执行两个或者多个异步操作时,每一个后来的操作都在前面的操作执行成功之后,带着上一步操作所返回的结果开始执行

promise 基本原理

then 方法可以让我们以扁平化的形式处理异步,它将返回一个 resolved 或 rejected 状态的 Promise 对象用于链式调用,且 Promise 对象的值就是这个返回值。

// 添加多个回调函数,会按照插入顺序并且独立运行。
// 后面的请求依赖于前面请求的结果
const p = new Promise(function (resolve, reject) {
resolve(1);
})
.then(function (value) {
// 第一个then // 1
console.log(value);
return value * 2;
})
.then(function (value) {
// 第二个then // 2
console.log(value);
})
.then(function (value) {
// 第三个then // undefined
console.log(value);
return Promise.resolve("resolve");
})
.then(function (value) {
// 第四个then // resolve
console.log(value);
return Promise.reject("reject");
})
.then(
function (value) {
// 第五个then // reject:reject
console.log("resolve:" + value);
},
function (err) {
console.log("reject:" + err);
}
);

错误机制

大多数浏览器中不能终止的 Promise 链里的 rejection,建议后面都跟上

// 一个 Promise 链式程序遇到异常就会停止,查看链式的底端
// 寻找 catch 处理程序来代替当前执行
.catch(error => console.log(error));

从源头发起的错误,会依次流经 .then(resolve, reject).catch(), 只要错误被提供的 reject 方法处理了,下游将不会有这个错误出现;只要存在错误,并且不曾被方法处理,最终都会被.catch()捕获。用传统的异步回调处理这些错误,很大概率会乱成一团~

其他方法

Promise.all(统一回调)

Promise.all 可以将多个 Promise 实例包装成一个新的 Promise 实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被 reject 失败状态的值。

// 传统异步回调处理三个请求
$.get("url_a", function (data) {
if (checkAllWith("a")) {
mainRender(data);
}
});
$.get("url_b", function (data) {
if (checkAllWith("b")) {
mainRender(data);
}
});
$.get("url_c", function (data) {
if (checkAllWith("c")) {
mainRender(data);
}
});
// 使用promise.all
// $getA 是上文中用过的 $.get() 方法的 promise 封装
let $getA = function () {
return $get("a").then(callback_a);
};
let $getB = function () {
return $get("b").then(callback_b);
};
let $getC = function () {
return $get("c").then(callback_c);
};
let all = Promise.all([$getA, $getB, $getC]);
// 3个promise 全部完成后,才会触发回调
all.then((data) => {
mainRender(data);
});
// 定时器例子
let wake = (time) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`${time / 1000}秒后醒来`);
}, time);
});
};

let p1 = wake(3000);
let p2 = wake(2000);

Promise.all([p1, p2])
.then((result) => {
console.log(result);
// [ '3秒后醒来', '2秒后醒来' ]
// 同接收到的数组顺序保持一致
})
.catch((error) => {
console.log(error);
});

在前端开发请求数据的过程中,偶尔会遇到发送多个请求并根据请求顺序获取和使用数据的场景,使用 Promise.all 毫无疑问可以解决这个问题。

Promise.race(竞跑)

Promise.race([p1, p2, p3])里面哪个结果返回的快,就返回那个结果,不管结果本身是成功状态还是失败状态

let p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("success");
}, 1000);
});

let p2 = new Promise((resolve, reject) => {
setTimeout(() => {
reject("failed");
}, 500);
});

Promise.race([p1, p2])
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error); // 打开的是 'failed'
});

尝试实现 api

all

并行执行多个 promise,并等待所有 promise 都准备就绪,但是如果中途有发生错误,则会忽略其他所有 promise

其实它的实现很简单,就是遍历执行 promise 的 then 函数,将回调追加到微任务里。加到微任务的时机则由 promise 确定

function promiseAll(promiseArr) {
let result = new Array(promiseArr.length).fill(null);
let count = 0;
return new Promise((resolve, reject) => {
for (let i = 0; i < promiseArr.length; i++) {
promiseArr[i]
.then((res) => {
result[i] = res;
count++;
if (count === promiseArr.length) {
resolve(result);
}
})
.catch((err) => {
reject(err);
});
}
});
}

allSettled

这是新出的 api,可以解决 all 这个方法的不足。allSettled 可以并行执行多个 promise,并等待所有 promise 就绪,不论是成功还是失败

function promiseAllSettled(promiseArr) {
let result = new Array(promiseArr.length).fill(null);
let count = 0;
return new Promise((resolve, reject) => {
for (let i = 0; i < promiseArr.length; i++) {
promiseArr[i]
.then((res) => {
result[i] = { status: "rejected", value: res };
count++;
if (count === promiseArr.length) {
resolve(result);
}
})
.catch((err) => {
result[i] = { status: "rejected", reason: err };
count++;
if (count === promiseArr.length) {
resolve(result);
}
});
}
});
}

race

与 Promise.all 类似,同样是并行执行多个 promise,但只等待第一个 settled 的 promise 并获取其结果

function promiseRace(promiseArr) {
return new Promise((resolve, reject) => {
for (let i = 0; i < promiseArr.length; i++) {
promiseArr[i]
.then((res) => {
resolve(res);
})
.catch((err) => {
reject(err);
});
}
});
}

resolve/reject

  • Promise.resolve 方法会将传入的对象转为 Promise 对象,并执行 then 方法
function promiseResolve(promise) {
return new Promise((resolve, reject) => {
promise.then((res) => {
resolve(res);
});
});
}
  • Promise.reject 方法会将传入的对象转为 Promise 对象,并执行 catch 方法
function promiseReject(promise) {
return new Promise((resolve, reject) => {
promise.then((res) => {
reject(res);
});
});
}

模拟实现串行调用

最简单的应该就是 promise 嵌套或 async/await

// promise
function serialPromise(ajaxArray) {
let p = Promise.resolve();
let arr = [];
ajaxArray.forEach((promise) => {
p = p.then(promise).then((data) => {
arr.push(data);
return arr;
});
});
return p;
}
// async
function serialPromise(promiseArr) {
let arr = [];
async function run() {
for (let p of promiseArr) {
let val = await p();
arr.push(val);
}
return arr;
}
return run();
}

也可以借助 reduce 实现

function serialPromise(tasks) {
var result = [];
return tasks.reduce((accumulator, item, index) => {
return accumulator.then((res) => {
return getResponse(item).then((res) => {
result[index] = res;
return index == tasks.length - 1 ? result : item;
});
});
}, Promise.resolve());
}

参考

使用 Promises - MDN

ES6 Promise 对象 - 菜鸟教程

重读 ES6 - 简书

· 7 min read

JavaScript 是一种单线程语言,主要是防止 dom 操作的不确定性。但是在同步执行任务的过程容易受到一些耗时任务的阻塞,比如网络请求、定时器和事件监听等,这样会影响到整体页面的加载;因此需要引入异步编程的能力

回调函数

回调地狱:如果某个业务,依赖于上层业务的数据,上层业务又依赖于更上一层的数据,我们还采用回调的方式来处理异步的话,就会出现回调地狱。

function f2(func) {
console.log("f2");
setTimeout(function () {
func();
}, 500);
}
function func() {
console.log("func");
}

promise

Promise 的方式虽然解决了 callback hell,但是这种方式充满了 Promise 的 then() 方法,如果处理流程复杂的话,整段代码将充满 then

function demo() {
new Promise(function (resolve, reject) {
fetch(url)
.then((data) => {
resolve(data);
})
.catch();
});
}
demo()
.then((data) => {})
.catch();

其实 promise 本质上使用了观察者模式,主要流程是 then 函数收集回调函数-->同步/异步触发 resolve 函数-->resolve 执行回调

先看下 promise 的几种基础特性:

  • promise 主要有三种状态,状态间的转移是不可逆的,只能有 pengding to resolved 或者 pending to rejected 两种变化
    • pending
    • resolved
    • rejected
  • then 函数接收两个参数,分别是成功回调和失败回调
  • then 会返回一个 promise,并且会返回上一次处理 resolve 的结果,即链式调用
  • 捕获错误

基于这三个特性,尝试设计一个 promise

实现状态和 then 函数

function MiniPromise(fn) {
this.status = "pending"; // 状态
this.data = null; // 成功信息
this.err = null; // 错误信息
this.onFulfillCallbacks = []; // 成功回调
this.onRejectCallbacks = []; // 成功回调

let self = this;

function resolve(data) {
// 确保状态单向转移
if (self.status === "pending") {
self.status = "resolved";
self.data = data;
}
// 执行回调
self.onFulfillCallbacks.forEach((onFulfillCallback) => {
onFulfillCallback();
});
}
function reject(err) {
if (self.status === "pending") {
self.status = "rejected";
self.err = err;
}
self.onRejectCallbacks.forEach((onRejectCallback) => {
onRejectCallback();
});
}

try {
fn(resolve, reject);
} catch (e) {
console.log(e);
}
}

MiniPromise.prototype.then = function (onFulfill, onReject) {
let self = this;
if (self.status === "pending") {
// 将执行回调存入回调数组
self.onFulfillCallbacks.push(function () {
onFulfill(self.data);
});
self.onRejectCallbacks.push(function () {
onReject(self.err);
});
}
if (self.status === "resolved") {
onFulfill(self.data);
}
if (self.status === "rejected") {
onReject(self.err);
}
};

let miniPromise = new MiniPromise(function (resolve, reject) {
setTimeout(() => {
resolve("success");
}, 1000);
});
miniPromise.then((data) => {
console.log(data);
});
// 1s后输出'success'

实现链式调用

then 函数应该要先判断传入的参数,如果是值类型直接返回该值,如果是函数或者对象,则返回一个新的 promise。下面稍微改造下 then 函数

MiniPromise.prototype.then = function (onFulfill, onReject) {
let self = this;
let promise = new MiniPromise(function (resolve, reject) {
if (self.status === "pending") {
// 将执行回调存入回调数组
self.onFulfillCallbacks.push(function () {
try {
let value = onFulfill(self.data);
// 将value作为下一个then函数的参数
resolve(value);
} catch (err) {
reject(err);
}
});
self.onRejectCallbacks.push(function () {
try {
let err = onReject(self.err);
reject(err);
} catch (err) {
reject(err);
}
});
}
if (self.status === "resolved") {
try {
let value = onFulfill(self.data);
resolve(value);
} catch (err) {
reject(err);
}
}
if (self.status === "rejected") {
try {
let err = onReject(self.err);
reject(err);
} catch (err) {
reject(err);
}
}
});
return promise;
};
// 正常链式调用
miniPromise
.then((data) => {
console.log(data);
return `1_data`;
})
.then((data) => {
console.log(data);
});
// 输出 success
// 输出 1_success

// 捕获第一次执行then的错误
miniPromise
.then((data) => {
console.log(data);
a = b;
return `1_${data}`;
})
.then(
(data) => {
console.log(data);
},
(err) => {
console.log(err.message);
}
);

Generator

Generator 的问题在于,函数的执行需要依靠执行器,每次都需要通过 g.next() 的方式去执行

function* getNum() {
yield 1;
yield 2;
return 3;
}
const gen = getNum();
console.log(gen.next().value);
console.log(gen.next().value);
console.log(gen.next().value);

Generator 是如何实现的呢?比如上面这段代码,通过 babel 转移后的代码如下:

function getNum() {
return regeneratorRuntime.wrap(function getNum$(_context) {
while (1) {
switch ((_context.prev = _context.next)) {
case 0:
_context.next = 2;
return 1;

case 2:
_context.next = 4;
return 2;

case 4:
return _context.abrupt("return", 3);

case 5:
case "end":
return _context.stop();
}
}
}, _marked);
}

var gen = getNum();
console.log(gen.next().value);
console.log(gen.next().value);
console.log(gen.next().value);

其实 Generator 实现的核心就在于上下文的保存,每一次执行到 yield,其实都执行了一遍传入的生成器函数(getNum$),只是在这个过程中间用了一个 context 对象储存上下文,使得每次执行生成器函数的时候,都可以从上一个执行结果开始执行

async 和 await

  • 更加语义化
  • 内置执行器
  • 返回 promise
  • await 会等待这个 Promise (也可以是任意表达式)完成,并将其 resolve 的结果返回
async function getUser() {
const user = await getUser();
return user;
}
getUser().then((res) => console.log(res));

async/await 的实现原理就是 generator 自动执行器 + promise

// generator 自动执行器
function getNumPromise(num) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(num);
}, 1000);
});
}

function* getNum() {
const f1 = yield getNumPromise(1);
const f2 = yield getNumPromise(f1 + 1);
return yield getNumPromise(f2 + 1);
}

function run(gen) {
// 生成一个迭代器
const g = gen();

function next(data) {
let result = g.next(data);
if (result.done) {
return result.value;
}
result.value.then((data) => {
console.log(data);
next(data);
});
}

next();
}

run(getNum);

可以通过 babel 查看转译 async 后的结果,其实跟上面内容差不多的