你真的瞭解 Web Component 嗎?

爲什麼使用框架?


對框架的理解

作爲現代前端開發者,擁抱框架是生存的不二法則,有些人一入場便投身框架的海洋,有些人則有幸見證過變革,從原生,到 jq,到各種框架大行其道的今天。而當前,國內佔領市場份額最多的要數 vue、react 和 angular,他們都有着各自的特點,這也是它們一路走來的立足之本。

那麼作爲使用者的我們,在使用框架高效處理業務的同時,對框架本身也是需要一定程度的理解,以此來輔助我們更好的學習、瞭解和應用框架。下面有一個表格,內容提煉自尤雨溪本人對三大框架的對比看法,也許可以一定程度提升我們對框架的認知。

職責範圍的意義:

框架的優勢

基於上述框架間的差異化,我們可以看出框架各自不同的設計、發展和其衍生出的生態其實都是源自於最初各自對於職責範圍界定的不同而來。但儘管差異不小,它們依然存在着共性,而共性,正是源於框架本身存在的意義和目標。

回頭審視,你會發現所有的框架其實都有共同的特點和目標,就是基於原生,然後更高的效率,更棒的性能,更好的差異抹平。

但我們需要正確理解這句話,這並不意味着框架的指標就優於原生,而是說,因爲有了框架,我們不用再手寫不依賴業務場景的數據 - 視圖的綁定,不用再手動抹平平臺或瀏覽器之間的差異,不用再陷入操作 dom 的同時還要兼顧性能苦惱。可以說框架提高了開發者開發和實現功能的各項下限,讓快速開發和基礎性能之間更好的平衡。我們以 react 和 vue 爲例,這兩大框架所帶來的優勢包括但不限於:

但這些優勢不是憑空而來,就像 vue 的雙向綁定,從使用 object.defineProperty 轉爲使用 proxy,這種類似的實現或者說轉變,核心之處都需要 js 語法以及瀏覽器的原生支持。因爲 web 應用最終都是要運行在宿主 -- 瀏覽器上的,所以制定規範的各大瀏覽器廠商以及提供原生 api 支持的瀏覽器環境纔是王道,而框架不是。我們之所以需要引入各類的框架、工具庫去實現各種優秀的設計與思想,比如組件化,本質上是因爲原生未直接提供對應的方式或是 api,所以才需要框架去構建棋盤之上的又一層規則體系,來實現開發者的訴求。

而框架這種在瀏覽器原生規則之上又一層較高程度的封裝,在帶來便利高效的同時,不可避免的帶來兩個缺陷:

(圖 1: 桌面 chrome; 圖 2: 平板 chrome; 圖 3: 移動端 chrome;)(下圖:桌面 chrome 下 react vs js 內存比較)

那麼如果原生可以提供某些 api,是不是就可以一定程度上替代框架的某些功能,在擁有便利高效的同時,跨平臺、跨框架的使用,還能較大限度的保持原生的性能?

這就是接下來要聊到的是 web component 和其所能帶來的可能甚至是變革。

認識 web component

web component

狹義的來說,web component 是瀏覽器環境提供的一些新的原生支持的 api 和模版。廣義的說,它是一套可以支持原生實現組件化的技術。從 MDN 的描述中可以看到,web component 的誕生,是爲了解決代碼複用、組件自定義、複用管理等問題。

回看上文中,我們對框架優勢的分析羅列,可以發現解決這些開發痛點的方案早已存在,也就是與之對應的框架優勢中的組件化。那麼根據上面的分析,既然原生支持了,是不是意味着可以顛覆框架?這種想法是有些衝動的,單純依靠原生的 api 去顛覆框架是不現實的,能顛覆框架的也必須是框架,因爲每一個框架都意味着對應的生態 (路由管理、狀態管理、dom 性能優化管理等)。如果有一天,當前框架中的大部分優秀的設計與思想被原生環境所吸收並支持,那麼在此基礎上衍生的框架,才能真正具備替代當前三大框架的能力,成爲前端唯一一類框架。

而現在,我們雖然還是無法捨棄框架擁抱原生,但是我們可以將其中的一部分進行替代,使之擁有框架提供的優勢,又能避免因框架而導致的缺陷。

原生組件化能否替代框架組件化?

我們先來看看組件化的特點:

再看看依據組件化的規範,框架組件化提供給我們最直觀的體驗:

最後我們來看看 web component 給我們提供了什麼:

從上述這些原生 api 所提供給我們的種種特性,說明 web component 同樣可以滿足我們對組件的自定義及複用、與文檔其他部分隔離、生命週期的鉤子函數,甚至是內容分發等這些訴求。

那麼至少從理論的角度上說,web component 是完全有能力替代框架組件化的,這意味着開發者可以在不使用的框架的前提下進行組件化開發,而且開發出的組件可以無縫嵌入使用了框架的項目中。有趣的是在最新發布的 vue3.2 中,也初步引入了對於 web component 的使用:

兼容性

作爲開發者,面對新的強大的 api,在充滿熱情的同時,更需要關注其可用性和普及範圍。我們可以通過 can i use 去查看它的兼容性:https://caniuse.com/?search=web%20component。從中我們可以看到:

1. Custom elements 兼容性

2. Shadow DOM 兼容性

3. HTML templates 兼容性

自主定製元素和自定義內置元素

在 Custom elements 兼容性的描述中,我們看到兩個概念,如下:

那麼這裏怎麼去理解自主定製元素自定義內置元素?我們可以從具體的 code 實現上進行觀察:

js: 
... 
customElements.define('custom-elements', class); 
... 
html: 
<body> 
... 
<custom-elements></custom-elements> 
... 
</body>
js:
...
customElements.define('custom-elements', class, { extends: 'p' });
...
html:
<body>
...
<p is="custom-elements"></p>
...
</body>

可以看到從聲明上是沒有太大區別的,都是通過 customElements.define 去定義聲明,並且需要一個 class 去構建內部的生命週期與屬性監聽。區別之處在於自定義內置元素需要在後面的配置項中設置要繼承的內置 HTML 元素 (指原生的元素)

而最大的區別是在於使用上,自主定製元素其實就是一個完整的自定義組件,可以讓我們在不依賴任何框架的前提下實現組件化。而自定義內置組件,可以理解爲是對所繼承的原生元素的改造(如上述 code 呈現,聲明定義自定義組件時,指定繼承的原生元素,後續使用該原生元素時,通過 is 屬性引用聲明的自定義組件,就可以改造該原生元素,使其擁有生命週期、自定義組件和作用域隔離的功能)。

web component api 的使用

自定義組件的聲明和使用

所依賴的主要接口是 CustomElementRegistry,該接口提供了,用作支持自定義組件的使用和聲明:

該方法用來聲明自定義組件,接受 3 個參數,無返回值:

  1. name:將要全局註冊的自定義組件名字 (必須是中劃線的形式)。

  2. constructor:一個類,如果聲明的是自主定製元素,則必須繼承自 HTMLElement;如果聲明的是自定義內置元素,則必須繼承它將要擴展的原生元素所屬的類 (如要擴展 div,那就必須繼承 HTMLDivElement)。並且類的構造函數中,必須執行 super。

  3. options:一個可選的配置對象,只有在聲明自定義內置元素時使用,且當前只有一個配置項 extends,值爲將要擴展的原生元素的標籤名。

聲明示例:

//自主定製元素 
class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    ... 
  } 
} 
customElements.define('custom-ele', CustomEle); 
 
//自定義內置元素,如果要擴展div的話 
class CustomEleBuiltIn extends HTMLDivElement { 
  constructor() { 
    super(); 
    ... 
  } 
} 
customElements.define('custom-ele-build-in', CustomEleBuiltIn, { extends: 'div' });

使用的方式也是多樣的。可以通過 document.createElement 的方式使用,也可以直接書寫在 html 中。使用示例:

//自主定製元素 
const customEle = document.createElement('custom-ele'); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
document.querySelector('#app').appendChild(customEle); 
customElements.define('custom-ele', CustomEle); 
//或 
customElements.define('custom-ele', CustomEle); 
const customEle = new CustomEle(); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
document.querySelector('#app').appendChild(customEle); 
//或 
<custom-ele img="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png" text="我是一段懸停說明"> 
 
//自定義內置元素,如果要擴展div的話 
customElements.define('custom-ele-build-in', CustomEleBuiltIn, { extends: 'div' }); 
const div = document.createElement('div'{ is: 'custom-ele-build-in' }); 
div.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
div.setAttribute('text''我是一段懸停說明'); 
document.querySelector('#app').appNode.appendChild(div); 
//或 
<div is="custom-ele-build-in" img="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png" text="我是一段懸停說明" />

