Viyi

Viyi 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

Viyi 赞了文章 · 11月25日

种草ECMAScript2021新特性

观感度:🌟🌟🌟🌟🌟

口味:赛螃蟹

烹饪时间:5min

本文已收录在前端食堂同名仓库Github github.com/Geekhyt,欢迎光临食堂,如果觉得酒菜还算可口,赏个 Star 对食堂老板来说是莫大的鼓励。

ECMAScript

ECMAScriptEcma International 颁布的一部语言标准,编号为 262,又称为 ECMA-262

Ecma International 则是一个制定信息和通讯技术方面的国际标准的组织,前身是欧洲计算机制造商协会(European Computer Manufacturers Association),随着计算机的国际化,机构名称改为其英文单词首字母缩写。

ECMAScriptEMCA InternationalTC39Technical Committee 39)技术委员会编写。

TC39 会将编写完成的 ECMAScript 标准文档提交给 Ecma International,并由其正式发布。

从 2015 年开始,ECMAScript 每年都会发布一个正式版,并在标题中写上年份,比如「ECMAScript® 2020 Language Specification, 11th edition」,可简称为ES2020ES11

五个流程阶段

如果想要新增或是改写规范,一般要经历5个阶段,如TC39 Process中所示:

  • Strawperson
  • Proposal
  • Draft
  • Candidate
  • Finished

经历过这5个阶段,进入 Finished 状态的修改才会被列入正式版的规范。

ECMAScript2021

https://github.com/tc39/proposals/blob/master/finished-proposals.md

了解了 ECMAScript,下面就进入正文,让我们来看看已经确定的 ECMAScript2021 的新特性吧。

image

1.String.prototype.replaceAll

https://github.com/tc39/proposal-string-replaceall

先来回顾下 String.prototype.replace 的用法:

const str = 'Stay Hungry. Stay Foolish.'
const newStr = str.replace('Stay', 'Always')
console.log(newStr) // Always Hungry. Stay Foolish.

如果我们这样写,只有第一个匹配的会被替换。

想要做到全部替换就需要使用正则表达式。

const str = 'Stay Hungry. Stay Foolish.'
const newStr = str.replace(/Stay/g, 'Always')
console.log(newStr) // Always Hungry. Always Foolish.

不过在使用正则的时候,如果需求是匹配 + 等符号时,还需要进行转义。如:

/\+/g

聪明的你也许会想到另外一种方案:使用 split + join 的方式

这里借用下官方的例子:

const queryString = 'q=query+string+parameters';
const withSpaces = queryString.split('+').join(' ');
// q=query string parameters

但这样做也是有性能开销的,加上这种操作十分常见。于是就诞生了 String.prototype.replaceAll 这个 API,我们可以更加方便的来进行操作。

const str = 'Stay Hungry. Stay Foolish.'
const newStr = str.replaceAll('Stay', 'Always')
console.log(newStr) // Always Hungry. Always Foolish.
String.prototype.replaceAll(searchValue, replaceValue)

注意:当 searchValue 是非全局正则表达式时,replaceAll 会引发异常。如果 searchValue 是全局正则表达式时,replaceAllreplace 行为是一致的。

2.Promise.any

https://github.com/tc39/proposal-promise-any

const p = Promise.all([p1, p2, p3]);
  • Promise.all (ES2015) 只有当传入的每个 Promise 实例(p1,p2,p3)的状态都变成 fulfilled 时,p 才 fulfilled,只要(p1,p2,p3)有一个被 rejected,p 的状态就变成 rejected。
  • Promise.race (ES2015) 当传入的 Promise 实例(p1,p2,p3)中有一个率先改变状态,那么 p 的状态就跟着改变,也就是说返回最先改变的 Promise 实例的返回值。
  • Promise.allSettled (ES2020) 只有等到所有传入的 Promise 实例(p1,p2,p3)都返回结果,不管是 fulfilled 还是 rejected,包装实例才会结束。
  • Promise.any (ES2021) 当其中任何一个 Promise 完成(fulfilled)时,就返回那个已经有完成值的 Promise。如果所有的 Promise 都拒绝 (rejected), 那么返回一个拒绝的 Promise。

对比记忆

  • 我们可以把 Promise.any() 理解成 Promise.all() 的反向操作。

image

致敬韦神!

  • Promise.any()Promise.race() 方法很像,有一个不同点是:前者不会因为某个 Promise 变成 rejected 状态而结束。

想要了解更多细节可以看阮老师的ECMAScript 6 入门

Promise.any(promises).then(
    (first) => {
        // 任何一个Promise完成
    },
    (error) => {
        // 所有的 Promise都拒绝了
    }
)

any 名字的由来

any 顾名思义,不仅清楚的描述了它的作用,而且在提供此功能的第三方库中都是这样命名的,用过的同学们一定觉得很亲切。

可预见的作用

官方提供了一个例子,可以应用 Promise.any() 检查哪个站点访问最快。

Promise.any([
  fetch('https://v8.dev/').then(() => 'home'),
  fetch('https://v8.dev/blog').then(() => 'blog'),
  fetch('https://v8.dev/docs').then(() => 'docs')
]).then((first) => {
  // Any of the promises was fulfilled.
  console.log(first);
  // → 'home'
}).catch((error) => {
  // All of the promises were rejected.
  console.log(error);
});

3.WeakRefs

https://github.com/tc39/proposal-weakrefs

注意:要尽量避免使用 WeakRefFinalizationRegistry,垃圾回收机制依赖于 JavaScript 引擎的实现,不同的引擎或是不同版本的引擎可能会有所不同。

这个提案主要包括两个主要的新功能:

  • 使用 WeakRef 类创建对象的弱引用
  • 使用 FinalizationRegistry 类对对象进行垃圾回收后,运行用户定义的终结器

它们可以分开使用也可以一起使用。

WeakRef 实例不会阻止 GC 回收,但是 GC 会在两次 EventLoop 之间回收 WeakRef 实例。GC 回收后的 WeakRef 实例的 deref() 方法将会返回 undefined

let ref = new WeakRef(obj)
let isLive = ref.deref() // 如果 obj 被垃圾回收了,那么 isLive 就是 undefined

FinalizationRegistry 注册 Callback,某个对象被 GC 回收后调用。

const registry = new FinalizationRegistry(heldValue => {
  // ....
});

// 通过 register 注册任何你想要清理回调的对象,传入该对象和所含的值
registry.register(theObject, "some value");

关于更多的细节你可以查阅:

4.Logical Assignment Operators 逻辑赋值操作符

https://github.com/tc39/proposal-logical-assignment

先来回顾下 ES2020 新增的空值合并操作符 ??

在当左侧操作数为 undefinednull 时,该操作符会将右侧操作数赋值给左侧变量。

const name = null ?? '前端食堂'
console.log(name) // 前端食堂

有了逻辑赋值运算符,我们可以替换掉如下旧的写法:

const demo = function() {
    // 旧的写法1
    // if (!a) {
    //     a = '西瓜'
    // }
    // 旧的写法2
    // a = a || '西瓜'

    // 新的写法
    a ||= '西瓜' 
}

a ||= b; // 等同于 a || (a = b);

a &&= b; // 等同于 a && (a = b);

a ??= b; // 等同于 a ?? (a = b);

5.Numeric separators 数字分隔符

https://github.com/tc39/proposal-numeric-separator

数字的可读性随着数字变长而变差,数字分隔符会让长数字更加清晰。

const x = 1000000000000
const y = 1_000_000_000_000
console.log(x === y) // true

在二进制、十六进制、BigInt 等中都可以使用。

