# react-cli 脚手架分析
create-react-app 01.react-cli
# package.json 指令分析
"scripts": {
"start": "react-scripts start", // 编译环境运行
"build": "react-scripts build", // 生成环境打包
"test": "react-scripts test", // 自动化测试
"eject": "react-scripts eject" // 暴露配置文件
},
npm run eject
# 配置结构目录分析
01.react-cli
├── config
│ ├── env.js
│ ├── getHttpsConfig.js
│ ├── modules.js
│ ├── paths.js # 路径处理模块
│ ├── pnpTs.js
│ ├── webpack.config.js # 打包|运行配置
│ └── webpackDevServer.config.js
├── scripts # node 目录目录
│ ├── build.js # 运行打包命令
│ ├── start.js # 运行编译命令
│ └── test.js # 运行测试命令
└── src
# vue-cli 脚手架分析
vue create app 02.vue-cli
vue inspect --mode=development > webpack.dev.js
vue inspect --mode=production > webpack.prod.js
#
# 自定义 loader
# 实现一个简单的 loader
// webpack.config.ts
import path from 'path'
import { Configuration, LoaderDefinition } from 'webpack'
const config: Configuration = {
module: {
rules: [
{
test: /\.js$/,
loader: 'loader-1'
}
]
},
entry: './src/index.js',
// 配置 loader 的解析规则
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
}
}
export default config
// loaders/loader-1.js
module.exports = function(content, map, meta) {
// content 打印编译文件内容
return content
}
# loader 的执行顺序
// loaders 的执行顺序默认是从下往上执行
module.exports = async function (content) {
}
// loaders pitch 则从上往下执行
module.exports.pitch = function () {
}
# loader 获取配置与校验
const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')
const schema = {
type: 'object',
properties: {
name: {
type: 'string',
description: '名称~'
},
// 是否允许追加属性
additionalProperties: true
}
}
module.exports = async function (content) {
// 获取配置
const options = getOptions(this)
// 校验配置
validate(schema, options, {
name: '我是 loader-3 的 name 字段'
})
}
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'loader-3',
options: { name: '' }
}
]
}
]
}
}
# 自定义 babel-loader
使用 babel 写一个 webpack loader,将配置传递到 babel transform 配置中,返回 code
// loaders/babel-loader.js
const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')
const { promisify } = require('util')
const babel = require('@babel/core')
// node async callback to promise function
const transform = promisify(babel.transform)
// options validate schema
const schema = {
type: 'object',
properties: {
presets: { type: 'array' }
},
addtionalProperties: true
}
module.exports = async function (content, _map, _meta) {
// 获取 loader 的 options 配置
const options = getOptions(this) || {}
// 校验 loader 的 options 配置
validate(schema, options, { name: 'Babel Loader' })
// transform content
const { code, map } = await transform(content, options)
// call return code
this.callback(null, code, map, _meta)
}
// webpack.config.ts
import path from 'path'
import { Configuration, LoaderDefinition } from 'webpack'
const config: Configuration = {
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
]
},
mode: 'production',
entry: './src/index.js',
// 配置 loader 的解析规则
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
}
}
export default config
#
# 自定义 plugin (tabpable)
tabpable (opens new window) 是一个基于观察者模式的工具库,提供了同步/异步的观察者构造函数,webpack plugin 的 compiler 基于 tabpable 实现,向外提供 webpack 的生命周期钩子。
# SyncHooks
创建同步的 hooks ,任务依次执行,参数为可接收的值
import { SyncHooks } from 'tabable'
// 创建一个 hooks
const hooks = new SyncHooks(['address'])
// 往 hooks 容器中注册事件/添加回调函数
hooks.tab('class-0318', (address) => {
/* .... */
})
// 触发所有钩子函数(监听者)
hooks.call('北京')
# SyncBailHooks
与 SyncHooks 基本一致,但回调中遇到返回值则停止调用剩余的 hooks
# AsyncParallelHook
创建一个异步并行的 hooks,并行执行
const asyncHooks = new AsyncParallelHook(['name', 'age'])
// 添加一个异步的 hook, 通过钓鱼 callback 代表执行完毕
hooks.leave.tapAsync('class-0510', async (name, age, callback) => {
await awaitPromise(1000)
console.log('class-0510', name, age)
callback()
})
# 自定义 plugin (get compiler)
自定义插件的定义,有两种方式,一种是使用类的方式,一种是函数方式,
compiler 是 webpack 提供的内置对象,有丰富的生命周期。
# 类模式
class Plugin {
apply(compiler) {}
}
// webpack.config.ts
const config = {
plugins: [new Plugin()]
}
# 函数模式
const plugin = (compiler) => {}
// webpack.config.ts
const config = {
plugins: [plugin]
}
# compiler 基本生命周期
Compiler
模块是 webpack 的主要引擎,它通过 CLI (opens new window) 传递的所有选项, 或者 Node API (opens new window),创建出一个 compilation 实例。 它扩展(extend)自 Tapable
类,用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler
上注册。
const plugin = (compiler) => {
// 输出 asset 到 output 目录之前执行
compiler.hooks.emit.tap('plugin-1', (compilation) => {
console.log('emit.tab 1')
})
compiler.hooks.emit.tapPromise('plugin-1', async (compilation) => {
await awaitPromise(1000)
console.log('emit.tab 1')
})
// 输出 asset 到 output 目录之后执行
compiler.hooks.afterEmit.tap('plugin-1', (compilation) => {
console.log('afterEmit.tab 1')
})
compiler.hooks.done.tap('plugin-1', (stats) => {
console.log('done.tab 1')
})
}
# compilation 基本生命周期
Compilation
模块会被 Compiler
用来创建新的 compilation 对象(或新的 build 对象)。 compilation
实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。 它会对应用程序的依赖图中所有模块, 进行字面上的编译(literal compilation)。 在编译阶段,模块会被加载(load)、封存(seal)、优化(optimize)、 分块(chunk)、哈希(hash)和重新创建(restore)。
// 获取 compilation
const plugin = (compiler) => {
compiler.hooks.thisCompilation.tab('plugin', (compilation) => {
console.log(compilation)
})
}
上述在初始化 compilation
时调用获取,在触发 compilation
事件之前调用,使用compilation
可对打包文件进行在处理。
import { WebpackPluginInstance, Compiler, sources } from 'webpack'
import fs from 'fs-extra'
import path from 'path'
class Plugin implements WebpackPluginInstance {
apply(compiler: Compiler) {
compiler.hooks.thisCompilation.tap('plugin-2', (compilation) => {
compilation.hooks.additionalAssets.tapPromise('plugin-2', async () => {
// 文章路径
const articlePath = path.resolve(__dirname, '../src/article.txt')
// 往要输出的资源中, 添加一个 article.text
const buff = await fs.readFile(articlePath)
// 创建 webpack 风格文件源
const sourceFile = new sources.RawSource(buff)
// 方式一: 直接添加
compilation.assets['article-1.txt'] = sourceFile
// 方式二: emitAsset
compilation.emitAsset('article-2.txt', sourceFile)
})
})
}
}
export default Plugin
# 自定义 plugin (copy-plugin)
// webpack.config.ts
import { Configuration } from 'webpack'
import CopyWebpackPlugin from './plugins/copy-webpack-plugin'
const config: Configuration = {
mode: 'development',
plugins: [new CopyWebpackPlugin({ from: 'public', ignore: ['**/index.html'] })]
}
export default config
// plugin/copy-webpack-plugin.ts
import { WebpackPluginInstance, Compiler, sources } from 'webpack'
import { validate } from 'schema-utils'
import { Schema } from 'schema-utils/declarations/validate'
import fg from 'fast-glob'
import path from 'path'
import fs from 'fs-extra'
/**
* CopyWebpackPlugin 配置
* @property from - copy 路径
* @property to - dist 拼接路径
* @property ignore - 忽略文件
*/
interface CopyWebpackOption {
from: string
to?: string
ignore?: string[]
}
/** 校验映射 */
const schema: Schema = {
type: 'object',
properties: {
from: { type: 'string' },
to: { type: 'string' },
ignore: { type: 'array' }
},
additionalProperties: false
}
/** 插件名称 */
const PLUGIN_NAME = 'copy-webpack-plugin'
/**
* @name CopyWebpackPlugin
* @description 复制 webpack 任意项目路径中的文件到 dist 打包目录
*/
class CopyWebpackPlugin implements WebpackPluginInstance {
constructor(private options: CopyWebpackOption) {
// 处理参数, 兼容 fast-glob 读取
options.from = path.join(options.from, './**').replace(/\\/g, '/')
// 校验参数
validate(schema, options, { name: PLUGIN_NAME })
}
apply(compiler: Compiler) {
const { to = '', from, ignore } = this.options
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
/** 将 from 中的资源复制并输出到 to 中 */
compilation.hooks.additionalAssets.tapPromise(PLUGIN_NAME, onAdditionalAssets)
async function onAdditionalAssets() {
// 1. 读取 from 中所有资源, 过滤掉 ignore 的文件
const paths = await fg(from, { ignore })
// 2. 生成 webpack 格式的资源
const sourceFiles = paths.map((p) => {
const basename = path.basename(p)
const assetPath = to ? path.join(basename) : basename
const file = fs.readFileSync(p)
const source = new sources.RawSource(file)
return { assetPath, source }
})
// 3. 添加 compilation assets 当中
sourceFiles.forEach(({ assetPath, source }) => {
compilation.emitAsset(assetPath, source)
})
}
})
}
}
export default CopyWebpackPlugin
#
# 手写 mini-webpack
使用 babel 手写 mini 版 webpack
# 基本实现
一个简单的 webpack 模块打包器
// lib/webpack/core.ts
// eslint-disable-next-line import/named
import { DeepRequired } from '@tuimao/core'
import path from 'path'
import { merge } from 'lodash'
import fs from 'fs-extra'
// @babel/parser 用于将字符串转换为 ast
import { parse } from '@babel/parser'
// @babel/traverse 用于节点遍历查询
import traverse from '@babel/traverse'
// @babel/generator 用于将节点转换为代码
import { transformFromAstSync } from '@babel/core'
export interface Configuration {
entry?: string
output?: {
path?: string
filename?: string
}
}
const base = process.cwd()
const defaultConfig = {
entry: path.resolve(base, './src/index.js'),
output: {
filename: path.resolve(base, 'main.js'),
path: path.resolve(base, './dist')
}
}
class Compiler {
$options: DeepRequired<Configuration>
constructor(options: Configuration) {
this.$options = merge(defaultConfig, options)
}
/**
* 创建 webpack 打包
*/
run() {
// 获取到文件文件夹路径
const dirname = path.dirname(this.$options.entry)
// 1. 读取入口文件内容
const entryFile = fs.readFileSync(this.$options.entry, 'utf-8')
// 2. 将其解析成 ast 抽象语法树
const ast = parse(entryFile, {
sourceType: 'module'
})
// 定义储存依赖的容器
const deps: Record<string, string> = {}
// 递归收集入口依赖
traverse(ast, {
// 内部遍历 ast 中的 program.body 判断里面语句类型
// 如果 type = ImportDeclaration 则触发当前函数
ImportDeclaration({ node }) {
const importPath = node.source.value
// 这里只做一个简单的拼接, 实际情况要复杂很多
// - 是否携带后缀 .js | .ts
// - 是否是 node_modules 中的 package
const absolutePath = path.resolve(dirname, importPath)
// 进行收集
deps[importPath] = absolutePath
}
})
// 将入口文件编译为浏览器可识别代码
const { code } = transformFromAstSync(ast, null, { presets: ['@babel/preset-env'] })
console.log(code)
}
}
export function webpack(config: Configuration) {
return new Compiler(config)
}
整段代码可以看到,收集的 deps 还没有处理,这里先不着急,把功能模块分离
# 模块分离
入口文件:webpack/index.ts
import { Configuration, defaultConfig } from './core'
import Compiler from './compiler'
import { merge } from 'lodash'
export function webpack(config: Configuration) {
const $config = merge(defaultConfig, config)
return new Compiler($config)
}
源码配置:webpack/core.ts
import path from 'path'
/** 基本地址 */
export const basePath = process.cwd()
/** 外部配置 */
export interface Configuration {
entry?: string
output?: {
path?: string
filename?: string
}
}
/** 内部读取配置 */
export type ConfigurationRead = Configuration & typeof defaultConfig
/** 默认配置 */
export const defaultConfig = {
entry: path.resolve(basePath, './src/index.js'),
output: {
filename: path.resolve(basePath, 'main.js'),
path: path.resolve(basePath, './dist')
}
}
解析方法:webpack/parser.ts
import fs from 'fs-extra'
import path from 'path'
// @babel/parser 用于将字符串转换为 ast
import { parse, ParserOptions } from '@babel/parser'
// @babel/traverse 用于节点遍历查询
import traverse from '@babel/traverse'
// transformFromAstSync 用于将节点转换
import { transformFromAstSync, Node } from '@babel/core'
/**
* 同步读取文件 ast
* @param path 路径
* @param options {ParserOptions}
*/
export const readFileAstSync = (path: string, options?: ParserOptions) => {
const file = fs.readFileSync(path, 'utf-8')
return parse(file, options || { sourceType: 'module' })
}
/**
* 读取 entry ast 中 module 并收集依赖
* @param entry
*/
export const readFileAstModuleDeps = (entry: string) => {
const ast = readFileAstSync(entry)
// 获取到文件文件夹路径
const dirname = path.dirname(entry)
// 定义储存依赖的容器
const deps: Record<string, string> = {}
// 递归收集入口依赖
traverse(ast, {
// 内部遍历 ast 中的 program.body 判断里面语句类型
// 如果 type = ImportDeclaration 则触发当前函数
ImportDeclaration({ node }) {
const importPath = node.source.value
// 这里只做一个简单的拼接, 实际情况要复杂很多
// - 是否携带后缀 .js | .ts
// - 是否是 node_modules 中的 package
const absolutePath = path.resolve(dirname, importPath)
// 进行收集
deps[importPath] = absolutePath.replace(/\\/g, '/')
}
})
return deps
}
/**
* 将 ast 经过 @babel/preset-env 处理返回代码
* @param ast
*/
export const transformPresetEnvCode = (ast: Node) => {
const { code } = transformFromAstSync(ast, null, { presets: ['@babel/preset-env'] })
return code || ''
}
编译器:webpack/compiler.ts
import { ConfigurationRead } from './core'
import { readFileAstSync, readFileAstModuleDeps, transformPresetEnvCode } from './parser'
class Compiler {
constructor(private $options: ConfigurationRead) {}
/**
* 创建 webpack 打包
*/
run() {
// 1. 读取入口文件 ast
const ast = readFileAstSync(this.$options.entry)
// 2. 收集引入依赖
const deps = readFileAstModuleDeps(this.$options.entry)
// 3. 将入口文件编译
const code = transformPresetEnvCode(ast)
console.log(ast, deps, code)
}
}
export default Compiler
# 代码生成
import { ConfigurationRead } from './core'
import { readFileAstSync, readFileAstModuleDeps, transformPresetEnvCode } from './parser'
import path from 'path'
import fs from 'fs-extra'
interface Source {
path: string
deps: Record<string, string>
code: string
}
interface Dependencys {
[key: string]: {
code: string
deps: Record<string, string>
}
}
class Compiler {
constructor(private $options: ConfigurationRead, private modules: Source[] = []) {}
/**
* 创建 webpack 打包
*/
run() {
// 1. 初次构建, 得到入口文件的信息
const entry = this.$options.entry
const source = this.build(entry)
this.modules.push(source)
// 2. 遍历所有的依赖, 收集所有模块
for (const { deps } of this.modules) {
// 遍历当前文件的所有依赖
for (const [_, absolutePath] of Object.entries(deps)) {
const depSource = this.build(absolutePath)
// 将处理信息再次加入 modules 中
// 下一步遍历将遍历该模块
this.modules.push(depSource)
}
}
// 3. 将依赖整理成依赖关系表
const depsGraph = this.modules.reduce((graph, source) => {
return {
...graph,
[source.path]: {
code: source.code,
deps: source.deps
}
}
}, <Dependencys>{})
this.generate(depsGraph)
}
/**
* 构建某个 js 文件
* @param filePath
*/
build(filePath: string) {
// 1. 读取文件 ast
const ast = readFileAstSync(filePath)
// 2. 收集引入依赖
const deps = readFileAstModuleDeps(filePath)
// 3. 将文件编译
const code = transformPresetEnvCode(ast)
return <Source>{ path: filePath, deps, code }
}
generate(depsGraph: Dependencys) {
const bundle = `
;(function (depsGraph) {
// require 目的: 加载入口文件
function require(module) {
// 模块内部的 require 函数, 调用模块中依赖模块, 再次进入外部 require
function localRequire(depModule) {
return require(depsGraph[module].deps[depModule])
}
// 定义暴露对象 (将来模块要暴露的内容)
let exports = {}
;(function (require, exports, code) {
// 调用 code, 存在 require 会进入内部的 localRequire
// 从而形成递归调用, 调用 code > 存在依赖 > 调用 localRequire > 调用 code ....
eval(code)
})(localRequire, exports, depsGraph[module].code)
// 返回模块中的 exports 对象
// 这里其实做的不完整, 因为 exports 会因为 module.exports 给覆盖
return exports
}
// 加载入口文件
require('${this.$options.entry}')
})(${JSON.stringify(depsGraph)})
`
const filePath = path.resolve(this.$options.output.path, this.$options.output.filename)
fs.ensureDirSync(this.$options.output.path)
fs.writeFileSync(filePath, bundle)
}
}
export default Compiler