实现简单的 JS 模块加载器

实现简单的 JS 模块加载器

1. 背景介绍

按需加载是前端性能优化的一个重要手段,按需加载的本质是从远程服务器加载一段JS代码(这里主要讨论JS,CSS或者其他资源大同小异),该JS代码就是一个模块的定义,如果您之前有去思考过按需加载的原理,那你可能已经知道按需加载需要依赖一个模块加载器。它可以加载所有的静态资源文件,比如:

  • JS 脚本
  • CSS  脚本
  • 图片 资源

如果你了解 webpack,那您可以发现在 webpack 内部,它实现了一个模块加载器。模块加载器本身需要遵循一个规范,当然您可以自定义规范,大部分运行在浏览器模块加载器都遵循 AMD 规范,也就是异步加载。

容易理解的是,对于某个应用使用了模块加载器,那么首先需要加载该模块加载器JS代码。然后有一个主模块,程序从主模块开始执行, requireJS 中使用main来标记,webpack 中叫 webpackBootstrap 模块。

2. 实现简单的加载器

2.1 需求整理

  • 模块的定义
  • 模块的加载
  • 已经加载过的模块需要缓存
  • 同一个模块并行加载的处理

2.2 运行流程图

实现简单的 JS 模块加载器_第1张图片

2.2 功能实现




  
  
  
  Document


  1. 相同模块的并发加载问题?


// module/lazyload.js
loader.define('lazyload', function(){
  return function () {
    console.log('I am lazyload')
  }
})

这个版本已经是简单的不能再简单了,首先它没有对模块加载失败设计异常处理机制,其次真实的场景中存在一个模块的定义依赖其他模块:

// toolbar 模块依赖common模块
loader.define('toolbar', ['common'], function(){
  return function () {
    console.log('I am toolbar')
  }
})

3. 模块的定义依赖其他模块

3.1 程序分析

假如模块A依赖模块B和C,这里有几个关键点:

  • 如何判断B和C都加载完,然后再执行模块A的导出函数
  • 使用 define 定义模块A时,需要先收集依赖,然后当A模块加载完成后(loadScript),再加载其依赖项,当所有依赖项都加载完成后,再获取模块A的导出值。

实现简单的 JS 模块加载器_第2张图片

演示代码:

// 定义模块
loader.define('toolbar', ['common', 'lazyload'], function(){
  return function () {
    const { common, lazyload } = loader.modules; // 通过这样访问依赖
    console.log('I am toolbar')
  }
})

// 加载模块
loader.require('lazyload', function(){
  console.log('require lazyload')
})

3.2 代码实现

/**
 * loader 模块加载器
 */
