Using the Push API - Web API 接口参考 编辑

W3C Push API 为开发人员在Web应用程序中提供了一些令人兴奋的新功能:本文提供了一个简单的演示,以获取Push通知的设置和运行。

在任何时候——不论这一应用是否处于激活状态——从服务器向客户端推送消息或者通知的能力,是一种原生应用已经享受了一段时间的能力。现在 Web 应用也拥有了这一能力。桌面系统上的 Firefox 43+ 和 Chrome 42+ 已经支持 Push 的大部分功能,移动平台也很可能在不久的将来提供支持。 PushMessageData 当前只在 Firefox Nightly (44+) 中提供实验性的支持,并且这一实现也可能会变更。

Note: Firefox OS 的早期版本使用了这一 API 的一个 proprietary 版本,叫做 Simple Push ,现在已经被 Push API 标准废弃。

Demo: the basis of a simple chat server app

我们创建的这一 demo 是一个简单的聊天应用。它提供了一个表单,用来输入聊天内容,还有一个按钮,用来订阅(subscribe)推送的消息。按下按钮后,你将订阅这一消息推送服务,服务器会记录你的信息,同时当前所有的订阅者会收到一个推送消息,告诉他们有人订阅。

此时,新订阅者的名字会出现在订阅者列表上,同时界面上会出现一个文本域和一个提交按钮,允许订阅者发送消息。

要运行这一 demo,请参阅 push-api-demo README。请注意,想要在 Chrome 中使用这一应用并且以一个更合理的方式运行,服务器端还需要大量的工作。然而,推送的细节解释起来特别麻烦,我们先概览这个推送接口是怎么运作的,然后再回来详细了解。

Technology overview

这一部分提供了这一例子中用到的技术的概览。

Web Push 消息是 service workers 技术族的一部分;特别的,一个service worker想要接收消息,就必须在一个页面上被激活。 在 service worker 接收到推送的消息后,你可以决定如何显示这一消息。你可以:

  • 发送一个 Web notification ,弹出一个系统通知提醒用户。这一操作需要发送推送消息的权限。
  • 通过 MessageChannel 将消息送回主页面。

通常这两者可以结合使用,下面的 demo 显示了两者的特点。

注:你需要在服务器端部署某种形式的代码来处理endpoint数据的加密和发送推送消息的请求。 在我们的Demo里,我们把那些代码放进了一个快速、劣质的服务器代码(a quick-and-dirty server )里,部署在 NodeJS 上。

service worker 需要订阅推送消息服务。在订阅服务时,每一个会话会有一个独立的端点(endpoint)。订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 service worker。不同浏览器需要用不同的推送消息服务器。

加密(Encryption)

Note: For an interactive walkthrough, try JR Conlin's Web Push Data Encryption Test Page.

在通过推送消息发送数据时,数据需要进行加密。数据加密需要通过 PushSubscription.getKey() 方法产生的一个公钥。这一方法在服务器端运行,通过复杂的密码学机制来生成公钥,详情可参阅 Message Encryption for Web Push 。以后可能会有更多用于处理推送消息加密的库出现,在这一 demo 中,我们使用 Marco Castelluccio's NodeJS web-push library.

Note: There is also another library to handle the encryption with a Node and Python version available, see encrypted-content-encoding.

Push workflow summary

