webpack打包出来的vue异步组件分析

3,053 阅读9分钟

ReactVue都是可以实现组件的异步加载的,React最新的版本已经有了React.lazySuspense来动态引入组件,以前网上都是使用webpack提供的插件来进行异步加载组件的。

但是该文章分析的是Vue的异步组件加载。

原因如下:

  • 第一,工作上刚好碰到了需要对Vue实现的组件库进行模块拆分支持异步加载的模式。
  • 第二,目前网上有针对Vue源码分析的文章,讲的比较清晰。
  • 第三,React的源码不是很看得懂。

接下来我通过vue-clibuild命令来创建一个库,从这里出发,来看看异步加载是怎么和webpack结合和支持的。有机会再看看React是怎么做的。

Vue-cli 创建项目

我们在全局安装@vue/cli来初始化一个Vue的基本项目结构,一路默认就行,不需要其他操作,不影响分析。

vue create vue-async-component-webpack

看一下目录结构:

目录结构

创建库的入口文件

先在src下面创建一个lib.js文件,用来输出Vue的组件,实现install方法,可以让使用方使用Vue.use(mylib)这样去调用。

//   src/lib.js
const install = function (Vue, _ops) { // 写下划线只是为了让我的eslint不报错而已,强迫症,不用在意。
  // ops 可以为Vue.use(lib, {})  这样调用时候传入的初始化参数。。。
  // 我们这里直接把App.vue当做组件输出出去
  Vue.component('my-component', () => import('./App.vue'))
  // 这里还可以注册其他组件或者做自己的的一些初始化操作等
}

export default {
  install, // 这里导出的install会被use的时候调用,这些是Vue的一些原理或者称为原理吧
}

Vue官网文档上面,介绍Vue的异步组件注册有三种模式,以上是其中一种,后面会根据一个Vue源码分析系列博客基本的分析一下异步注册的原理。

创建打包命令

我们修改package.josn,添加打包组件库的命令,这些命令vue-cli已经提供了。

create lib

// package.json
...
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "build:lib": "vue-cli-service build --target lib --name mylib src/lib.js",
    "lint": "vue-cli-service lint"
  },
...

打包库

运行这条命令,得到打包后没有压缩的代码进行分析。

npm run build:lib

build lib

好了,现在组件库打包完了,现在就开始进行分析吧。

webpack打包后的文件分析

我们打开dist/mylib.umd.js文件,这里面不具体说webpack最后打包的代码结构,用图片解释一下吧。

webpack的基本结构
如上,文件里面是一个立即执行函数,函数体是用来判断各个加载模式,比如:ES6的模块加载、nodejs的模块加载和浏览器的script标签的加载等。函数执行的时候传入了一个工厂函数,用来获取整个Appwebpack的结构先说到这里吧,网上有很多文章介绍,这里先不说了。

我们先直接在文件里面搜索install函数,看看被转换成什么样子了。

install

如上图,我们主要关注vue.component这行代码:

return __webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, "3dfd"));

看到,返回了一个__webpack_require__.e函数的执行结果,看到了then,那肯定就是一个Promise的对象了。

找到 __webpack_require__.e函数,一步一步溯源:

e 函数

中间的if/else我先折叠了,可看到注释,就是来判断加载的资源是css还是js,那么刚才install函数那里调用的地方,传进来的值为1,看看代码:

...
// object to store loaded CSS chunks
var installedCssChunks = {
  0: 0
}
...
if(installedCssChunks[chunkId]) promises.push(installedCssChunks[chunkId]);
else if(installedCssChunks[chunkId] !== 0 && cssChunks[chunkId]) {
  promises.push(installedCssChunks[chunkId] = new Promise(function(resolve, reject) {
  var href = "css/" + ({}[chunkId]||chunkId) + "." + {"1":"e2713bb0"}[chunkId] + ".css";
  var fullhref = __webpack_require__.p + href;
  var existingLinkTags = document.getElementsByTagName("link");
...
}

根据代码判断,这里去加载需要的css文件去了。因为重点是js文件,这里的css就不具体展开说了,其实加载和js如出一辙,区别不大。

继续往下面看,现在看加载js的代码:

// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if(installedChunkData !== 0) { // 0 means "already installed".

  // a Promise means "currently loading".
  if(installedChunkData) {
    promises.push(installedChunkData[2]);
  } else {
    // setup Promise in chunk cache
    var promise = new Promise(function(resolve, reject) {
      installedChunkData = installedChunks[chunkId] = [resolve, reject];
    });
    promises.push(installedChunkData[2] = promise);

    ...
    script.src = jsonpScriptSrc(chunkId);  //  可看 sonpScriptSrc 函数,里面已经写好了 js 文件的名称
    onScriptComplete = function (event) {
      // ... 省略,主要是处理加载出错了返回错误信息
    };
    var timeout = setTimeout(function(){
      onScriptComplete({ type: 'timeout', target: script });
    }, 120000);
    script.onerror = script.onload = onScriptComplete;
    document.head.appendChild(script);
  }
}
 		

css的加载很像,这里是先判断一下这个文件是否已经被加载过,如果没有则去创建一个PromisePromise里面之后会去创建script标签,然后设置src的路径,这里面就会去请求被拆分出去的组件代码了。

到这里就可以明白,它的步骤被分为这样几步:

  1. 其实异步加载组价也就是去创建了script标签,然后把代码下载下来。

  2. 当代码被浏览器成功加载后,里面的函数会往一个数组里面push一个对象,这个数组的push方法在dist/mylib.umd.js文件中被重写了,导致push函数的执行其实是去执行了一个叫webpackJsonpCallback的函数,从字面意思上可以看出是一个jsonp的回调函数。 这里可能需要贴几张图

dist/mylib.umd.js 文件里面, 重写某个数组的 push 方法

push重写

mylib.common.1.js 或者 mylib.common.[2,3,4....].js,就看分了多少吧,都有这个函数的。这里我折叠了一下,知道逻辑就行。

执行push函数

  1. 当 push 方法被执行的时候,我们通过第一步就尅看到,其实是执行的dist/mylib.umd.js里面的webpackJsonpCallback函数:
function webpackJsonpCallback(data) {
  ...
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(installedChunks[chunkId]) {
    // 关键是这里,将istalledChunks[chunkId][0]
    //  也就是`e`函数里面创建`Promise`时,里面的`resolve`函数。
    resolves.push(installedChunks[chunkId][0]);  
   }  
   ... 
  while(resolves.length) {
     // 出队列去执行 resolve 函数
    resolves.shift()();  
  }
   ...
}

当执行了 resolve 函数后,我们终于可以回到 install函数了,我们继续看install函数的后面一节。

then 的回调函数

return __webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, "3dfd"));

回到install函数的then,看到传入了一个函数__webpack_require__,并且手动绑定了一个参数3dfd, 其实这个参数就是我们需要异步加载的那个组件对象的映射key。我们可以先看__webpack_require__函数。

function __webpack_require__(moduleId) {

  // 检查是否已经被加载过,如果是就直接返回对象的 exports ,这种检查还真是多
  if(installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }
  // 创建一个 ”模板“  的对象,里面包含一下几个属性 
  var module = installedModules[moduleId] = {
    i: moduleId,
    l: false,
    exports: {}
  };

  // 将该对象作为上下文去执行要 `modules[moduleId]`方法
  // ,方法里面会往 module.exports **对象里面挂载值**,也就是模拟模块的导出。
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  // Flag the module as loaded
  module.l = true;

  // Return the exports of the module
  return module.exports;    / / 最后返回那个模块导出的内容
}

代码上我已经尽量注释了,moduleId就是 install 那里 bind绑定的参数(key),我们可以根据这个moduleIdmylib.common.1.js 文件里面找到对应的函数。里面就要我们组件的实现,比如被转成了render函数的模板代码、属性和生命周期等。

组件代码

看最后module.exports的挂载代码:

exports

到这里,webpack 是怎么将拆分后的代码结合的,就梳理清楚了。到这里,组件的代码终于是注册到了Vue里面去,当Vue渲染到相应的组件时,就会去执行这一套逻辑,加载组件了。那么Vue是怎么处理注册的组件为Promise函数的呢?也就是异步加载组件的呢? Vue.js 技术揭秘里面有足够清晰的分析,首先在这里感谢作者。接下来的内容大部分内容来自《Vue.js 技术揭秘》了,建议有兴趣的同学去仔细阅读一遍。

Vue 对异步组件的处理

Vue的异步组件的注册与普通的模式是不一样的,Vue 注册的组件不再是一个对象,而是一个工厂函数。注册的模式有三种:

// 1
Vue.component('async-example', function (resolve, reject) {
   // 这个特殊的 require 语法告诉 webpack
   // 自动将编译后的代码分割成不同的块,
   // 这些块将通过 Ajax 请求自动下载。
   require(['./my-async-component'], resolve)
})

// 2
Vue.component(
  'async-webpack-example',
  // 该 `import` 函数返回一个 `Promise` 对象。
  () => import('./my-async-component')
)

// 3   称为高级模式,可以自定义一些东西
const AsyncComp = () => ({
  // 需要加载的组件。应当是一个 Promise
  component: import('./MyComp.vue'),
  // 加载中应当渲染的组件
  loading: LoadingComp,
  // 出错时渲染的组件
  error: ErrorComp,
  // 渲染加载中组件前的等待时间。默认:200ms。
  delay: 200,
  // 最长等待时间。超出此时间则渲染错误组件。默认:Infinity
  timeout: 3000
})
Vue.component('async-example', AsyncComp)

