跳到主要内容

· 阅读需 4 分钟
熊滔

思维导图布局实现,如下

简单考虑,只考虑向右展开的布局。

已经条件,知道根节点的位置 (rootX,rootY)(\text{rootX}, \text{rootY}),以及所有节点的大小(宽高),求其他节点的位置 (x,y)(x, y)

首先看子节点的 xx 和父节点 parentX\text{parentX} 的关系,非常简单,子节点在父节点右侧的 marginX\text{marginX} 处,即

childX=parentX+parentWidth+marginX\text{childX} = \text{parentX} + \text{parentWidth} + \text{marginX}

对于子节点的 yy 的计算就比较复杂了,考虑下图

在知道父元素的位置和宽高的情况下以及所有子节点总体高度的情况下,计算所有子节点整体的 yy,在 yy 方向上要满足一个关系,就是父节点和子节点整体是居中对齐的,即满足

parentY+parentHeight2=childrenY+childrenHeight2\text{parentY} + \dfrac{\text{parentHeight}}{2} = \text{childrenY} + \frac{\text{childrenHeight}}{2}

可以得到

childrenY=parentY+parentHeight2childrenHeight2\text{childrenY} = \text{parentY} + \dfrac{\text{parentHeight}}{2} - \frac{\text{childrenHeight}}{2}

在知道总体的一个位置之后,计算单个子节点的位置就很简单了,比如对于第 ii 个子节点,它离顶部的距离就是之前所有节点的实际高度加上所有间距

child[i].childrenTop=index=0i1child[index].actualHeight+(i1)×marginY\text{child[i].childrenTop} = \sum_{\text{index} = 0}^{i - 1}\text{child[index].actualHeight} + (i - 1) \times \text{marginY}

但是这里还需要做一个调整,这里计算的是这个节点占据区域的整体位置,有可能这个节点的高度小于占据的高度,我们还需要将这个节点居中

child.top = child.childrenTop+child.actualHeight - child.height2\text{child.top = child.childrenTop} + \frac{\text{child.actualHeight - child.height}}{2}

上面我们假设 childrenHeight\text{childrenHeight} 是已知的,但实际上是需要计算的,当然很好算,就是所有子节点的占据的高度之和加上所有的间距,注意,这里占据的高度和节点的 height\text{height} 值并不相等,一个节点占据的高度(actualHeight\text{actualHeight})的计算方法是

node.actualHeight=max(node.height,node.childrenHeight))\text{node.actualHeight} = \max(\text{node.height}, \text{node.childrenHeight}))

所有 childrenHeight\text{childrenHeight} 的计算为

node.childrenHeight=inchildren[i].actualHeight+(n1)×marginY\text{node.childrenHeight} = \sum_{i}^{n}\text{children[i].actualHeight} + (n - 1) \times \text{marginY}

需要两次遍历,第一次遍历计算 xxchildrenHeight\text{childrenHeight}actualHeight\text{actualHeight},第二次遍历计算 yy,可以参考以下代码

const MARGIN_X = 36;
const MARGIN_Y = 16;

const dfs = (node: MindNodeElement, {
before,
after,
}, parent: MindNodeElement | null = null, index = 0) => {
before?.(node, parent, index);
node.children.forEach((child, index) => {
this.dfs(child, {
before,
after,
}, node, index);
});
after?.(node, parent, index);
}

const layout = (node: MindNodeElement): MindNodeElement => {
this.dfs(draft, {
before: (node, parent) => {
if (parent) {
node.x = parent.x + parent.width + MARGIN_X;
}
},
after: (node) => {
if (node.children.length > 0) {
node.childrenHeight = node.children.reduce((pre, cur) => {
return pre + cur.actualHeight;
}, 0) + (node.children.length - 1) * MARGIN_Y;
node.actualHeight = Math.max(node.childrenHeight, node.height);
} else {
node.actualHeight = node.height;
node.childrenHeight = 0;
}
}
})

this.dfs(draft, {
before: (node, parent, index) => {
if (!parent) return;

const baseY = parent.y + parent.height / 2 - parent.childrenHeight / 2;
const beforeSiblings = index > 0 ? parent.children.slice(0, index) : [];
const siblingsHeight = beforeSiblings.reduce((pre, cur) => {
return pre + cur.actualHeight;
}, 0) + beforeSiblings.length * MARGIN_Y;

const childrenTop = baseY + siblingsHeight;
node.y = childrenTop + (node.actualHeight - node.height) / 2;
}
})
}

· 阅读需 10 分钟
熊滔