❤️爱心三连击

1.如果你觉得食堂酒菜还合胃口,就点个赞支持下吧,你的是我最大的动力。

2.关注公众号前端食堂,吃好每一顿饭!

3.点赞、评论、转发 === 催更!

查看原文

赞 8 收藏 1 评论 0

Viyi 回答了问题 · 11月22日

vue请问这个报错是为什么,数据都已经渲染出来了

从提供的信息建议给 product 初始化

data() {
    return {
        product: {
             productItems: []
        }
    };
}

关注 10 回答 8

Viyi 赞了文章 · 11月22日

数组去重(JavaScript 为例)

数组去重,就是在数组中查找相同的元素,保留其中一个,去除其他元素的程。

从这句话揭示了数组去重的两个关键因素:

  1. 找到重复项
  2. 去除重复项

本文告诉你在遇到去重问题时该如何思考,并以 JavaScript 为例,进行详细解释。使用 JavaScript 示例主要是因为它环境比较好找,而且直接对象 (Plain Object) 用起来很方便。

JavaScript 的环境:Node.js 或者浏览器的开发者控制台。

找到重复项

找到重复项最关键的算法是判定元素是否相同。判定相同,说起来似乎很简单 —— 用比较运算符就好了嘛!真的这么简单吗?

用 JavaScript 来举个例:

const a = { v: 10 };
const b = { v: 10 };

肉眼观察,这里的 ab 相同吧?但是 JavaScript 不这么认为:

console.log(a == b);    // false
console.log(a === b);   // false

肉眼观察和程序比较使用了不同的判断方法。肉眼观察很直接的采用了字符串比对的方法,而程序压根没管是不是数据相同,只是直接判断它们是不是同一个对象的引用。我们一般会更倾向于使用符合人眼直观判断的方法,所以可能会想到使用 JSON.stringify() 把对象变成字符串来判断:

console.log(JSON.stringify(a) === JSON.stringify(b));   // true

现在如果我们把 ab 略作改变,又该如何?

const a = { v: 10, n: "1" };
const b = { n: "1", v: 10 };

乍一看,ab 不同。用 JSON.stringify() 的结果来比对,也确实不同。但是仔细一看,他们的属性是完全相同的,唯一的区别在于属性的顺序不一样。那么到底顺序应不应该作为一个判断相同的依据呢?

这个问题现在真没法回答。“该如何”取决于我们的目标,也就是业务需求。

从上面的例子我们可以了解:判断相同并不是一个简单的事情,根据不同的业务要求,需要选择不同的判断方法;而不同的判断方法,可能产生不同的判断结果。

接下来先讲讲常见的判断方法。

最直接的:比较运算符

比较运算符主要用于比较基本类型的值,比如字符串、数、布尔等。

普通比较运算符 (==) 在比较不同类型的值时,会先把它们转换为相同类型再来比较;而严格比较运算符 (===) 则更为严格,会直接将类型不同值判定为不同。这些都是基本的 JavaScript 语法知识。现代开发中为了能更好的利用工具,除极少数特殊情况外,都应该使用 === 来进行判断。尤其是在 TypeScript 中,几乎都不会出现 == 了。

JavaScript 中,比较运算符不会比较对象属性,只会比较对象的引用是否相同。如果要比较对象具体信息,需要用到接下来讲到的方法。

完整信息比对

顾名思议,就是对对象或者数组进行完整地比对,将对象的属性,或者数组的元素拿出来一一对比,判断是否完全相同。比如前面示例中的 ab,在不考虑属性顺序的情况下,他们都有相同的属性 vn,而且每个属性的值也相同,所以我们可以判定他们是完全相同的。

这需要通过遍历属性的方式来比较:

function compare(a, b) {
    // 先判断属性个数,如果属性个数不等,那肯定不相同
    const aEntries = Object.entries(a);
    const bEntries = Object.entries(b);
    if (aEntries.length !== bEntries.length) {
        return false;
    }

    // 再遍历逐一判断属性,只要有一个不等,那就整个不相同
    for (const [key, value] of aEntries) {
        if (b[key] !== value) { return false; }
    }

    return true;
}

上例中那个简单的 compare 函数似乎可以达到目的,但是很容易被证伪:

  • 如果 ab 是数组怎么办?
  • 如果 ab 是多层属性结构怎么办?

    比如:{ v: 10, n: "1", c: { m: true } }

逻辑更严密的比较方法需要判断类型,不同的类型进行不同的比较;同时,对于多层次的属性结构,通过递归深入比较(注意阅读注释)。

function deepCompare(a, b) {
    // 类型相同,值或引用相同,那肯定相同
    if (a === b) { return true; }

    // 如果 a 或者 b 中有一个是 null 或 undefined,那二者不同,
    // 因此在这个条件下,a 和 b 可能相同的情况已经在前一条分支中过滤掉了。
    // 同时这个分支结合上一条分支,排除掉了 null 和 undefiend 的情况,之后不用判空了。
    if (a === null || b === null || a === undefined || b === undefined) {
        return false;
    }

    const [aType, bType] = [a, b].map(it => typeof (it));
    // 如果 a 和 b 类型不同,那就不同
    if (aType !== bType) { return false; }

    // 我们重点要深入判断的是对象和数组,它们的 typeof 运算结果都是 "object",
    // 其他类型就简单判断。前面已经处理了等值和空值的情况,剩下的就直接返回 false 了
    if (aType !== "object") { return false; }

    if (Array.isArray(a)) {
        // 作为数组进行比较,数组是一个单独的逻辑,
        // 使用 IIFE 封装是为了保证能 return,避免混入后面的逻辑。
        // 所以这里的 IIFE 不是必须的。
        return (() => {
            if (a.length !== b.length) { return false; }
            for (const i in a) {
                if (!deepCompare(a[i], b[i])) {
                    return false;
                }
            }
            return true;
        })();
    }

    // 使用之前的逻辑判断对象,记得把属性值判断那里改成递归判断,
    // 使用 IIFE 封装逻辑
    return (() => {
        // 先判断属性个数,如果属性个数不等,那肯定不相同
        const aEntries = Object.entries(a);
        const bEntries = Object.entries(b);
        if (aEntries.length !== bEntries.length) {
            return false;
        }

        // 再遍历逐一判断属性,只要有一个不等,那就整个不相同
        for (const [key, value] of aEntries) {
            if (!deepCompare(value, b[key])) { return false; }
        }

        return true;
    })();
}

上面的 deepCompare 可以处理大部分直接对象和数组数据的比较,但仍然会有一些特殊的情况处理不了,比如对象中存在循环引用时 deepCompare 会陷入死循环。这个 deepCompare 只是简单介绍了一下完整信息对比思路,在生产环境中可以使用 Lodash 的 _.isEqual() 方法。

关键信息比对

完整信息比对消耗较大。对于某些特定的业务对象来说,可能会有一些标识性的属性用来进行快速判定。比如对于用户信息来说,通常会有用户 ID 能唯一识别用户,所以比对的时候只需要简单的比对用户 ID 就可以了。比如,下面的 u1u2

const u1 = {
    userId: 123,
    name: "James",
};

const u2 = {
    userId: 123,
    phone: "12345678900"
}

虽然 u1u2 有着不同的属性,但是关键信息是相同的,所以可以认定为同一人的信息。只是比对过后,我们可能需要对这两个信息进行一个选择,或者进行合并。这将是后面“去重”要干的事情。

HASH 比对