var loader = {
  config: { // 配置
    baseDir: window.location.origin + '/module'
  },
  modules: {}, // 缓存
  installed: {}, // 加载成功
  status: {}, // 加载状态
  deps: {}, // 模块的依赖
  moduleDefined: {}, // 缓存模块的定义

  /**
   * @description 注册模块, 每个模块最多只会注册1次
   * @example:
   * define('sleep', function(){ return 5 }) 定义模块名为sleep,导出值为5
   * define('sleep', function(){ return { name: 5 } }) 定义模块名为sleep,导出一个对象
   * define('sleep', function(){ return function(){ console.log(5)}}) 定义模块名为sleep,导出一个函数
   */
  define: function() {
    let name, fn, deps, args = arguments
    if (args.length === 2) {
      name = args[0]
      fn = args[1]
    } else if (args.length === 3) {
      name = args[0]
      deps = (typeof args[1] === 'string') ? [args[1]] : args[1]
      fn = args[2]
    } else {
      throw "invalid params for define function"
    }

    // 收集依赖
    if (deps) this.deps[name] = deps;

    // 缓存模块导出函数
    this.moduleDefined[name] = fn
  },

  /**
   * @description 加载模块
   * @param {string} name 模块名
   * @param {*} requireCb 加载完成回调函数
   */
  require: function(name, requireCb) {
    if (this.modules[name]) {
      // 已经加载成功, 直接从缓存读取
      requireCb(this.modules[name])
    } else {
      if (this.status[name]) {
        // 加载过了, 但是还未加载成功
        this.installed[name].push(requireCb)
      } else {
        // 还未加载过
        this.installed[name] = []
        this.installed[name].push(requireCb)
        this.loadScript(name)
        this.status[name] = true
      }
    }
  },

  /**
   * @description 加载多个模块
   * @param {string} names 模块名数组
   * @param {*} fn 回调函数
   */
  requires: function(names, fn) {
    let excuted = false
    names.forEach(name => {
      this.require(name, () => {
        if (!excuted) {
          // 保证回调只执行一次
          if (names.filter(v => this.modules[v] !== undefined).length === names.length) {
            excuted = true
            fn && fn()
          }
        }
      })
    })
  },

  /**
   * @description 加载JS文件
   * @param {string} name 模块名
   */
  loadScript: function (name) {
    let _this = this
    let script = document.createElement('script')
    script.src = this.config.baseDir + '/' + name + '.js'

    script.onload = function () {
      // 需要注意, 当模块的JS文件加载完成, 不能立即调用require(name, fn) 所注册的fn回调函数
      // 因为它可能依赖其它模块, 需要将依赖的模块也加载完成之后, 再触发
      // _this.installed[name] 为数组是因为并行加载时, 注册了多个回调
      if (!_this.deps[name]) {
        _this.modules[name] = _this.moduleDefined[name]();
        _this.installed[name].forEach(fn => {
          fn(_this.modules[name]);
        })
      } else {
        _this.requires(_this.deps[name], () => {
          // 依赖项全部加载完成
          _this.modules[name] = _this.moduleDefined[name]();
          _this.installed[name].forEach(fn => {
            fn(_this.modules[name]);
          })
        });
      }
    }
    document.body.append(script)
  }
}

4. 注入依赖到导出函数

在第3步骤中,虽然实现了模块定义的依赖支持,但是没有注入到导出函数中,我们希望模块的定义改成下面的样子:

// 定义模块
loader.define('toolbar', ['common', 'lazyload'], function(common, lazyload){
  return function () {
    // const { common, lazyload } = loader.modules; // 不使用这种方式
    console.log('I am toolbar')
  }
})

这个还是比较简单,只需要修改下面标识Mark的位置:

if (!_this.deps[name]) {
  _this.modules[name] = _this.moduleDefined[name]();
  _this.installed[name].forEach(fn => {
    fn(_this.modules[name]);
  })
} else {
  _this.requires(_this.deps[name], () => {
    // 依赖项全部加载完成
    const injector = _this.deps[name].map(v => _this.modules[v]) // Mark
    _this.modules[name] = _this.moduleDefined[name](...injector); // Mark
    _this.installed[name].forEach(fn => {
      fn(_this.modules[name]);
    })
  });
}

5. require 支持列表的方式

按照之前的方式,如果先后require两个模块,代码可能是:

loader.require('common', function(common){
  loader.require('lazyload', function(lazyload){
     console.log('require1 toolbar', Date.now())
  })
})

这种嵌套的方式看起来非常糟糕,希望把它换成下面这种方式:

loader.require(['common', 'lazyload'], function(common, lazyload){
  console.log('require1 toolbar', Date.now())
})

这里主要修改 require 方法:

  require: function(name, requireCb) {    
    if (Array.isArray(name)) {
      // 加载多个
      this._requires(name, () => {
        const injector = name.map(v => this.modules[v])
        requireCb(...injector)
      })
      return
    }
    if (this.modules[name]) {
      // 已经加载成功, 直接从缓存读取
      requireCb(this.modules[name])
    } else {
      if (this.status[name]) {
        // 加载过了, 但是还未加载成功
        this.installed[name].push(requireCb)
      } else {
        // 还未加载过
        this.installed[name] = []
        this.installed[name].push(requireCb)
        this.loadScript(name)
        this.status[name] = true
      }
    }
  }