什么是拷贝?对一个变量进行拷贝是指将其表示的数据复制到另一个变量中,大部分情况下的目的是为了避免直接修改原始数据,以便后续进行多种不同的处理或对比。

最简单的拷贝行为,直接将一个变量赋值给另一个变量

let a = 1;
let b = a;
b = 2;
console.log(a, b); // 1, 2
const originalObj = {
a: 1
}
const copyObj = originalObj;
copyObj.a = 2;
console.log(originalObj.a, copyObj.a); // 2, 2

可以看到,这种直接赋值的方式对于基本数据类型和对象是不一样的,对于基本数据类型(number, string, null, undefined, boolean, symbol, BigInt)直接赋值是完全隔离的,对拷贝的修改不会影响原数据,而对于对象(包括数组、日期、函数等特殊对象)直接进行赋值拷贝,拷贝的仅仅是引用,因此对通过拷贝所持有的引用对数据进行的修改也会影响原来的对象。

因此对于对象的拷贝不能通过直接赋值来达到目的,而是需要将引用的数据进行拷贝,根据拷贝方式的不同,分为浅拷贝和深拷贝。

浅拷贝即只复制对象的一层属性,常见的方式有

const originalObj = {
a: 1
}
const copyObj = {};
for (let key in obj1) {
obj2[key] = obj1[key];
}
const originalObj = {
a: 1
}
const copyObj = {
...obj1
}
const originalObj = {
a: 1
}
const copyObj = Object.assign({}, originalObj);

如果对象的属性的值也是一个对象,那么就会存在问题,复制的仍然是一个引用,对该引用的修改同样会影响原对象

const originalObj = {
a: {
b: 1
}
}
const copyObj = Object.assign({}, originalObj);
copyObj.a.b = 2;
console.log(originalObj.a.b, copyObj.a.b); // 2 2

如果希望对拷贝的值的修改完全不影响原对象,这种情况下就需要进行深拷贝了。

信息

大部分情况下不需要使用深拷贝,原因:

  • 浅拷贝够用了
  • 深拷贝性能较低

JSON.parse、JSON.stringify

一种序列化的方式是将对象先序列为字符串,然后根据字符串重新反序列化为对象,该种方式创建的对象是完全开辟一块新的内存空间,和原来的对象没有任何关系:

const originalObj = {
a: {
b: 1
}
}
const copyObj = JSON.parse(JSON.stringify(originalObj));
copyObj.a.b = 2;

console.log(originalObj.a.b, copyObj.a.b); // 1 2

但是该种方式有很多缺点:

  • 只能处理被序列化的对象,对于特殊类型的对象在反序列化时会丢失特定的类型信息

    • Date 会被序列化为字符串

    • RegExpPromise、Map、Set、WeakMap、WeakSet 会被序列化为空对象

    • Function ,DOM 对象会被忽略

    const originalObj = {
    a: 1,
    b: new Date(),
    c: new RegExp('\\d{2}'),
    d: function () {
    return 'hello'
    },
    e: Promise.resolve('world'),
    f: new Map([['a', 1], ['b', 2]]),
    g: new Set([1, 2, 3]),
    h: new WeakMap(),
    i: new WeakSet()
    }
    const copyObj = JSON.parse(JSON.stringify(originalObj));

  • 无法处理循环引用,如果对象中存在循环引用,则 JSON.stringify 会发生错误

    const originalObj = {}
    originalObj.self = originalObj;
    const copyObj = JSON.parse(JSON.stringify(originalObj));

  • undefinedsymbolBigInt 这些值的无法处理,对于值为

    • undefinedsymbol 的属性在序列化会被忽略

      const originalObj = {
      a: 1,
      b: "Hello",
      c: true,
      d: null,
      e: undefined,
      f: Symbol("symbol"),
      }
      const copyObj = JSON.parse(JSON.stringify(originalObj));

    • BigInt 则会报错

      const originalObj = {
      a: 1,
      b: BigInt(123),
      }
      const copyObj = JSON.parse(JSON.stringify(originalObj));

  • NaNInfinity 会被序列化为 null

    const originalObj = {
    a: 1,
    b: NaN,
    c: Infinity,
    }
    const copyObj = JSON.parse(JSON.stringify(originalObj));

structuredClone 和 postMessage

浏览器提供了一个 structuredClone API,它可以对数据进行深拷贝,它会递归的对输入对象进行拷贝,并且在递归的过程中会维护一个存储访问过的对象的表,防止无限递归和解决循环引用的问题。