这里我们总结一下推送消息的实现。在之后的章节中你可以找到这一 demo 代码的更多细节。

  1. 请求 web 通知及你所使用的其他功能的权限。
  2. 调用 ServiceWorkerContainer.register() ,注册一个 service worker。
  3. 使用 PushManager.subscribe() 订阅推送消息。
  4. 取得与订阅相关联的 endpoint (PushSubscription.endpoint),并且生成一个客户公钥(PushSubscription.getKey()) 。注意 getKey() 是试验性的,只在 Firefox 有效。
  5. 将详细信息发送给服务器,服务器可以用这些信息来发送推送消息。这一 demo 使用 XMLHttpRequest ,但你也可以使用 Fetch
  6. 如果你使用 Channel Messaging API 来和 service worker 通信,则创建一个新的 message channel (MessageChannel.MessageChannel()) ,并且在 service worker 调用 Worker.postMessage() ,将 port2 发送给 service worker ,以建立 communication channel 。你应该设置一个 listener 来响应从 service worker 发来的消息。
  7. 在服务器端,存储端点以及其他在发送推送消息给订阅者时需要的信息(我们使用一个简单的文本文件,但你可以使用数据库,或者其他你喜欢的方式)。在生产环境中,请保护好这些信息,以防恶意的攻击者用这些信息给订阅者推送垃圾消息。
  8. 要发送一个推送消息,你需要向端点 URL 发送一个 HTTP POST 。这一请求需要包括一个 TTL 头,用来规定用户离线时消息队列的最大长度。要在请求中包括数据,你需要使用客户公钥进行加密。在我们的 demo 中,我们使用 web-push 模块来处理困难的部分。
  9. 在你的 service worker 中,设置一个 push 事件句柄来响应接收到的推送消息。
    1. 如果你想要将一个信道消息发送回主 context(看第6步),你需要先取得之前发送给 service worker 的  port2 的引用 (MessagePort) 。这个可以通过传给 onmessage handler (ServiceWorkerGlobalScope.onmessage) 的MessageEvent 对象取得。 具体地说,是 ports 属性的索引 0 。 之后你可以用 MessagePort.postMessage() 来向 port1 发送消息 。
    2. 如果你想要使用系统通知,可以调用 ServiceWorkerRegistration.showNotification() 。注意,在我们的代码中,我们将其运行在一个 ExtendableEvent.waitUntil() 方法中——这样做将事件的 生命周期(lifetime)扩展到了通知被处理后,使得我们可以确认事情像我们期望的那样进行。

Building up the demo

让我们浏览一下 demo 的代码,理解一下它是如何工作的。

The HTML and CSS

这个 demo 的 HTML 和 CSS 没有什么需要特别留意的地方。初始化时,HTML包含一个简单的表单、一个按钮和两个列表。按钮用来订阅,两个列表分别显示订阅者和聊天消息。订阅之后,会出现用来输入聊天消息的控件。

为了对不干扰 Push API 的理解,CSS被设计得非常简单。

The main JavaScript file

JavaScript 明显更加重要。让我们看看主 JS 文件。

Variables and initial setup

开始时,我们声明一些需要使用的变量:

var isPushEnabled = false;
var useNotifications = false;

var subBtn = document.querySelector('.subscribe');
var sendBtn;
var sendInput;

var controlsBlock = document.querySelector('.controls');
var subscribersList = document.querySelector('.subscribers ul');
var messagesList = document.querySelector('.messages ul');

var nameForm = document.querySelector('#form');
var nameInput = document.querySelector('#name-input');
nameForm.onsubmit = function(e) {
  e.preventDefault()
};
nameInput.value = 'Bob';

首先,是两个布尔变量,一个用来记录 Push 是否被订阅,一个用来记录是否有通知的权限。

其次,是订阅/取消订阅 <button> 的引用,以及发送消息按钮的引用和输入的引用(订阅成功之后按钮和输入才会创建)。

接下来,是页面上的三个<div> 元素的引用,在需要插入元素时会用到(比如创建 Send Chat Message 按钮,或者 Messages 列表中增加聊天消息时)。

最后,是 name selection 表单和 <input> 元素的引用。我们给 input 一个默认值,并且使用 preventDefault() 方法,让按下回车时不会自动提交表单。

之后,我们通过 requestPermission() 请求发送web通知的权限:

Notification.requestPermission();

onload 时我们运行一段代码,让应用开始初次加载时的初始化过程。首先我们给 subscribe/unsubscribe 按钮添加 click event listener ,让这一按钮在已经订阅(isPushEnabled为真)时执行 unsubscribe() 函数,否则执行 subscribe()

window.addEventListener('load', function() {
  subBtn.addEventListener('click', function() {
    if (isPushEnabled) {
      unsubscribe();
    } else {
      subscribe();
    }
  });

之后,我们检查 service workers 是否被支持。如果支持,则用 ServiceWorkerContainer.register() 注册一个 service worker 并且运行 initialiseState() 函数。若不支持,则向控制台输出一条错误信息。

  // Check that service workers are supported, if so, progressively
  // enhance and add push messaging support, otherwise continue without it.
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('sw.js').then(function(reg) {
      if(reg.installing) {
        console.log('Service worker installing');
      } else if(reg.waiting) {
        console.log('Service worker installed');
      } else if(reg.active) {
        console.log('Service worker active');
      }

      initialiseState(reg);
    });
  } else {
    console.log('Service workers aren\'t supported in this browser.');
  }
});