去重的过程中通常需要对一个对象进行多轮比对,如果不能使用关键信息快速比较,每次都进行完整信息比对可能会非常耗时——尤其是对象层次较深而且数据庞大的时候。这种情况下我们可以考虑 HASH 比对。也就是根据对象的属性,计算出来一个相对唯一的 HASH 值,每次比对时只需要检查 HASH 值是否相同,就会非常快速。

比如对上述示例中的 ab 对象,可以使用这样一个简单的 HASH 算法:

// 算法仅用于示意,并未验证其有效性

const computeHash = (() => {
    /**
     * @param {string?} s
     * @return {number}
     */
    function hashString(s) {
        if (!s) { return 0; }
        let hash = 0x37;
        for (let i = 0; i < s.length; i++) {
            hash = (hash << 4) ^ s.charCodeAt(i);
        }
        return hash;
    }

    /**
     * @param {{v: number, n: string}} obj
     * @return {number}
     */
    return function (obj) {
        const hn = hashString(obj.n);
        const hv = obj.v;
        return Math.pow(hv, 7) ^ hn;
    }
})();

computeHash 函数也使用 IIFE 进行了封装,主要是想把 hashString() 作为一个内部算法保护起来,不被外部直接调用。

然后,可以分别计算 ab 的 Hash 值用于比较

const [aHash, bHash] = [a, b].map(it => computeHash(it));
console.log(aHash, bHash);

console.log(computeHash(a) === computeHash(b));

// 9999809 9999809
// true

不过,在去重的过程中,每次都调用 computeHash 仍然不能达到减少消耗 CPU 的目的。所以应该使用一个属性把 HASH 缓存起来。对于可以改变原对象的情况,直接找个无害的名称,比如 _hash 作为属性名保存起来就好:

// 假设去重数组叫 list

for (const it of list) {
    it._hash = computeHash(it);
}

如果不能改变原对象,可以考虑对原对象进行一层封装:

// 去重前封装,这里封装成数组,也可以封装成对象
const wrapped = list.map(it => ([it, computeHash(it)]));

// 去重后拆封
const result = resultList.map(it => it[0]);

使用 Hash 的办法的确是可以较大程度地节约比较时间,但它仍然存在两个问题:

  1. 计算 Hash 需要知道参与比较的单个元素结构
  2. Hash 存在碰撞,也就是说,可能存在两个不同的对象算出相同的 Hash。好的 Hash 算法可以降低碰撞概率,但不能杜绝。

综合比对:hashCode + equals

鉴于 Hash 算法存在碰撞的可能 ,我们在比较时并不能完全信任 Hash 比较。我们知道:

  1. 相同对象 Hash 得到的结果相同
  2. 不同对象的 Hash 存在碰撞的可能

可以总结出:Hash 不同时,计算出这个 Hash 的对象一定不同。

因此,我们可以使用 Hash 来进行快速失败计算,也就是比较 Hash 不同时,这两个对象一定不同,直接返回 false。比较 Hash 相同,再进行细致地比对,也就是完整信息比对。那么这个算法的示意就是:

function compare(a, b) {
    if (computeHash(a) !== computeHash(b)) { return false; }
    return deepCompare(a, b);
}

这就是我们常说的 hashCode + equals 比对方法。像 Java、C# 等语言都在 Object 基类中定义了 hash code 和 equals 接口,方便用于快速比较。JavaScript 虽然没有定义这样的接口,但是可以自己在写对象的时候进行实现。如果使用 TypeScript 和 class 语法,还可以有更强的静态检查来确保这两个方法得以实现。

在数组去重过程中,关于判定相同的方法就介绍这些。接下来是介绍“去重”这一过程。

去除重复项

有去除就有保留。我们首先要确定保留什么,去除什么。

在数组去重的过程中,通常会保留数组中找到的第一个非重复对象,并将其作为参照对象,拿数组中后面的元素跟它进行比较。下面是一个典型的去重过程:

典型去重(不改变原数组)

function makeUnique(arr) {
    // 结果集,也是非重复对象参照集
    const result = [];
    for (const it of arr) {
        // 遍历数组,检查数组中每个元素是否存在于 result 中,
        // 已存在则抛弃,未存在则加入 result
        if (!result.find(rIt => compare(it, rIt))) {
            result.push(it);
        }
    }
    return result;
}

这个算法不会改变原数组,去除重复项的结果会保存到一个新的数组中返回出来。

Lodash 中也提供了很方便的去重方法 _.uniqWith(arr, equals)equals 是用于比较两个对象是否相同的函数,可以用上面定义的 compare 函数,或者干脆就用 Lodash 提供的 _.isEqual(),所以使用 Lodash 去重很简单:

const result = _.uniqWith(list, _.isEqual);

直接从原数组去重

function makeUnique(arr) {
    for (let i = 0; i < arr.length; i++) {
        // 在之前的对象中检索,看是否已经存在
        for (let j = 0; j < i; j++) {
            if (compare(arr[j], arr[i])) {
                // 若在之前的部分中已经存在,删除当前元素,
                // 注意删除后,后面的元素会前移,所以 i 值不应该改变,
                // 考虑到下次循环前会进行 i++,所以先 i--
                arr.splice(i, 1);
                i--;
            }
        }
    }
}

直接从原数组去重时,已经遍历过的元素一定是非重复的,可以作为非重复项缓存来使用。所以这里不需要再单独定义一个缓存,直接使用数组的前半部分就好,因此第 2 重循环中的 j 值范围是 [0, i)

基于 Hash 算法的去重

function makeUnique(arr, hashCode, equals = () => true) {
    // 用新的对象将 Hash 值和原对象封装起来,
    // 为了方便阅读,这里使用了对象封装,而不是前面示例中的数组封装
    const wrapped = arr.map(value => ({
        hash: hashCode(value),
        value
    }));
    
    // 遍历去重的算法和前面典型去重算法一样
    const wrappedResult = [];
    for (const it of wrapped) {
        if (!wrappedResult.find(rIt =>
            it.hash === rIt.hash
            // 如果 hash 相同,还需要细致对比。
            // 不过默认的 equals 放弃了细致对比(直接返回 true)
            && equals(it, rIt)
        )) {
            wrappedResult.push(it);
        }
    }

    // 去重后的结果要解除封装后返回
    return wrappedResult.map(it => it.value);
}

Lodash 的 uniqBy()

Lodash 也提供了 _.uniqBy(arr, identity),用于根据 identity 的计算结果来判断重复,注意,这并不是基于 hashCode + equals 的判重算法。_.uniqBy() 方法并没有提供第三个参数,不能进行细致比较,所以它要求 identity 参数要能找到或算出唯一识别对象的值。

所以 _.uniqBy() 多数是用于对对象的唯一值属性判断,比如:

_.uniqBy(users, user => user.id);

如果需要对对象的多个属性进行联合判断,也就是非唯一关键信息比对时,_.uniqWith()_.uniqBy() 更合适。

保留最后一个,或者合并

通常我们认为重复的对象是完全一样的,所以保留找到的第 1 个,而将后面出现的删除掉。但是如果通过关键信息比对,这些被判定重复的对象就有可能不完全一样。这种情况下,根据业务需求,可能存在两种处理方式:

  1. 保留最后一个。可扩展为保留最近的、版本号最大的等。
  2. 合并重复对象,比如前面“关键信息比较”示例中的 u1u2

保留最后一个,就是找到重复项之后,把非重复项缓存中的那一个给替换掉。以经典去重为例,因为要改变目标数组的元素,所以 find() 就不好用了,应该改为 findIndex()

function makeUnique(arr) {
    const result = [];
    for (const it of arr) {
        const index = result.findIndex(rIt => compare(it, rIt));
        if (index < 0) {
            // 没找到仍然加入
            result.push(it);
        } else {
            // 找到了则替换
            result[index] = it;
        }
    }
    return result;
}

