上文我们写了一个打包器,但是只能加载 JS 文件,现在我们尝试让他可以加载 CSS

如何加载 CSS

思路

  1. 我们的 bundle 只能加载 JS
  2. 我们想要加载 CSS
  3. 如果我们能把 CSS 变成 JS。那么就可以加载 CSS 了
    1
    2
    3
    4
    5
    6
    7
    8
    // 获取文件内容,将内容放至 depRelation
    let code = readFileSync(filepath).toString()
    if (/\.css$/.test(filepath)) {
    code = `
    const code = ${JSON.stringify(code)};
    export default code;
    `
    }

    如此一来,我们的 CSS 文件就变成了 js文件,但是目前并没有用,CSS 并不会生效。

再加一个骚操作即可让 CSS 生效

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取文件内容,将内容放至 depRelation
let code = readFileSync(filepath).toString()
if (/\.css$/.test(filepath)) {
code = `
const code = ${JSON.stringify(code)};
if (document) {
const style = document.createElement('style');
style.innerText = code;
document.head.appendChild(style);
}
export default code;
`
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { writeFileSync, readFileSync } from 'fs'
import { resolve, relative, dirname, join } from 'path';
import * as babel from '@babel/core'
import {mkdir} from 'shelljs'

// 设置根目录
const projectName = 'project_css'
const projectRoot = resolve(__dirname, projectName)
// 类型声明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = [] // 数组!

// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))

// 先创建 dist 目录
const dir = `./${projectName}/dist`
mkdir('-p', dir)
// 再创建 bundle 文件
writeFileSync(join(dir, 'bundle.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(i => i.key === key)) {
// 注意,重复依赖不一定是循环依赖
return
}
// 获取文件内容,将内容放至 depRelation
let code = readFileSync(filepath).toString()
if (/\.css$/.test(filepath)) {
code = `
const code = ${JSON.stringify(code)};
if (document) {
const style = document.createElement('style');
style.innerText = code;
document.head.appendChild(style);
}
export default code;
`
}

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, '/')
}

让我们搞个页面试试代码就知道了