该 API 较新,可能存在兼容性问题

另外它只能拷贝可序列化的数据,如果存在不可序列化的数据则会报错,下面这些数据是不可序列化的:

  • Symbol

  • Function

  • Promise

  • WeakMap

  • WeakSet

  • DOM 对象

const originalObj = {
a: 1,
b: "Hello",
c: true,
d: undefined,
e: null,
f: NaN,
g: Infinity,
h: new Date(),
i: /\d{2}/,
j: new Map([['a', 1], ['b', 2]]),
k: new Set([1, 2, 3]),
l: {
a: 1,
}
}
originalObj.self = originalObj;

const copyObj = structuredClone(originalObj);
copyObj.j.set('c', 3);
copyObj.k.add(4);
copyObj.l.a = 2;

也可以使用 MessageChannel 对数据进行拷贝,使用 postMessage 传递数据时,也是深拷贝,其使用的算法和 structuredClone 相同,不同之处在于 MessageChannel 拷贝数据是异步的

const deepCopy = (obj) => {
const channel = new MessageChannel();
return new Promise((resolve) => {
channel.port2.onmessage = (e) => {
resolve(e.data);
}
channel.port1.postMessage(obj);
});
}

手动递归拷贝

除了使用已有的 API,还可以手动递归对象来进行深拷贝。首先实现基本的递归拷贝

const deepCopy = (value) => {
if (typeof value !== 'object' || value === null) {
return value;
}
const newObj = Array.isArray(value) ? [] : {};
for (const key in value) {
if (value.hasOwnProperty(key)) {
newObj[key] = deepCopy(value[key]);
}
}
return newObj;
}
const originalObj = {
a: 1,
b: 'hello',
c: true,
d: undefined,
e: null,
f: NaN,
g: Infinity,
h: {
a: 1,
},
i: [1, 2, 3]
}

const copyObj = deepCopy(originalObj);
copyObj.h.a = 2;
copyObj.i.push(4);

对于基本的对象和数组拷贝没有问题,但是如果存在循环引用,如果自己引用自己,两个对象之间互相引用等,那么就会陷入无限的递归,直至爆栈

const originalObj = {
a: 1,
b: 'hello',
c: true,
d: undefined,
e: null,
f: NaN,
g: Infinity,
h: {
a: 1,
},
i: [1, 2, 3]
}
originalObj.self = originalObj;

const copyObj = deepCopy(originalObj);

为了解决这个问题,需要在递归的过程中维护已处理过的对象,当后续访问到相同对象时,直接返回该对象而不用递归处理

const deepCopy = (value, weakMap = new WeakMap()) => {
if (weakMap.has(value)) return weakMap.get(value);
if (typeof value !== 'object' || value === null) {
return value;
}
const newObj = Array.isArray(value) ? [] : {};
weakMap.set(value, newObj);
for (const key in value) {
if (value.hasOwnProperty(key)) {
newObj[key] = deepCopy(value[key], weakMap);
}
}
return newObj;
}

接下来需要对一些特殊的对象进行处理,如 FunctionDateMapSet 等,对于不能进行深拷贝的对象当作普通对象处理,如 WeakMapWeakSetPromise

const deepCopy = (value, weakMap = new WeakMap()) => {
if (weakMap.has(value)) return weakMap.get(value);
if (typeof value !== 'object' || value === null) {
return value;
}
let newObj;
if (typeof value === 'function') {
newObj = new Function('return ' + value.toString())();
} else if (Array.isArray(value)) {
newObj = [];
} else if (value instanceof Date) {
newObj = new Date(value);
} else if (value instanceof RegExp) {
const { source, flags } = value;
newObj = new RegExp(source, flags);
// lastIndex 不可遍历
newObj.lastIndex = value.lastIndex;
} else if (value instanceof Map) {
newObj = new Map();
for (const [key, val] of value) {
newObj.set(deepCopy(key, weakMap), deepCopy(val, weakMap));
}
} else if (value instanceof Set) {
newObj = new Set();
for (const val of value) {
newObj.add(deepCopy(val, weakMap));
}
} else {
newObj = {};
}
weakMap.set(value, newObj);
for (const key in value) {
if (value.hasOwnProperty(key)) {
newObj[key] = deepCopy(value[key], weakMap);
}
}
return newObj;
}
const originalObj = {
a: 1,
b: 'hello',
c: true,
d: undefined,
e: null,
f: NaN,
g: Infinity,
h: Symbol("symbol"),
i: BigInt(123),
j: {
a: 1,
},
k: [1, 2, 3],
l: new Date(),
m: /\d{2}/,
n: new Map([['a', 1], ['b', 2]]),
o: new Set([1, 2, 3]),
p: new WeakMap(),
q: new WeakSet(),
r: function () {
return 'Hello'
},
s: Promise.resolve("Hello")
}
originalObj.self = originalObj;

