写一个简易的webpack

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2021-05-10

注:该文章为网络公开课笔记 原链接

注:github 地址

Webpack 是为了支持前端模块化开发,最终打包生成能跑在浏览器上的代码文件的工具。

原理分析

在模块化开发时,常有以下工程结构:

.
├── src 
│    ├─── index.js 
│    └─── add.js
├── index.html
// add.js
exports.default = function (a, b) {
  return a + b;
}
var add = require('./add.js').default;
console.log(add(1, 2));

这是一个简单的应用,要想将其跑在浏览器上,则需要引入到 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>Document</title>
</head>
<body>
  <script src="./src/index.js"></script>
</body>
</html>

很显然,这个程序不会正常执行,因为在浏览器环境,没有 exportsrequire 语法。

所以,需要借助一些工具,将我们模块化编写的代码,处理成浏览器支持的代码。 webpack 就用来解决这些问题。

该如何解决呢? 无疑就是读取代码、分析代码、转换代码;那么让我一步一步的实现 简易版的 webpack 吧!

首先,我们如果想让上面 html 引入的 index.js 能够执行,最终要做哪些处理呢?最终打包之后的代码有是什么样的呢?

// 造一个假的 exports 对象,将 require 的 add.js 代码 读出来 通过 eval 执行这个代码字符串
var exports = {};
eval('exports.default = function (a, b) { return a + b }')
console.log(exports.default(1, 2));

为了避免全局污染(上述代码中使用 eval 可以直接修改、声明全局变量,而我们读出来的模块代码是不应该影响全局或其他包括的)的问题,我们进行将其改造成一个自执行函数。

var exports = {};

(function (exports, code) {
  eval(code)
})(exports, 'exports.default = function (a, b) { return a + b }')

console.log(exports.default(1, 2));

接下来的问题是,我们在 src/index.js 中使用模块化开发会出现模块之间的依赖,在我们不改变 src/index.js 的模块化写法的前提下,我们需要造一个 require 函数,来支持模块化。

function require(file) {
  var exports = {};

  (function (exports, code) {
    eval(code)
  })(exports, 'exports.default = function (a, b) { return a + b }')

  return exports;
}

// 原src/indx.js代码
var add = require('./add.js').default;
console.log(add(1, 2));

这样,我们就可以在 index.html 引入这个 require 函数,然后直接引入 src/index.js 就可以执行了。现在的问题是,我们的代码字符串是写死的,上面的 require 函数也不是真正的读取代码,

而当我们需要依赖多个模块化代码的时候,就需要 require 多个模块。那么最终的webpack处理后的代码结构应该是怎样的呢?

最终想得到的代码

(function (list) {
  function require(file) {
    var exports = {};
  
    (function (exports, code) {
      eval(code)
    })(exports, list[file])
  
    return exports;
  }

  require('index.js')
})(
  {
    "index.js": `
      var add = require('add.js').default;
      console.log(add(1, 2));
    `,
    "add.js": `
      exports.default = function (a, b) {
        return a + b;
      }
    `
  }
)

这样,我们就可以分析出最终要做的事情了,需要将各模块文件读取成代码字符串,然后组成一个图结构传递给主自执行函数,然后通过伪造的 require 去将这些代码字符串取出来并执行。这样我们开发的时候就可以使用模块化开发,而 webpack 最终就会将代码打包组织在一起,最终生成一套代码可直接跑在浏览器上。

而我们在开发Web应用时,是使用 ES6 的模块化语法:

// src/index.js
import add from './add.js';
console.log(add(1, 2));
// src/add.js
export default function (a, b) {
  return a + b;
}

同样的,浏览器在之前不支持 script type module 的情况下,是无法支持 es6 模块化语法的,那么最终我们要做的就是以下三个步骤:

  • 收集依赖
  • ES6转ES5
  • 替换 require 和 exports

那们我们就可以开始 webpack 简易版的编码工作了。

编码

1、安装依赖

# 转换语法树
yarn add @babel/parser

# 依赖分析
yarn add @babel/traverse

# 核心包 es6 => es5
yarn add @babel/core

# babel 转换时需要的插件
yarn add @babel/preset-env

2、编码

编码 webpack.js

// webpack.js
const fs = require('fs')
const path = require('path')

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const objectKeys = require('object-keys')

