美文网首页
Webpack 核心原理

Webpack 核心原理

作者: littleyu | 来源:发表于2021-03-03 22:33 被阅读0次

    webpack 要解决的两个问题

    现有代码(接上文)

    很遗憾,这三个文件不能运行
    因为浏览器不支持直接运行带有 import 和 export 关键字的代码

    怎么样才能运行 import / export

    • 不同浏览器功能不同
      • 现代浏览器可以通过 <script type=midule> 来支持 import export
      • IE 8~15不支持 import export,所以不可能运行
    • 兼容策略
      • 激进的兼容策略:把代码全放在 <script type=module> 里
      • 缺点:不被 IE 8~15 支持;而且会导致文件请求过多(每个 import 的文件浏览器都会发出一个请求)
      • 平稳的兼容策略:把关键字转译为普通代码,并把所有文件打包成一个文件
      • 缺点:需要复杂的代码来完成这件事情,接下来我们将完成这件事

    编译 import 和 export 关键字

    解决第一个问题,怎么把 import / export 转换成函数

    @babel/core 已经帮我们做了

    // bundler_1.ts
    import { parse } from "@babel/parser"
    import traverse from "@babel/traverse"
    import { readFileSync } from 'fs'
    import { resolve, relative, dirname } from 'path';
    import * as babel from '@babel/core'
    
    // 设置根目录
    const projectRoot = resolve(__dirname, 'project_1')
    // 类型声明
    type DepRelation = { [key: string]: { deps: string[], code: string } }
    // 初始化一个空的 depRelation,用于收集依赖
    const depRelation: DepRelation = {}
    
    // 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
    collectCodeAndDeps(resolve(projectRoot, 'index.js'))
    
    console.log(depRelation)
    console.log('done')
    
    function collectCodeAndDeps(filepath: string) {
      const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
      if (Object.keys(depRelation).includes(key)) {
        // 注意,重复依赖不一定是循环依赖
        return
      }
      // 获取文件内容,将内容放至 depRelation
      const code = readFileSync(filepath).toString()
      const { code: es5Code } = babel.transform(code, {
        presets: ['@babel/preset-env']
      })
      // 初始化 depRelation[key]
      depRelation[key] = { deps: [], code: es5Code }
      // 将代码转为 AST
      const ast = parse(code, { sourceType: 'module' })
      // 分析文件依赖,将内容放至 depRelation
      traverse(ast, {
        enter: path => {
          if (path.node.type === 'ImportDeclaration') {
            // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
            const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
            // 然后转为项目路径
            const depProjectPath = getProjectPath(depAbsolutePath)
            // 把依赖写进 depRelation
            depRelation[key].deps.push(depProjectPath)
            collectCodeAndDeps(depAbsolutePath)
          }
        }
      })
    }
    // 获取文件相对于根目录的相对路径
    function getProjectPath(path: string) {
      return relative(projectRoot, path).replace(/\\/g, '/')
    }
    

    运行 node -r ts-node/register bundler_1.ts

    a.js 的变化
    1、import 关键字不见了
    2、变成了 require
    3、export 关键字不见了
    4、变成了 exports['default']

    具体分析转译后的 a.js 代码

    "use strict"
    Object.defineProperty(exports, "__esModule", {value: true});
    exports["default"] = void 0;
    var _b = _interopRequireDefault(require("./b.js")); // 细节 1
    function _interopRequireDefault(obj) { // 细节 1
       return obj && obj.__esModule ? obj : { "default": obj }; // 细节 1
    }
    var a = {
      value: 'a',
      getB: function getB() {
        return _b["default"].value + ' from a.js'; // 细节 1
      }
    }
    var _default = a; // 细节 2
    exports["default"] = _default; // 细节 2
    

    第一行 Object.defineProperty(exports, "__esModule", {value: true}); 其实等价于 exports['__esModule'] = true

    • 给当前模块添加 __esModule: true 属性,方便跟 CommonJS 模块区分开
    • 那为什么不直接用 exports.__esModule = true 非要装隔壁?
    • 其实可以用选项来切换的,两种区别不大,上面的写法功能更强,exports.__esModule 兼容性更好

    第二行 exports['default'] = void 0;

    • void 0 等价于 undefined,来JSer 的常用过时技巧
    • 这句话是为了强制清空 exports['default'] 的值
    • 为什么要清空?目前暂时不理解,可能是有些特殊情况我现在没有想到

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

    • _interopRequireDefault 这个函数在做什么,其实就是一句话 obj && obj.__esModule ? obj : { "default": obj } ,看你是不是 es 模块,如果是就直接导出(因为 es 模块有默认导出),如果不是就给你加一个默认导出(CommonJS 模块没有默认导出,加上方便兼容)
    • 其他 _interop 开头的函数大多为了兼容旧代码

    细节 2 export default a 变成了 var _default = a; exports["default"] = _default;,简化一下就是 exports["default"] = a

    • 并不看不出来这样写的作用
    • 如果不是默认导出,那么代码会是什么样子呢?
    • export const x = 'x'; 会变成 var x = 'x'; exports.x = x
    以上我们可以知道 @babel/core 会把 import 关键字变成 require 函数,export 关键字会变成 exports 对象

    本质:ESModule 语法变成了 CommonJS 规则
    但我们还没有发现 require 函数是怎么写的,目前先假设 require 已经写好了

    把多个文件打包成一个

    打包成一个什么样的文件?

    肯定包含了所有模块,然后能执行所有模块

    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... }
    ] 
    
    execute(depRelation[0].key) // 执行入口文件
    function execute ......
    

    为什么把 depRelation 从对象改为数组?
    因为我们需要知道入口文件,数组的第一项就是入口,而对象没有第一项的概念

    现在有三个问题还没解决

    1、depRelation 是对象,需要编程一个数组
    2、code 是字符串,需要变成一个函数
    3、execute 函数待完善

    问题 1
    import { parse } from "@babel/parser"
    import traverse from "@babel/traverse"
    import { readFileSync } from 'fs'
    import { resolve, relative, dirname } from 'path';
    import * as babel from '@babel/core'
    
    // 设置根目录
    const projectRoot = resolve(__dirname, 'project_1')
    // 类型声明
    type DepRelation = { key: string, deps: string[], code: string }[] // 变动!!!
    // 初始化一个空的 depRelation,用于收集依赖
    const depRelation: DepRelation = [] // 变动!!!
    // 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
    collectCodeAndDeps(resolve(projectRoot, 'index.js'))
    
    console.log(depRelation)
    console.log('done')
    
    function collectCodeAndDeps(filepath: string) {
      const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
      if (depRelation.find(item => item.key === key)) { // 变动!!!
        // 注意,重复依赖不一定是循环依赖
        return
      }
      // 获取文件内容,将内容放至 depRelation
      const code = readFileSync(filepath).toString()
      const { code: es5Code } = babel.transform(code, {
        presets: ['@babel/preset-env']
      })
      // 初始化 depRelation[key]
      const item = { key, deps: [], code: es5Code } // 变动!!!
      depRelation.push(item) // 变动!!!
      // 将代码转为 AST
      const ast = parse(code, { sourceType: 'module' })
      // 分析文件依赖,将内容放至 depRelation
      traverse(ast, {
        enter: path => {
          if (path.node.type === 'ImportDeclaration') {
            // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
            const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
            // 然后转为项目路径
            const depProjectPath = getProjectPath(depAbsolutePath)
            // 把依赖写进 depRelation
            item.deps.push(depProjectPath) // 变动!!!
            collectCodeAndDeps(depAbsolutePath)
          }
        }
      })
    }
    // 获取文件相对于根目录的相对路径
    function getProjectPath(path: string) {
      return relative(projectRoot, path).replace(/\\/g, '/')
    }
    
    问题 2

    把 code 由字符串改为函数

    • 步骤
      1、在 code 字符串外面包一个 function(require, module, exports){...}(/reqire,module,export 这三个参数是 CommonJS 2 规范规定的/)
      2、把 code 写到文件里,引号不会出现在文件中
      3、不要用 eval,我们不需要执行这个函数,只需要写进文件当中就好了
    • 举例
    code = `
      var b = require('./b.js)
      exports.default = 'a'
    `
    code2 = `
      function(require, module, exports) {
        ${code}
      }
    ` 
    

    然后把 code: ${code2} 写入最终文件中
    最终文件里的 code 就是函数了
    更加详细的栗子🌰:比如 writeFileSync('hello.txt', '你好'),那么文件中将出现 你好 两个字,但是如果我们这么写 writeFileSync('hello.txt', '"你好"'),那么文件中将出现 "你好"

    完善 execute 函数(主体思路)

    const modules = {} // modules 用于缓存所有模块�function execute(key) { 
      if (modules[key]) { return modules[key] }
      var item = depRelation.find(i => i.key === key)
      var require = (path) => {
        return execute(pathToKey(path))
      }
      modules[key] = { __esModule: true } // modules['a.js']
      var module = { exports: modules[key] }
      item.code(require, module, module.exports) 
      return modules.exports
    }
    
    以上,我们就解决了上面的三个问题,下面就是我们最终的文件的主要内容(目前是手写的,之后将用程序生成)

    我们直接用 node 运行这个文件

    和之前的未转译的代码(
    import a from './a.js'; import b from './b.js'; console.log(a.getB()); console.log(b.getA());
    )一模一样(这就对了),区别就是1、语法不同2、之前需要引入其他文件,现在 dist 不需要引入其他文件,因为我们把所有内容写进了一个文件,这就是 bundle,

    但,怎么得到最终文件?

    答案很简单:拼凑出字符串,然后写进文件
    var dist = ""
    dist += content
    writeFileSync('dist.js', dist)

    // 请确保你的 Node 版本大于等于 14
    // 请先运行 yarn 或 npm i 来安装依赖
    // 然后使用 node -r ts-node/register 文件路径 来运行,
    // 如果需要调试,可以加一个选项 --inspect-brk,再打开 Chrome 开发者工具,点击 Node 图标即可调试
    import { parse } from "@babel/parser"
    import traverse from "@babel/traverse"
    import { writeFileSync, readFileSync } from 'fs'
    import { resolve, relative, dirname } from 'path';
    import * as babel from '@babel/core'
    
    // 设置根目录
    const projectRoot = resolve(__dirname, 'project_1')
    // 类型声明
    type DepRelation = { key: string, deps: string[], code: string }[]
    // 初始化一个空的 depRelation,用于收集依赖
    const depRelation: DepRelation = [] // 数组!
    
    // 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
    collectCodeAndDeps(resolve(projectRoot, 'index.js'))
    
    writeFileSync('dist_2.js', generateCode())
    console.log('done')
    
    function generateCode() {
      let code = ''
      code += 'var depRelation = [' + depRelation.map(item => {
        const { key, deps, code } = item
        return `{
          key: ${JSON.stringify(key)}, 
          deps: ${JSON.stringify(deps)},
          code: function(require, module, exports){
            ${code}
          }
        }`
      }).join(',') + '];\n'
      code += 'var modules = {};\n'
      code += `execute(depRelation[0].key)\n`
      code += `
      function execute(key) {
        if (modules[key]) { return modules[key] }
        var item = depRelation.find(i => i.key === key)
        if (!item) { throw new Error(\`\${item} is not found\`) }
        var pathToKey = (path) => {
          var dirname = key.substring(0, key.lastIndexOf('/') + 1)
          var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
          return projectPath
        }
        var require = (path) => {
          return execute(pathToKey(path))
        }
        modules[key] = { __esModule: true }
        var module = { exports: modules[key] }
        item.code(require, module, module.exports)
        return modules[key]
      }
      `
      return code
    }
    
    function collectCodeAndDeps(filepath: string) {
      const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
      if (depRelation.find(item => item.key === key)) {
        // 注意,重复依赖不一定是循环依赖
        return
      }
      // 获取文件内容,将内容放至 depRelation
      const code = readFileSync(filepath).toString()
      const { code: es5Code } = babel.transform(code, {
        presets: ['@babel/preset-env']
      })
      // 初始化 depRelation[key]
      const item = { key, deps: [], code: es5Code }
      depRelation.push(item)
      // 将代码转为 AST
      const ast = parse(code, { sourceType: 'module' })
      // 分析文件依赖,将内容放至 depRelation
      traverse(ast, {
        enter: path => {
          if (path.node.type === 'ImportDeclaration') {
            // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
            const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
            // 然后转为项目路径
            const depProjectPath = getProjectPath(depAbsolutePath)
            // 把依赖写进 depRelation
            item.deps.push(depProjectPath)
            collectCodeAndDeps(depAbsolutePath)
          }
        }
      })
    }
    // 获取文件相对于根目录的相对路径
    function getProjectPath(path: string) {
      return relative(projectRoot, path).replace(/\\/g, '/')
    }
    

    至此我们实现了最简易的打包器,这就是webpack 就核心的功能,但是目前还有很多问题,webpack 是强大的打包工具,我们有很多的重复,而且 webpack 只能诸多类型的文件(通过loader),我们只支持 js 文件,还有 webpack 支持配置文件(如:入口文件,变量....),目前只是可以理解 webpack 的核心原理。

    相关文章

      网友评论

          本文标题:Webpack 核心原理

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