用于优化移动性能的 HTML5 技术

发布于 2022-01-09 14:50:17 字数 21366 浏览 979 评论 0

旋转刷新、断断续续的页面转换和点击事件的周期性延迟只是当今移动 Web 环境中的一些令人头疼的问题。 开发人员正试图尽可能接近原生,但经常被黑客、重置和僵化的框架所破坏。

在本文中,我们将讨论创建移动 HTML5 Web 应用程序所需的最低限度。 重点是揭露当今移动框架试图隐藏的隐藏复杂性。 您将看到一种简约​​的方法(使用核心 HTML5 API)和基本原理,使您能够编写自己的框架或为您当前使用的框架做出贡献。

硬件加速

通常,GPU 处理详细的 3D 建模或 CAD 图表,但在这种情况下,我们希望我们的原始图形(div、背景、带有阴影的文本、图像等)通过 GPU 看起来平滑且动画流畅。
不幸的是,大多数前端开发人员都将这个动画过程交给第三方框架,而不关心语义,但是这些核心 CSS3 特性是否应该被屏蔽? 让我给你几个为什么关心这些东西很重要的理由:

  1. 内存分配和计算负担——如果你只是为了硬件加速而去合成 DOM 中的每个元素,下一个处理你的代码的人可能会追赶你并严重击败你。
  2. 功耗——显然,当硬件启动时,电池也会启动。 在为移动设备开发时,开发人员在编写移动 Web 应用程序时不得不考虑广泛的设备限制。 随着浏览器制造商开始允许访问越来越多的设备硬件,这将更加普遍。
  3. 冲突——我在对已经加速的页面部分应用硬件加速时遇到了故障行为。 所以知道你是否有重叠的加速度是*非常*重要的。

为了使用户交互流畅并尽可能接近原生,我们必须让浏览器为我们工作。 理想情况下,我们希望移动设备 CPU 设置初始动画,然后让 GPU 在动画过程中只负责合成不同的图层。 这就是 translate3d、scale3d 和 translateZ 所做的——它们赋予动画元素自己的层,从而使设备能够平滑地渲染所有内容。 要了解有关加速合成和 WebKit 工作原理的更多信息,Ariya Hidayat 有很多很好的信息 在 上 他的博客

页面转换

让我们看一下开发移动 Web 应用程序时最常见的三种用户交互方法:滑动、翻转和旋转效果。

您可以在此处查看运行中的代码 http://slidfast.appspot.com/slide-flip-rotate.html (注意:此演示是为移动设备构建的,因此请启动模拟器,使用您的手机或平板电脑,或者将浏览器窗口的大小减小到 ~1024px 或更小)。

首先,我们将剖析滑动、翻转和旋转过渡以及它们是如何加速的。 注意每个动画只需要三四行 CSS 和 JavaScript。

滑动

三种过渡方法中最常见的一种,滑动页面过渡模仿了移动应用程序的原生感觉。 调用幻灯片转换以将新的内容区域带入视口。

对于幻灯片效果,首先我们声明我们的 HTML:

<div class="page">
  <h1>Home Page</h1>
</div>

<div class="page stage-right">
  <h1>Products Page</h1>
</div>

<div class="page stage-left">
  <h1>About Page</h1>
</div>

请注意我们是如何拥有向左或向右暂存页面的概念。 它基本上可以是任何方向,但这是最常见的。

我们现在只需要几行 CSS 就拥有了动画和硬件加速功能。 实际动画发生在我们交换页面 div 元素上的类时。

.page {
  position: absolute;
  width: 100%;
  height: 100%;
  /*activate the GPU for compositing each page */
  -webkit-transform: translate3d(0, 0, 0);
}

translate3d(0,0,0)被称为“银弹”方法。

当用户单击导航元素时,我们执行以下 JavaScript 来交换类。 没有使用第三方框架,这是直接的 JavaScript!

function getElement(id) {
  return document.getElementById(id);
}

