揭開Vue異步組件的神秘面紗

簡介

在大型應用里臣咖,有些組件可能一開始并不顯示,只有在特定條件下才會渲染自点,那么這種情況下該組件的資源其實不需要一開始就加載遂赠,完全可以在需要的時候再去請求久妆,這也可以減少頁面首次加載的資源體積,要在Vue中使用異步組件也很簡單:

// AsyncComponent.vue
<template>
  <div>我是異步組件的內容</div>
</template>

<script>
export default {
    name: 'AsyncComponent'
}
</script>
// App.vue
<template>
  <div id="app">
    <AsyncComponent v-if="show"></AsyncComponent>
    <button @click="load">加載</button>
  </div>
</template>

<script>
export default {
  name: 'App',
  components: {
    AsyncComponent: () => import('./AsyncComponent'),
  },
  data() {
    return {
      show: false,
    }
  },
  methods: {
    load() {
      this.show = true
    },
  },
}
</script>

我們沒有直接引入AsyncComponent組件進行注冊跷睦,而是使用import()方法來動態(tài)的加載筷弦,import()ES2015 Loader 規(guī)范 定義的一個方法,webpack內置支持抑诸,會把AsyncComponent組件的內容單獨打成一個js文件烂琴,頁面初始不會加載,點擊加載按鈕后才會去請求蜕乡,該方法會返回一個promise奸绷,接下來,我們從源碼角度詳細看看這一過程层玲。

通過本文号醉,你可以了解Vue對于異步組件的處理過程以及webpack的資源加載過程。

編譯產物

首先我們打個包辛块,生成了三個js文件:

image-20211214194854431.png

第一個文件是我們應用的入口文件畔派,里面包含了main.jsApp.vue的內容润绵,另外還包含了一些webpack注入的方法线椰,第二個文件就是我們的異步組件AsyncComponent的內容,第三個文件是其他一些公共庫的內容尘盼,比如Vue憨愉。

然后我們看看App.vue編譯后的內容:

image-20211224161447196.png

上圖為App組件的選項對象呜魄,可以看到異步組件的注冊方式,是一個函數莱衩。

image-20211224161252075.png

上圖是App.vue模板部分編譯后的渲染函數恋捆,當_vm.showtrue的時候立镶,會執(zhí)行_c('AsyncComponent')耳幢,否則執(zhí)行_vm._e()牙寞,創(chuàng)建一個空的VNode吃型,_ccreateElement方法:

vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };

接下來看看當我們點擊按鈕后刑桑,這個方法的執(zhí)行過程棕硫。

createElement方法

function createElement (
  context,
  tag,
  data,
  children,
  normalizationType,
  alwaysNormalize
) {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children;
    children = data;
    data = undefined;
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE;
  }
  return _createElement(context, tag, data, children, normalizationType)
}

contextApp組件實例搓萧,tag就是_c的參數AsyncComponent戚啥,其他幾個參數都為undefinedfalse奋单,所以這個方法的兩個if分支都沒走,直接進入_createElement方法:

function _createElement (
 context,
 tag,
 data,
 children,
 normalizationType
) {
    // 如果data是被觀察過的數據
    if (isDef(data) && isDef((data).__ob__)) {
        return createEmptyVNode()
    }
    // v-bind中的對象語法
    if (isDef(data) && isDef(data.is)) {
        tag = data.is;
    }
    // tag不存在猫十,可能是component組件的:is屬性未設置
    if (!tag) {
        return createEmptyVNode()
    }
    // 支持單個函數項作為默認作用域插槽
    if (Array.isArray(children) &&
        typeof children[0] === 'function'
       ) {
        data = data || {};
        data.scopedSlots = { default: children[0] };
        children.length = 0;
    }
    // 處理子節(jié)點
    if (normalizationType === ALWAYS_NORMALIZE) {
        children = normalizeChildren(children);
    } else if (normalizationType === SIMPLE_NORMALIZE) {
        children = simpleNormalizeChildren(children);
    }
    // ...
}

上述邏輯在我們的示例中都不會進入览濒,接著往下看:

function _createElement (
 context,
 tag,
 data,
 children,
 normalizationType
) {
    // ...
    var vnode, ns;
    // tag是字符串
    if (typeof tag === 'string') {
        var Ctor;
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);
        if (config.isReservedTag(tag)) {
            // 是否是保留元素,比如html元素或svg元素
            if (false) {}
            vnode = new VNode(
                config.parsePlatformTagName(tag), data, children,
                undefined, undefined, context
            );
        } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
            // 組件
            vnode = createComponent(Ctor, data, context, children, tag);
        } else {
            // 其他未知標簽
            vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
            );
        }
    } else {
        // tag是組件選項或構造函數
        vnode = createComponent(tag, data, context, children);
    }
    // ...
}

對于我們的異步組件拖云,tagAsyncComponent贷笛,是個字符串,另外通過resolveAsset方法能找到我們注冊的AsyncComponent組件:

function resolveAsset (
  options,// App組件實例的$options
  type,// components
  id,
  warnMissing
) {
  if (typeof id !== 'string') {
    return
  }
  var assets = options[type];
  // 首先檢查本地注冊
  if (hasOwn(assets, id)) { return assets[id] }
  var camelizedId = camelize(id);
  if (hasOwn(assets, camelizedId)) { return assets[camelizedId] }
  var PascalCaseId = capitalize(camelizedId);
  if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] }
  // 本地沒有宙项,則在原型鏈上查找
  var res = assets[id] || assets[camelizedId] || assets[PascalCaseId];
  if (false) {}
  return res
}

Vue會把我們的每個組件都先創(chuàng)建成一個構造函數乏苦,然后再進行實例化,在創(chuàng)建過程中會進行選項合并尤筐,也就是把該組件的選項和父構造函數的選項進行合并:

image-20211227112643613.png

上圖中汇荐,子選項是App的組件選項,父選項是Vue構造函數的選項對象盆繁,對于components選項掀淘,會以父類的該選項值為原型創(chuàng)建一個對象,然后把子類本身的選項值作為屬性添加到該對象上改基,最后這個對象作為子類構造函數的options.components的屬性值:

image-20211227113823227.png
image-20211227113909991.png
image-20211227113657329.png

然后在組件實例化時繁疤,會以構造函數的options對象作為原型創(chuàng)建一個對象,作為實例的$options

image-20211227135444816.png

所以App實例能通過$options從它的構造函數的options.components對象上找到AsyncComponent組件:

image-20211227140124998.png

可以發(fā)現就是我們前面看到過的編譯后的函數秕狰。

接下來會執(zhí)行createComponent方法:

function createComponent (
 Ctor,
 data,
 context,
 children,
 tag
) {
    // ...
    // 異步組件
    var asyncFactory;
    if (isUndef(Ctor.cid)) {
        asyncFactory = Ctor;
        Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
        if (Ctor === undefined) {
            return createAsyncPlaceholder(
                asyncFactory,
                data,
                context,
                children,
                tag
            )
        }
    }
    // ...
}

接著又執(zhí)行了resolveAsyncComponent方法:

function resolveAsyncComponent (
 factory,
 baseCtor
) {
     // ...
    var owner = currentRenderingInstance;
    if (owner && !isDef(factory.owners)) {
        var owners = factory.owners = [owner];
        var sync = true;
        var timerLoading = null;
        var timerTimeout = null

        ;(owner).$on('hook:destroyed', function () { return remove(owners, owner); });
        var forceRender = function(){}
        var resolve = once(function(){})
        var reject = once(function(){})
        // 執(zhí)行異步組件的函數
        var res = factory(resolve, reject);
    }
     // ...
}

到這里終于執(zhí)行了異步組件的函數稠腊,也就是下面這個:

