編寫一個簡易webpack

實現功能

  1. 支持 esModule
  2. 支持 import() 異步加載文件
  3. 支持 loader

準備工做

咱們須要藉助 babel 來解析,先 npm init -yhtml

npm i @babel/parser @babel/traverse @babel/core @babel/preset-env -D
複製代碼

最終的文件目錄結構node

|-- dist           // 打包目標文件夾 
|   |-- 0.bundle.js                       
|   |-- 1.bundle.js                    
|   |-- result.js                       
|-- src            // 項目測試代碼                         
|   |-- entry.js                   
|   |-- messgae.js            
|   |-- name.js            
|   |-- a.js              
|   |-- b.js            
|-- index.html      // 加載文件打包出的文件             
|-- app.js          // 啓動文件         
|-- init.js         // 打包項目須要的初始化代碼
|-- babel-plugin.js // babel插件
|-- loader.js       // loader
|-- package.json   
複製代碼

文件內容 entry.jswebpack

import message from "./message.js";
console.log(message);
import("./a.js").then(() => {
  console.log("a done");
});
複製代碼

message.jsgit

import { name } from "./name.js";
export default `hello ${name}!`;
import("./a.js").then(() => {
  console.log("copy a done");
});
複製代碼

name.jsgithub

export const name = "world";
import("./b.js").then(() => {
  console.log("b done");
});
複製代碼

a.jsweb

console.log("import a");
setTimeout(() => {
  document.body.style = "background:red;";
}, 3000);
複製代碼

b.jsnpm

console.log("import b");
複製代碼

編寫

我在以前寫的 webpack系列之輸出文件分析 文章說過,webpack打包出來的代碼大體的樣子是👇json