其中 else 的部分可以进一步判断,比如比较两个对象的 version 属性,留大舍小:

if (index < 0) { result.push(it); }
else if (it.version > result[index].version) {
    // 当新元素的 version 比较大时替换结果中的旧元素
    result[index] = it;
}

而合并也是一样的在 else 分支进行处理,比如

if (index < 0) { result.push(it); }
else {
    Object.assign(result[index], it);
}

因为不需要替换元素,而且 Object.assign 会直接修改第 1 个参数的对象,所以用 find() 也是可以的:

const found = result.find(rIt => compare(it, rIt));
if (!found) { result.push(it); }
else { Object.assign(found, it); }

小结

数组去重是一个老生长谈的问题,从众多提问者的疑惑来看,主要问题是在查找重复项上,找不到正确的判断重复的办法,本文的第一部分详细介绍了判断对象相同的方法。

另外常见的一个疑惑在于不能正确把握删除数组元素之后的元素序号。关于这个问题,只需要关注到,删除数组元素会改变后序元素的序号就容易理解了。当然,如果不改变原数组,处理起来会更方便也更不容易出错。

在进行“完整信息比对”的时候,请注意到 deepCompare 是一个很“重”的方法,不仅存在大量的判断,还需要进行递归。如果我们的对象结构明确,在很大程度上可以简化比对过程。TypeScript 无疑可以很好地约束对象结构,在 TypeScript 类型约束下,采用“关键信息比对”方法对对象的部分属性或所有属性进行比对 (equals),再适当结合 hashCode 算法,可以极大的提高比对效率。

TypeScript 已经成为前端必备技能之一,欢迎来到我的《TypeScript从入门到实践 【2020 版】》课程,好好地学一盘。


边城客栈

请关注公众号边城客栈

看完别走,点个赞 ⇓ 啊,或者 ⇘ 请作者喝咖啡

查看原文

赞 17 收藏 9 评论 1

Viyi 赞了文章 · 11月22日

还搞不懂闭包算我输(JS 示例)

闭包并不是 JavaScript 特有的,大部分高级语言都具有这一能力。

什么是闭包?

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).

这段是 MDN 上对闭包的定义,理解为:一个函数及其周围封闭词法环境中的引用构成闭包。可能这句话还是不好理解,看看示例:

function createAction() {
    var message = "封闭环境内的变量";
    
    return function() {
        console.log(message);
    }
}

const showMessage = createAction();
showMessage();    // output: 封闭环境内的变量

这个示例是一个典型的闭包,有这么几点需要注意:

  1. showMessagecreateAction 执行后从中返回出来的一个函数
  2. createAction 内部是一个封闭的词法环境,message 作为该封装环境内的变量,在外面是绝不可能直接访问。
  3. showMessagecreateAction 外部执行,但执行时却访问到其内部定义的局部变量 message(成功输出)。这是因为 showMessage 引用的函数(createAction 内部的匿名函数),在定义时,绑定了其所处词法环境(createAction 内部)中的引用(message 等)。
  4. 绑定了内部语法环境的匿名函数被 return 带到了 createAction 封闭环境之外使用,这才能形成闭包。如果是在 createAction 内部调用,不算是闭包。

好了,我相信 1, 2, 4 都好理解,但是要理解最重要的第 3 点可能有点困难 —— 困难之处在于,这不是程序员能决定的,而是由语言特性决定的。所以不要认为是“你”创建了闭包,因为闭包是语言特性,你只是利用了这一特性

如果语言不支持闭包,类似上面的代码,在执行 showMessage 时,就会找不到 message 变量。我特别想去找一个例子,但是很不幸,我所知道的高级语言,只要能在函数/方法内定义函数的,似乎都支持闭包。

把局部定义的函数“带”出去

前面我们提到了可以通过 return 把局部定义的函数带出去,除此之外有没有别的办法?

函数在这里已经成为“货”,和其他货(变量)没有区别。只要有办法把变量带出去,那就有办法把函数带出去。比如,使用一个“容器”对象:

function encase(aCase) {
    const dog = "狗狗";
    const cat = "猫猫";
    aCase.show = function () {
        console.log(dog, cat);
    };
}

const myCase = {};
encase(myCase);
myCase.show();      // output: 猫猫 狗狗

是不是受到了启发,有没有联想到什么?

模块和闭包

对了,就是 exports 和 module.exports。在 CJS (CommonJS) 定义的模块中,就可以通过 exports.something 逐一带货,也可以通过 module.exports = ... 打包带货,但不管怎么样,exports 就是带货的那一个,只是它有可能是原来安排的 exports 也可能是被换成了自己人的 exports

ESM (ECMAScript Module) 中使用了 importexport 语法,也只不过是换种方法带货出去而已,和 return 带货差不多,区别只在于 return 只能带一个(除非打包),export 可以带一堆。

还要补充的是,不管是 CJS 还是 ESM,模块都是一个封装环境,其中定义的东西只要不带出去,外面是访问不到的。这和网页脚本默认的全局环境不同,要注意区别。

如果用代码来表示,大概是定义模块的时候以为是这样:

const var1 = "我是一个顶层变量吧";
function maybeATopFunction() { }

结果在运行环境中,它其实是这样的(注意:仅示意):

// module factory
function createModule_18abk2(exports, module) {
    const var1 = "我是一个顶层变量吧";
    function maybeATopFunction() { }
}

// ... 遥远的生产线上,有这样的示意代码
const module = { exports: {} };
const m18abk2 = createModule_18abk2(module) ?? module;

// 想明白 createModule_18abk2 为什么会有一个随机后缀没?

还是那个函数吗?

扯远了,拉回来。思考一个问题:理论上来说,函数是一个静态代码块,那么多次调用外层函数返回出来的闭包函数,是同一个吗?

试试:

function create() {
    function closure() { }
    return closure;
}

const a = create();
const b = create();

console.log(a === b);   // false

如果觉得意外,那把 closure() 换种方式定义看会不会好理解一点:

function create() {
    closure = function() { }
    return closure;
}

如果还不能理解,再看这个:

function create() {
    const a = function () { };
    const b = function () { };
    console.log(a === b);   // false
}

能理解了不:每一次 function 都定义了一个新的函数。函数是新的,名字不重要 —— 你能叫小明,别人也能叫小明不是。

所以,总结一下:


闭包是由一个函数以及其定义时所在封闭环境内的各种资源(引用)构成,拿到的每一个闭包都是独一无二的,因为构成闭包的环境资源不同(不同的局部环境,定义了不同的局部变量,传入了不同的参数等)。

闭包,这回搞明白了!


边城客栈

请关注公众号边城客栈

看完了先别走,点个赞 ⇓ 啊,赞赏 ⇘ 就更好啦!

查看原文

赞 41 收藏 21 评论 1

Viyi 赞了文章 · 11月22日

默认值+TS类型约束提高数据处理成功率

我们在处理数据时,常常会遇到某项数据,或者属性是 undefined,从而引起中断性错误,造成数据处理失败。解决这一问题最直接的办法就是在使用前判断是否 undefined ,但是如果每一个数据使用前都进行判断,非常繁琐,而且容易遗漏。所以这里给大家介绍两个办法:

  • 利用默认值解决 undefined,避免繁琐的判断过程;
  • 利用 TypeScript 的类型系统对数据进行检查,避免遗漏。

为了更直观的描述问题和解决办法,我们设计了这样一个需求:

需求:后端响应数据转换成 UI 所需要的数据