接下来是 initialiseState() 函数。查看 initialiseState() source on Github 可获得有注释的完整源码(简洁起见,此处省略)。

initialiseState() 首先检查 service workers 是否支持 notifications ,如果支持则将 useNotifications 变量设为真。之后检查用户是否允许 said notifications , push messages 是否支持,并分别进行设置。

最后,使用 ServiceWorkerContainer.ready() 来检测 service worker 是否被激活并开始运行,会返回一个Promise对象。当这一 promise 对象 resolve 时,我们访问 ServiceWorkerRegistration.pushManager 属性,得到一个 PushManager 对象,再调用该对象的 PushManager.getSubscription()方法,最终获得用来推送消息的订阅对象。当第二个 promise 对象 resolve 时,我们启用 subscribe/unsubscribe 按钮(subBtn.disabled = false;),并确认订阅对象。

这样做了之后,订阅的准备工作已经完成了。即使这一应用并没有在浏览器中打开, service worker 也依然可能在后台处于激活状态。如果我们已经订阅,则对UI进行更新,修改按钮的标签,之后将 isPushEnabled 设为真,通过PushSubscription.endpoint 取得订阅的端点,通过 PushSubscription.getKey() 生成一个公钥,调用updateStatus() 方法与服务器进行通信。

此外,我们通过 MessageChannel.MessageChannel() 得到一个新的 MessageChannel 对象,并通过 ServiceworkerRegistration.active 得到一个激活的 service worker 的引用,然后通过 Worker.postMessage() 在主浏览器 context 和 service worker 间建立起一个信道。浏览器 context 接收 MessageChannel.port1 中的消息。当有消息到来时,我们使用 handleChannelMessage() 方法来决定如何处理数据(参阅Handling channel messages sent from the service worker)。

订阅和取消订阅(Subscribing and unsubscribing)

现在把我们的注意力转到 subscribe() 和 unsubscribe() 函数上,它们用来订阅或取消订阅 来自服务器的通知。

在订阅的时候,我们使用 ServiceWorkerContainer.ready()方法再一次确认service worker处于激活状态并且可以使用了。当 promise 成功执行,我们用PushManager.subscribe()方法订阅服务。如果订阅成功,我们会得到一个 PushSubscription 对象,它携带了endpoint信息 ,并且可以产生(generate)一个公钥的方法 (再多说一点,PushSubscription.endpoint属性和PushSubscription.getKey()方法),我们要把这两个信息传递给updateStatus() 函数,同时还要传递第三个信息——更新状态的类型(订阅还是不订阅),让它能够把这些必要的细节传递给服务器。

我们也需要更新我们应用的状态 (设置 isPushEnabledtrue) 和 UI (激活订阅或者不订阅的按钮,同时改变标签的显示状态,让用户下一次点击按钮的时候变成不订阅的状态或者订阅的状态。)

不订阅 unsubscribe() 函数在结构上和订阅函数相识,然而基本上它们做的是完全相反的事; 最值得注意的不同是得到当前订阅对象是使用PushManager.getSubscription()方法,而且使用PushSubscription.unsubscribe()方法获得的promise对象。

在两个函数中也提供了适当的错误处理函数。

为了节省时间,我们只在下面展示subscribe()的代码;查看全部请点击 subscribe/unsubscribe code on Github.

function subscribe() {
  // Disable the button so it can't be changed while
  // we process the permission request

  subBtn.disabled = true;

  navigator.serviceWorker.ready.then(function(reg) {
    reg.pushManager.subscribe({userVisibleOnly: true})
      .then(function(subscription) {
        // The subscription was successful
        isPushEnabled = true;
        subBtn.textContent = 'Unsubscribe from Push Messaging';
        subBtn.disabled = false;

        // Update status to subscribe current user on server, and to let
        // other users know this user has subscribed
        var endpoint = subscription.endpoint;
        var key = subscription.getKey('p256dh');
        updateStatus(endpoint,key,'subscribe');
      })
      .catch(function(e) {
        if (Notification.permission === 'denied') {
          // The user denied the notification permission which
          // means we failed to subscribe and the user will need
          // to manually change the notification permission to
          // subscribe to push messages
          console.log('Permission for Notifications was denied');

        } else {
          // A problem occurred with the subscription, this can
          // often be down to an issue or lack of the gcm_sender_id
          // and / or gcm_user_visible_only
          console.log('Unable to subscribe to push.', e);
          subBtn.disabled = false;
          subBtn.textContent = 'Subscribe to Push Messaging';
        }
      });
  });
}