(function(modules) {
  function __webpack_require__(moduleId) {
    ...
  }
  ...
  return __webpack_require__(__webpack_require__.s = "./src/main.js");
})({
  "./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}
  "./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {}
  "./src/main.js": (function(module, __webpack_exports__, __webpack_require__) {}
})
複製代碼

借鑑他的思路,咱們也能夠很快寫出來一個簡單的 webpack,首先 (function(modules) {...}) 內部的代碼基本上能夠寫死,也就是咱們等會須要寫的 init.js,接着看,這是一個自執行的函數, 傳入的是一個對象,首先執行的是主入口的文件,而後再分別去找他們的依賴去執行相應的文件。數組

熱身

咱們這裏藉助bable來編譯代碼 先簡單看一下👇這個示例promise

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
let id = 0;

const resolve = function(filename) {
  let content = "";
  content = fs.readFileSync(path.resolve(__dirname, filename), "utf-8");
  // 轉ast樹
  const ast = parser.parse(content, {
    sourceType: "module",
  });
  // 依賴
  const dependencies = [];
  traverse(ast, {
    ImportDeclaration({ node }) {
      // import '' from ''
      dependencies.push(node.source.value);
    },
  });
  // ES6轉成ES5
  const { code } = babel.transformFromAstSync(ast, null, {
    presets: ["@babel/preset-env"],
  });
  return {
    id: id++,
    dependencies,
    filename,
    code,
  };
};
const result = resolve("./src/entry.js");
console.log(result);
複製代碼

打印結果

{ id: 0,
  dependencies: [ './message.js' ],
  filename: './src/entry.js',
  code: '"use strict";\n\nvar _message = _interopRequireDefault(require( ....."
}
複製代碼

咱們這裏解析了一個入口文件,而後經過 babel 轉成 astImportDeclaration 攔截到 import,將它添加到 dependencies 依賴內,處理完 import後把代碼轉成 es5,最後輸出對象,包含當前的文件的id,依賴關係,文件名,以及編譯後的源代碼。這段代碼是整篇的精髓,不過如今只處理了一個文件,咱們剛剛找到了當前文件的依賴,接着須要遞歸查找下一個文件的依賴關係,最後把他們組合起來,跟以前看 webpack 輸出的文件思想差很少。

遞歸查找全部依賴

在下面添加如下代碼👇,順便刪除最後兩行 const result = resolve("./src/entry.js"); console.log(result);

const start = function(filename) {
  const entry = resolve(filename);
  const queue = [entry];
  for (const asset of queue) {
    const dependencies = asset.dependencies;
    const dirname = path.dirname(asset.filename);
    asset.mapping = {};
    dependencies.forEach((val) => {
      const result = resolve(path.join(dirname, val));
      asset.mapping[val] = result.id;
      queue.push(result);
    });
  }
  return queue;
};
const fileDependenceList = start("./src/entry.js");
console.log(fileDependenceList);
複製代碼

執行後結果,咱們捋一捋 入口 entry.js import 👉 message.jsmessage.js import 👉 name.jsname.js 沒有 import 別的文件因此依賴是空的

[
  {
    id: 0,
    dependencies: [ './message.js' ],
    filename: './src/entry.js',
    code: '"use strict";\n\nvar _message = _interopRequireDefault(require( ....."'
  },
  {
    id: 1,
    dependencies: [ './name.js' ],
    filename: 'src/message.js',
    code: '"..."'
  },
  {
    id: 2,
    dependencies: [],
    filename: 'src/name.js',
    code: '"..."'
  },
]
複製代碼

結果咱們獲得了,目前還不是以前想要的那個結構,繼續添加如下代碼

let moduleStr = "";
fileDependenceList.forEach((value) => {
  moduleStr += `${value.id}:[ function(require, module, exports) { ${value.code}; }, ${JSON.stringify(value.mapping)} ],`;
});
const result = `(${fs.readFileSync("./init.js", "utf-8")})({${moduleStr}})`;
fs.writeFileSync("./dist/result.js", result); // 注意這裏須要有dist文件夾
複製代碼

這裏把 init.js 引入了,內容以下

function init(modules) {
  function require(id) {
    var [fn, mapping] = modules[id];
    function localRequire(relativePath) {
      return require(mapping[relativePath]);
    }
    var module = { exports: {} };
    fn(localRequire, module, module.exports);
    return module.exports;
  }
  //執行入口文件,
  return require(0);
}
複製代碼

執行以後在 dist/ 下有一個 result 文件,咱們放到瀏覽器去執行,index.html 加載

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>webpack</title>
  </head>
  <body>
    <script src="./dist/result.js"></script>
  </body>
</html>
複製代碼

不出意外控制檯輸出 hello world,接着會有三個報錯,沒錯,由於咱們沒有處理 import().then() 這種代碼,這個須要單獨處理,若是你想把錯誤去掉,去 src 文件夾把 import() 都註釋就完事了。

你去看 result 的代碼內容,會發現代碼咱們首先執行 require(0),從入口觸發,而後遞歸調用 require 來完成整個流程,看咱們以前 moduleStr 輸出的代碼,結構跟 webpack 輸入的有點區別,思路差很少

{
  0: [
    function(require, module, exports) {
      var _message = _interopRequireDefault(require("./message.js"));
      function _interopRequireDefault(obj) {
        return obj && obj.__esModule ? obj : { default: obj };
      }
      console.log(_message["default"]);
    },
    { "./message.js": 1 },
  ],
  1: [function(require, module, exports) { ... }, { "./name.js": 2 }],
  2: [function(require, module, exports) { ... }, {}],
}
複製代碼

咱們 require 都是當前文件的 id,可是咱們看內部有一段 require("./message.js") ,其實它執行的是 localRequire 方法,經過當前文件數組的第二個值 { "./message.js": 1 } 來定位它要執行的 id 是什麼,這裏的 id 是1,下面就是它的邏輯,經過文件名filename,去查找 mapping 對應的 id

var [fn, mapping] = modules[id];
function localRequire(relativePath) {
  return require(mapping[relativePath]);
}
複製代碼

支持 import() 異步加載

首先先來解釋如下如何異步加載,咱們須要先生成 0.bundle.js 1.bundle.js這樣的文件,而後經過 document.createElement("script") 把它 push 到頁面的 head 內完成加載。 修改babel部分

....

+ let bundleId = 0;
+ const installedChunks = {};
const resolve = function(filename) {
  let content = "";
  content = fs.readFileSync(path.resolve(__dirname, filename), "utf-8");
  const ast = parser.parse(content, {
    sourceType: "module",
  });
  const dependencies = [];
  traverse(ast, {
    ImportDeclaration({ node }) {
      // import '' from ''
      dependencies.push(node.source.value);
    },
+ CallExpression({ node }) {
+ // import()
+ if (node.callee.type === "Import") {
+ const realPath = path.join(
+ path.dirname(filename),
+ node.arguments[0].value
+ );
+ if (installedChunks[realPath] !== undefined) return;
+ let sourse = fs.readFileSync(realPath, "utf-8");
+ // 轉es5
+ const { code } = babel.transform(sourse, {
+ presets: ["@babel/preset-env"]
+ });
+ sourse = `jsonp.load([${bundleId}, function(){${code}}])`;
+ fs.writeFileSync(`./dist/${bundleId}.bundle.js`, sourse);
+ installedChunks[realPath] = bundleId;
+ bundleId++;
+ process.installedChunks = {
+ nowPath: path.dirname(filename),
+ ...installedChunks,
+ };
+ }
+ },
  });
  // ES6轉成ES5
  const { code } = babel.transformFromAstSync(ast, null, {
+ plugins: ["./babel-plugin.js"],
    presets: ["@babel/preset-env"],
  });
  return {
    id: id++,
    dependencies,
    filename,
    code,
  };
};

...

複製代碼

咱們看到上面咱們新增使用 babel 插件 plugins: ["./babel-plugin.js"],不懂的能夠看babel-handbook

babel-plugin.js

const nodePath = require("path");

module.exports = function({ types: t }) {
  return {
    visitor: {
      CallExpression(path) {
        if (path.node.callee.type === "Import") {
          path.replaceWith(
            t.callExpression(
              t.memberExpression(
                t.identifier("require"),
                t.identifier("import")
              ),
              [
                t.numericLiteral(
                  process.installedChunks[
                    nodePath.join(
                      process.installedChunks["nowPath"],
                      path.node.arguments[0].value
                    )
                  ]
                ),
              ]
            )
          );
        }
      },
    },
  };
};
複製代碼

上面插件的功能就是把 import('./a.js') 轉成 require.import(0)

修改 init.js,主要是新增 import 方法,借鑑自 webpack

function init(modules) {
  function require(id) {
    var [fn, mapping] = modules[id];
    function localRequire(relativePath) {
      return require(mapping[relativePath]);
    }
    var module = { exports: {} };
    localRequire.import = require.import; // 新增
    fn(localRequire, module, module.exports);
    return module.exports;
  }
  var installedChunks = {}; // 當前新增
  require.import = function(chunkId) { // 當前新增
    var promises = [];
    var installedChunkData = installedChunks[chunkId];
    // 若是沒有加載
    if (installedChunkData !== 0) {
      if (installedChunkData) {
        promises.push(installedChunkData[2]);
      } else {
        var promise = new Promise(function(resolve, reject) {
          installedChunkData = installedChunks[chunkId] = [resolve, reject];
        });
        promises.push((installedChunkData[2] = promise));
        // start chunk loading
        var script = document.createElement("script");
        var onScriptComplete;
        script.charset = "utf-8";
        script.src = "dist/" + chunkId + ".bundle.js";
        var error = new Error();
        onScriptComplete = function(event) {
          // avoid mem leaks in IE.
          script.onerror = script.onload = null;
          clearTimeout(timeout);
          var chunk = installedChunks[chunkId];
          if (chunk !== 0) {
            if (chunk) {
              var errorType =
                event && (event.type === "load" ? "missing" : event.type);
              var realSrc = event && event.target && event.target.src;
              error.message =
                "Loading chunk " +
                chunkId +
                " failed.\n(" +
                errorType +
                ": " +
                realSrc +
                ")";
              error.name = "ChunkLoadError";
              error.type = errorType;
              error.request = realSrc;
              chunk[1](error);
            }
            installedChunks[chunkId] = undefined;
          }
        };
        var timeout = setTimeout(function() {
          onScriptComplete({ type: "timeout", target: script });
        }, 120000);
        script.onerror = script.onload = onScriptComplete;
        document.head.appendChild(script);
      }
    }
    return Promise.all(promises);
  };
  window.jsonp = {}; // 當前新增
  jsonp.load = function(bundle) { // 當前新增
    var chunkId = bundle[0];
    var fn = bundle[1];
    var resolve = installedChunks[chunkId][0];
    installedChunks[chunkId] = 0;
    // 執行異步加載文件代碼
    fn();
    // 執行resolve
    resolve();
  };
  //執行入口文件,
  return require(0);
}
複製代碼

咱們異步加載的文件都會執行 jsonp.load 方法,,在生成文件 *.bunnd.js 以前都會把代碼改裝一下,獲得下面的結構,這樣就能夠控制執行源代碼及 .then() .catch() 等操做了

jsonp.load([
  0,
  function() {
   // 原文件代碼
  },
]);

複製代碼

而後執行,你會發現 dist 多了兩個文件,0.bundle.js 1.bundle.js,前提是你沒有註釋以前 import() 寫的代碼,而後去瀏覽器控制檯查看,分別打印如下,接着3秒後頁面背景變爲紅色

hello world!
import b
b done
import a
copy a done
a done
複製代碼

等等,咱們使用了三個 import,爲何只有兩個文件,由於有一個 import('./a.js') 使用了兩次,這裏我作了緩存,因此重複異步引入的文件會緩存利用

支持loader

loader 支持很簡單,其實就是把文件的內容交給它單獨處理返回新的結果,咱們新建文件 loader.js,內容以下:

module.exports = function(content) {
  return content + "; console.log('loader')";
};
複製代碼

在每一個js文件後都加上打印loader的代碼

接着修改resolve方法內的代碼

+ const loader = require("./loader");
const resolve = function(filename) {
  let content = "";
  content = fs.readFileSync(path.resolve(__dirname, filename), "utf-8");
+ content = loader(content);
  const ast = parser.parse(content, {
    sourceType: "module",
  });
  ....
}
複製代碼

而後運行代碼,瀏覽器控制檯會打印是三個 loader

最後

至此,咱們完成了 esModule 的支持,文件異步加載的支持、loader 的支持,咱們順便還寫了一個 babel 插件,整個流程沒有什麼難理解的地方,一個 webpack 就這樣完成了,固然還能夠再把功能完善。支持插件?把 tapable 加入?等等,時間有限,點到爲止,若有錯誤還望指正

本章代碼部分借鑑 webpack 輸出的 bundleYou Gotta Love Frontend 的視頻 Ronen Amiel - Build Your Own Webpack

代碼已上傳至 GitHubgithub.com/wclimb/my-w…

本文地址 www.wclimb.site/2020/04/22/…

公衆號

img
相關文章
相關標籤/搜索