首頁 > 軟體

mini webpack打包基礎解決包快取和環依賴

2022-09-30 14:01:45

正文

本文帶你實現 webpack 最基礎的打包功能,同時解決包快取和環依賴的問題 ~

發車,先來看範例程式碼。

index.js 主入口檔案

我們這裡三個檔案,index.js 是主入口檔案:

// filename: index.js
import foo from './foo.js'
foo();
//filename: foo.js
import message from './message.js'
function foo() {
  console.log(message);
}
// filename: message.js
const message = 'hello world'
export default message;

接下來,我們會建立一個 bundle.js 打包這三個檔案,打包得到的結果是一個 JS 檔案,執行這個 JS 檔案輸出的結果會是 'hello world'。

bundle.js 就是 webpack 做的事情,我們範例中的 index.js 相當於 webpack 的入口檔案,會在 webpack.config.js 的 entry 裡面設定。

讓我們來實現 bundle.js 的功能。

讀主入口檔案

最開始的,當然是讀主入口檔案了:

function createAssert(filename) {
  const content = fs.readFileSync(filename, {
    encoding: 'utf-8'
  });
  return content;
} 
const content = createAssert('./example/index.js');

接下來,需要做的事情就是把 import 語法引入的這個檔案也找過來,在上圖中,就是 foo.js,同時還得把 foo.js 依賴的也找過來,依次遞推。

現在得把 foo.js 取出來,怎麼解析 import foo from './foo.js' 這句,把值取出來呢?

把這行程式碼解析成 ast 會變成:

接下來的思路就是把上面的程式碼轉化成 ast,接著去取上圖框框裡那個欄位。

對依賴檔案進行讀取操作

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
function createAssert(filename) {
  const dependencies = [];
  const content = fs.readFileSync(filename, {
    encoding: 'utf-8'
  });
  const ast = babylon.parse(content, {
    sourceType: 'module',
  }); 
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      dependencies.push(node.source.value);
    }
  })
  console.log(dependencies); // [ './foo.js' ]
  return content;
}

上面我們做的事情就是把當前的檔案讀到,然後再把當前檔案的依賴加到一個叫做 dependencies 的陣列裡面去。

然後,這裡的 createAssert 只返回原始碼還不夠,再完善一下:

let id = 0;
function getId() { return id++; }
function createAssert(filename) {
  const dependencies = [];
  const content = fs.readFileSync(filename, {
    encoding: 'utf-8'
  });
  const ast = babylon.parse(content, {
    sourceType: 'module',
  }); 
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    }
  })
  return {
    id: getId(),
    code: content,
    filename,
    dependencies,
    mapping: {},
  };
}

假如對主入口檔案 index.js 呼叫,得到的結果會是(先忽略 mapping):

我們不能只對主入口檔案做這件事,得需要對所有在主入口這鏈上的檔案做,上面 createAssert 針對一個檔案做,我們基於這個函數,建一個叫做 crateGraph 的函數,裡面進行遞迴呼叫。

不妨先直接看結果,來了解這個函數是做什麼的。

執行這個函數,得到的結果如下圖所示:

mapping 欄位做了當前項 dependencies 裡的檔案和其他項的對映,這個,我們在後面會用到。

function createGraph(entry) {
  const modules = [];
  createGraphImpl(
    path.resolve(__dirname, entry),
  );
  function createGraphImpl(absoluteFilePath) {
    const assert = createAssert(absoluteFilePath);
    modules.push(assert);
    assert.dependencies.forEach(relativePath => {
      const absolutePath = path.resolve(
        path.dirname(assert.filename),
        relativePath
      );
      const id = createGraphImpl(absolutePath);
      assert.mapping[relativePath] = child.id;
    });
    return assert.id
  }
  return modules;
}

大家可以注意到,截圖中,陣列中每一項的 code 就是我們的原始碼,但是這裡面還留著 import 語句,我們先使用 babel 把它轉成 commonJS 。

做的也比較簡單,就是用 babel 修改 createAssert 中返回值的 code:

const code = transformFromAst(ast, null, {
  presets: ['env'],
}).code

擷取其中一項,結果變成了:

接下來要做的一步剛上來會比較難以理解,最關鍵的是我們會重寫 require 函數,非常的巧妙,不妨先看:

我們新建一個函數 bundle 來處理 createGraph 函數得到的結果。