const copyObj = deepCopy(originalObj);
copyObj.j.a = 2;
copyObj.k.push(4);
copyObj.n.set('c', 3);
copyObj.o.add(4);
const value = copyObj.r();
console.log(value); // Hello

参考

· 阅读需 12 分钟
熊滔

缓存是一种用来提高访问数据速度的技术,通过保存曾经访问过的数据或者预先加载可能用到数据,以便下次访问数据时能更快的提供,不仅可以提高数据的访问速度,还可以减少请求次数,进一步减少数据源的压力。

在网页中,对于一些静态资源在很长的一段时间都是不变的,因此可以将数据缓存下来,在下次请求数据时,直接从缓存中读取,而不必发起网络请求,以加快资源加载速度。虽然静态资源很长一段时间不变,但是当资源发生变化时,希望用户尽快能访问到新的资源,或者有的资源时刻在发生变化,不希望是有缓存,因此需要有一套机制来设置缓存策略,这套缓存策略是通过 HTTP 的一些请求头和响应头进行设置的。

强缓存和协商缓存

HTTP 缓存分为两种,强缓存和协商缓存,强缓存指的是只要资源没有过期,不发起网络请求,直接使用缓存的数据,而协商缓存指的是需要和服务器协商一下,看看缓存是否有效,如果服务器说还没有过期则使用缓存中的资源,否则服务器应当下发新的资源。

和强缓存有关的请求头/响应头有 Cache-ControlExpires,和协商缓存的请求头/响应头有 If-Modified-Since/Last-ModifiedIf-None-Match/Etag

强缓存拥有更高的优先级,协商缓存是在强缓存失效时用于验证缓存的有效性的。

Expires

Expires 是一个 HTTP 1.0 引入的响应头,它的值是一个日期,语法如下:

Expires: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
  • <day-name>

    Mon, Tue, Wed, Thu, Fri, Sat, or Sun 其中之一(大小写敏感)

  • <day>

    两个数字表示的日期,例如, "04"、"23"

  • <month>

    Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec 其中之一(大小写敏感)

  • <year>

    四位数字表示的年份,例如 "1990"、"2016"

  • <hour>

    两位数字表示的小时, 例如 "09"、"23"

  • <minute>

    两位数字表示的分钟,例如 "04"、"59"

  • <second>

    两位数字表示的秒,例如 "04"、"59"

  • GMT

    格林威治时间,HTTP 的日期始终使用格林威治时间表示,而不是本地时间

一个示例:

Expires: Wed, 21 Oct 2024 07:28:00 GMT

如果当前的时间在 Expires 指定的时间内,则表示资源有效,则可以直接复用。如果值是 0,则表示是过去的日期,即资源已经失效。

危险

随着 HTTP 1.1 已经普及,Expires 已经过时,没必要使用 Expires 响应头,而应当使用下面的 Cache-Control 响应头。

Cache-Control

Cache-Control 是 HTTP 1.1 引入的,既可以作为请求头又可以作为响应头,它有很多的指令用来进行访问控制和缓存设置。

语法:

  • 指令是大小写不敏感的,但是建议使用小写,因为有些规范不识别大写的指令

  • 多个指令使用逗号分隔

    Cache-Control: public, no-cache
  • 有些指令可能存在可选参数,可选参数和指令名称使用 = 连接

    Cache-Control: max-age=600
RequestResponse
max-agemax-age
max-stale-
max-fresh-
-s-maxage
no-cacheno-cache
no-storeno-store
no-transformno-transform
only-if-cached-
-must-revalidate
-proxy-revalidate
-must-understand
-private
-public
-immutable
-stale-while-revalidate
slate-if-errorslate-if-error

因为指令实在是太多了,因此只介绍一些常见的指令,更多的指令用法可以参考 MDN 文档

max-age

对于响应头,max-age=N 表示在 N 秒内,响应都是有效的,在有效期内,可以直接直接复用此响应。

