最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 想学习 webpack 吗?先来做一个简易打包器吧

    正文概述 掘金(李彦辉Jacky)   2021-04-01   428

    前言

    webpack 是现在前端编程中必不可少的一个工具,他的作用是将多个模块打包成一个或多个 bundle, 作为一个前端菜鸟,一直以来我都觉得 webpack 就像 magic 一样神秘,经过一段时间的学习后,我发现其实 webpack 的原理并不复杂,但是这篇文章并不是要去分析源码,其实在没了解原理之前看源码是一件效率很低的事情,我们可以试着自己去实现一个低配版的 webpack

    由于本文用到的代码比较多,我就先把项目代码献上吧

    下面我们一起来做个简单的打包器吧

    先从 babel 说起

    熟悉前端的小伙伴应该都知道,babel 是一个 Javascript 编译器,它的作用是将 js 中新的语法,编译成旧的浏览器可运行的语法。

    babel 的原理

    babel 编译代码的步骤分为三部

    1. parse:把代码 code 变成 AST
    2. traverse: 遍历 AST 进行修改
    3. generate: 把 AST 变成代码 code2

    认识 AST

    AST 抽象语法树 (Abstract Syntax Tree),它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

    下面我们使用 babel 工具手动的将 let 语法转为 var 语法,并观察 AST 的结构

    完整代码仓库连接补上

    import traverse from '@babel/traverse'
    import {parse} from '@babel/parser'
    import generate from '@babel/generator'
    
    const code = `let a = 1; let b = 2`
    const ast = parse(code, {sourceType: 'module'})
    console.log(ast, 'ast');
    traverse(ast, {
      enter: item => {
        if (item.node.type === 'VariableDeclaration') {
          if(item.node.kind === 'let') {
            item.node.kind = 'var'
          }
        }
      }
    })
    const result = generate(ast, {}, code)
    console.log(result.code);
    

    上面的代码只能在 node 中运行,为了方便调试,我们使用这个命令 node -r ts-node/register --inspect-brk let_to_var.ts 加入 --inspect-brk 后就可以在浏览器的控制台中调试了

    我们通过断点的方式,可以看到 AST 的结构是这样的

    想学习 webpack 吗?先来做一个简易打包器吧

    nodejs 运行后得到的结果

    想学习 webpack 吗?先来做一个简易打包器吧

    推荐一个在线的 ast 分析器astexplorer,可以更方便的研究 ast 里的结构

    想学习 webpack 吗?先来做一个简易打包器吧

    es6 to es5

    了解了 let -> var 的转换之后,是不是有小伙伴就想尝试把 es6 -> es5,如果我们把每个语法都用 if 来判断似乎有点不切实际,幸运的是 babel/core 提供了转换的函数

    import {parse} from '@babel/parser'
    import * as babel from '@babel/core'
    
    const code = `let a = 1; const b = 2`
    const ast = parse(code, {sourceType: 'module'})
    const result = babel.transformFromAstSync(ast, code, {
      presets: ['@babel/preset-env']
    })
    
    console.log(result.code);
    

    依赖分析

    使用 babel 工具,我们除了用来转换 JS 语法 还能做什么呢? 我们来试试分析 JS 的依赖关系吧

    首先我们建立三个 js 文件

    index.js

    import a from './a.js'
    import b from './b.js'
    console.log(a.value + b.value)
    

    a.js

    const a = {
      value: 1
    }
    
    export default a
    

    b.js

    const b = {
      value: 1
    }
    
    export default b
    

    依赖分析代码

    import * as fs from 'fs';
    import {parse} from '@babel/parser';
    import {relative, resolve, dirname} from 'path';
    import traverse from '@babel/traverse';
    
    // 设置根目录
    const projectRoot = resolve(__dirname, 'project1');
    // 类型声明
    type DepRelation = {
      [key: string]: { deps: string[], code: string }
    }
    // 初始化一个空的 depRelation 用于收集数据
    const depRelation:DepRelation = {};
    
    const collectCodeAndDeps = (filePath: string) => {
      // 文件的项目路径 如 index.js
      const key = getProjectPath(filePath);
      // 获取文件内容, 将内容放至 depRelation 里面
      const code = fs.readFileSync(filePath).toString();
      depRelation[key] = {deps: [], code};
      // 将代码转化位 AST
      const ast = parse(code, {sourceType: 'module'});
      traverse(ast, {
        enter: item => {
          if (item.node.type === 'ImportDeclaration') {
            // path.node.source.value 目录往往是一个相对目录,如 ./a3.js, 需要先把他转换为一个绝对路径
            const depAbsolutePath = resolve(dirname(filePath), item.node.source.value);
            // 然后转为项目路径
            const depProjectPath = getProjectPath(depAbsolutePath)
            // 把依赖写进 depRelation
            depRelation[key].deps.push(depProjectPath)
          }
        }
      });
    };
    
    const getProjectPath = (filePath: string) => {
      return relative(projectRoot, filePath);
    };
    
    collectCodeAndDeps(resolve(projectRoot, 'index.js'))
    
    console.log(depRelation);
    

    代码思路

    1. 调用 collectCodeAndDeps('index.js')
    2. 先把 depRelation['index.js'] 初始化为 {deps: [], code}
    3. 把 index.js 的代码,转换成 ast
    4. 遍历 ast,看看 import 了哪些依赖,假设依赖了 a.js 和 b.js
    5. 将 a.js 和 b.js 的路径写入 depRelation['index.js'].deps

    最终打印的 depRelation

    想学习 webpack 吗?先来做一个简易打包器吧

    抬杠环节

    上面的代码只能分析一层依赖,如果是多层依赖呢?

    多层依赖

    解析多层依赖的情况实际上很好解决,就是使用递归,当然使用递归是有风险的,如果依赖层级过深,可能会有 call stack overflow 的风险

    想学习 webpack 吗?先来做一个简易打包器吧

    环形依赖

    那如果是环形依赖呢?

    比如 a.js 中 import 了 b.js, b.js 中 import 了 a.js

    如果直接用上面的代码处理有环形依赖,那递归就会不停的进行下去,最后导致 call stack overflow

    解决办法是在调用解析函数之前,加入条件判断,判断这个文件是否已经记录在 depRelation['index.js'].deps 里面了,如果已经记录了就终止递归

    想学习 webpack 吗?先来做一个简易打包器吧

    总结

    bebel的原理

    graph TD
    code --> parse处理 --> AST --> traverse --> AST2 --> generate --> code2
    

    分析依赖的过程首先是要把代码转为 ast,然后遍历 ast,每当发现 import 语句的时候,我们就把依赖记录下来,对于多层依赖关系,我们可以采用递归的方式处理,如果是环形依赖,则需要对依赖进行检查,如果是已经记录的依赖就不记录

    webpack 核心 bundler

    bundler 就是打包器,bundle 由 bundler 产生,那么 bundle 是什么呢?

    下面是官方文档中的解析

    也就是是说,bundle 是一个包含了所有模块,并能执行所有模块的文件,它可以直接在浏览器中运行

    所以这一节我们要解决的问题就是

    • 让模块中的代码可执行
    • 多个模块打包成一个模块

    开始前准备

    index.js

    import a from './a.js'
    import b from './b.js'
    console.log(a.getB())
    console.log(b.getA())
    

    a.js

    import b from './b.js'
    const a = {
      value: 'a',
      getB() {
        return b.value + ' from b.js'
      }
    }
    
    export default a
    

    b.js

    import a from './a.js'
    const b = {
      value: 'b',
      getA() {
        return a.value + ' from a.js'
      }
    }
    
    export default b
    

    使用上节分析依赖的代码得到

    {                                             
      'index.js': {                               
        deps: [ 'a.js', 'b.js' ],                 
        code: "import a from './a.js'\r\n" +      
          "import b from './b.js'\r\n" +          
          'console.log(a.getB())\r\n' +           
          'console.log(b.getA())\r\n'             
      },                                          
      'a.js': {                                   
        deps: [ 'b.js' ],                         
        code: "import b from './b.js'\r\n" +      
          'const a = {\r\n' +                     
          "  value: 'a',\r\n" +                   
          '  getB() {\r\n' +                      
          "    return b.value + ' from b.js'\r\n" 
          '  }\r\n' +                             
          '}\r\n' +                               
          '\r\n' +                                
          'export default a\r\n'                  
      },                                          
      'b.js': {                                   
        deps: [ 'a.js' ],                         
        code: "import a from './a.js'\r\n" +      
          'const b = {\r\n' +                     
          "  value: 'b',\r\n" +                   
          '  getA() {\r\n' +                      
          "    return a.value + ' from a.js'\r\n" 
          '  }\r\n' +                             
          '}\r\n' +                               
          '\r\n' +                                
          'export default b\r\n'                  
      }                                           
    }                                                                                      
    

    让模块中的代码可执行

    在上面的代码里,import / export 是浏览器无法直接运行的,需要转换成函数

    这时我们需要用到 bable/core 将 es6 的 import/export 语法转换成 es5 的 require 函数和 exports 对象

    const { code: es5Code } = babel.transform(code, {
        presets: ['@babel/preset-env']
      })
    

    转换之后的代码是这样的

    想学习 webpack 吗?先来做一个简易打包器吧

    代码详解

    我们对 a.js 进行一个代码详解吧,看到 webpack 编译后的代码是怎样的

    想学习 webpack 吗?先来做一个简易打包器吧

    疑惑一

    Object.defineProperties(exports, '__esModule', {value: true})
    

    这个代码的作用是

    • 给当前模块增加一个 __esModule: true ,方便和 CommonJS 模块分开
    • exports.__esMoudle = true 的效果相同,兼容性更强

    疑惑二

    exports["default"] = void 0;
    

    相当于 exports["default"] = undefined 上面是老式的写法,用于清空 exports["default"] 的值

    细节一

    // import b from './b.js' 变成了
    var _b = _interopRequireDefault(require("./b.js"))
    // b.value 变成了
    _b['default'].value
    

    解析 _interopRequireDefault 函数

    • 该函数是为了给模块添加 defualt,因为 commonJS 没有默认导出,加到 'default' 为了兼容
    • _ 下划线是避免和其他函数同名
    • _interop 为前缀的函数大多数都是为了兼容旧代码

    细节二

    var _default = a
    exports['default'] = _default
    

    相当于 exports.default = a

    小结

    通过 babel 的转换

    • import 关键字,变成了 require 函数
    • export 关键字,变成了 exports 对象

    多个模块打包成一个模块

    为此我们需要写一个 打包器(bundler)

    首先我们要知道打包之后的代码是怎样的

    var depRelation = [
      {key: 'index.js' , deps: ['a.js' , 'b.js'], code: function... },
      {key: 'a.js' , deps: ['b.js'], code: function... },
      {key: 'b.js' , deps: ['a.js'], code: function... }
    ] 
    // 为什么把 depRelation 从对象改为数组?
    // 因为数组的第一项就是入口,而对象没有第一项的概念
    execute(depRelation[0].key) // 执行入口文件
    function execute(key){
      var item = depRelation.find(i => i.key === key)
      item.code(???) 
      // 执行 item 的代码,因此 code 最好是个函数,方便执行
      // 但是目前还不知道要传什么参数给 code
      // 代码待完善
    }
    

    目前要解决的三个问题

    • depRelation 是个对象,需要改成数组
    • code 是字符串,怎么改成函数
    • execute 函数需要完善

    depRelation 改造成数组

    depRelation[key] = {deps: [], code};
    

    改成了

    const item = { key, deps: [], code: es5Code }
    depRelation.push(item);
    // 其他代码修改,请看最后实现
    

    code 是字符串,怎么改成函数

    步骤

    1. 把 code 字符串包在一个函数里面 function(require, module, exports) ,其中 require module exports 三个参数是 CommonJS 2 规范规定的
    2. 最后把 code 写进打包生成的文件里,code 的引号就会消失,可以理解为从字符串变成了代码
    code = `
       var b = 1
       b += 1
       exports.defult = b
    `
    
    code2 = `
        function(require, module, exports) {
            $(code)
        }
    `
    

    完善 execute 函数

    主体思路

    const modules = {} // 用于缓存所有模块
    function execute(key) {
      if (modules[key]) {return modules[key]} // 当模块已缓存,直接返回
      var item = depRelation.find(i => i.key === key) // 找到需要执行的模块
      var require = (path) => { // 定义 require 函数,require 模块就是执行这个模块
        return execute(pathToKey(path))
      }
      modules[key] = { __esModule: true } // 定义 __esModule 属性,方便与 CommonJS 区分
      var module = { exports: module[key] }
      // 执行这个模块, 会把导出内容挂载到  exports.default, 见上面 babel 编译后的代码
      item.code(require, module, module.exports) 
      return module.exports // {default: [导出的对象], __esModule: true}
    }
    

    简易打包器

    打包好的文件长什么样子

    var depRelation = [
      {key: 'index.js' , deps: ['a.js' , 'b.js'], code: function... },
      {key: 'a.js' , deps: ['b.js'], code: function... }, 
      {key: 'b.js' , deps: ['a.js'], code: function... }
    ]
    var modules = {} // modules 用于缓存所有模块
    execute(depRelation[0].key)
    function execute(key){
      var require = ...
      var module = ...
      item.code(require, module, module.exports)
      ...
    }
    

    怎么生成这个文件呢?

    答案很简单:拼凑出字符串,然后写入文件

    var dist = ''
    dist += content
    writeFileSync('dist.js', dist)
    

    dist文件 由于代码太长,就不放在这里了

    运行 dist 文件,得到

    想学习 webpack 吗?先来做一个简易打包器吧

    打包后的文件运行成功

    小结

    打包后的文件,实际上是多个模块的集合并通过 depRelation 数组存储起来,deRelation[0] 即入口文件,deRelation 数组的每一个元素就是一个模块,单个元素存储有 模块名key、模块依赖deps、模块的可执行代码 function,这个函数有三个参数,分别是 require module exports 是 CommonJS 规定的

    我们已经了解了 webpack 打包的原理,并制作了一个简易的打包器,但是它还存在很多问题

    • 生成的代码中有多个重复的 __interopXXX 函数
    • 只能引入和运行 JS 文件
    • 只能理解 import,无法理解 require
    • 不支持插件
    • 不支持配置入口文件和 dist 文件名

    ...接下来要怎么解决呢?

    Loader

    loader 是什么,为什么需要 loader

    回顾一下我们做好的简易打包器,这个打包器居然只能加载 JS,连 CSS 都不能加载,什么破玩意

    不行,拿得写一个 css loader ,让这个打包器支持 css

    css-loader 自制版

    三段式逻辑

    • 我们的 bundler 只能加载 JS
    • 我们想加载 CSS

    推论:如果我们可以把 CSS 变成JS,那么就可以加载 CSS 了

    怎么转换成 JSvar str = [css 代码] 用一个变量存起来

    怎么让css 生效,新建一个 style 标签,把 css 代码写进去,然后写入 <head> 里面

    // css-loader
    const cssLoader = (code) => { // 接受代码
        return `
          const str = ${JSON.stringify(code)}
          if (document) {
            const style = document.createElement('style')
            style.innerHTML = str
            document.head.appendChild(style)
          }
        `
    }
    
    module.exports = cssLoader
    

    loader 已经写出来了,怎么用?

    想学习 webpack 吗?先来做一个简易打包器吧

    depRelation 对象创建时,使用 css-loader 对代码进行加工

    完整代码

    webpack 的单一职责原则

    webpack 里每一个 loader 只做一件事

    目前我们的 loader 做了两件事

    • 把 CSS 转成 JS 字符串
    • 把 JS 字符串放到 style 标签里

    改造目标,做成两个 loader 的连续调用

    想学习 webpack 吗?先来做一个简易打包器吧

    失败了

    按照上面的目标改造

    css-loader 把 CSS 转成为 JS 字符串

    // css-loader 
    const cssLoader = (code) => {
        return `
          const str = ${JSON.stringify(code)}
          module.exports str
        `
    }
    
    module.exports = cssLoader
    

    style-loader 把 JS 字符串插入到 style 标签里面

    const styleLoader = (code) => {
        return `
          if (document) {
            const style = document.createElement('style')
            style.innerHTML = ${JSON.stringify(code)} 
            document.head.appendChild(style)
          }
        `
    }
    
    module.exports = styleLoader
    

    然后再次打包...

    结果是这样的

    想学习 webpack 吗?先来做一个简易打包器吧

    咦?怎么有奇奇怪怪的字符串

    实际上 style-loader 中加入的字符串,并不不是 css-loader 导出的代码,而是 str 变量里的 css 代码,所以这是实现不了的

    看看 webpack style-loader 是怎么实现的

    webpack style-loader 源码

    想学习 webpack 吗?先来做一个简易打包器吧

    想学习 webpack 吗?先来做一个简易打包器吧

    style-loader 在 pitch 钩子里通过 css-loader 来 require 文件内容,然后在文件内容后面添加 injectStylesIntoStyleTag(content, ...) 代码

    webpack 的实现方式和我们不同的是,webpack 可用通过 request 来获取需要的代码

    小结

    这一次我们尝试自己写 loader ,但是遇到了一个坑,原因是这样的

    因为 style-loader 不是转译单单转译代码

    • 像 saas-loader 、less-loader 这些 loader 是把代码从一种语言翻译成另一种
    • 这种 loader 是可以链式调用的
    • 但 style-loader 是插入代码,而不是转译代码,所以需要寻找插入的时机和插入的位置
    • style-loader 插入的时机是 css-loader 获取结果之后

    webpack 的实现方式:

    style-loader 在 pitch 钩子里通过 css-loader 来 require 文件内容,然后在文件内容后面添加 injectStylesIntoStyleTag(content, ...) 代码

    最后总结

    通过了多次尝试,我们实现了一个简易打包器和loader,尽管和 webpack 功能有很大的差距,但是我们都在做同一件事,那就是分析依赖,生成成bundle,最后输出成 dist 文件,其中 loader 的作用就是将非 js 模块,转为 js 模块,因为 webpack 只能识别 js


    本篇文章篇幅比较长,感谢各位看官看到这里,如果觉得有用的,麻烦动动你的小手点个赞吧,谢谢

    如果对源码有兴趣,可以移步到这篇博客浅析 webpack 源码


    起源地下载网 » 想学习 webpack 吗?先来做一个简易打包器吧

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元