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 的核心原理。