更新应用和服务器的状态

接下来的一个主要的JavaScript函数就是updateStatus(),当订阅和取消订阅的时候,它负责更新UI中与服务器沟通的信息并发送状态更新的请求给服务器。

这个函数做了三件事当中的哪一件事,取决于下面赋值给statusType的类型:

  • subscribe: 一个按钮和和一个聊天信息的input输入框被创建后,就被插入到UI界面里面,然后通过XHR请求发送了一个包含状态信息的字面量对象给服务器,包含了statusType(subscribe)、订阅者的名字( username of the subscriber)、订阅终端(subscription endpoint)和客户端公钥(client public key)。
  • unsubscribe: 这个和订阅基本上是相反的——聊天的按钮和输入框被移除,同时又一个字面量对象被发送给服务器,告诉它取消订阅。
  • init: 当应用被第一次载入或者安装的时候会运行——它创建与服务器沟通的UI,并且发送一个对象告诉服务器是哪一个用户初始化(重新载入)了。

再多说一句,为了简介这里不会展示全部的代码。检查全部代码点击: full updateStatus() code on Github.

处理在service worker中发送过来的channel message

正如刚才我们提到的,当我们接收到从service worker发送的channel message 时,我们的 handleChannelMessage() 函数才会去执行它。 我们用channel.port1.onmessage事件处理函数去处理message event, :

channel.port1.onmessage = function(e) {
  handleChannelMessage(e.data);
}

这个函数会在service worker中发送信息给页面的时候在页面中执行(This occurs when the service worker sends a channel message over)。

 handleChannelMessage() 函数如下:

function handleChannelMessage(data) {
  if(data.action === 'subscribe' || data.action === 'init') {
    var listItem = document.createElement('li');
    listItem.textContent = data.name;
    subscribersList.appendChild(listItem);
  } else if(data.action === 'unsubscribe') {
    for(i = 0; i < subscribersList.children.length; i++) {
      if(subscribersList.children[i].textContent === data.name) {
        subscribersList.children[i].parentNode.removeChild(subscribersList.children[i]);
      }
    }
    nameInput.disabled = false;
  } else if(data.action === 'chatMsg') {
    var listItem = document.createElement('li');
    listItem.textContent = data.name + ": " + data.msg;
    messagesList.appendChild(listItem);
    sendInput.value = '';
  }
}

这个函数会做什么取决于传入的action参数的赋值:

  • subscribe or init (在启动和重启的时候,我们需要在这个示例中做同样的事情):一个<li> 元素被创建,它的text content 被设置为 data.name (订阅者的名字),然后它被添加到订阅者的列表里(一个简单的 <ul>元素里),所以这里是订阅者(再次)加入聊天的一个视觉反馈。
  • unsubscribe: 我们循环遍历订阅者的列表,查找谁的 text content 和data.name (取消订阅者的名字)相同,然后删除这个节点以提供一个视觉反馈表示有人取消订阅了。
  • chatMsg: 和第一个表现的形式类似, 一个 <li>被创建,它的text content设置为data.name + ": " + data.msg (例如: "Chris: This is my message"), 然后它被添加到每一个用户UI的聊天列表里。

注:  我们需要在更新DOM之前传递需要的数据给主页面(main context), 因为service worker不能操作DOM。你在使用前一定要知道service worker的一些限制。阅读 Using Service Workers 获取更多细节.

发送聊天信息

当‘Send Chat Message‘ 按钮被点击后,相关联的文本域的内容就作为聊天内容被发送出去。这个由 sendChatMessage() 函数处理(再多说一句,为了简洁就不展示了). 这个和 updateStatus() 的不同之处也是差不多的。 (查看 Updating the status in the app and server) — 我们获得 endpoint 和 public key 是来自一个 PushSubscription 对象, 这个对象又是来自于 ServiceWorkerContainer.ready() 方法和PushManager.subscribe()方法。它们(endpoint、public key)被传递进一个字面量对象里面( in a message object),同时含有订阅用户的名字,聊天信息,和chatMsg的statusType,然后通过XMLHttpRequest对象发送出去的,。