一般来说,现在的前端组件对表格数据的要求,都是:

  • 以数组表示数据行,即当前页数据
  • 每行是一个数据对象,其属性对应于表格列
  • 除数据行之外,还包含一些附加信息用于分页,比如

    • 库存数据总数(不是当前数据行的总数)
    • 每页数据呈现条数(一般由前端传给后端,后端返回数据时带出来)
    • 当前页数(一般由前端传给后端,后端返回数据时带出来)

那么,从后端返回的数据可能会是这样(JSON 示例)

{
  "code": 0,
  "message": "查询成功",
  "data": {
    "total": 12345,
    "page": 3,
    "rows": [
      {
        "id": 34,
        "title": "第 34 号数据",
        "stamp": "2020-06-25T20:18:19Z"
      },
      ...
    ]
  }
}

前端呈现如果使用 Layui 的数据表格,它所要求的数据格式是这样的(来自官方文档):

{
  "code": 0,
  "msg": "",
  "count": 1000,
  "data": [{}, {}]
} 

远程响应数据结构和表格需要的数据结构之间,对应关系非常明显。

我们用 res 来表示后端 JSON 转成的 JavaScript 对象,那么为 Layui 准备的数据会这样取值:

const tableData = {
    code: res.code,
    msg: res.message,
    count: res.data.total,
    data: res.data.rows
}

这个处理转换非常简单,只是做了一个属性名称的变化。

但是远端返回的数据,可能没有 code,或者 message,甚至没有 data,那么上面的处理结果就可能包括 undefined。前端组件不希望有这样的值,所以需要添加默认值处理。

利用解构变量可以赋予默认值这一特性

为了演示默认值处理,我们假设,后端返回的数据规范比较灵活,为了节约网络资源,有一个默认值约定:

  • 如果请求正常完成,省略 "code": 0
  • 如果没有特殊消息,省略 "message": ""
  • 如果没有数据,即 "total": 0 的情况,省略 "data": {}
  • 如果当前页没有数据,省略 "rows": []

这种情况下,在进行数据转换时就需要充分考虑到某项数据可能不存在,避免 undefined 错误。

Object.assign() 或者 Spread 运算符可以部分解决这个问题,但是

  • 只能解决单层(直接)属性,不能解决深层属性默认值
  • 有坑,它们对 “undefiend 属性”和“不存在的属性”处理行为不同

不过我们可以利用解构变量能够赋予默认值的特性来进行处理。下面这段代码就巧妙地利用了这一特性来避免 undefined 错误。

function getData(res) {
    const { code = 0, message: msg, data = {} } = res;
    const { total: count = 0, rows = [] } = data;

    return {
        code,
        msg,
        count,
        data: rows
    }
}

const tableData = getData(res);

解构确实是可以解决问题,但是如果遗漏或者写错属性,调试起来恐怕不易。比如上面

const { message: msg } = res;

就很容易错写成

const { msg } = res

这是 JS 的硬伤,即使用 ESLint 这样强悍的静态检查工具也不能解决。但是如果引入强类型定义,使用 TypeScript 就好办了。

使用 TypeScript 和类型检查转换过程

既然用 TypeScript,就需要为两个数据结构定义对应的类型了。

我们有两个数据结构 restableData,在 TypeScript 里可以直接把它们定义为 any 类型,这是最简单的操作,但是没什么用 —— 因为 TypeScript 不检查 any 类型。

所以先根据我们之前的约定,把 restableData 的类型定义出来:

interface FetchData<T> {
    total: number;
    page?: number;
    rows?: T[];
}

interface FetchResult<T> {
    code?: number;
    message?: string;
    data?: FetchData<T>;
}

interface LayuiTalbeData<T> {
    code: number;
    msg?: string;
    count: number;
    data: T[];
}

然后要把 res 声明成 FetchResult<T> 类型。这里我们暂时不关心具体每行数据的结构,所以直接用 any 代替好了

const res: FetchResult<any> = await fetch();
// 或者
// const res = await fetch() as FetchResult<any>;

这种情况下,假如我们不小心写错了属性名,比如解构时把源属性 message 错写成了目录属性名 msg,即 const { msg } = res,VSCode 是会提示错误的:

image.png

解决的办法是使用解构重命名:

const { message: msg } = res;

或者,如果我们忘了处理 undefined,比如忘了给解构的 rows 赋予初始值,那也会得到错误提示,因为

  • 源数据定义中 rows?: T[] 表示它可省略,即可能是 undefined
  • 目标数据定义中 data: T[] 表示它一定不会是 undefined

image.png

解决的办法是,赋予初始值,使其不可能为 undefined

const { rows = [] } = data;

完整的 TypeScript 代码如下(类型定义参考前面的定义):

function getData<T>(res: FetchResult<T>): LayuiTalbeData<T> {
    const { code = 0, message: msg, data = {} as FetchData<T> } = res;
    const { total: count = 0, rows = [] } = data;

    return {
        code,
        msg,
        count,
        data: rows
    }
}

// 这里 TypeScript 可以推导出 tableData 类型是 LayuiTalbeData<any>
const tableData = getData(res);

使用 Optional Chain 和 Nullish Coalescing

回到 JavaScript,其实还有一个办法可以处理默认值的问题:

const tableData = {
    code: res.code || 0,
    msg: res.message || "",
    count: (res.data && res.data.total) || 0,
    data: (res.data && res.data.rows) || []
}

这也是一个非常常见的办法。这个办法在 TypeScript 中配置类型定义同样可行。只是对多层属性的处理仍然显得有点麻烦。不过 JavaScript 最近引入了“Optional Chain”和“Nullish Coalescing”特性,这个代码可以更简洁:

const tableData = {
    code: res.code ?? 0,
    msg: res.message,
    count: res.data?.total ?? 0,
    data: res.data?.rows ?? []
};

放在 TypeScript 中是这样写的:

const tableData: LayuiTalbeData<any> = {
    code: res.code ?? 0,
    msg: res.message,
    count: res.data?.total ?? 0,
    data: res.data?.rows ?? []
}

上面的 TypeScript 代码中,如果错写了 res.msg 或者忘了加 ?? [] 等,TSC 或者 VSCode 都会有错误提示,以保证你能修正代码。

看,新特性配合 TypeScript 的强类型检查,简直是完美!

小结

我们讲了最简单的数据转换:直接按源数据属性取值。虽然简单,但是有坑,也有处理的方法和技巧:

  • 注意可能出现的 undefinednull,甚至 NaN 等需要特殊处理的数据
  • 使用解构将属性提取出来,并根据数据结构的需要适当赋予初始值
  • 使用 Optional Chain 和 Nullish Coalescing 简化对可能为 undefined 属性的处理
  • 使用 TypeScript 在开发期检查错误

这里讲的数据处理比较基础,但其中的坑也比较容易被忽略。后面在专栏或订阅号中,我们还会继续探讨更复杂一些的数据处理分析方法和处理技巧,请关注!

查看原文

赞 8 收藏 3 评论 1

Viyi 赞了文章 · 4月25日

开奖名单公布!丨写在 SegmentFault 社区停机整改 2 天后,一起来分享你和思否的故事

各位社区开发者们:

大家好,过去的两天对我来说真的是度日如年。由于社区内出现了少量不合规内容,我们被网监部门要求配合内容审查,也让 SegmentFault 网站出现了无法正常访问的情况。在此,我谨代表思否团队向大家表达诚挚的歉意。

在这段时间里,我们收到了很多用户的反馈:事件发生时正在撰写技术文章、观看思否编程视频课程、有技术问题亟待解决希望发帖提问、需要查看在思否的笔记……

