webpack の loader 作ってみる
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 0
◆ 単純な altcss っぽいのを作って loader を使ってロードしてみる
webpack の loader の仕組みは自由度高めなので 自分で独自のフォーマットのファイルを作って何かの処理をするというのが簡単にできそうです
今回は単純に altcss っぽいのを作ってみることにしました
JavaScript は複雑になりすぎて作るのは大変ですけど CSS は単純なので簡単ですから
機能は とりあえずありがち?なインデントベースで書けるものにしました
ついでにネスト対応と 再利用できる形で定義しておいて好きな場所に埋め込める機能も入れておきました
サンプルなこんな感じ
だいたい想像どおりかと思いますが これを css 化するとこうなります
変換処理は これです
あとはこれを webpack の loader にします
処理した結果を return で返せばいいです
返す文字列は JavaScript のコードになってる必要があります
その文字列が bundle 中に埋め込まれます
複数 loader を繋げる場合は前の loader の結果を受け取って 次の loader に渡すことになります
そのまま bundle じゃなくて 次にまだ loader が来る前提なら その loader に渡すフォーマットになってれば良いです
ccss-loader では css-loader と同じ出力にして style-loader に渡そうかとも考えましたが ccss-loader で style タグ埋め込みまでやってしまうことにしました
css-loader に合わせたほうが style-loader を使ったり css ファイルを別に作る loader を使ったりと既存 loader と組み合わせて動作を変えれたりしますが 今回は実用性あるものでもないですからね
ccss-loader のファイルはこうなります
上にも書いたやつなので parse とか build とかの関数は省略しました
引数の文字列から parse して build した style を埋め込みます
実際 bundle したファイル中の出力はこうなってます
node_modules じゃないので直接 ccss-loader の絶対パスを指定します