服务端(The Server)

正如我们上面提到的,我们需要一个服务端的容器去存储订阅者的信息,并且还要在状态更新时发送推送消息给客户端。 我们已经用一种hack的方式把一些需要的东西放到了一个快速-劣质(quick-and-dirty)的NodeJS 服务端上(server.js),用于处理来自客户端JavaScript的异步请求。

它用一个文本文件 (endpoint.txt)去存储订阅者的信息;这个文件一开始是空的(empty)。 有四种不同类型的请求,分别由被传输过来的对象中的statusType决定;这些在客户端通俗易懂的的状态类型同样也适用于服务端,因为服务端也有相同的状态。下面是这四个个状态在服务端代表的各自的含义。

  • subscribe: 服务器将会添加订阅者的信息到存储的容器里 (endpoint.txt),包括endpoint等,然后会推送给所有已经订阅了的订阅者一条消息,告诉每一个订阅者有新的订阅者加入了聊天。
  • unsubscribe: 服务器在存储订阅信息的容器里找到发送该类型请求的订阅者的详情,并移除这个订阅者的信息,然后推送一条消息给所有依然订阅的用户,告诉他们这个人取消订阅了。
  • init: 服务器从那个存储信息的文本中读取所有订阅者的信息,然后告诉他们有人初始化或者再次加入了这个聊天。
  • chatMsg: 推送一条订阅者想发送的信息给所有的订阅者;服务器从存储容器里读取现在订阅者的信息,然后服务器给每一个人推送一条包含聊天信息的消息。

其他需要注意的东西:

  • 我们使用的是Node.js的 https 模块  去创建的服务器,因为出于安全考虑,service worker只允许工作在一个安全的连接里(https only)。这也就是为什么我们要在应用里包含了.pfx 安全证书,然后在Node代码里引用这个证书。
  • 当你发送一条没有数据的推送消息的时候,只需要把它用http的post请求发送给对应订阅者的endpoint的URL。然而,当推送消息里包含数据的时候,你需要加密它,这个加密过程往往很复杂。随着时间的推移,一些出现的库文件(libraries)帮你做了这部分的工作。在这个demo里面我们用了 Marco Castelluccio的NodeJS库文件(web-push library)。查阅这些源代码了解更多加密过程是怎么完成的 (查阅 Message Encryption for Web Push 获取更多细节)。库文件让发送一条推送消息变得简单( The library makes sending a push message simple)。

The service worker

现在让我们来看看服务工作者代码(sw.js),它响应由push事件表示的推送消息。 这些通过(ServiceWorkerGlobalScope.onpush)事件处理程序在服务工作人员的范围内处理; 它的工作就是为每个收到的消息做出回应。 我们首先通过调用PushMessageData.json()将收到的消息转换回对象。然后,通过查看这个对象的action属性,就能知道这个推送消息的类型:

  • subscribe 或者 unsubscribe: 我们发送一个系统通知是通过 fireNotification() 函数,但同时,也通过MessageChannel把这个消息发送回函数的上下文环境(main context),以便我们相应地更新订阅者列表(subscriber list) (查看 Handling channel messages sent from the service worker 了解详细).
  • init 或者 chatMsg: 我们只是发送一个消息回主要的上下文(main context)来处理 initchatMsg 情况(这些不需要系统通知).
self.addEventListener('push', function(event) {
  var obj = event.data.json();

  if(obj.action === 'subscribe' || obj.action === 'unsubscribe') {
    fireNotification(obj, event);
    port.postMessage(obj);
  } else if(obj.action === 'init' || obj.action === 'chatMsg') {
    port.postMessage(obj);
  }
});

下一步, 让我们看看 fireNotification() 函数的代码 (它真的太他妈简单了).

function fireNotification(obj, event) {
  var title = 'Subscription change';
  var body = obj.name + ' has ' + obj.action + 'd.';
  var icon = 'push-icon.png';
  var tag = 'push';

  event.waitUntil(self.registration.showNotification(title, {
    body: body,
    icon: icon,
    tag: tag
  }));
}