需要注意的是 max-age 表示的不是收到响应后经过的时间,而是指服务器生成响应后经过的时间(这个时间可以通过 Date 响应头知道),但是如果中间经过了一些路由或其他缓存服务呢,比如 CDN,在这些缓存服务上有一定的处理时间,那么实际的有效期应当减去这个处理时间,因为服务器的响应时间是相对于这个缓存服务的,这个处理时间会作为 Age 响应头返回,例如在缓存服务上花费了 100 秒处理,那么就会返回一个 Age: 100 的响应头,我们从 max-age 中减去 Age 就是实际的有效期。

Cache-Control: max-age=100
Age: 100

对于请求头,max-age=N 表示允许重用 N 秒内服务器生成的响应。

有的时候在请求时会设置 max-age=0,比如直接输入网址请求一个 HTML 文件时,那么本地的缓存肯定是不可以直接复用的,以保证每次请求都是最新的资源。这里 max-age=0 并不表示不使用缓存,而是指需要先询问服务器响应是否可复用,如何服务器发现响应未发生改变,可以返回 304 状态码那么就会使用本地的缓存。

备注

对于 -1233.9 这种值,在规范中并未定义,但是建议当作 0 处理。

no-cache

在响应头中,no-cache 表示本地可以存储这个响应,但是在每次决定是否复用前需要询问服务器,如果希望每次都检查内容是否更新,那么就使用这个指令。

在请求头中,no-cache 的含义同响应头一致,也表示在复用缓存前先与源服务器进行验证,以确保访问的是最新的资源,当用户强制重新加载页面时,浏览器通常会将 **no-cache** 添加到请求中。

no-store

no-store 表示任何响应都不应该被缓存,无论是公共的还是私有的。

no-store 是在极端情况下用于确保缓存中不存储任何数据,适用于对数据安全性和实时性的要求较高的场景。

public, private

在解释 publicprivate 指令的含义之前先给出两个概念:

  • Shared Cache:共享缓存,存在于源服务器与客户端之间的缓存,如代理服务器、CDN,它存储响应供多名用户使用

  • Private Cache:私有缓存,存在于客户端的缓存,如浏览器,为单个用户提供个性化的内容

public 响应指令用于表示响应可以存储在共享缓存中

提示

带有 Authorization 头字段的请求的响应不得存储在共享缓存中;因为使用 Authorization 请求头,则表示该访问是受限制的,是和个人数据高度相关的,因此不适合放入共享缓存中,但是可以使用 public 突破这一限制。

Cache-Control: public, max-age=604800

private 响应指令表示响应只能存储在专用缓存(如浏览器的本地缓存)中。如何没有添加 private 指令,大部分情况下都是按照 public 指令处理的,所以对于一些包含个人隐私的数据需要添加 private 指令,防止被添加共享缓存中,导致隐私泄露。

If-Modified-Since、Last-Modified

If-Modified-SinceLast-Mofified 是一对用于协商缓存的请求响应头。

Last-Modified 出现在响应头中,表示某个资源的最后修改时间,If-Modified-Since 出现在请求头中,它的作用是告诉服务器想获取指定时间以来修改过的资源。

语法

If-Modified-Since: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
Last-Modified: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT

例子:

If-Modified-Since: Sat, 07 Dec 2024 01:30:37 GMT
Last-Modified: Sat, 07 Dec 2024 01:21:59 GMT

当客户端首次请求资源时,服务器会返回该资源及 Last-Modified 时间,在后续的请求中将上一次的 Last-Modified 时间放入到 If-Modified-Since 请求头中,服务器根据 If-Modified-Since 来判断资源是否被修改过,如果未被修改,则返回 304 Not Modified,客户端可以使用本地的缓存,如果资源已经被修改,则服务器返回新的资源以及最新的 Last Modified

If-None-Match、ETag

If-Modified-SinceLast-Modified 使用时间来判断资源是否过期,有两个缺点:

  1. 时间的粒度是秒,如果资源在一秒内多次发生改变,不能精确的标注修改时间
  2. 某些文件是定时生成的,虽然内容没有变化,但是 Last-Modified 却变了,导致缓存失效

If-None-MatchETag(Entity Tag) 可以解决这一问题,ETag 出现在响应头中,表示资源标识,一般是资源内容的哈希值或者版本号,If-None-Match 出现在请求头中,它的作用同 If-Modified-Since,不过其内容是 ETag 而不是时间。

语法:

If-None-Match: "<etag_value>"
ETag: "<etag_value>"

<etag_value> 是所请求资源的实体标记,是由 ASCII 字符组成的字符串,放在双引号之间(如 "675af34563dc-tr34"),并可以用 W/ 作为前缀,表示应使用弱比较算法。

其工作流程和 If-Modified-Since/Last-Modified 的流程一致,不展开介绍。