耽误了大家的正常使用,真的是非常抱歉。所幸的是在昨天下午 17:00,SegmentFault 网站已经完全恢复正常访问。

另外,最让我们感动的是,很多社区用户通过各种各样的渠道,给我们发来慰问和支持的信息:

微信图片_20200423143439_副本.jpg

大家对我们的支持与信任,我们都记录了下来,也会付诸在之后的行动当中。

这是社区上线以来最严重的一次运营事故,也是思否团队所经历的最焦灼难熬的 2 天。

但在这次的「危机」中,我们也感受到了开发者对 SegmentFault 的信任与依赖,听到了社区用户最真实的声音与表达。这些正是支撑着我们努力坚持下去、并引以为傲的根本。

我们也会更加坚定公司的使命和愿景,为开发者、为行业创造更大的价值,帮助开发者获得成功!

微信截图_20200423144932.png


为了回馈各位开发者对社区的支持,我们在经过商议后决定免费开放思否编程 8 门技术课程,大家可以联系思否小姐姐(微信ID:bobonadia)直接领取。

1_副本.jpg

同时,我们也希望大家在本篇文章下回帖评论,一起分享你和思否的故事,或许你参加过 SegmentFault 的线下活动,或许在这里观看过视频教学,或许你是社区的内容创作者,或许你一直在这里默默学习成长……

我们期待你们的分享,同时也会选取出 20 个让我们或声泪俱下或感慨万千或哈哈大笑的故事,送出思否限量版文化衫。(留言评选活动将于 5 月 1 日 00:00 份截止留言统计,最终入选名单也将在文章底部进行公开展示)

微信图片_20200423150938.png

最后,仍然要对这次的事故说一声对不起。但我们绝对不会辜负社区开发者们的期待与信任。

We're Back!


相关阅读:
特别鸣谢:网易易盾助力 SegmentFault 加强内容风控管理
SegmentFault 思否 - 7 年如一日做好开发者社区这件事,成为中文开发者领域最被信赖的创领者

更新:开奖了!开奖了!

首先,感谢大家对 SegmentFault 思否社区的支持与鼓励。看到大家的留言我们公司全体员工都非常感动,一致决定特别为本次参与活动的中奖用户定制一批新的文化衫,并且将获奖名单从 20 名增加到 30 名~

但因疫情原因工厂尚未加工完成,预计五月中旬就可以验货提货了,大家还要稍微等一等才能收到小礼物。

下面是本次留言活动的 30 位入选用户,入选者可以添加文末「思否小姐姐」的微信,提供一下您的衣服尺码、姓名、电话和邮寄地址,等新款文化衫到货后,我们会第一时间给您寄出~

日日是好日,年年是好年,我们一起加油吧!

排名不分先后:

@泥瓦匠 / @码农小胖哥 / @Yian / @wjf9492 / @edagarli / @趁你还年轻 / @chow / @猿人谷 / @Corwien / @一个奕 / @民工哥 / @李柠萌 / @若川 / @Luffy / @折腾不止的追梦人 / @godtail / @0x400 / @lioney / @kuntang / @imknown / @jsRong / @gxcuizy / @五柳 / @Peter谭金杰 / @yangrunkang / @sogouo / @G_Koala_C / @叶箫大人 / @stardew / @悠讓

clipboard.png

查看原文

赞 68 收藏 0 评论 90

Viyi 赞了文章 · 4月25日

从小小题目逐步走进 JavaScript 异步调用

问题

原题来自 @若泽提问

可修改下面的 aa() 函数,目的是在一秒后用 console.log() 输出 want-value

function aa() {
    setTimeout(function() {
        return "want-value";
    }, 1000);
}

但是,有额外要求:

  1. aa() 函数可以随意修改,但是不能有 console.log()
  2. 执行 console.log() 语句里不能有 setTimeout 包裹

解答

也许这是个面试题,管它呢。问题的主要目的是考察对异步调用执行结果的处理,既然是异步调用,那么不可能同步等待异步结果,结果一定是异步的

setTimeout() 经常用来模拟异步操作。最早,异步是通过回调来通知(调用)处理程序处理结果的

function aa(callback) {
    setTimeout(function() {
        if (typeof callback === "function") {
            callback("want-value");
        }
    }, 1000);
}

aa(function(v) {
    console.log(v);
});

不过回调在用于稍大型一点的异步应用时,容易出现多层嵌套,所以之后提出了一些对其进行“扁平”化,这一部分可以参考闲谈异步调用“扁平”化。当然 Promise 是非常流行的一种方法,并最终被 ES6 采纳。用 Promise 实现如下:

function aa() {
    return new Promise(resolve => {
        setTimeout(function() {
            resolve("want-value");
        }, 1000);
    });
}

aa().then(v => console.log(v));

就这个例子来说,它和前面回调的例子大同小异。不过它会引出目前更推荐的一种方法——async/await,从 ES2017 开始支持:

function aa() {
    return new Promise(resolve => {
        setTimeout(function() {
            resolve("want-value");
        }, 1000);
    });
}

async function main() {
    const v = await aa();
    console.log(v);
}

main();

aa() 的定义与 Promise 方法中的定义是一样的,但是在调用的时候,使用了 await,异步等待,等待到异步的结果之后,再使用 console.log() 对其进行处理。

这里需要注意的是 await 只能在 async 方法中使用,所以为了使用 await 必须定义一个 async 的 main 方法,并在全局作用域中调用。由于 main 方法是异步的(申明为 async),所以如果 main() 调用之后还有其它语句,比如 console.log("hello"),那么这一句话会先执行。