// 获取模块信息的方法
function getModuleInfo(file) {
  // 1.读取文件
  const body = fs.readFileSync(file, 'utf-8');

  // 2.转换AST语法树,便于之后分析依赖、代码转换
  const ast = parser.parse(body, {
    sourceType: 'module'
  })

  // 3.分析和收集当前模块的依赖
  // 这个函数最终的返回应该是这样的
  // {
  //   'file': './src/index.js',
  //    deps: {
  //      './add.js': './src/add.js'
  //    },
  //    code: '...'
  // }
  const deps = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      // 当前执行目录
      const dirname = path.dirname(file); // dirname => ./src

      // node.source.value 就是当前模块依赖的模块的文件名称,即 import xx from 'abc' 中的 abc
      // 获取依赖模块的绝对路径
      const abspath = './' + path.join(dirname, node.source.value) // abspath => ./src/add.js

      deps[node.source.value] = abspath; // deps =>   { './add.js': './src/add.js' }
    }
  })

  // 4.es6转es5
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })

  // console.log('code: ' + code + '\n'); // 这个code就是转换出来的当前模块的代码

  // 5.组装成moduleInfo
  return {
    file,
    deps,
    code
  }
}

// 从入口开始层层递归处理模块信息
function parseModules(file) {
  // 获取入口模块的模块信息
  const entry = getModuleInfo(file);
  // 用来存储整个模块链的各个模块信息
  const temp = [entry];

  // 通过入口文件递归生成依赖关系图
  getDeps(temp, entry)

  // 组装成最终的依赖关系图
  const depsGraph = {};
  temp.forEach(info => {
    depsGraph[info.file] = {
      deps: info.deps,
      code: info.code
    }
  })

  return depsGraph;
  // depsGraph 最终的结构是这样的,即符合我们上面封装成的自执行函数中的传参 list 的样子了

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

function getDeps(temp, { deps }) {
  // 拿到模块的 deps,当deps为空时,则不会执行forEach了
  Object.keys(deps).forEach(key => {
    // 依赖的模块信息
    const child = getModuleInfo(deps[key])
    // 收集
    temp.push(child)
    // 递归获取依赖的依赖
    getDeps(temp, child)
  })
}

// console.log(parseModules('./src/index.js'));

// 通过 parseModules 我们得到了我们最终想要的自执行函数的 list 参数,那么现在需要的就是将list组装进我们的 require函数中就可以了
// 需要注意的是,depsGraph 的 key 值都是绝对路径,那么就需要做点修改
// 当递归执行依赖的时候,需要 require 一个模块的时候,代码常写的是相对路径
// 所以 提供一个 absRequire 来覆盖 eval 中依赖代码执行时的 require
// 作了一层封装,即代码层面写的仍然是相对路径,通过 deps 找到绝对路径,然后传给真正的require
function bundle(file) {
  const depsGraph = JSON.stringify(parseModules('./src/index.js'))
  return `(function (list) {
    function require (file) {
      function absRequire(relPath) {
        return require(list[file].deps[relPath])
      }

      var exports = {};
      (function (require, exports, code) {
        eval(code)
      })(absRequire, exports, list[file].code)
    
      return exports;
    }
    require('${file}')
  })(${depsGraph})`
}

const content = bundle('./src/index.js');

// 生成 dist
// 不存在就创建
!fs.existsSync('./dist') && fs.mkdirSync('./dist')
fs.writeFileSync('./dist/bundle.js', content)

3、运行 node webpack.js 即可以得到我们想要的处理后的代码 ./dist/bundle.js。这样在 index.html 中引用 ./dist/bundle.js 就可以了,对于应用代码也可以使用模块化开发了。

关于 script type module

script type module

如果我们采用es6 模块化的写法,那么在 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>Document</title>
</head>
<body>
  <script src="./src/index.js" type="module"></script>
</body>
</html>

通过 live server 打开就可以正常运作了, 而 vite 就是采用了 script type module 的方式,则大大加快了编译过程,无需打包处理使用了es6模块化开发的js代码,浏览器将直接支持 export 和 import 模块化语法。

自己编写代码实现一个 live server

// server.js
const Koa = require('koa')
const path = require('path')
const fs = require('fs')

const app = new Koa();
app.use(async (ctx) => {
  let {
    request: { url }
  } = ctx;

  // 首页
  if (url === '/') {
    ctx.type = "text/html"
    let content = fs.readFileSync('./index.html', 'utf-8')
    ctx.body = content
  } else if (url.endsWith('.js')) {
    console.log('aaa', url);
    // js文件
    const p = path.resolve(__dirname, url.slice(1));
    ctx.type = "application/javascript"
    const content = fs.readFileSync(p, 'utf-8')
    ctx.body = content
  }
})

app.listen(3000, () => {
  console.log('vite start...');
})
<!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>Document</title>
</head>
<body>
  <!-- <script src="./src/index.js"></script> -->
  <!-- <script src="./version4.js"></script> -->
  <!-- <script src="./dist/bundle.js"></script> -->

  <script src="./src/index.js" type="module"></script>
</body>
</html>

这样我们运行 node server.js 就会启动本地服务,我们访问 localhost:3000 的时候,就会把 index.html 的文件返回给浏览器,而在渲染 html 的时候,碰到了 script type module 时浏览器就会像web服务器请求对应的 ./src/index.js 文件,那么 web服务器就将 js 文件返回给浏览器。