美文网首页
手写webpack

手写webpack

作者: miao8862 | 来源:发表于2021-05-08 23:58 被阅读0次

    在开始手写之前,我们先来看下为什么要使用webpack呢?我们用个例子来演示热身:

    不使用webpack会有什么问题?

    为了说明问题,我们先创建几个文件add.jsindex.jsindex.html

    文件目录

    使用es5写法导入导出模块,这里是使用commonjs规范

    // src/add.js
    exports.default = function(a, b) {return a + b;}
    
    // src/index.js
    var add  = require('./add.js')
    console.log(add(1,3))
    
    <!-- src/index.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>测试模块化开发的代码</title>
      <script src="./index.js"></script>
    </head>
    <body>
    </body>
    </html>
    
    image.png

    我们直接使用模块化开发时,可以看到浏览器并不识别commonjs的模块化用法,会提示require is not defined,这就不利于我们进行工程化开发了,所以webpack最核心解决的问题,它使用将读取这些文件,按照模块间的依赖关系,重新组装成了能运行的脚本。

    webpack是怎么解决这个问题的呢?

    一、实现最初的打包bundle.js

    先看下它有几个问题和它们各自的解决方案:

    第一步,加载子模块

    往往模块是别的库(比如nodejs),用的commonjs来写的,那么我们就要处理加载模块的问题:
    1. 读取子模块add.js文件后的代码字符串是不能直接运行的

    // 读取到的文件内容,它返回的是一个字符串,并不是一个可执行的语句,比如下面这样:
    `exports.default = function(a, b) {return a + b;}`
    

    那么,如何使字符串能够变成可执行代码呢?

    • 使用new Function
    new Function(`1+5`)
    // 等同于
    function (){
      1+5
    }
    (new Function(`1+5`))() // 6
    
    image.png
    • 使用eval
    console.log(eval(`1+5`)) //6
    

    可以看出,使用eval非常简洁方便,所以这里我们使用eval来解决。解决第一步后,我们将其放在html的script脚本运行一下:

      <script>
        // 读取到的文件内容
        `exports.default = function(a, b) {return a + b;}`
        // 第一种运行方式:使用new Function
        // (new Function(`exports.default = function(a, b) {return a + b;}`))()
        // 第二种运行方式:eval
        eval(`exports.default = function(a, b) {return a + b;}`)
      </script>
    
    image.png

    2. 导出的变量提示不存在
    解决:创建一个exports对象,这是为了符合commonjs的规范的导出写法

        // 创建一个exports对象,为了使其符合cjs规范
        var exports = {}
        eval(`exports.default = function(a, b) {return a + b;}`)
        console.log(exports.default(1, 5))
    

    这时,再看浏览器已经不报错了,继续


    image.png

    3. 变量全局污染
    如果在导出的文件中,还要一些其它的变量,比如var a = 1;之类的,就会造成全局污染
    解决:为了避免全局污染,我们使用自执行函数包裹起来,它会为其创建一个独立的作用域,这也是很多框架中会使用到的技巧

        // 2. 创建一个exports对象,为了使其符合cjs规范
        var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
        // 1. 使用eval将字符串转化为可执行脚本
        // eval(`exports.default = function(a, b) {return a + b;}`)
        // 3. 为了避免全局污染
        (function(exports, code) {
          eval(code)
        })(exports, `exports.default = function(a, b) {return a + b;}`)
        console.log(exports.default(1, 5))
    

    再打开浏览器,还是显示结果6,没毛病,继续!

    第二步,实现加载模块

    这一步,是实现 index.js中,调用子模块中方法,并执行的步骤,我们可以先将index.js内容拷贝到脚本,看会提示什么错误,再根据错误,一步步去解决

    <!-- src/index.html -->
    
      <script>
        var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
    
        (function(exports, code) {
          eval(code)
        })(exports, `exports.default = function(a, b) {return a + b;}`)
    
        // index.js的内容
        var add  = require('./add.js')
        console.log(add(1,3))
      </script>
    
    image.png
    1. 提示require未定义
      解决:自己模拟实现一个require方法,在刚刚的立即执行函数外,封装一个require方法,并将exports.default(也就是add方法,这里写成exports.default也是为了符合cjs规范)返回
        // 4. 实现require方法
        function require(file) {
          (function(exports, code) {
            eval(code)
          })(exports, `exports.default = function(a, b) {return a + b;}`)
          return exports.default;
        }
        
        var add  = require('./add.js');
        console.log(add(1,3))
    
    image.png

    第三步,文件读取

    require('./add.js')这时的文件是写死的,还不能按照参数形式处理

        // 5. 这时的文件是写死的,还不能按照参数形式处理
        var add  = require('./add.js');
        console.log(add(1,3))
    

    解决:文件我们可以用对象映射方式,再套一个自执行函数,以它的参数形式传入

        // 文件列表对象大概长这样
        {
          "index.js": `
            var add  = require('./add.js')
            console.log(add(1,3))
          `,
          "add.js": `
            exports.default = function(a, b) {return a + b;}
          `
        }
    
    <!-- src/index.html -->
    
    <head>
      <!-- <script src="./index.js"></script> -->
    </head>
    <body>
      <script>
        var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
        
        (function(list) {
          function require(file) {
            (function(exports, code) {
              eval(code)
            })(exports, list[file])
            return exports.default;
          }
          require('./index.js')
        })({
          "./index.js": `
            var add  = require('./add.js')
            console.log(add(1,3))
          `,
          "./add.js": `
            exports.default = function(a, b) {return a + b;}
          `
        })
      </script>
    </body>
    </html>
    

    再看下结果:


    image.png

    没毛病,噔噔噔噔,有没有觉得,这一串东东老熟悉了?

      // 打包后的结果,bundle.js
     (function(list) {
          function require(file) {
            (function(exports, code) {
              eval(code)
            })(exports, list[file])
            return exports.default;
          }
          require('./index.js')
        })({
          "./index.js": `
            var add  = require('./add.js')
            console.log(add(1,3))
          `,
          "./add.js": `
            exports.default = function(a, b) {return a + b;}
          `
        })
    

    这就是我们平常用webpack打包后看到的那一堆看都不想看的结果了='=(也就是万恶的bundle.js),这就是一个webpack最小模块打包的雏形了

    二、分析模块间的依赖关系,获取依赖图

    刚刚的例子呢,是为了让大家快速了解webpack的原理,我们是人工分析依赖关系,来写的一个小demo,但是实际情况要比我们刚刚说的复杂多了,比如依赖之间是往往一个模块依赖多个模块,模块之间还有嵌套问题,比如下面这样的图形结构;使用的还不是ES5的语法,而是ES6语法,还需要我们转义。

    {
       "./src/index.js": {
         "deps": { "./add.js": "./src/add.js" },
         "code": "....."
       },
       "./src/add.js": {
         "deps": {},
         "code": "......"
       }
    }
    

    我们还要处理的问题,大概可以总结为:

    1. 收集依赖
    2. ES6ES5
    3. 实现importexport

    为了高大上些,我们使用ES6语法改下文件:

    // src/add.js
    // ES6语法
    export default (a, b) => a + b; 
    
    // src/index.js
    // ES6语法
    import add from './add.js'
    console.log(add(1,3))
    

    开始来实现我们的webpack.js

    1、实现单个模块的分析方法 getModuleInfo

    第一步,使用fs模块读取文件

    // 引入fs模块,用来读写文件
    const fs = require('fs')
    /**
     * 模块分析
     * @param {*} file 
     */
    function getModuleInfo(file) {
      // 1. 读取文件
      const body = fs.readFileSync(file, 'utf-8')
    
    }
    
    

    第二步,使用babel的parser模块,将文件字符串内容转换成AST树

    • 什么是AST树?

    ast是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,这所以说是抽象的,是因为抽象语法树并不会表示出真实语法出现的每一个细节,比如说,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现。抽象语法树并不依赖于源语言的语法,也就是说语法分析阶段所采用的上下文无文文法,因为在写文法时,经常会对文法进行等价的转换(消除左递归,回溯,二义性等),这样会给文法分析引入一些多余的成分,对后续阶段造成不利影响,甚至会使合个阶段变得混乱。因些,很多编译器经常要独立地构造语法分析树,为前端,后端建立一个清晰的接口

    • 体验AST树
      网站:https://astexplorer.net/
      下面图,是将console.log(111)转换成AST的结果

      image.png
    • 安装babel
      一般将字符串转换为抽象语法树,我们都是通过工具来完成的,这点上babel就已经实现的比较完美了,在使用前,我们需要先安装相关依赖:
      npm i @babel/parser @babel/traverse @babel/core @babel/preset-env

    • 使用babel转换AST

    const parser = require('@babel/parser')
    
    function getModuleInfo(file) {
      // 1. 读取文件
      const body = fs.readFileSync(file, 'utf-8')
    
      // 2. 转换AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // ES模块
      })
    }
    
    

    第三步,使用babel的traverse模块,分析AST树,收集依赖

    const traverse = require('@babel/traverse').default
    function getModuleInfo(file) {
      // 1. 读取文件
      const body = fs.readFileSync(file, 'utf-8')
    
      // 2. 转换AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // ES模块
      })
      // console.log("ast:", ast)
    
      // 3. 收集依赖
      const deps = {}
      traverse(ast, {
        ImportDeclaration({node}) {
          // 获取当前目录名
          const dirname = path.dirname(file)
          // 设置绝对路径
          const abspath = './' + path.join(dirname, node.source.value)
          deps[node.source.value] = abspath
        }
      })
      console.log("deps:", deps)  // deps: { './add.js': './src\\add.js' }
    }
    getModuleInfo('./src/index.js')
    

    这时候,就可以看到index.js的依赖文件为add.js

    第四步,使用babel的transformFromAst模块,将ES6转为ES5

    const babel = require("@babel/core");
    
      // 4. ES6转换ES5
      const { code } = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
      })
      console.log("code:", code)
    

    结果输出如下:


    image.png

    第五步,输出模块信息

      // 5. 输出模块信息
      const moduleInfo = {
        file,
        deps,
        code
      }
      return moduleInfo
    

    完整的单个模块分析代码:

    // 引入fs模块,用来读写文件
    const fs = require('fs')
    // 引入path模块,处理路径问题
    const path = require('path')
    
    const parser = require('@babel/parser')
    const traverse = require('@babel/traverse').default
    const babel = require('@babel/core')
    
    /**
     * 模块分析
     * @param {*} file 
     */
    function getModuleInfo(file) {
      // 1. 读取文件
      const body = fs.readFileSync(file, 'utf-8')
    
      // 2. 转换AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // ES模块
      })
    
      // 3. 收集依赖
      const deps = {}
      traverse(ast, {
        ImportDeclaration({node}) {
          // 获取当前目录名
          const dirname = path.dirname(file)
          // 设置绝对路径
          const abspath = './' + path.join(dirname, node.source.value)
          deps[node.source.value] = abspath
        }
      })
    
      // 4. ES6转换ES5
      const { code } = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
      })
    
      // 5. 输出模块信息
      const moduleInfo = {
        file,
        deps,
        code
      }
      return moduleInfo
    }
    
    
    const info = getModuleInfo('./src/index.js')
    console.log({info})
    // { info:
    //   { file: './src/index.js',
    //     deps: { './add.js': './src\\add.js' },
    //     code:
    //      '"use strict";\n\nvar _add = _interopRequireDefault(require("./add.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\n// src/index.js\n// ES5语法\n// var add  = require(\'./add.js\')\n// console.log(add(1,3))\n// ES6语法\nconsole.log((0, _add["default"])(1, 3));' } }
    

    2、分析模块间的依赖关系

    // 解析模块间的关系
    function parseModules(file) {
      // 从入口开始
      const entry = getModuleInfo(file)
      const temp = [entry]
      // 依赖关系图
      const depsGraph = {}
      // 递归获取依赖关系
      getDeps(temp, entry)
    
      // 组装依赖
      temp.forEach((moduleInfo) => {
        depsGraph[moduleInfo.file] = {
          deps: moduleInfo.deps,
          code: moduleInfo.code,
        };
      });
      return depsGraph
    
    }
    
    // 递归获取依赖关系
    function getDeps(temp, {deps}) {
      Object.keys(deps).forEach(key => {
        const child = getModuleInfo(deps[key])
        temp.push(child)
        getDeps(temp, child)
      })
    }
    
    const graph = parseModules('./src/index.js')
    console.log('graph:', graph)
    

    可以看到,输出结果,就是我们想要的依赖图结构了:


    image.png

    三、最终组合打包

    有了依赖树,前面第一个demo我们写了bundle.js,那么我们将它们组装起来,就是我们想要最终打包结果了

    // 9. 打包
    function bundle(file) {
      // 获取依赖图
      const depsGraph = JSON.stringify(parseModules(file))
      // 跟第一个demo中的打包文件,拼接起来
      return `
      (function (graph) {
        function require(file) {
          function absRequire(relPath) {
            return require(graph[file].deps[relPath])
          }
          var exports = {};
          (function (require,exports,code) {
            eval(code)
          })(absRequire,exports,graph[file].code)
          return exports
        }
        require('${file}')
      })(${depsGraph})`;
    }
    
    const content = bundle('./src/index.js')
    console.log(content)
    

    四、最后,输出 dist/bundle.js 文件

    // 判断有没dist目录,没有就创建
    !fs.existsSync("./dist") && fs.mkdirSync("./dist");
    // 将打包后的文件写入./dist/bundle.js中
    fs.writeFileSync("./dist/bundle.js", content);
    

    结果展示:

    // dist/bundle.js
    
      (function (graph) {
        function require(file) {
          function absRequire(relPath) {
            return require(graph[file].deps[relPath])
          }
          var exports = {};
          (function (require,exports,code) {
            eval(code)
          })(absRequire,exports,graph[file].code)
          return exports
        }
        require('./src/index.js')
      })({"./src/index.js":{"deps":{"./add.js":"./src\\add.js"},"code":"\"use strict\";\n\nvar _add = _interopRequireDefault(require(\"./add.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\n// src/index.js\n// ES5语法\n// var add  = require('./add.js')\n// console.log(add(1,3))\n// ES6语法\nconsole.log((0, _add[\"default\"])(1, 3));"},"./src\\add.js":{"deps":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\n// src/add.js\n// 使用es5导出模块:src/add.js\n// exports.default = function(a, b) {return a + b;}\n// ES6语法\nvar _default = function _default(a, b) {\n  return a + b;\n};\n\nexports[\"default\"] = _default;"}})
    

    五、测试,这个打包结果

    <!-- index2.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <title>测试手写的webpack.js</title>
      <script src="../dist/bundle.js"></script>
    </head>
    <body>
    </body>
    </html>
    
    image.png

    至此,一个基本的手写webpack就完成了,总结下webpack的处理流程:

    1. 读取⼊⼝⽂件;
    2. 基于 AST(抽象语法树) 分析⼊⼝⽂件,并产出依赖列表;
    3. 使⽤ Babel 将相关模块编译到 ES5;
    4. webpack有⼀个智能解析器(各种babel),⼏乎可以处理任何第三⽅库。⽆论它们的模块形式是
      CommonJS、AMD还是普通的JS⽂件;甚⾄在加载依赖的时候,允许使⽤动态表require("、/templates/"+name+"、jade")。以下这些⼯具底层依赖了不同的解析器⽣成AST,⽐如eslint使⽤了espree、babel使⽤了acorn
    5. 对每个依赖模块产出⼀个唯⼀的 ID,⽅便后续读取模块相关内容;
    6. 将每个依赖以及经过 Babel 编译过后的内容,存储在⼀个对象中进⾏维护;
    7. 遍历上⼀步中的对象,构建出⼀个依赖图(Dependency Graph);
    8. 将各模块内容 bundle 产出

    附上完整代码:

    // ./webpack.js
    // 引入fs模块,用来读写文件
    const fs = require('fs')
    // 引入path模块,处理路径问题
    const path = require('path')
    
    const parser = require('@babel/parser')
    const traverse = require('@babel/traverse').default
    const babel = require('@babel/core')
    
    /**
     * 模块分析
     * @param {*} file 
     */
    function getModuleInfo(file) {
      // 1. 读取文件
      const body = fs.readFileSync(file, 'utf-8')
    
      // 2. 转换AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // ES模块
      })
      // console.log("ast:", ast)
    
      // 3. 收集依赖
      const deps = {}
      traverse(ast, {
        ImportDeclaration({node}) {
          // 获取当前目录名
          const dirname = path.dirname(file)
          // 设置绝对路径
          const abspath = './' + path.join(dirname, node.source.value)
          deps[node.source.value] = abspath
        }
      })
      console.log("deps:", deps) // deps: { './add.js': './src\\add.js' }
    
      // 4. ES6转换ES5
      const { code } = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
      })
      // console.log("code:", code)
    
      // 5. 输出模块信息
      const moduleInfo = {
        file,
        deps,
        code
      }
      return moduleInfo
    }
    
    // const info = getModuleInfo('./src/index.js')
    // console.log({info})
    
    // 6. 解析模块间的关系
    function parseModules(file) {
      // 从入口开始
      const entry = getModuleInfo(file)
      const temp = [entry]
      // 依赖关系图
      const depsGraph = {}
      // 7. 递归获取依赖关系
      getDeps(temp, entry)
    
      // 8. 组装依赖
      temp.forEach((moduleInfo) => {
        depsGraph[moduleInfo.file] = {
          deps: moduleInfo.deps,
          code: moduleInfo.code,
        };
      });
      return depsGraph
    
    }
    
    
    // 递归获取依赖关系
    function getDeps(temp, {deps}) {
      Object.keys(deps).forEach(key => {
        const child = getModuleInfo(deps[key])
        temp.push(child)
        getDeps(temp, child)
      })
    }
    
    // console.log('graph:', graph)
    
    
    // 9. 打包
    function bundle(file) {
      // 获取依赖图
      const depsGraph = JSON.stringify(parseModules(file))
      // 跟第一个demo中的打包文件,拼接起来
      return `
      (function (graph) {
        function require(file) {
          function absRequire(relPath) {
            return require(graph[file].deps[relPath])
          }
          var exports = {};
          (function (require,exports,code) {
            eval(code)
          })(absRequire,exports,graph[file].code)
          return exports
        }
        require('${file}')
      })(${depsGraph})`;
    }
    
    const content = bundle('./src/index.js')
    console.log(content)
    
    // 判断有没dist目录,没有就创建
    !fs.existsSync("./dist") && fs.mkdirSync("./dist");
    // 将打包后的文件写入./dist/bundle.js中
    fs.writeFileSync("./dist/bundle.js", content);
    

    参考:
    https://juejin.cn/post/6854573217336541192

    相关文章

      网友评论

          本文标题:手写webpack

          本文链接:https://www.haomeiwen.com/subject/vklpdltx.html