async/await 语法让异步调用写起来像写同步代码,在编写代码的时候,可以避免逻辑跳跃,写起来会更轻松。(参考:从地狱到天堂,Node 回调向 async/await 转变

当然,定义 main() 再调用 main() 这部分可以用 IIFE 封装一下,

(async () => {
    const v = await aa();
    console.log(v);
})();
查看原文

赞 19 收藏 17 评论 4

Viyi 赞了文章 · 4月25日

从不用 try-catch 实现的 async/await 语法说错误处理

前不久看到 Dima Grossman 写的 How to write async await without try-catch blocks in Javascript。看到标题的时候,我感到非常好奇。我知道虽然在异步程序中可以不使用 try-catch 配合 async/await 来处理错误,但是处理方式并不能与 async/await 配合得很好,所以很想知道到底有什么办法会比 try-catch 更好用。

Dima 去除 try-catch 的方法

当然套路依旧,Dima 讲到了回调地狱,Promise 链并最终引出了 async/await。而在处理错误的时候,他并不喜欢 try-catch 的方式,所以写了一个 to(promise) 来对 Promise 进行封装,辅以解构语法,实现了同步写法但类似 Node 错误标准的代码。摘抄代码如下

// to.js
export default function to(promise) {
    return promise
        .then(data => {
            return [null, data];
        })
        .catch(err => [err]);
}

应用示例:

import to from "./to.js";

async function asyncTask(cb) {
    let err, user, savedTask;

    [err, user] = await to(UserModel.findById(1));
    if (!user) return cb("No user found");

    [err, savedTask] = await to(TaskModel({ userId: user.id, name: "Demo Task" }));
    if (err) return cb("Error occurred while saving task");

    if (user.notificationsEnabled) {
        const [err] = await to(NotificationService.sendNotification(user.id, "Task Created"));
        if (err) return cb("Error while sending notification");
    }

    cb(null, savedTask);
}

Dima 的办法让人产生的了熟悉的感觉,Node 的回调中不是经常都这样写吗?

(err, data) => {
    if (err) {
        // deal with error
    } else {
        // deal with data
    }
}

所以这个方法真的很有意思。不过回过头来想一想,这段代码中每当遇到错误,都是将错误消息通过 cb() 调用推出去,同时中断后续过程。像这种中断式的错误处理,其实正适合采用 try-catch。

使用 try-catch 改写上面的代码

要用 try-catch 改写上面的代码,首先要去掉 to() 封装。这样,一旦发生错误,需要使用 Promise.prototype.catch() 进行捕捉,或者使用 try-catch 对 await promise 语句进行捕捉。捕捉到的,当然是每个业务代码里 reject 出来的 err

然而注意,上面的代码中并没有直接使用 err,而是使用了自定义的错误消息。所以需要对 reject 出来的 err 进一步处理成指定的错误消息。当然这难不到谁,比如

someAsync().catch(err => Promise.reject("specified message"));

然后再最外层加上 try-catch 就好。所以改写之后的代码是:

async function asyncTask(cb) {
    try {
        const user = await UserModel.findById(1)
            .catch(err => Promise.reject("No user found"));

        const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" })
            .catch(err => Promise.reject("Error occurred while saving task"));

        if (user.notificationsEnabled) {
            await NotificationService.sendNotification(user.id, "Task Created")
                .catch(err => Promise.reject("Error while sending notification"));
        }

        cb(null, savedTask);
    } catch (err) {
        cb(err);
    }
}

上面这段代码,从代码量上来说,并没有比 Dima 的代码减少了多少工作量,只是去掉了大量 if (err) {} 结构。不习惯使用 try-catch 的程序员找找不到中断点,但习惯了 try-catch 的程序员都知道,业务过程中一旦发生错误(异步代码里指 reject),代码就会跳到 catch 块去处理 reject 出来的值。

但是,一般业务代码 reject 出来的信息通常都是有用的。假如上面的每个业务 reject 出来的 err 本身就是错误消息,那么,用 Dima 的模式,仍然需要写

if (err) return cb(err);

而用 try-catch 的模式,就简单多了

async function asyncTask(cb) {
    try {
        const user = await UserModel.findById(1);
        const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" });

        if (user.notificationsEnabled) {
            await NotificationService.sendNotification(user.id, "Task Created");
        }

        cb(null, savedTask);
    } catch (err) {
        cb(err);
    }
}

为什么?因为在 Dima 的模式中,if (err) 实际上处理了两个业务:一是捕捉会引起中断的 err ,并将其转换为错误消息,二是通过 return 中断业务过程。所以当 err 转换为错误消息这一过程不再需要的时候,这种捕捉中断再重新引起中断的处理就显得多余了。

继续改进

用函数表达式改善 try-catch 逻辑

当然还有改进的空间,比如 try {} 块中的代码比较长,会造成阅读不太方便,try-catch 的逻辑有被“切断”的感觉。这种情况下可以使用函数表达式来改善

async function asyncTask(cb) {
    async function process() {
        const user = await UserModel.findById(1);
        const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" });

        if (user.notificationsEnabled) {
            await NotificationService.sendNotification(user.id, "Task Created");
        }
        return savedTask;
    }

    try {
        cb(null, await process());
    } catch (err) {
        cb(err);
    }
}

如果对错误的处理代码比较长,也可以写成单独的函数表达式。

如果过程中每一步的错误处理逻辑不同怎么办

如果发生错误,不再转换为错误消息,而是特定的错误处理逻辑,怎么办?

思考一下,我们用字符串来表示错误消息,以后可以通过 console.log() 来处理处理。而逻辑,最适合的表示当然是函数表达式,最终可以通过调用来进行统一处理

async function asyncTask(cb) {
    async function process() {
        const user = await UserModel.findById(1)
            .catch(err => Promise.reject(() => {
                // deal with error on looking for the user
                return "No user found";
            }));

        const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" })
            .catch(err => Promise.reject(() => {
                // making model error
                // deal with it
                return err === 1
                    ? "Error occurred while saving task"
                    : "Error occurred while making model";
            }));

        if (user.notificationsEnabled) {
            await NotificationService.sendNotification(user.id, "Task Created")
                .catch(err => Promise.reject(() => {
                    // just print a message
                    logger.log(err);
                    return "Error while sending notification";
                }));
        }

        return savedTask;
    }

    try {
        cb(null, await process());
    } catch (func) {
        cb(func());
    }
}

甚至还可以处理更复杂的情况

现在应该都知道 .catch(err => Promise.reject(xx)),这里的 xx 就是 try-catch 的 catch 块捕捉到的对象,所以如果不同的业务 reject 出来不同的对象,比如有些是函数(表示错误处理逻辑),有些是字符串(表示错误消息),有些是数字(表示错误代码)——其实只需要改 catch 块就行

    try {
        // ...   
    } catch(something) {
        switch (typeof something) {
            case "string":
                // show message something
                break;
            case "function":
                something();
                break;
            case "number":
                // look up something as code
                // and show correlative message
                break;
            default:
                // deal with unknown error
        }
    }

小结

我没有批判 Dima 的错误处理方式,这个错误处理方式很好,很符合 Node 错误处理的风格,也一定会受到很多人的喜爱。由于 Dima 的错误处理方式给带灵感,同时也让我再次审视了一直比较喜欢的 try-catch 方式。

用什么方式取决于适用场景、团队约定和个人喜好等多种因素,在不同的情况下需要采用不同的处理方式,并不是说哪一种就一定好于另一种——合适的才是最好的!

查看原文

赞 50 收藏 51 评论 13

Viyi 赞了文章 · 4月25日

前后分离模型之封装 Api 调用

Ajax 和异步处理

调用 API 访问数据采用的 Ajax 方式,这是一个异步过程,异步过程最基本的处理方式是事件或回调,其实这两种处理方式实现原理差不多,都需要在调用异步过程的时候传入一个在异步过程结束的时候调用的接口。比如 jQuery Ajax 的 success 就是典型的回调参数。不过使用 jQuery 处理异步推荐使用 Promise 处理方式。

Promise 处理方式也是通过注册回调函数来完成的。jQuery 的 Promise 和 ES6 的标准 Promise 有点不一样,但在 then 上可以兼容,通常称为 thenable。jQuery 的 Promise 没有提供 .catch() 接口,但它自己定义的 .done().fail().always() 三个注册回调的方式也很有特色,用起来很方便,它是在事件的方式来注册的(即,可以注册多个同类型的处理函数,在该触发的时候都会触发)。

当然更直观的一点的处理方式是使用 ES2017 带来的 async/await 方式,可以用同步代码的形式来写异步代码,当然也有一些坑在里面。对于前端工程师来说,最大的坑就是有些浏览器不支持,需要进行转译,所以如果前端代码没有构建过程,一般还是就用 ES5 的语法兼容性好一些(jQuery 的 Promise 是支持 ES5 的,但是标准 Promise 要 ES6 以后才可以使用)。

关于 JavaScript 异步处理相关的内容可以参考

自己封装工具函数

在处理 Ajax 的过程中,虽然有现成的库(比如 jQuery.ajax,axios 等),它毕竟是为了通用目的设计的,在使用的时候仍然不免繁琐。而在项目中,对 Api 进行调用的过程几乎都大同小异。如果设计得当,就连错误处理的方式都会是一样的。因此,在项目内的 Ajax 调用其实可以进行进一步的封装,使之在项目内使用起来更方便。如果接口方式发生变化,修改起来也更容易。

比如,当前接口要求使用 POST 方法调用(暂不考虑 RESTful),参数必须包括 action,返回的数据以 JSON 方式提供,如果出错,只要不是服务器异常都会返回特定的 JSON 数据,包括一个不等于 0 的 code 和可选的 message 属性。

那么用 jQuery 写这么一个 Ajax 调用,大概是这样

const apiUrl = "http://api.some.com/";

jQuery
    .ajax(url, {
        type: "post",
        dataType: "json",
        data: {
            action: "login",
            username: "uname",
            password: "passwd"
        }
    })
    .done(function(data) {
        if (data.code) {
            alert(data.message || "登录失败!");
        } else {
            window.location.assign("home");
        }
    })
    .fail(function() {
        alert("服务器错误");
    });

初步封装

同一项目中,这样的 Ajax 调用,基本上只有 data 部分和 .done 回调中的 else 部分不同,所以进行一次封装会大大减少代码量,可以这样封装

function appAjax(action, params) {
    var deffered = $.Deferred();

    jQuery
        .ajax(apiUrl, {
            type: "post",
            dataType: "json",
            data: $.extend({
                action: action
            }, params)
        })
        .done(function(data) {
            // 当 code 为 0 或省略时,表示没有错误,
            // 其它值表示错误代码
            if (data.code) {
                if (data.message) {
                    // 如果服务器返回了消息,那么向用户呈现消息
                    // resolve(null),表示不需要后续进行业务处理
                    alert(data.message);
                    deffered.resolve();
                } else {
                    // 如果服务器没返回消息,那么把 data 丢给外面的业务处理
                    deferred.reject(data);
                }
            } else {
                // 正常返回数据的情况
                deffered.resolve(data);
            }
        })
        .fail(function() {
            // Ajax 调用失败,向用户呈现消息,同时不需要进行后续的业务处理
            alert("服务器错误");
            deffered.resolve();
        });

    return deferred.promise();
}

而业务层的调用就很简单了

appAjax("login", {
    username: "uname",
    password: "passwd"
}).done(function(data) {
    if (data) {
        window.location.assign("home");
    }
}).fail(function() {
    alert("登录失败");
});

更换 API 调用接口

上面的封装对调用接口和返回数据进行了统一处理,把大部分项目接口约定的内容都处理掉了,剩下在每次调用时需要处理的就是纯粹的业务。

现在项目组决定不用 jQuery 的 Ajax,而是采用 axios 来调用 API(axios 不见得就比 jQuery 好,这里只是举例),那么只需要修改一下 appAjax() 的实现即可。所有业务调用都不需要修改。

假设现在的目标环境仍然是 ES5,那么需要第三方 Promise 提供,这里拟用 Bluebird,兼容原生 Promise 接口(在 HTML 中引入,未直接出现在 JS 代码中)。

function appAjax(action, params) {
    var deffered = $.Deferred();

    axios
        .post(apiUrl, {
            data: $.extend({
                action: action
            }, params)
        })
        .then(function(data) { ... }, function() { ... });

    return deferred.promise();
}

这次的封装采用了 axios 来实现 Web Api 调用。但是为了保持原来的接口(jQuery Promise 对象有提供 .done().fail().always() 事件处理),appAjax 仍然不得不返回 jQuery Promise。这样,即使所有地方都不再需要使用 jQuery,这里仍然得用。

项目中应该用还是不用 jQuery?请阅读为什么要用原生 JavaScript 代替 jQuery?

去除 jQuery

就只在这里使用 jQuery 总让人感觉如芒在背,想把它去掉。有两个办法

  1. 修改所有业务中的调用,去掉 .done().fail().always(),改成 .then()。这一步工作量较大,但基本无痛,因为 jQuery Promise 本身支持 .then()。但是有一点需要特别注意,这一点稍后说明

  2. 自己写个适配器,兼容 jQuery Promise 的接口,工作量也不小,但关键是要充分测试,避免差错。

上面提到第 1 种方法中有一点需要特别注意,那就是 .then().done() 系列函数在处理方式上有所不同。.then() 是按 Promise 的特性设计的,它返回的是另一个 Promise 对象;而 .done() 系列函数是按事件机制实现的,返回的是原来的 Promise 对象。所以像下面这样的代码在修改时就要注意了

appAjax(url, params)
    .done(function(data) { console.log("第 1 处处理", data) })
    .done(function(data) { console.log("第 2 处处理", data) });
// 第 1 处处理 {}
// 第 2 处处理 {}

简单的把 .done() 改成 .then() 之后(注意不需要使用 Bluebird,因为 jQuery Promise 支持 .then()

appAjax(url, params)
    .then(function(data) { console.log("第 1 处处理", data); })
    .then(function(data) { console.log("第 2 处处理", data); });
// 第 1 处处理 {}
// 第 2 处处理 undefined

原因上面已经讲了,这里正确的处理方式是合并多个 done 的代码,或者在 .then() 处理函数中返回 data

appAjax(url, params)
    .then(function(data) {
        console.log("第 1 处处理", data);
        return data;
    })
    .then(function(data) {
        console.log("第 2 处处理", data);
    });

使用 Promise 接口改善设计

我们的 appAjax() 接口部分也可以设计成 Promise 实现,这是一个更通用的接口。既使用不用 ES2015+ 特性,也可以使用像 jQuery Promise 或 Bluebird 这样的三方库提供的 Promise。

function appAjax(action, params) {
    // axios 依赖于 Promise,ES5 中可以使用 Bluebird 提供的 Promise
    return axios
        .post(apiUrl, {
            data: $.extend({
                action: action
            }, params)
        })
        .then(function(data) {
            // 这里调整了判断顺序,会让代码看起来更简洁
            if (!data.code) { return data; }
            if (!data.message) { throw data; }
            alert(data.message);
        }, function() {
            alert("服务器错误");
        });
}

不过现在前端有构建工具,可以使用 ES2015+ 配置 Babel,也可以使用 TypeScript …… 总之,选择很多,写起来也很方便。那么在设计的时候就不用局限于 ES5 所支持的内容了。所以可以考虑用 Promise + async/await 来实现

async function appAjax(action, params) {
    // axios 依赖于 Promise,ES5 中可以使用 Bluebird 提供的 Promise
    const data = await axios
        .post(apiUrl, {
            data: $.extend({
                action: action
            }, params)
        })
        // 这里模拟一个包含错误消息的结果,以便后面统一处理错误
        // 这样就不需要用 try ... catch 了
        .catch(() => ({ code: -1, message: "服务器错误" }));

    if (!data.code) { return data; }
    if (!data.message) { throw data; }

    alert(data.message);
}

上面代码中使用 .catch() 来避免 try ... catch ... 的技巧在从不用 try-catch 实现的 async/await 语法说错误处理中提到过。

当然业务层调用也可以使用 async/await(记得写在 async 函数中):

const data = await appAjax("login", {
    username: "uname",
    password: "passwd"
}).catch(() => {
    alert("登录失败");
});

if (data) {
    window.location.assign("home");
}

对于多次 .done() 的改造:

const data = await appAjax(url, params);
console.log("第 1 处处理", data);
console.log("第 2 处处理", data);

小结

本文以封装 Ajax 调用为例,看似在讲述异步调用。但实际想告诉大家的东西是:如何将一个常用的功能封装起来,实现代码重用和更简洁的调用;以及在封装的过程中需要考虑的问题——向前和向后的兼容性,在做工具函数封装的时候,应该尽量避免和某个特定的工具特性绑定,向公共标准靠拢——不知大家是否有所体会。

查看原文

赞 58 收藏 375 评论 4

认证与成就

  • 获得 26 次点赞
  • 获得 7 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-09-04
个人主页被 378 人浏览