6. 总结

通过一步一步的功能丰富,到此一个满足大部分功能的JS模块加载器就实现了。在梳理其过程中加深了我对依赖注入的理解。下面是完整代码:

/**
 * loader 模块加载器
 */
var loader = {
  config: { // 配置
    baseDir: window.location.origin + '/module'
  },
  modules: {}, // 缓存
  installed: {}, // 加载成功
  status: {}, // 加载状态
  deps: {}, // 模块的依赖
  moduleDefined: {}, // 缓存模块的定义

  /**
   * @description 注册模块, 每个模块最多只会注册1次
   * @example:
   * define('sleep', function(){ return 5 }) 定义模块名为sleep,导出值为5
   * define('sleep', function(){ return { name: 5 } }) 定义模块名为sleep,导出一个对象
   * define('sleep', function(){ return function(){ console.log(5)}}) 定义模块名为sleep,导出一个函数
   * define('sleep', ['common'], function(common){ return function(){}}) sleep模块依赖 common模块
   */
  define: function() {
    let name, fn, deps, args = arguments
    if (args.length === 2) {
      name = args[0]
      fn = args[1]
    } else if (args.length === 3) {
      name = args[0]
      deps = (typeof args[1] === 'string') ? [args[1]] : args[1]
      fn = args[2]
    } else {
      throw "invalid params for define function"
    }

    // 收集依赖
    if (deps) this.deps[name] = deps;

    // 缓存模块导出函数
    this.moduleDefined[name] = fn
  },

  /**
   * @description 加载模块
   * @param {string} name 模块名
   * @param {*} requireCb 加载完成回调函数
   * @examples:
   * require('common', function(common){}) 加载一个模块
   * require(['common', 'toolbar], function(common, toolbar){}) 加载多个模块
   */
  require: function(name, requireCb) {    
    if (Array.isArray(name)) {
      // 加载多个
      this._requires(name, () => {
        const injector = name.map(v => this.modules[v])
        requireCb(...injector)
      })
      return
    }
    if (this.modules[name]) {
      // 已经加载成功, 直接从缓存读取
      requireCb(this.modules[name])
    } else {
      if (this.status[name]) {
        // 加载过了, 但是还未加载成功
        this.installed[name].push(requireCb)
      } else {
        // 还未加载过
        this.installed[name] = []
        this.installed[name].push(requireCb)
        this.loadScript(name)
        this.status[name] = true
      }
    }
  },

  /**
   * @description 加载多个模块
   * @param {string} names 模块名数组
   * @param {*} fn 回调函数
   */
  _requires: function(names, fn) {
    let excuted = false
    names.forEach(name => {
      this.require(name, () => {
        if (!excuted) {
          // 保证回调只执行一次
          if (names.filter(v => this.modules[v] !== undefined).length === names.length) {
            excuted = true
            fn && fn()
          }
        }
      })
    })
  },

  /**
   * @description 处理某个模块加载完成
   * @param {string} name 
   */
  _onLoadScriptSuccess: function(name) {
    if (!this.deps[name]) {
      this.modules[name] = this.moduleDefined[name]();
      this.installed[name].forEach(fn => {
        fn(this.modules[name]);
      })
    } else {
      this._requires(this.deps[name], () => {
        const injector = this.deps[name].map(v => this.modules[v])
        this.modules[name] = this.moduleDefined[name](...injector);
        this.installed[name].forEach(fn => {
          fn(this.modules[name]);
        })
      });
    }
  },

  /**
   * @description 加载JS文件
   * @param {string} name 模块名
   */
  loadScript: function (name) {
    let script = document.createElement('script')
    script.src = this.config.baseDir + '/' + name + '.js'
    script.onload = () => {
      this._onLoadScriptSuccess(name)
    }
    document.body.append(script)
  }
}

你可能感兴趣的:(实现简单的 JS 模块加载器)