组件注册最终会走到Vue源码中的createComponent函数,(细节分析),我们看一下这个函数:

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }

  const baseCtor = context.$options._base

  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }
  
  // ...

  // async component
  let asyncFactory
  if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
    if (Ctor === undefined) {
      // return a placeholder node for async component, which is rendered
      // as a comment node but preserves all the raw information for the node.
      // the information will be used for async server-rendering and hydration.
      return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
      )
    }
  }
}

代码里面省略掉了普通组件注册的逻辑,这里只关注异步组件的注册逻辑。

由于我们这个时候传入的 Ctor 是一个函数,那么它也并不会执行 Vue.extend逻辑,因此它的 cidundefiend,进入了异步组件创建的逻辑。这里首先执行了 Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)方法,它的定义在 src/core/vdom/helpers/resolve-async-component.js 中,这函数的实现比较复杂:

export function resolveAsyncComponent (
  factory: Function,
  baseCtor: Class<Component>,
  context: Component
): Class<Component> | void {
  if (isTrue(factory.error) && isDef(factory.errorComp)) {
    return factory.errorComp
  }

  if (isDef(factory.resolved)) {
    return factory.resolved
  }

  if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
    return factory.loadingComp
  }

  if (isDef(factory.contexts)) {
    // already pending
    factory.contexts.push(context)
  } else {
    const contexts = factory.contexts = [context]
    let sync = true

    const forceRender = () => {
      for (let i = 0, l = contexts.length; i < l; i++) {
        contexts[i].$forceUpdate()
      }
    }

    const resolve = once((res: Object | Class<Component>) => {
      // cache resolved
      factory.resolved = ensureCtor(res, baseCtor)
      // invoke callbacks only if this is not a synchronous resolve
      // (async resolves are shimmed as synchronous during SSR)
      if (!sync) {
        forceRender()
      }
    })

    const reject = once(reason => {
      process.env.NODE_ENV !== 'production' && warn(
        `Failed to resolve async component: ${String(factory)}` +
        (reason ? `\nReason: ${reason}` : '')
      )
      if (isDef(factory.errorComp)) {
        factory.error = true
        forceRender()
      }
    })

    const res = factory(resolve, reject)

    if (isObject(res)) {
      if (typeof res.then === 'function') {
        // () => Promise
        if (isUndef(factory.resolved)) {
          res.then(resolve, reject)
        }
      } else if (isDef(res.component) && typeof res.component.then === 'function') {
        res.component.then(resolve, reject)

        if (isDef(res.error)) {
          factory.errorComp = ensureCtor(res.error, baseCtor)
        }

        if (isDef(res.loading)) {
          factory.loadingComp = ensureCtor(res.loading, baseCtor)
          if (res.delay === 0) {
            factory.loading = true
          } else {
            setTimeout(() => {
              if (isUndef(factory.resolved) && isUndef(factory.error)) {
                factory.loading = true
                forceRender()
              }
            }, res.delay || 200)
          }
        }

        if (isDef(res.timeout)) {
          setTimeout(() => {
            if (isUndef(factory.resolved)) {
              reject(
                process.env.NODE_ENV !== 'production'
                  ? `timeout (${res.timeout}ms)`
                  : null
              )
            }
          }, res.timeout)
        }
      }
    }

    sync = false
    // return in case resolved synchronously
    return factory.loading
      ? factory.loadingComp
      : factory.resolved
  }
}

简单总结为如下几个步骤:

  1. 理了 3 种异步组件的创建方式,不同模式进入不同分支流程
  2. 判断传入的工厂函数的上下文,多个地方同时加载的时候,保证只会返回一个实例。
  3. 进入实际加载逻辑,定义了 forceRenderresolvereject 函数
  4. 执行工程函数,并且传入resolvereject函数。
  5. 异步在未加载成功之前,先返回一个空的组件对象。这里是第一次渲染。
  6. 组件返回后,得到组件实例,然后调用forceRender函数强制刷新,这是第二次渲染。

步组件实现的本质是 2 次渲染,除了 0 delay 的高级异步组件第一次直接渲染成 loading 组件外,其它都是第一次渲染生成一个注释节点,当异步获取组件成功后,再通过 forceRender 强制重新渲染,这样就能正确渲染出我们异步加载的组件了。

总结

知道Vue一直支持异步组件加载,通过这次分析,算是弄懂了实现的原理,与webpack的结合还真的是巧妙。不过文章对Vue具体是怎么实现异步加载组件的,只是简单的的说了一下逻辑步骤,webpack是怎么打包成这个样子的,这里面的逻辑还是值得深究的。

原文地址

参考文章 《Vue.js 技术揭秘》