function slideTo(id) {
  //1.) the page we are bringing into focus dictates how
  // the current page will exit. So let's see what classes
  // our incoming page is using. We know it will have stage[right|left|etc...]
  var classes = getElement(id).className.split(' ');

  //2.) decide if the incoming page is assigned to right or left
  // (-1 if no match)
  var stageType = classes.indexOf('stage-left');

  //3.) on initial page load focusPage is null, so we need
  // to set the default page which we're currently seeing.
  if (FOCUS_PAGE == null) {
    // use home page
    FOCUS_PAGE = getElement('home-page');
  }

  //4.) decide how this focused page should exit.
  if (stageType > 0) {
    FOCUS_PAGE.className = 'page transition stage-right';
  } else {
    FOCUS_PAGE.className = 'page transition stage-left';
  }

  //5. refresh/set the global variable
  FOCUS_PAGE = getElement(id);

  //6. Bring in the new page.
  FOCUS_PAGE.className = 'page transition stage-center';
}

stage-left或者 stage-right变成 stage-center并强制页面滑入中心视口。 我们完全依赖 CSS3 来完成繁重的工作。

.stage-left {
  left: -480px;
}

.stage-right {
  left: 480px;
}

.stage-center {
  top: 0;
  left: 0;
}

接下来,让我们看一下处理移动设备检测和定位的 CSS。

我们可以解决每个设备和每个分辨率(请参阅 媒体查询分辨率 )。 我在此演示中仅使用了几个简单的示例来涵盖移动设备上的大多数纵向和横向视图。 这对于为每个设备应用硬件加速也很有用。 例如,由于 WebKit 的桌面版本加速了所有转换的元素(无论是 2-D 还是 3-D),因此创建媒体查询并排除该级别的加速是有意义的。

请注意,硬件加速技巧不会在 Android Froyo 2.2+ 下提供任何速度提升。 所有的合成都是在软件内完成的。

/* iOS/android phone landscape screen width*/
@media screen and (max-device-width: 480px) and (orientation:landscape) {
  .stage-left {
    left: -480px;
  }

  .stage-right {
    left: 480px;
  }

  .page {
    width: 480px;
  }
}

翻转

在移动设备上,翻页实际上就是将页面滑开。 在这里,我们使用一些简单的 JavaScript 在 iOS 和 Android(基于 WebKit)设备上处理此事件。

查看实际操作 http://slidfast.appspot.com/slide-flip-rotate.html

在处理触摸事件和转换时,您首先需要的是获取元素当前位置的句柄。 有关 WebKitCSSMatrix 的更多信息,请参阅此文档。

function pageMove(event) {
  // get position after transform
  var curTransform = new WebKitCSSMatrix(window.getComputedStyle(page).webkitTransform);
  var pagePosition = curTransform.m41;
}

由于我们使用 CSS3 缓出过渡来进行翻页,所以通常 element.offsetLeft 不管用。

接下来,我们要确定用户正在翻转的方向并设置事件(页面导航)发生的阈值。

if (pagePosition >= 0) {
 //moving current page to the right
 //so means we're flipping backwards
   if ((pagePosition > pageFlipThreshold) || (swipeTime < swipeThreshold)) {
     //user wants to go backward
     slideDirection = 'right';
   } else {
     slideDirection = null;
   }
} else {
  //current page is sliding to the left
  if ((swipeTime < swipeThreshold) || (pagePosition < pageFlipThreshold)) {
    //user wants to go forward
    slideDirection = 'left';
  } else {
    slideDirection = null;
  }
}

您还会注意到,我们正在测量 swipeTime以毫秒为单位。 如果用户快速滑动屏幕以翻页,这允许导航事件触发。

为了在手指触摸屏幕时定位页面并使动画看起来是原生的,我们在每个事件触发后使用 CSS3 过渡。

function positionPage(end) {
  page.style.webkitTransform = 'translate3d('+ currentPos + 'px, 0, 0)';
  if (end) {
    page.style.WebkitTransition = 'all .4s ease-out';
    //page.style.WebkitTransition = 'all .4s cubic-bezier(0,.58,.58,1)'
  } else {
    page.style.WebkitTransition = 'all .2s ease-out';
  }
  page.style.WebkitUserSelect = 'none';
}

我尝试使用三次贝塞尔曲线来为过渡提供最佳的原生感觉,但缓出效果很好。

最后,为了实现导航,我们必须调用我们之前定义的 slideTo()我们在上一个演示中使用的方法。

track.ontouchend = function(event) {
  pageMove(event);
  if (slideDirection == 'left') {
    slideTo('products-page');
  } else if (slideDirection == 'right') {
    slideTo('home-page');
  }
}

旋转