1
2
3
4
5
6
// index.js
import a from './a.js'
import b from './b.js'
import './index.css'
console.log(a.getB())
console.log(b.getA())
1
2
// index.css
body {background-color: #c03;}s

运行 node -r ts-node/register bundler_css.ts ,然后新建一个页面引入即可

1
2
3
4
5
6
7
8
9
10
11
12
<!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="./dist/bundle.js"></script>
</body>
</html>

到此我们已经成功加载一个 CSS 文件,但是我们没有使用 loader,我们目前是写死在打包器的。

创建 CSS loader

其实很简单

只需要创建文件 css-loader.js,并把代码复制过去即可

1
2
3
4
5
6
7
8
9
10
11
12
// css-loader.js
const cssLoader = code => `
const code = ${JSON.stringify(code)};
if (document) {
const style = document.createElement('style');
style.innerText = code;
document.head.appendChild(style);
}
export default code;
`

module.exports = cssLoader

之前的代码变成引入的文件

1
2
3
if (/\.css$/.test(filepath)) {
code = require('./loader/css-loader.js')(code)
}

为什么要用 require,因为很多 loader 的名字都是从配置文件中读取的,主要是为了方便动态加载

loader 长什么样子
  • 一个loader 可以是普通函数
  • function transform(code){
      const code2 = doSomething(code)
      return code        
    }
    modules.exports = transform
    
    1
    2
    3
    4
    5
    6
    7
    - 一个 loader 也可以是一个异步函数
    - ```js
    async function transform(code){
    const code2 = await doSomething(code)
    return code
    }
    modules.exports = transform

简单的 loader 搞定,开始优化

单一职责原则
  • webpack 里每个 loader 只做一件事
  • 目前我们的 css-loader 做了两件事
  • 一是把 CSS 变为 JS 字符串
  • 二是把 JS 字符串放到 style 标签里

不浮于表面,是 P6 的觉悟
如果你知道的东西跟别人差不多,很难进大公司

很显然我们只要把我们的 loader 拆成两个 loader 就可以了
1
2
3
4
5
6
7
// css-loader
const cssLoader = code => `
const code = ${JSON.stringify(code)};
export default code;
`

module.exports = cssLoader
1
2
3
4
5
6
7
8
9
// style-loader
const styleLoader = code => `
if (document) {
const style = document.createElement('style');
style.innerText = ${JSON.stringify(code)};
document.head.appendChild(style);
}
`
module.exports = styleLoader
1
2
3
4
5
// bundle_css_loader_1
if (/\.css$/.test(filepath)) {
code = require('./loader1/css-loader.js')(code)
code = require('./loader1/style-loader.js')(code)
}

运行发现检查代码发现这是行不通的

经过 style-loader 转换过的代码,并不是我们想要的结果,说明我们的思路存在一些问题

分析

我的代码错在哪儿呢?

  • style-loader 不是转译
  • sass-loader、less-loader 这些 loader 是把代码从一种语言转译为另一种语言
  • 因此将这样的 loader 连接起来不会出问题
  • 但 style-loader 是在插入代码,不是转译,所以需要寻找插入时机和插入位置
  • 插入代码的时机应该是在获取到 css-loader 的结果之后
  • 插入代码的位置应该是在就代码的下面
目前缺乏一种机制可以让我们随意插入代码,而 webpack 是可以的,所以目前来说我们做不到–写不出 style-loader
  • Webpack 官方 style-loader 的思路
  • style-loader 在 pitch 钩子里通过 css-loader 来 require 文件内容
  • 然后在文件内容后面添加 injectStyleIntoStyleTag(content, …) 代码
  • 接下来看看 webpack 的核心代码在哪儿
  • 并分析

阅读 style-loader 源码理解 webpack

  • 不推荐这么做
  • 直接看源码
  • 应该这么做
  • 不看源码,大胆假设
  • 遇到问题,小心求证
  • 带着问题看源码唯一正确的方式(我认为)
  • 一定要自己先想一次
  • 当你的思路无法满足需求的时候,去看别人的实现
  • 看懂了,就悟了

全部折叠以后,代码结构十分清晰,首先他声明了一个 loaderApi 函数,然后添加了一个 pitch 函数(非常重要

这个 style-loader 非常奇怪,本身竟然是一个空函数,所有的逻辑都在 pitch 函数里面。

由于我折叠了所有代码,逻辑结构得长清楚,首先获取所有的选项,然后验证这些选项,之后声明一些变量,最终会在一个 switch case 负责主要的逻辑

一般来说我们会把代码插入到 style 标签里面

返回的东西中,会判断是否是最新的模块系统,是的话就es6的,否则就用 nodeJS 的模块,所以我们只展开最新的

首先引入一个 api 函数,直接看后面的英文(’runtime/injectStylesIntoStyleTag.js’),我们可以很容易发现这个 api 函数就是一个把 style 插入 styleTag 的函数
其次是引入 content,很显然这就是要插入的 style 内容,
我们有了一个插入函数和插入内容,有了这两样东西,那就逻辑上来说就很简单了,直接一结合就是想要的结果

果然,把 content 和 options 传给 api 这个函数,这样之后页面就有想要的样式了,所以这个style-loader 核心代码就三块。

其他代码基本就是在做各种兼容

这个架构看起来好像和我们写的 style-loader 一样的,但是为什么我们就很难实现了,关键在于 webpack 的 style-loader 可以去加载这个request,但是我们没有这个 request 对象,
再来看看我们的问题

我们这个地方能写啥呢?我们拿不到内容,而webpack 比我们多传入了一个 request 对象,它可以拿到代码之外的东西。

webpack 到底有多少个 loader