blue, red, purple が反映されています
button にマウスを乗せると green になります
use に指定する loader のところに require で指定する名前を書きます
そこでロードされるモジュールでは関数をエクスポートして
関数ではファイルの中身を受け取って JavaScript のテキストを return するだけです
簡単なものならこれで十分ですが 非同期が入ったり ソースマップも使うとなると this.callback を使ったり バイナリファイルの場合は raw を true にしたり もっと複雑なものもあります
詳しいことは公式リファレンス
https://webpack.js.org/api/loaders/
今回は単純に altcss っぽいのを作ってみることにしました
JavaScript は複雑になりすぎて作るのは大変ですけど CSS は単純なので簡単ですから
ccss
カスタム css ってことで ccss って名前にします機能は とりあえずありがち?なインデントベースで書けるものにしました
ついでにネスト対応と 再利用できる形で定義しておいて好きな場所に埋め込める機能も入れておきました
サンプルなこんな感じ
@@fragment abc
.p
.q
a
color: blue
.r
b
font-size: 20px
p
margin: 0
padding: 3px
.z
@@insert abc
.s &
.t &
color: purple
button
color: red
&:hover
color: green
だいたい想像どおりかと思いますが これを css 化するとこうなります
.z .p .q a{
color: blue;
}
.z .p .r b{
font-size: 20px;
}
.z .p .r p{
margin: 0;
padding: 3px;
}
.t .s .z{
color: purple;
}
.z button{
color: red;
}
.z button:hover{
color: green;
}
変換処理は これです
const nestParser = () => {
const tree = {indent: -1, list: [{}]}
const cptr = {
stack: [tree],
get list(){
return this.stack[this.stack.length - 1].list
},
get last(){
return this.list[this.list.length - 1]
},
add(...a){
this.list.push(...a)
},
nest(indent){
const new_list = []
this.last.children = new_list
this.stack.push({indent, list: new_list})
},
unnest(indent){
while(this.stack.length){
this.stack.pop()
if(this.stack[this.stack.length - 1].indent === indent){
return
}
}
throw new Error("indent not matched")
},
}
return {cptr, getRoot(){return tree.list[0].children}}
}
const detectType = str => {
if(str.includes("@@")){
return "special"
}else if(str.includes(": ")){
return "style"
}else{
return "selector"
}
}
const parse = str => {
let pre_indent = -1
const lines = str.split("\n")
const {cptr, getRoot} = nestParser()
for(const [index, line] of Object.entries(lines)){
const [, space, text] = line.match(/^(\s*)(.*)$/)
const content = text.trim()
if(!content) continue
const indent = space.length
const data = {type: detectType(content), content}
if(indent - pre_indent > 0){
cptr.nest(indent)
cptr.add(data)
}else if(indent === pre_indent){
cptr.add(data)
}else{
cptr.unnest(indent)
cptr.add(data)
}
pre_indent = indent
}
return getRoot()
}
const build = parsed => {
const styles = {}
const rec = (items, selector, ctx) => {
const context = Object.create(ctx)
while(items.length){
const item = items.shift()
if(item.type === "style"){
const sel = selector || "*"
const selector_styles = styles[sel] = styles[sel] || []
selector_styles.push(item.content)
}else if(item.type === "selector"){
if(item.children){
const new_selector = selector
? item.content.includes("&")
? item.content.replace(/&/g, selector)
: selector + " " + item.content
: item.content
rec(item.children, new_selector, context)
}
}else if(item.type === "special"){
if(item.content.startsWith("@@fragment")){
const [op, name] = item.content.split(" ")
context.fragment = context.fragment || {}
context.fragment[name] = item.children
}else if(item.content.startsWith("@@insert")){
const [op, name] = item.content.split(" ")
if(context.fragment && context.fragment[name]){
items.unshift(...context.fragment[name])
}
}
}
}
}
rec(parsed, "", null)
return Object.entries(styles).map(([selector, ss]) => {
return `${selector}{\n${ss.map(e => `\t${e};`).join("\n")}\n}`
}).join("\n")
}
console.log(build(parse(ccss_string)))
あとはこれを webpack の loader にします
loader
loader は関数で引数にファイルの中身を受け取ります処理した結果を return で返せばいいです
返す文字列は JavaScript のコードになってる必要があります
その文字列が bundle 中に埋め込まれます
複数 loader を繋げる場合は前の loader の結果を受け取って 次の loader に渡すことになります
そのまま bundle じゃなくて 次にまだ loader が来る前提なら その loader に渡すフォーマットになってれば良いです
ccss-loader では css-loader と同じ出力にして style-loader に渡そうかとも考えましたが ccss-loader で style タグ埋め込みまでやってしまうことにしました
css-loader に合わせたほうが style-loader を使ったり css ファイルを別に作る loader を使ったりと既存 loader と組み合わせて動作を変えれたりしますが 今回は実用性あるものでもないですからね
ccss-loader のファイルはこうなります
module.exports = (src) => {
const style = build(parse(src))
return `
const s = document.createElement("style")
s.textContent = ${JSON.stringify(style)}
document.head.append(s)
`
}
// 略
上にも書いたやつなので parse とか build とかの関数は省略しました
引数の文字列から parse して build した style を埋め込みます
実際 bundle したファイル中の出力はこうなってます
/***/ "./index.ccss":
/*!********************!*\
!*** ./index.ccss ***!
\********************/
/*! no static exports found */
/***/ (function(module, exports) {
const s = document.createElement("style")
s.textContent = ".z .p .q a{\n\tcolor: blue;\n}\n.z .p .r b{\n\tfont-size: 20px;\n}\n.z .p .r p{\n\tmargin: 0;\n\tpadding: 3px;\n}\n.t .s .z{\n\tcolor: purple;\n}\n.z button{\n\tcolor: red;\n}\n.z button:hover{\n\tcolor: green;\n}"
document.head.append(s)
/***/ }),
例
ちゃんとスタイルが影響してることを確認するようの簡単な例ですindex.js
ccss ファイルをロードして html を作るだけですimport "./index.ccss"
document.body.innerHTML = `
<div class="p">
<div class="q">
<a>p q a</a>
</div>
</div>
<div class="z">
<div class="p">
<div class="q">
<a>z p q a</a>
</div>
</div>
<button>z button</button>
</div>
<div class="t">
<div class="s">
<div class="z">t s z</div>
</div>
</div>
`
index.ccss
ccss ファイルは最初に書いた例と同じものです@@fragment abc
.p
.q
a
color: blue
.r
b
font-size: 20px
p
margin: 0
padding: 3px
.z
@@insert abc
.s &
.t &
color: purple
button
color: red
&:hover
color: green
webpack.config.js
use で指定する loader の名前は require に入れるものと一緒ですnode_modules じゃないので直接 ccss-loader の絶対パスを指定します
module.exports = {
mode: "development",
entry: {
index: "./index.js",
},
output: {
path: __dirname,
filename: "[name]-bundle.js",
},
devtool: false,
module: {
rules: [
{
test: /\.ccss$/,
use: [
__dirname + "/ccss-loader.js",
],
},
],
},
}
ccss-loader.js
ccss-loader は上に書いたものと同じですが省略せず全部書いておきますmodule.exports = (src) => {
const style = build(parse(src))
return `
const s = document.createElement("style")
s.textContent = ${JSON.stringify(style)}
document.head.append(s)
`
}
const nestParser = () => {
const tree = {indent: -1, list: [{}]}
const cptr = {
stack: [tree],
get list(){
return this.stack[this.stack.length - 1].list
},
get last(){
return this.list[this.list.length - 1]
},
add(...a){
this.list.push(...a)
},
nest(indent){
const new_list = []
this.last.children = new_list
this.stack.push({indent, list: new_list})
},
unnest(indent){
while(this.stack.length){
this.stack.pop()
if(this.stack[this.stack.length - 1].indent === indent){
return
}
}
throw new Error("indent not matched")
},
}
return {cptr, getRoot(){return tree.list[0].children}}
}
const detectType = str => {
if(str.includes("@@")){
return "special"
}else if(str.includes(": ")){
return "style"
}else{
return "selector"
}
}
const parse = str => {
let pre_indent = -1
const lines = str.split("\n")
const {cptr, getRoot} = nestParser()
for(const [index, line] of Object.entries(lines)){
const [, space, text] = line.match(/^(\s*)(.*)$/)
const content = text.trim()
if(!content) continue
const indent = space.length
const data = {type: detectType(content), content}
if(indent - pre_indent > 0){
cptr.nest(indent)
cptr.add(data)
}else if(indent === pre_indent){
cptr.add(data)
}else{
cptr.unnest(indent)
cptr.add(data)
}
pre_indent = indent
}
return getRoot()
}
const build = parsed => {
const styles = {}
const rec = (items, selector, ctx) => {
const context = Object.create(ctx)
while(items.length){
const item = items.shift()
if(item.type === "style"){
const sel = selector || "*"
const selector_styles = styles[sel] = styles[sel] || []
selector_styles.push(item.content)
}else if(item.type === "selector"){
if(item.children){
const new_selector = selector
? item.content.includes("&")
? item.content.replace(/&/g, selector)
: selector + " " + item.content
: item.content
rec(item.children, new_selector, context)
}
}else if(item.type === "special"){debugger
if(item.content.startsWith("@@fragment")){
const [op, name] = item.content.split(" ")
context.fragment = context.fragment || {}
context.fragment[name] = item.children
}else if(item.content.startsWith("@@insert")){
const [op, name] = item.content.split(" ")
if(context.fragment && context.fragment[name]){
items.unshift(...context.fragment[name])
}
}
}
}
}
rec(parsed, "", null)
return Object.entries(styles).map(([selector, ss]) => {
return `${selector}{\n${ss.map(e => `\t${e};`).join("\n")}\n}`
}).join("\n")
}
index.html
webpack コマンドでバンドルした結果の index-bundle.js ファイルを単純にロードする HTML ファイルを用意します<!doctype html>
<script src="index-bundle.js" defer></script>
結果
結果はこうなりました
blue, red, purple が反映されています
button にマウスを乗せると green になります
まとめ
結構簡単でしたねuse に指定する loader のところに require で指定する名前を書きます
そこでロードされるモジュールでは関数をエクスポートして
関数ではファイルの中身を受け取って JavaScript のテキストを return するだけです
簡単なものならこれで十分ですが 非同期が入ったり ソースマップも使うとなると this.callback を使ったり バイナリファイルの場合は raw を true にしたり もっと複雑なものもあります
詳しいことは公式リファレンス
https://webpack.js.org/api/loaders/