接下来,让我们看看这个演示中使用的旋转动画。 您可以随时通过点击“联系”菜单选项将当前正在查看的页面旋转 180 度以显示背面。 同样,这只需要几行 CSS 和一些 JavaScript 来分配一个过渡类 onclick.
注意:旋转过渡在大多数版本的 Android 上都无法正确呈现,因为它缺少 3D CSS 变换功能。 不幸的是,Android 没有忽略翻转,而是通过旋转而不是翻转来使页面“车轮”消失。 我们建议谨慎使用此过渡,直到支持得到改善。

标记(正面和背面的基本概念):

<div class="normal">
...
</div>
<div class="flipped">
    <div class="page">
        <h1>Contact Page</h1>
    </div>
</div>

JavaScript:

function flip(id) {
  // get a handle on the flippable region
  var front = getElement('front');
  var back = getElement('back');

  // again, just a simple way to see what the state is
  var classes = front.className.split(' ');
  var flipped = classes.indexOf('flipped');

  if (flipped >= 0) {
    // already flipped, so return to original
    front.className = 'normal';
    back.className = 'flipped';
    FLIPPED = false;
  } else {
    // do the flip
    front.className = 'flipped';
    back.className = 'normal';
    FLIPPED = true;
  }
}

CSS:

/*----------------------------flip transition */
#back,
#front {
  position: absolute;
  width: 100%;
  height: 100%;
  -webkit-backface-visibility: hidden;
  -webkit-transition-duration: .5s;
  -webkit-transform-style: preserve-3d;
}

.normal {
  -webkit-transform: rotateY(0deg);
}

.flipped {
  -webkit-user-select: element;
  -webkit-transform: rotateY(180deg);
}

调试硬件加速

现在我们已经介绍了基本的转换,让我们来看看它们如何工作和组合的机制。

为了让这个神奇的调试会话发生,让我们启动几个浏览器和您选择的 IDE。

首先从命令行启动 Safari 以利用一些调试环境变量。 我在 Mac 上,因此命令可能会因您的操作系统而异。

打开终端并键入以下内容:

  • $> export CA_COLOR_OPAQUE=1
  • $> export CA_LOG_MEMORY_USAGE=1
  • $> /Applications/Safari.app/Contents/MacOS/Safari

这将启动 Safari,并带有几个调试助手。 CA_COLOR_OPAQUE 向我们展示了哪些元素实际上是合成或加速的。 CA_LOG_MEMORY_USAGE 显示我们在将绘图操作发送到 时使用了多少内存 后备存储 。 这可以准确地告诉您您对移动设备施加了多大的压力,并且可能会提示您的 GPU 使用可能如何耗尽目标设备的电池。

现在让我们启动 Chrome,以便我们可以看到一些好的每秒帧数 (FPS) 信息:

  1. 打开 Google Chrome 网络浏览器。
  2. 在 URL 栏中,键入 https://www.html5rocks.com/zh/mobile/optimization-and-performance/about:flags
  3. 向下滚动几个项目,然后单击 FPS Counter 的“启用”。

注意:不要在所有页面上启用 GPU 合成选项。 如果浏览器检测到您的标记中的合成,则 FPS 计数器只会出现在左角——这就是我们在这种情况下想要的。

如果您 查看 此页面 在增强版 Chrome 中 ,您会在左上角看到红色的 FPS 计数器。
Chrome FPS

这就是我们知道硬件加速已打开的方式。 它还让我们了解动画如何运行以及是否有任何泄漏(应该停止的连续运行动画)。

实际可视化硬件加速的另一种方法是在 Safari 中打开相同的页面(使用我上面提到的环境变量)。 每个加速的 DOM 元素都带有红色调。 这准确地向我们展示了按层合成的内容。
请注意,白色导航不是红色的,因为它没有加速。
Composited Contact

也提供了类似的 Chrome 设置 https://www.html5rocks.com/zh/mobile/optimization-and-performance/about:flags “合成渲染层边框”中 。

查看合成图层的另一个好方法 查看 WebKit 落叶演示 是在应用此 mod 时 。
Composited Leaves

最后,要真正了解我们应用程序的图形硬件性能,让我们看看内存是如何消耗的。

在这里,我们看到我们正在将 1.38MB 的绘图指令推送到 Mac OS 上的 CoreAnimation 缓冲区。 Core Animation 内存缓冲区在 OpenGL ES 和 GPU 之间共享,以创建您在屏幕上看到的最终像素。
Coreanimation 1