function AsyncComponent() {
    return __webpack_require__.e( /*! import() */ "chunk-1f79b58b").then(__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d"));
}

欲知res是什么,我們就得看看這幾個webpack的函數是干什么的鸣哀。

加載組件資源

webpack_require.e方法

先看__webpack_require__.e方法:

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    // 已經加載的chunk
    var installedChunkData = installedChunks[chunkId];
    if (installedChunkData !== 0) { // 0代表已經加載
      // 值非0即代表組件正在加載中架忌,installedChunkData[2]為promise對象
      if (installedChunkData) {
        promises.push(installedChunkData[2]);
      } else {
        // 創(chuàng)建一個promise,并且把兩個回調參數緩存到installedChunks對象上
        var promise = new Promise(function (resolve, reject) {
          installedChunkData = installedChunks[chunkId] = [resolve, reject];
        });
        // 把promise對象本身也添加到緩存數組里
        promises.push(installedChunkData[2] = promise);
        // 開始發(fā)起chunk請求
        var script = document.createElement('script');
        var onScriptComplete;
        script.charset = 'utf-8';
        script.timeout = 120;
        // 拼接chunk的請求url
        script.src = jsonpScriptSrc(chunkId);
        var error = new Error();
        // chunk加載完成/失敗的回到
        onScriptComplete = function (event) {
          script.onerror = script.onload = null;
          clearTimeout(timeout);
          var chunk = installedChunks[chunkId];
          if (chunk !== 0) {
            // 如果installedChunks對象上該chunkId的值還存在則代表加載出錯了
            if (chunk) {
              var errorType = event && (event.type === 'load' ? 'missing' : event.type);
              var realSrc = event && event.target && event.target.src;
              error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
              error.name = 'ChunkLoadError';
              error.type = errorType;
              error.request = realSrc;
              chunk[1](error);
            }
            installedChunks[chunkId] = undefined;
          } 
        };
        // 設置超時時間
        var timeout = setTimeout(function () {
          onScriptComplete({
            type: 'timeout',
            target: script
          });
        }, 120000);
        script.onerror = script.onload = onScriptComplete;
        document.head.appendChild(script);
      }
    }
    return Promise.all(promises);
  };

這個方法雖然有點長我衬,但是邏輯很簡單叹放,首先函數返回的是一個promise饰恕,如果要加載的chunk未加載過,那么就創(chuàng)建一個promise井仰,然后緩存到installedChunks對象上埋嵌,接下來創(chuàng)建script標簽來加載chunk,唯一不好理解的是onScriptComplete函數俱恶,因為在這里面判斷該chunkinstalledChunks上的緩存信息不為0則當做失敗處理了雹嗦,問題是前面才把promise信息緩存過去,也沒有看到哪里有進行修改合是,要理解這個就需要看看我們要加載的chunk的內容了:

image-20211227153327294.png

可以看到代碼直接執(zhí)行了了罪,并往webpackJsonp數組里添加了一項:

window["webpackJsonp"] = window["webpackJsonp"] || []).push([["chunk-1f79b58b"],{..}])

看著似乎也沒啥問題,其實window["webpackJsonp"]push方法被修改過了:

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
var parentJsonpFunction = oldJsonpFunction;

被修改成了webpackJsonpCallback方法:

function webpackJsonpCallback(data) {
    var chunkIds = data[0];
    var moreModules = data[1];
    var moduleId, chunkId, i = 0,
        resolves = [];
    for (; i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
            // 把該chunk的promise的resolve回調方法添加到resolves數組里
            resolves.push(installedChunks[chunkId][0]);
        }
        // 標記該chunk已經加載完成
        installedChunks[chunkId] = 0;
    }
    // 將該chunk的module數據添加到modules對象上
    for (moduleId in moreModules) {
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            modules[moduleId] = moreModules[moduleId];
        }
    }
    // 執(zhí)行原本的push方法
    if (parentJsonpFunction) parentJsonpFunction(data);
    // 執(zhí)行resolve函數
    while (resolves.length) {
        resolves.shift()();
    }
}

這個函數會取出該chunk加載的promiseresolve函數聪全,然后將它在installedChunks上的信息標記為0泊藕,代表加載成功,所以在后面執(zhí)行的onScriptComplete函數就可以通過是否為0來判斷是否加載失敗难礼。最后會執(zhí)行resolve函數娃圆,這樣前面__webpack_require__.e函數返回的promise狀態(tài)就會變?yōu)槌晒Α?/p>

讓我們再回顧一下AsyncComponent組件的函數:

function AsyncComponent() {
    return __webpack_require__.e( /*! import() */ "chunk-1f79b58b").then(__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d"));
}

chunk加載完成后會執(zhí)行__webpack_require__方法。

__webpack_require__方法

這個方法是webpack最重要的方法鹤竭,用來加載模塊:

function __webpack_require__(moduleId) {
    // 檢查模塊是否已經加載過了
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // 創(chuàng)建一個新模塊踊餐,并緩存
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
    // 執(zhí)行模塊函數
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 標記模塊加載狀態(tài)
    module.l = true;
    // 返回模塊的導出
    return module.exports;
}

所以上面的__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d")其實是去加載了c61d模塊,這個模塊就在我們剛剛請求回來的chunk里:

image-20211227161841023.png

這個模塊內部又會去加載它依賴的模塊臀稚,最終返回的結果為:

image-20211227162447114.png

其實就是AsyncComponent的組件選項吝岭。

回到createElement方法

回到前面的resolveAsyncComponent方法:

var res = factory(resolve, reject);

現在我們知道這個res其實就是一個未完成的promiseVue并沒有等待異步組件加載完成吧寺,而是繼續(xù)向后執(zhí)行:

if (isObject(res)) {
    if (isPromise(res)) {
        // () => Promise
        if (isUndef(factory.resolved)) {
            res.then(resolve, reject);
        }
    }
}

return factory.resolved

把定義的resolvereject函數作為參數傳給promise res窜管,最后返回了factory.resolved,這個屬性并沒有被設置任何值稚机,所以是undefined幕帆。

接下來回到createComponent方法:

Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
if (Ctor === undefined) {
    // 返回異步組件的占位符節(jié)點,該節(jié)點呈現為注釋節(jié)點赖条,但保留該節(jié)點的所有原始信息失乾。
    // 這些信息將用于異步服務端渲染。
    return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
    )
}

因為Ctorundefined纬乍,所以會執(zhí)行createAsyncPlaceholder方法返回一個占位符節(jié)點:

function createAsyncPlaceholder (
  factory,
  data,
  context,
  children,
  tag
) {
  // 創(chuàng)建一個空的VNode碱茁,其實就是注釋節(jié)點
  var node = createEmptyVNode();
  // 保留組件的相關信息
  node.asyncFactory = factory;
  node.asyncMeta = { data: data, context: context, children: children, tag: tag };
  return node
}

最后讓我們再回到_createElement方法:

// ...
vnode = createComponent(Ctor, data, context, children, tag);
// ...
return vnode

很簡單,對于異步節(jié)點仿贬,直接返回創(chuàng)建的注釋節(jié)點纽竣,最后把虛擬節(jié)點轉換成真實節(jié)點,會實際創(chuàng)建一個注釋節(jié)點:

image-20211227181319356.png

現在讓我們來看看resolveAsyncComponent函數里面定義的resolve,也就是當chunk加載完成后會執(zhí)行的:

var resolve = once(function (res) {d
    // 緩存結果
    factory.resolved = ensureCtor(res, baseCtor);
    // 非同步解析時調用
    // (SSR會把異步解析為同步)
    if (!sync) {
        forceRender(true);
    } else {
        owners.length = 0;
    }
});

resAsyncComponent的組件選項蜓氨,baseCtorVue構造函數聋袋,會把它們作為參數調用ensureCtor方法:

function ensureCtor (comp, base) {
  if (
    comp.__esModule ||
    (hasSymbol && comp[Symbol.toStringTag] === 'Module')
  ) {
    comp = comp.default;
  }
  return isObject(comp)
    ? base.extend(comp)
    : comp
}

可以看到實際上是調用了extend方法:

image-20211227182323558.png

前面也提到過,Vue會把我們的組件都創(chuàng)建一個對應的構造函數穴吹,就是通過這個方法幽勒,這個方法會以baseCtor為父類創(chuàng)建一個子類,這里就會創(chuàng)建AsyncComponent子類:

image-20211227182849384.png

子類創(chuàng)建成功后會執(zhí)行forceRender方法:

var forceRender = function (renderCompleted) {
    for (var i = 0, l = owners.length; i < l; i++) {
        (owners[i]).$forceUpdate();
    }

    if (renderCompleted) {
        owners.length = 0;
        if (timerLoading !== null) {
            clearTimeout(timerLoading);
            timerLoading = null;
        }
        if (timerTimeout !== null) {
            clearTimeout(timerTimeout);
            timerTimeout = null;
        }
    }
};

owners里包含著App組件實例港令,所以會調用它的$forceUpdate方法代嗤,這個方法會迫使 Vue 實例重新渲染,也就是重新執(zhí)行渲染函數缠借,進行虛擬DOMdiffpath更新。

所以會重新執(zhí)行App組件的渲染函數宜猜,那么又會執(zhí)行前面的createElement方法泼返,又會走一遍我們前面提到的那些過程,只是此時AsyncComponent組件已經加載成功并創(chuàng)建了對應的構造函數姨拥,所以對于createComponent方法绅喉,這次執(zhí)行resolveAsyncComponent方法的結果不再是undefined,而是AsyncComponent組件的構造函數:

Ctor = resolveAsyncComponent(asyncFactory, baseCtor);

function resolveAsyncComponent (
 factory,
 baseCtor
) {
    if (isDef(factory.resolved)) {
        return factory.resolved
    }
}

接下來就會走正常的組件渲染邏輯:

var name = Ctor.options.name || tag;
var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
);

return vnode

可以看到對于組件其實也是創(chuàng)建了一個VNode叫乌,具體怎么把該組件的VNode渲染成真實DOM不是本文的重點就不介紹了柴罐,大致就是在虛擬DOMdiffpatch過程中如果遇到的VNode是組件類型,那么會new一個該組件的實例關聯到VNode上憨奸,組件實例化和我們new Vue()沒有什么區(qū)別革屠,都會先進行選項合并、初始化生命周期排宰、初始化事件似芝、數據觀察等操作,然后執(zhí)行該組件的渲染函數板甘,生成該組件的VNode党瓮,最后進行patch操作,生成實際的DOM節(jié)點盐类,子組件的這些操作全部完成后才會再回到父組件的diffpatch過程寞奸,因為子組件的DOM已經創(chuàng)建好了,所以插入即可在跳,更詳細的過程有興趣可自行了解枪萄。

以上就是本文全部內容。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末硬毕,一起剝皮案震驚了整個濱河市呻引,隨后出現的幾起案子,更是在濱河造成了極大的恐慌吐咳,老刑警劉巖逻悠,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件元践,死亡現場離奇詭異,居然都是意外死亡童谒,警方通過查閱死者的電腦和手機单旁,發(fā)現死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來饥伊,“玉大人象浑,你說我怎么就攤上這事±哦梗” “怎么了愉豺?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長茫因。 經常有香客問我蚪拦,道長,這世上最難降的妖魔是什么冻押? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任驰贷,我火速辦了婚禮,結果婚禮上洛巢,老公的妹妹穿的比我還像新娘括袒。我一直安慰自己,他們只是感情好稿茉,可當我...
    茶點故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布锹锰。 她就那樣靜靜地躺著,像睡著了一般漓库。 火紅的嫁衣襯著肌膚如雪城须。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天米苹,我揣著相機與錄音糕伐,去河邊找鬼。 笑死蘸嘶,一個胖子當著我的面吹牛良瞧,可吹牛的內容都是我干的。 我是一名探鬼主播训唱,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼褥蚯,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了况增?” 一聲冷哼從身側響起赞庶,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后歧强,有當地人在樹林里發(fā)現了一具尸體澜薄,經...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年摊册,在試婚紗的時候發(fā)現自己被綠了肤京。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,039評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡茅特,死狀恐怖忘分,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情白修,我是刑警寧澤妒峦,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站兵睛,受9級特大地震影響舟山,放射性物質發(fā)生泄漏。R本人自食惡果不足惜卤恳,卻給世界環(huán)境...
    茶點故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望寒矿。 院中可真熱鬧突琳,春花似錦、人聲如沸符相。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽啊终。三九已至镜豹,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蓝牲,已是汗流浹背趟脂。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留例衍,地道東北人昔期。 一個月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像佛玄,于是被迫代替她去往敵國和親硼一。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,786評論 2 345

推薦閱讀更多精彩內容