信息

If-Modified-Since/Last-ModifiedIf-None-Mathc/ETag 是可以共存的,当同时出现时,If-None-Mathc/ETag 的优先级高于 If-Modified-Since/Last-Modified,除非服务器不支持 If-None-Mathc/ETag

参考

· 阅读需 13 分钟
熊滔

端外引流是一个提升 DAU 非常重要的手段,常见的端外引流方式有:

  • 广告投放
  • 分享裂变
  • 算法推荐

这些手段的形式大多都是准备一个 H5 页面,这个 H5 是在别的 APP 打开的,当用户打开这个页面的时候,能通过某种手段打开自己的 APP 或者引导未下载 APP 的用户下载 APP,这个打开自己 APP 的过程就叫做唤端。

唤端方式

URL Schema

URL 是标识和访问资源的方式,比如我们访问网络资源就需要通过 HTTP/HTTPS URL,URL 由如下部分组成:

schema://host[:port]/path[?query][#fragment]

其中 Schema 指的是 URL 中的协议部分,Schema 可以分为两类:

  • 系统默认

    • http/https

    • ftp

    • mailto

    • tel

    • sms

  • 应用注册

    • wechat

    • alipay

    • taobao

    • amapuri

在应用首次安装或运行的时候,应用会在操作系统中进行登记,登记成功后当用户访问指定的 URL Schema 时,操作系统就会打开相应的应用程序。

常见 APP 的 URL Schema:

APP微信支付宝淘宝知乎高德
URL Schemaweixin://alipay://taobao://zhihu://amapuri://

在 H5 中一般有两种方法通过 URL Schema 打开 APP

  1. 通过 iframe 来访问 URL Schema

    const CALL_APP_IFRAME_ID = 'call-app-iframe';
    let callAppIframe = document.getElementById(CALL_APP_IFRAME_ID);
    if (!callAppIframe) {
    callAppIframe = document.createElement('iframe');
    callAppIframe.display = 'none';
    callAppIframe.id = CALL_APP_IFRAME_ID;
    document.body.append(callAppIframe);
    }

    callAppIframe.src = schema;

    iframe 是使用最多的了,因为在未安装 APP 的情况下,不会去跳转错误页面。但是在 iOS 9+ 的 Safari、QQ、UC 等浏览器中,均无法通过此种方式唤端,因此该种方式常用于在 Android 中唤端。

  2. 通过 location.href 直接访问 URL Schema,常见于 iOS

    const schema = 'amapuri://root';
    location.href = schema;
    信息

    在 QQ 中需要通过 top.location.href 进行唤端。

Universal Link 是在 WWDC 2015 上为 iOS9 引入的新功能,通过传统的 HTTP 链接即可打开 APP。如果用户未安装 APP,则会跳转到该链接所对应的页面。

首先在构建应用程序时需要在 XCode 中指定支持哪些域名,接着需要在所有的域名服务器上准备一个名为 apple-app-site-association(AASA) 的 JSON 文件,这个文件放在服务器的根目录或者 .well-known 目录下,这个文件定义了当访问哪些路径时打开 APP,一个示例如下,各字段含义可参考文档

{
"applinks": {
"details": [
{
"appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ],
"components": [
{
"#": "no_universal_links",
"exclude": true,
"comment": "Matches any URL with a fragment that equals no_universal_links and instructs the system not to open it as a universal link."
},
{
"/": "/buy/*",
"comment": "Matches any URL with a path that starts with /buy/."
},
{
"/": "/help/website/*",
"exclude": true,
"comment": "Matches any URL with a path that starts with /help/website/ and instructs the system not to open it as a universal link."
},
{
"/": "/help/*",
"?": { "articleNumber": "????" },
"comment": "Matches any URL with a path that starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly four characters."
}
]
}
]
},
"webcredentials": {
"apps": [ "ABCDE12345.com.example.app" ]
},


"appclips": {
"apps": ["ABCDE12345.com.example.MyApp.Clip"]
}
}

当我们安装或更新应用程序时,都会从服务器中拉取此配置文件,并根据文件内容向系统进行注册,当我们访问 H5 链接时,如果命中了 Universal Link,就打开 APP,如果没命中则直接跳转 H5 页面。

备注

从 macOS 11 和 iOS 14 开始,应用程序不再将 AASA 文件请求直接发送到 Web 服务器,而是将这些请求发送到专用于关联域的 Apple CDN,Apple CDN 会定时从我们的服务器拉取文件

信息