当我们简单地调整或最大化浏览器窗口时,我们会看到内存也在扩大。
Coreanimation 2

仅当您将浏览器的大小调整到正确的尺寸时,这才让您了解移动设备上的内存消耗情况。 如果您正在调试或测试 iPhone 环境,请将大小调整为 480 像素 x 320 像素。
我们现在确切地了解了硬件加速的工作原理以及调试需要什么。 阅读它是一回事,但实际看到 GPU 内存缓冲区在视觉上工作确实可以让事情变得清晰。

幕后:获取和缓存

现在是时候让我们的页面和资源缓存更上一层楼了。 与 JQuery Mobile 和类似框架使用的方法非常相似,我们将使用并发 AJAX 调用预取和缓存我们的页面。

让我们解决一些核心的移动网络问题以及我们需要这样做的原因:

  • 抓取:预取我们的页面允许用户将应用程序脱机,并且在导航操作之间也无需等待。 当然,我们不想在设备上线时阻塞设备的带宽,所以我们需要谨慎使用此功能。
  • 缓存:接下来,我们需要在获取和缓存这些页面时采用并发或异步方法。 我们还需要使用 localStorage(因为它在设备中得到很好的支持),不幸的是它不是异步的。
  • AJAX 和解析响应:使用 innerHTML() 将 AJAX 响应插入 DOM 是危险的(而且 不可靠 ?)。 我们改为使用 可靠的机制 来插入 AJAX 响应并处理并发调用。 我们还利用 HTML5 的一些新特性来解析 xhr.responseText.

基于 的代码 Slide、Flip 和 Rotate 演示中 ,我们首先添加一些辅助页面并链接到它们。 然后,我们将解析链接并动态创建转换。
iPhone Home

View the Fetch and Cache demo here.

如您所见,我们在这里利用语义标记。 只是指向另一个页面的链接。 子页面遵循与其父页面相同的节点/类结构。 我们可以更进一步,将 data-* 属性用于“页面”节点等……

这是位于单独的 html 文件 (/demo2/home-detail.html) 中的详细信息页面(子页面),该文件将被加载、缓存并设置为在应用加载时进行转换。

<div class="page">
  <h1>Home Page</h1>
  <a href="demo2/home-detail.html" class="fetch">Find out more about the home page!</a>
</div>

现在让我们看一下 JavaScript。 为简单起见,我在代码中留下了任何帮助或优化。 我们在这里所做的只是遍历指定的 DOM 节点数组以挖掘链接以获取和缓存。

注意—对于这个演示,这个方法 fetchAndCache()在页面加载时被调用。 当我们检测到网络连接并确定何时应该调用它时,我们将在下一节中对其进行修改。