function bundle(graph) {
  let moduleStr = '';
  graph.forEach(module => {
    moduleStr += `
    ${module.id}: [
      // require,module,exports 作為引數傳進來
      // 在下面我們自己定義了,這裡記作【位置 1】
      function(require, module, exports) {
        ${module.code}
      },
      ${JSON.stringify(module.mapping)}
    ],
    `
  })
  const result = `
    (function(modules){
      function require(id) {
        const [fn, mapping] = modules[id];
        // 這其實就是一個空物件,
        // 我們匯出的那個東西會掛載到這個物件上
        const module = { exports: {} }
        // fn 就是上面【位置 1】 那個函數
        fn(localRequire, module, module.exports)
        // 我們使用 require 是 require(檔名)
        // 所有這裡要做一層對映,轉到 require(id)
        function localRequire(name) {
          return require(mapping[name])
        }       
        return module.exports;
      }
      require(0);
    })({${moduleStr}}) 
  `
  return result;
}

最終的使用就是:

const graph = createGraph('./example/index.js');
const res = bundle(graph);

res 就是最終打包的結果,複製整段到控制檯執行,可見成功輸出了 'hello world':

於是基本的功能就完成了,也就是 webpack 最基本的功能。

接下來解決包快取的問題,目前來說,import 過的檔案,會被轉成 require 函數。每一次都會重新呼叫 require 函數,現在先辦法把已經呼叫過的快取起來:

function createGraph(entry) {
  const modules = [];
  const visitedAssert = {}; // 增加了這個物件
  createGraphImpl(
    path.resolve(__dirname, entry),
  );
  function createGraphImpl(absoluteFilePath) {
    // 如果已經存取過了,那就直接返回
    if (visitedAssert[absoluteFilePath]) {
      return visitedAssert[absoluteFilePath]
    }
    const assert = createAssert(absoluteFilePath);
    modules.push(assert);
    visitedAssert[absoluteFilePath] = assert.id;
    assert.dependencies.forEach(relativePath => {
      const absolutePath = path.resolve(
        path.dirname(assert.filename),
        relativePath
      );
      // 優化返回值,只返回 id 即可
      const childId = createGraphImpl(absolutePath);
      assert.mapping[relativePath] = childId;
    });
    return assert.id
  }
  return modules;
}
function bundle(graph) {
  let moduleStr = '';
  graph.forEach(module => {
    moduleStr += `
    ${module.id}: [
      function(require, module, exports) {
        ${module.code}
      },
      ${JSON.stringify(module.mapping)}
    ],
    `
  })
  const result = `
    (function(modules){
      // 增加對已存取模組的快取
      let cache = {};
      console.log(cache);
      function require(id) {
        if (cache[id]) {
          console.log('直接從快取中取')
          return cache[id].exports;
        }
        const [fn, mapping] = modules[id];
        const module = { exports: {} }
        fn(localRequire, module, module.exports)
        cache[id] = module;
        function localRequire(name) {
          return require(mapping[name])
        }       
        return module.exports;
      }
      require(0);
    })({${moduleStr}}) 
  `
  return result;
}

解決依賴成環問題

這個問題比較經典,如下所示,這個例子來自於 Node.js 官網

// filename: a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
// filename: b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
// filename: main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

目前我們只支援額外把 import 語句參照的檔案加到依賴項裡,還不夠,再支援一下 require。做的也很簡單,就是 解析 AST 的時候再加入 require 語法的解析就好:

 traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
    CallExpression ({ node }) {
      if (node.callee.name === 'require') {
        dependencies.push(node.arguments[0].value)
      }
    }
  })

然後,如果這樣,我們直接執行,按照現在的寫法處理不了這種情況,會報錯棧溢位:

但是我們需要改的也特別少。先看官網對這種情況的解釋:

When main.js loads a.js, then a.js in turn loads b.js. At that point, b.js tries to load a.js. In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module. b.js then finishes loading, and its exports object is provided to the a.js module.

解決方法就是這句話:『an unfinished copy of the a.js exports object is returned to the b.js module』。也就是,提前返回一個未完成的結果出來。我們需要做到也很簡單,只需要把快取的結果提前就好了。

之前我們是這麼寫的:

fn(localRequire, module, module.exports)
cache[id] = module;

接著改為:

cache[id] = module;
fn(localRequire, module, module.exports)

這樣就解決了這個問題:

到現在我們就基本瞭解了它的實現原理,實現了一個初版的 webpack,撒花~

明白了它的實現原理,我才知道為什麼網上說 webpack 慢是因為要把所有的依賴都先收集一遍,且看我們的 createGraph 。它確實是做了這件事。

但是寫完發現,這個題材不適合寫文章,比較適合視訊或者直接看程式碼,你覺得呢?ಥ_ಥ

所有的程式碼在這個倉庫

以上就是mini webpack打包基礎解決包快取和環依賴的詳細內容,更多關於mini webpack包快取環依賴的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com