即使页面打开是 404,只要网址格式符合规则,都可以命中并成功唤起,但是一般我们会准备一个 H5 页面,未命中跳转到这个页面时可以跳转到 App Store 引导用户下载 APP。

注意:

  1. 同域名无法唤端

  2. 必须使用有效证书的 https:// 托管 AASA 文件,且不得重定向

  3. 安装了软件但是无法通过 Universal Link 唤端,可能原因是软件安装时无法获取到 AASA 文件,当程序安装后大约每隔一周才会从 CND 重新校验 AASA 文件,此时需要重新安装或更新 APP 以重新拉取此文件,大部分情况下可以唤端成功

微信开放标签

由于微信对 URL Schema 这种唤端方式进行了拦截,在微信中无法直接通过 URL Schema 进行唤端,iOS 虽然可以通过 Universal Link 唤端,但是 Android 只能引导用户到浏览器打开唤端,或者使用应用宝唤端(但这种方式需要用户下载应用宝),这无疑会影响回流量。

在这种情况下需要借助微信提供的开放标签进行唤端,此功能仅开放给已认证的服务号,服务号绑定JS 接口安全域名下的网页可使用此标签跳转 App,关于安全域名设置操作可参考此文档,除此之外,在 APP 中还需要接入微信提供的 SDK,接入可参考此文档

当这些前置工作准备就绪后,下面介绍在 H5 页面使用微信的 SDK 进行唤端:

  1. 在项目中引入微信 JS SDK:https://res.wx.qq.com/open/js/jweixin-1.6.0.js,可以通过 script 直接引入,当然可以判断当前浏览器环境,当仅在微信环境下才引入此脚本,当脚本加载成功后在 window 下会有一个 wx 对象,可以通过判断该对象是否存在来知道脚本是否加载成功

    const loadScript = async (url) => {
    return new Promise((resolve, reject) => {
    const script = document.createElement('script');


    script.addEventListener('load', () => {
    resolve();
    });
    script.addEventListener('error', () => {
    reject();
    });

    script.setAttribute('type', 'text/javascript');
    script.setAttribute('src', url);
    document.head.appendChild(script);
    })
    }
    const loadWechatSDK = async () => {
    if (window.wx) {
    return window.wx;
    }
    const wxJsSDK = "https://res.wx.qq.com/open/js/jweixin-1.6.0.js";
    try {
    await loadScript(wxJsSDK);
    } finally {
    return window.wx;
    }
    }
  2. 签名,签名算法可以参考此文档,由于牵涉到一些密钥,签名操作一般放在服务端,因此需要服务提供一个接口获取签名信息,获取到签名信息后,调用 wx.config 进行配置

    const wx = await loadWechatSDK();
    if (!wx) return;
    wx.config({
    appId: '', // 必填,公众号的唯一标识
    timestamp: xxx, // 必填,生成签名的时间戳
    nonceStr: '', // 必填,生成签名的随机串
    signature: '', // 必填,签名
    jsApiList: [], // 必填,需要用到的 JS API,比如打开相册
    openTagList: ["wx-open-launch-app"], // 选填,需要用到的开放标签
    });

    wx.ready(() => {
    // config 验证成功
    });
    wx.error(() => {
    // config 验证失败
    })
  3. 使用开放标签,因为无法通过 API 的方式唤端,需要用户实际点击才可以唤端,因此我们会将开放标签作为蒙层盖住被点击的对象,这样点击时就可以触发唤端

    <wx-open-launch-app
    appid="公众号的唯一标识"
    extinfo="携带的扩展信息"
    path="跳转的 schema"
    >
    <script type="text/wxtag-template">
    <style>
    .wx-btn {
    position: "absolute";
    width: 100%;
    height: 100%;
    top: 0;
    left: 0;
    opacity: 0;
    }
    </style>
    </script>
    <div class="wx-btn"></div>
    </wx-open-launch-app>

因为必须要用户点击才能唤端,因此就会带来一个缺点,无法自动唤端。

唤端成功和失败

对于使用微信开放标签,微信提供了事件可以知道唤端是否失败

<wx-open-launch-app
appid="公众号的唯一标识"
extinfo="携带的扩展信息"
path="跳转的 schema"
>
<script type="text/wxtag-template">
<style>
.wx-btn {
position: "absolute";
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0;
}
</style>
</script>
<div class="wx-btn"></div>
</wx-open-launch-app>
<script>
var btn = document.getElementById('launch-btn');
btn.addEventListener('launch', function (e) {
console.log('success');
});
btn.addEventListener('error', function (e) {
console.log('fail', e.detail);
});
</script>