var fetchAndCache = function() {
  // iterate through all nodes in this DOM to find all mobile pages we care about
  var pages = document.getElementsByClassName('page');

  for (var i = 0; i < pages.length; i++) {
    // find all links
    var pageLinks = pages[i].getElementsByTagName('a');

    for (var j = 0; j < pageLinks.length; j++) {
      var link = pageLinks[j];

      if (link.hasAttribute('href') &&
      //'#' in the href tells us that this page is already loaded in the DOM - and
      // that it links to a mobile transition/page
         !(/[\#]/g).test(link.href) &&
        //check for an explicit class name setting to fetch this link
        (link.className.indexOf('fetch') >= 0))  {
         //fetch each url concurrently
         var ai = new ajax(link,function(text,url){
              //insert the new mobile page into the DOM
             insertPages(text,url);
         });
         ai.doGet();
      }
    }
  }
};

我们通过使用“AJAX”对象来确保适当的异步后处理。 在 有关于在 AJAX 调用中使用 localStorage 的更高级的解释 Working Off the Grid with HTML5 Offline 中 。 在此示例中,您会看到缓存每个请求的基本用法,并在服务器返回除成功 (200) 响应之外的任何内容时提供缓存对象。

function processRequest () {
  if (req.readyState == 4) {
    if (req.status == 200) {
      if (supports_local_storage()) {
        localStorage[url] = req.responseText;
      }
      if (callback) callback(req.responseText,url);
    } else {
      // There is an error of some kind, use our cached copy (if available).
      if (!!localStorage[url]) {
        // We have some data cached, return that to the callback.
        callback(localStorage[url],url);
        return;
      }
    }
  }
}

不幸的是,由于 localStorage 使用 UTF-16 进行字符编码,每个单个字节存储为 2 个字节,从而使我们的存储限制从 5MB 增加到 2.6MB 。 下一节将揭示在应用程序缓存范围之外获取和缓存这些页面/标记的全部原因。

随着 的最新进展 iframe 元素 HTML5 中 ,我们现在有了一种简单有效的方法来解析 responseText 我们从 AJAX 调用中返回。 有很多 3000 行的 JavaScript 解析器和正则表达式,可以删除脚本标签等。 但是为什么不让浏览器做它最擅长的事情呢? 在这个例子中,我们将编写 responseText 进入一个临时隐藏的 iframe。 我们正在使用 HTML5 “沙盒”属性,它禁用脚本并提供许多安全功能…

从规范:

指定沙箱属性后,将对 iframe 托管的任何内容启用一组额外限制。 它的值必须是一组无序的唯一以空格分隔的标记,这些标记不区分 ASCII 大小写。 允许的值为 allow-forms、allow-same-origin、allow-scripts 和 allow-top-navigation。 设置该属性后,内容将被视为来自唯一来源,禁用表单和脚本,阻止链接针对其他浏览上下文,并禁用插件。

var insertPages = function(text, originalLink) {
  var frame = getFrame();
  //write the ajax response text to the frame and let
  //the browser do the work
  frame.write(text);

  //now we have a DOM to work with
  var incomingPages = frame.getElementsByClassName('page');

  var pageCount = incomingPages.length;
  for (var i = 0; i < pageCount; i++) {
    //the new page will always be at index 0 because
    //the last one just got popped off the stack with appendChild (below)
    var newPage = incomingPages[0];

    //stage the new pages to the left by default
    newPage.className = 'page stage-left';

    //find out where to insert
    var location = newPage.parentNode.id == 'back' ? 'back' : 'front';

    try {
      // mobile safari will not allow nodes to be transferred from one DOM to another so
      // we must use adoptNode()
      document.getElementById(location).appendChild(document.adoptNode(newPage));
    } catch(e) {
      // todo graceful degradation?
    }
  }
};

Safari 正确地拒绝将节点从一个文档隐式移动到另一个文档。 如果在不同的文档中创建了新的子节点,则会引发错误。 所以在这里我们使用 adoptNode一切都很好。

那么为什么是 iframe 呢? 为什么不只使用innerHTML? 尽管 innerHTML 现在是 HTML5 规范的一部分,但将来自服务器(无论是好是坏)的响应插入到未经检查的区域中是一种危险的做法。 在写这篇文章的过程中,我找不到*任何人*使用除了 innerHTML 之外的任何东西。 我知道 JQuery 在它的核心使用它,只在异常时附加回退。 JQuery Mobile 也使用它。 然而,我还没有对 innerHTML 进行任何繁重的测试 “随机停止工作” ,但是看到所有受此影响的平台将会非常有趣。 看看哪种方法性能更高也会很有趣……我也听到了双方对此的说法。

网络类型检测、处理和分析

现在我们有能力缓冲(或预测缓存)我们的 Web 应用程序,我们必须提供适当的连接检测功能,使我们的应用程序更智能。 这就是移动应用程序开发对在线/离线模式和连接速度极为敏感的地方。
输入 网络信息 API 。 每次我在演示文稿中展示此功能时,观众中都会有人举手问“我将其用于什么用途?”。 因此,这是一种设置极其智能的移动 Web 应用程序的可能方法。

首先是无聊的常识场景……
在高速列车上通过移动设备与 Web 交互时,网络很可能会在不同的时刻消失,并且不同的地理区域可能支持不同的传输速度(例如,HSPA 或 3G 可能在某些城市地区可用,但偏远地区)地区可能支持慢得多的 2G 技术)。 以下代码解决了大多数连接方案。

以下代码提供:

  • 通过离线访问 applicationCache.
  • 检测是否已添加书签和离线。
  • 检测何时从离线切换到在线,反之亦然。
  • 检测慢速连接并根据网络类型获取内容。

同样,所有这些功能都需要很少的代码。 首先我们检测我们的事件和加载场景:

window.addEventListener('load', function(e) {
 if (navigator.onLine) {
  // new page load
  processOnline();
 } else {
   // the app is probably already cached and (maybe) bookmarked...
   processOffline();
 }
}, false);

window.addEventListener("offline", function(e) {
  // we just lost our connection and entered offline mode, disable eternal link
  processOffline(e.type);
}, false);

window.addEventListener("online", function(e) {
  // just came back online, enable links
  processOnline(e.type);
}, false);

在上面的 EventListeners 中,我们必须告诉我们的代码是从事件还是实际页面请求或刷新中调用它。 主要原因是因为身体 onload在在线和离线模式之间切换时不会触发事件。

接下来,我们有一个简单的检查 ononline或者 onload事件。 此代码在从离线切换到在线时重置禁用的链接,但如果此应用程序更复杂,您可能会插入逻辑来恢复获取内容或处理间歇性连接的 UX。

function processOnline(eventType) {

  setupApp();
  checkAppCache();

  // reset our once disabled offline links
  if (eventType) {
    for (var i = 0; i < disabledLinks.length; i++) {
      disabledLinks[i].onclick = null;
    }
  }
}

这同样适用于 processOffline(). 在这里,您将在离线模式下操作您的应用程序,并尝试恢复在幕后进行的任何事务。 下面的这段代码会挖掘出我们所有的外部链接并禁用它们——将用户永远困在我们的离线应用程序中 muhahaha!

function processOffline() {
  setupApp();

  // disable external links until we come back - setting the bounds of app
  disabledLinks = getUnconvertedLinks(document);

  // helper for onlcick below
  var onclickHelper = function(e) {
    return function(f) {
      alert('This app is currently offline and cannot access the hotness');return false;
    }
  };

  for (var i = 0; i < disabledLinks.length; i++) {
    if (disabledLinks[i].onclick == null) {
      //alert user we're not online
      disabledLinks[i].onclick = onclickHelper(disabledLinks[i].href);

    }
  }
}

好的,等到好东西。 既然我们的应用知道它处于什么连接状态,我们还可以检查它在线时的连接类型并进行相应的调整。 我在每个连接的评论中列出了典型的北美供应商下载和延迟。

警告: type 以下示例中使用的属性已从网络信息 API 中删除。 使用 bandwidth 而是财产。

function setupApp(){
  // create a custom object if navigator.connection isn't available
  var connection = navigator.connection || {'type':'0'};
  if (connection.type == 2 || connection.type == 1) {
      //wifi/ethernet
      //Coffee Wifi latency: ~75ms-200ms
      //Home Wifi latency: ~25-35ms
      //Coffee Wifi DL speed: ~550kbps-650kbps
      //Home Wifi DL speed: ~1000kbps-2000kbps
      fetchAndCache(true);
  } else if (connection.type == 3) {
  //edge
      //ATT Edge latency: ~400-600ms
      //ATT Edge DL speed: ~2-10kbps
      fetchAndCache(false);
  } else if (connection.type == 2) {
      //3g
      //ATT 3G latency: ~400ms
      //Verizon 3G latency: ~150-250ms
      //ATT 3G DL speed: ~60-100kbps
      //Verizon 3G DL speed: ~20-70kbps
      fetchAndCache(false);
  } else {
  //unknown
      fetchAndCache(true);
  }
}

我们可以对我们的 fetchAndCache 进程进行许多调整,但我在这里所做的只是告诉它为给定的连接异步(true)或同步(false)获取资源。

边缘(同步)请求时间线
Edge Sync

WIFI(异步)请求时间线
WIFI Async

这允许至少一些基于慢速或快速连接的用户体验调整方法。
这绝不是万能的解决方案。 另一个待办事项是在单击链接时(在慢速连接上)抛出加载模式,而应用程序仍可能在后台获取该链接的页面。
这里的重点是减少延迟,同时利用用户与最新最好的 HTML5 提供的连接的全部功能。
在此处查看网络检测演示

结论

移动 HTML5 应用的旅程才刚刚开始。 现在您看到了仅围绕 HTML5 及其支持技术构建的移动“框架”的非常简单和基本的基础。 我认为对于开发人员来说,重要的是要在其核心处使用和解决这些特性,而不是被包装器掩盖。

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

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

发布评论

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

关于作者

南冥有猫

暂无简介

0 文章
0 评论
544 人气
更多

推荐作者

漫雪独思

文章 0 评论 0

垂暮老矣

文章 0 评论 0

鹊巢

文章 0 评论 0

萌酱

文章 0 评论 0

雨说

文章 0 评论 0

冰葑

文章 0 评论 0

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