這裏的幾 種使用方式其實還是有差異的,在初始化的時候,直接引用的方式可以在構造階段就拿到掛載的各個屬性;但是採用 create 的方式時,構造階段無法第一時間獲取屬性,當然,利用生命週期的鉤子函數,也是解決該問題的。

該方法用來獲取自定義組件的構造函數,接受一個參數,即聲明過的自定義組件的 name,返回構造函數。

const getCustomConstructorBefore = customElements.get('custom-ele'); 
console.log('getCustomConstructor-before', getCustomConstructorBefore);//undefined 
customElements.define('custom-ele', CustomEle); 
const getCustomConstructorAfter = customElements.get('custom-ele'); 
console.log('getCustomConstructor-after', getCustomConstructorAfter);//CustomEle

該方法是用來更新掛載主文檔之前的包含 shadow dom 的自定義組件的,接受一個參數,即包含了 shadow dom 的自定義組件節點,無返回值。(自定義組件在被 append 到主文檔的時候,會觸發自動更新)。

//先創建了自定義元素 
const customEle = document.createElement('custom-ele'); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
//後聲明自定義元素 
customElements.define('custom-ele', CustomEle); 
//結果爲false,null 
console.log(customEle instanceof CustomEle, customEle.shadowRoot); 
//進行更新節點 
customElements.upgrade(customEle);//或document.querySelector('#app').appendChild(customEle); 
//true,#document-fragment 
console.log(customEle instanceof CustomEle, customEle.shadowRoot);

該方法是用來檢測並提供自定義組件被定義聲明完畢的時機得,接受一個參數,即自定義元素的 name,返回值是一個 promise(只檢測自定義組件是否被 defined,不檢測是否被掛載於主文檔)。若提供的 name 無效,則觸發 promise 的 catch。

//創建了自定義元素dom 
const customEle = document.createElement('custom-ele'); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
//用來判斷關閉定時器得標識 
let isStop = false; 
//獲取自定義組件定義完畢的時機 
customElements.whenDefined('custom-ele').then(() ={ 
  console.log('定義完畢'); 
  isStop = true; 
}); 
//一個用於觀察得計時器 
const timer = setInterval(() ={ 
  if (isStop) { 
    clearInterval(timer); 
    return; 
  } 
  console.log(Math.floor(Date.now() / 1000)); 
}, 1000); 
//延遲3秒進行自定義組件的定義及聲明 
setTimeout(() ={ 
  customElements.define('custom-ele', CustomEle); 
}, 3000)

自定義組件的生命週期

自定義組件的第一個生命週期,用來初始化自定義組件本身。觸發的時機在自定義組件被 document.createElement 的時候 (前提是組件已經被 customElements.define 過,如果組件是先 create,後 defined,那麼 constructor 的執行時機在 append 到主文檔裏時)。

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    console.log('constructor被執行'); 
    ...... 
  } 
} 
 
customElements.define('custom-ele', CustomEle); 
const customEle = document.createElement('custom-ele');

在組件被成功添加到主文檔時觸發的生命週期,在 constructor 之後。

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    console.log('constructor被執行'); 
    ...... 
  }  
  connectedCallback () { 
    console.log('connectedCallback被執行'); 
  } 
} 
 
customElements.define('custom-ele', CustomEle); 
const customEle = document.createElement('custom-ele'); 
document.querySelector('#app').appendChild(customEle);

自定義組件最關鍵的一個生命週期。觸發時機在組件屬性被增加、刪除或修改的時候。如果你是在組件被 append 之前設置了屬性,那麼就會在 connectedCallback 之前觸發;反之,則在 connectedCallback 之後觸發。需要配合靜態方法 observedAttributes 來使用,只有註冊在 observedAttributes 中的屬性纔會被監聽。

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    console.log('constructor被執行'); 
    ...... 
  }  
  connectedCallback () { 
    console.log('connectedCallback被執行'); 
  } 
  static get observedAttributes () { return [ 'img''text' ]; } 
  attributeChangedCallback (name, oldValue, newValue) { 
    console.log('attributeChangedCallback', name) 
    if (name === 'img') { 
      this.shadowRoot.querySelector('img').src = this.getAttribute('img'); 
    } 
    if (name === 'text') { 
      this.shadowRoot.querySelector('.info').textContent = this.getAttribute('text'); 
    } 
  } 
} 
 
customElements.define('custom-ele', CustomEle); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
const customEle = document.createElement('custom-ele'); 
document.querySelector('#app').appendChild(customEle);

當元素被移動到新的文檔時,被調用。即元素是另一個文檔的元素,而 adoptedCallback 是新文檔下的自定義組件的回調。