对于非 SDK 唤端,没有事件透出,只能通过监听当前页面是否隐藏来判断是否唤端,唤端成功后,会打开 APP,当前 H5 页面就会隐藏,如果没有打开 APP,那么就会没有反应,停留在当前页面,通过监听几秒内 visibilitychange 事件是否有触发并且状态是否为 hidden 来判断是否唤端成功

const checkCallAppSuccess = (timeout = 3000) => {
return new Promise((resolve) => {
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
resolve(true);
document.removeEventListener('visibilitychange', onVisibilityChange);
}
}
setTimeout(() => {
resolve(false);
document.removeEventListener('visibilitychange', onVisibilityChange);
}, timeout);

document.addEventListener('visibilitychange', onVisibilityChange);
});
}

实践

我们的目标是提供一个 callApp 的异步方法,接收一个 schema 参数,返回之是一个 Promise,如果唤端成功,值就是 true,否则就是 false,该方法不适用于微信开放标签,因为微信开放标签不提供 API 进行唤端。

为了不在代码中充斥着 if-else,我们使用一个配置来说明在各个平台和操作系统下的唤端方式:

const config = {
Safari: {
// Android 没有 Safari 浏览器
Android: {
action: '',
fallback: ''
},
iOS: {
action: 'ul', // Universal Link 唤端
fallback: 'appstore' // 跳转应用商店
}
},
Taobao: {
Android: {
action: 'schema',
fallback: 'offcial', // 跳转官网
},
iOS: {
action: 'schema',
fallback: 'appstore',
}
},
// 微信空配置,因为微信开放标签无法通过 API 唤端
// 如果没有配置微信开放标签,可以在这里添加配置
// 比如 Android 打开应用宝,iOS 使用 Universal Link
Wechat: {
Android: {
action: '',
fallback: '',
},
iOS: {
action: '',
fallback: '',
}
},
// ...
}

准备好所有的唤端方式:

const callBySchema = (schema) => {
if (isAndroid) {
callByIframe(schema);
} else {
callByLocation(schema);
}
}

const callbyUniversalLink = (schema) => {
location.href = `${universalLink}?schema=${schema}`
}

// 其他唤端方式,跳转官方,App Store 等

const methodConfig = {
'schema': callBySchema,
'ul': callByUniversalLink
}
const getCallMethod = (method) => {
return methodConfig[method];
}

最后提供一个对外的 callApp 方法:

const callApp = async (schema) => {
// 获取操作系统,是 Android 还是 iOS
const system = getSystem();
const ruleWithSystem = config[system];
const action = ruleWithSystem.action;
const fallback = ruleWithSystem.fallback;
if (!action) {
return false;
}
const callMethod = getCallMethod(action);
callMetod?.(schema);
const isSuccess = await checkCallAppSuccess();
if (!isSuccess && fallback) {
const fallbackCallMethod = getCallMethod(action);
fallbackCallMethod?.(schema);
}

return isSuccess;
}

参考

· 阅读需 2 分钟
熊滔

在项目开发,一定少不了显示列表,对于列表项我们往往需要一定的间隔分开,下面就要探讨实现这一效果的方式。

margin

通常情况下我们只需要给每一个元素设置一个 margin-bottom 即可

.item {
margin-bottom: 12px;
}

但是这样会带来最后一个元素也会有 margin-bottom,这是我们不希望的,这个时候需要对最后一个元素进行处理

.item {
margin-bottom: 12px;
}

.item:last-child {
margin-bottom: 0;
}

或者使用“猫头鹰”表达式

.item + .item {
margin-top: 20px;
}

margin-trim

CSS 属性 margin-trim 可以实现上述效果而不用这么麻烦,它的取值语法如下:

margin-trim = 
none |
block |
inline |
[ block-start || inline-start || block-end || inline-end ]
  • block:块级子元素与容器的外边距会被裁剪为 0

  • inline:内联子元素与容器的外边距会被裁剪为 0

默认值为 none

所以为了实现效果,可采用如下写法:

.list {
/* 块级子元素与父容器的外边距裁剪为 0 */
margin-trim: block;
}

.item {
margin: 12px 0;
}

坏消息是只有 Safari 支持

flex + gap

更加简单的方式是使用 Flex 布局,然后通过 gap 属性指定子项之间的间距

.list {
display: flex;
flex-direction: column;
gap: 12px;
}

大部分浏览器都支持 Flex 布局的 gap 属性