我们先在这里列出通知消息框所需要的资源:标题、主体内容、图标。然后我们通过 ServiceWorkerRegistration.showNotification() 方法把这个通知发送出去,同时提供一个"push"给tag属性,表示这是一个推送消息,以便我们能够在全部的通知消息中找到(identify)这个推送消息。 当我们成功发送一条推送消息,它可能会在用户对应的电脑或者设备上展示一个系统通知对话框,在不同的设备上通知对话框的外观可能是不一样的(下面的这张图片展示的是Mac OSX系统上的通知对话框)。

注意,我们做的这些都包裹在ExtendableEvent.waitUntil() 方法里;这是为了让ServiceWorker在发送通知消息的过程中依然保持活动,知道消息发送完成。 waitUntil() 会延长service worker的生命周期至(这个周期里)所有活动都已经完成。

Note: Web notifications from service workers were introduced around Firefox version 42, but are likely to be removed again while the surrounding functionality (such as Clients.openWindow()) is properly implemented (see bug 1203324 for more details.)

处理推送订阅过早失效(Handling premature subscription expiration)

有时候,推送订阅会过早失效,而不会调用PushSubscription.unsubscribe() 。例如,当服务器过载或长时间处于脱机状态时,可能会发生这种情况。这是高度依赖于服务器的,所以确切的行为很难预测。 在任何情况下,您都可以通过查看pushsubscriptionchange 事件来处理此问题,您可以通过提供ServiceWorkerGlobalScope.onpushsubscriptionchange 事件处理程序来侦听此事件;这个事件只会在这种情况下才会被触发。

self.addEventListener('pushsubscriptionchange', function() {
  // do something,一般来说都会在这里重新订阅,
  // 并通过XHR或者Fetch发送新的订阅信息回服务器
});

注意,我们在demo里没有覆盖这种情况,因为订阅端点(subscription ending)作为一个简单的聊天服务器并不是什么难事。但是对于更复杂的示例,您可能需要重新订阅(resubscribe )用户。

(让Chrome支持的额外步骤)Extra steps for Chrome support

为了让应用也能够在Chrome上面运行,我们需要一些额外的步骤,因为在Chrome上,现在需要依赖谷歌云消息推送(Google's Cloud Messaging) 服务才能正常工作。

配置谷歌云消息推送(Setting up Google Cloud Messaging)

按照以下步骤配置:

  1. 跳转到 Google Developers Console  然后创建一个新项目。
  2. 进入你项目主页(例如:我们的示例是在 https://console.developers.google.com/project/push-project-978), 然后
    1. 在你的应用选项里打开 Google APIs
    2. 在下一屏,在移动API那一部分下面点击“Cloud Messaging for Android” 。
    3. 点击开启API按钮。
  3. 现在你需要记下你的项目编号和API key,因为待会儿你会用到他们。 它们在这些地方:
    1. 项目编号: 点击左侧的主页;项目编号在你的项目主页上方很容易看见的位置。
    2. API key: 点击左边菜单里的证书(Credentials );API key 能够在这个页面找到。

manifest.json

你需要在你的应用里包含Google应用风格的 manifest.json ,这里面的 gcm_sender_id 参数需要设置为你的项目编号。下面是一个简单的示例(manifest.json):

{
  "name": "Push Demo",
  "short_name": "Push Demo",
  "icons": [{
        "src": "push-icon.png",
        "sizes": "111x111",
        "type": "image/png"
      }],
  "start_url": "/index.html",
  "display": "standalone",
  "gcm_sender_id": "224273183921"
}

同时,你也需要在HTML文档用一个<link> 标签里指向你的manifest文件:

<link rel="manifest" href="manifest.json">

userVisibleOnly参数

Chrome 要求你在订阅推送服务的时候设置 userVisibleOnly 参数 为 true , 表示我们承诺每当收到推送通知时都会显示通知。这可以在我们的 subscribe() 函数 中看到。

See also

Note: Some of the client-side code in our Push demo is heavily influenced by Matt Gaunt's excellent examples in Push Notifications on the Open Web. Thanks for the awesome work, Matt!

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

词条统计

浏览:121 次

字数:47168

最后编辑:7 年前

编辑次数:0 次

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文