//聲明自定義組件的類 
class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    ......   
  } 
  adoptedCallback () { 
    console.log('adoptedCallback被執行'); 
  } 
} 
//創造場景,增加iframe,即舊文檔 
appNode.innerHTML = '<iframe></iframe>'; 
const p = document.createElement('p'); 
p.innerHTML = 'iframe'; 
appNode.querySelector('iframe').contentWindow.document.body.appendChild(p); 
 
//新文檔中創建自定義組件 
const customEle = document.createElement('custom-ele'); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
customElements.define('custom-ele', CustomEle); 
appNode.appendChild(customEle); 
 
//將元素從舊文檔遷移到新文檔 
setTimeout(() ={ 
  console.log('開始對元素進行adoptNode操作') 
  const node = appNode.querySelector('iframe').contentWindow.document.body.firstElementChild; 
  appNode.appendChild(document.adoptNode(node)) 
}, 2000);

該回調函數並不常用,瞭解即可。

自定義組件的最後一個生命週期,觸發的時機在組件被成功從主文檔移除時。

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    ......   
  }  
  disconnectedCallback () { 
    console.log('disconnectedCallback被執行'); 
  } 
} 
 
customElements.define('custom-ele', CustomEle); 
const customEle = document.createElement('custom-ele'); 
document.querySelector('#app').appendChild(customEle); 
setTimeout(() ={ 
  appNode.removeChild(customEle); 
}, 2000)

注意:瀏覽器關閉或 tabs 關閉,不會觸發 disconnectedCallback。

Shadow DOM 的使用

其作用是將標記結構、樣式和行爲隱藏起來,並與頁面上的其他代碼相隔離。Shadow DOM 都不是一個新事物,在過去的很長一段時間裏,瀏覽器用它來封裝一些元素的內部結構,回憶一下 video 標籤內部被隱藏起來的控制按鈕們。

attachShadow 接受一個對象參數,只需關注一個配置屬性 mode,如果設置爲 open,表示可以從外部獲取 Shadow DOM 內部的元素;如果設置爲 closed,則表示隱藏 Shadow DOM 內部,例如

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    const shadow = this.attachShadow({ mode: 'open' }); 
    ...... 
  } 
} 
customElements.define('custom-ele', CustomEle); 
const customEle = document.createElement('custom-ele'); 
document.querySelector('#app').appendChild(customEle); 
console.log(customEle.shadowRoot)

若 mode 設置爲 closed:

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    const shadow = this.attachShadow({ mode: 'open' }); 
 
    const wrapper = document.createElement('span'); 
    wrapper.setAttribute('class''wrapper'); 
 
    const icon = document.createElement('span'); 
    icon.setAttribute('class''icon'); 
 
    const info = document.createElement('span'); 
    info.setAttribute('class''info'); 
 
    const text = this.getAttribute('text'); 
    info.textContent = text; 
 
    const img = document.createElement('img'); 
    img.src = this.getAttribute('img'); 
    icon.appendChild(img); 
 
    const style = document.createElement('style'); 
    // console.log('CustomEle', style.isConnected); 
    style.textContent = ` 
      .wrapper { 
        position: relative; 
      } 
      .info { 
        font-size: 0.8rem; 
        width: 200px; 
        display: inline-block; 
        border: 1px solid black; 
        padding: 10px; 
        background: white; 
        border-radius: 10px; 
        opacity: 0; 
        transition: 0.6s all; 
        position: absolute; 
        bottom: 20px; 
        left: 10px; 
        z-index: 3; 
      } 
      img { 
        width: 1.2rem; 
      } 
      .icon:hover + .info, .icon:focus + .info { 
        opacity: 1; 
      } 
    `; 
    shadow.appendChild(style); 
    // console.log('CustomEle', style.isConnected); 
 
    shadow.appendChild(wrapper); 
    wrapper.appendChild(icon); 
    wrapper.appendChild(info); 
  } 
} 
const customEle = document.createElement('custom-ele'); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
customElements.define('custom-ele', CustomEle); 
document.querySelector('#app').appendChild(customEle);

如果想添加樣式表,則可以把上述代碼中的代碼:

const style = document.createElement('style'); 
    // console.log('CustomEle', style.isConnected); 
    style.textContent = ` 
      .wrapper { 
        position: relative; 
      } 
      .info { 
        font-size: 0.8rem; 
        width: 200px; 
        display: inline-block; 
        border: 1px solid black; 
        padding: 10px; 
        background: white; 
        border-radius: 10px; 
        opacity: 0; 
        transition: 0.6s all; 
        position: absolute; 
        bottom: 20px; 
        left: 10px; 
        z-index: 3; 
      } 
      img { 
        width: 1.2rem; 
      } 
      .icon:hover + .info, .icon:focus + .info { 
        opacity: 1; 
      } 
    `; 
    shadow.appendChild(style);

替換爲:

const linkElem = document.createElement('link'); 
linkElem.setAttribute('rel''stylesheet'); 
linkElem.setAttribute('href''style.css');//樣式的地址 
 
shadow.appendChild(linkElem);

需要注意的是:由於 link 元素不會打斷 shadow root 的繪製, 因此在加載樣式表時可能會出現未添加樣式內容(FOUC),導致閃爍。

模版

使用包裹的內容不會在頁面上顯示,但是卻可以被 js 引用到。這就意味着有些內容我們不用重複構建多遍,使用 構建一遍,然後多次引用處理就好了。

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    console.log('constructor被執行'); 
    const shadow = this.attachShadow({ mode: 'open' }); 
 
    let template = document.getElementById('my-paragraph'); 
    if (template) { 
      let templateContent = template.content; 
      shadow.appendChild(templateContent.cloneNode(true)); 
    } 
    ...... 
  } 
} 
appNode.innerHTML = '<template id="my-paragraph"><style>p {color: white;background-color: #666;padding: 5px;}</style><p>My paragraph</p></template>'; 
const customEle = document.createElement('custom-ele'); 
customEle.setAttribute('img''https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201409%2F06%2F20140906020558_h4VfY.png'); 
customEle.setAttribute('text''我是一段懸停說明'); 
customElements.define('custom-ele', CustomEle); 
appNode.appendChild(customEle);

然後在自定義組件的 innerhtml 中使用即可。

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    console.log('constructor被執行'); 
    const shadow = this.attachShadow({ mode: 'open' }); 
 
    let template = document.getElementById('my-paragraph'); 
    if (template) { 
      let templateContent = template.content; 
      shadow.appendChild(templateContent.cloneNode(true)); 
    } 
    const slot2 = document.createElement('slot'); 
    slot2.setAttribute('name''newText2'); 
    shadow.appendChild(slot2); 
    ...... 
  } 
} 
appNode.innerHTML = '<template id="my-paragraph"><style>p {color: white;background-color: #666;padding: 5px;}</style><slot >newText1</p></custom-ele>'; 
const customEle = document.createElement('custom-ele'); 
customEle.innerHTML = '<p slot="newText2">newText2</p>'; 
customElements.define('custom-ele', CustomEle); 
appNode.appendChild(customEle);

class CustomEle extends HTMLElement { 
  constructor() { 
    super(); 
    let template = document.getElementById('my-paragraph'); 
    if (template) { 
      let templateContent = template.content; 
      shadow.appendChild(templateContent.cloneNode(true)); 
    } 
    const slots = shadow.querySelectorAll('slot'); 
    slots.forEach(slot ={ 
      slot.addEventListener('slotchange'function (e) { 
        console.log('slotchange', slot.name, e); 
      }); 
    }); 
    ...... 
  } 
} 
 
appNode.innerHTML = '<template id="my-paragraph">' + 
  '<style>p {color: white;background-color: #666;padding: 5px;}</style>' + 
  '<slot ></slot>' + 
  '<slot ></slot>' + 
  '</template>' + 
  '<h3>' + 
  '<custom-ele class="newText1Box">' + 
  '<p slot="newText1">newText1</p>' + 
  '<span slot="spanText">spanText</span>' + 
  '</custom-ele>' + 
  '</h3>'; 
setTimeout(() ={ 
  document.querySelector('.newText1Box').removeChild(document.querySelector('.newText1Box p')); 
  //或 
  document.querySelector('.newText1Box p').removeAttribute('slot'); 
}, 2000) 
在添加slot時(直接插入包含slot屬性的元素或給已插入的元素增加slot屬性)或刪除slot時(直接remove包含slot屬性的元素或給已插入的元素removeAttribute slot屬性),都會觸發slotchange事件。

相關的其他 api

opt 的配置項:

相關的庫及網站

參考

❤️ 謝謝支持

以上便是本次分享的全部內容,希望對你有所幫助 ^_^

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/o79-iOAJMKX_7a__gmSX2A