nuejs とは?
Vue・React・Svelte に似た ウェブサイトを作るための軽量なライブラリ。
hooks・effect・propsのような概念を知っていなくても、通常の HTML・CSS・Javascript の知識があれば作れて、なんと言っても売りは軽量であることだそうです。
React と比べると 10分の1 以下の大きさになっているそうですね(react-dom か react かは不明)。
Vue や React に疲れている人が増えているせいか、すごい勢いでスター数を伸ばしていることで有名になりました。
nuejs のフォルダ構成
今回は、この nuejs を2回か3回に分けて、読んでみたいと思います。
自分も nuejs は 1周しか読んでおらず、コントリビュートも簡単なものしかしていないので、間違った内容もあるかもしれませんが、間違っていたら、ご指摘くださると嬉しいです。
nuejs のフォルダ構成を見ると、以下のようになっています。
軽量のフレームワークを目指しているだけあって、量は少ないですね。
この src と ssr の意味は何なのでしょうか?
ssr フォルダ
サーバーサイドでコードジェネレートをするためのものになっています。
主に使うファイルは、compile.js と render.js で、
-
render.js
フロントエンドでリアクティブに動かすのではなく、サーバーサイドで nue ファイルを HTML に変換するために使われます。 -
compile.js
フロントエンドでリアクティブに動かすファイルを作るためのファイルになります。下記の src フォルダ内のファイルと一緒に フロントに配置することで、リアクティブな動きが可能になります。 -
expr.js / fn.js
render.js と compile.js の HTML 操作を補助するファイル。expr.js は 変数の代入・if文・for文 などの操作を、fn.js は helper 的な役割をしています。
src フォルダ
src の compile.js でコンパイルしたファイルを リアクティブに動かすためのものになっています。
- nue.js
compile.js でコンパイルしたファイルから、リアクティブな動きを作るファイルになります。 - if.js
src/nue.js が 参照している if文を作るファイルです。 - for.js
src/nue.js が 参照している for文を作るファイルです。
今回は、このファイルの内、render.js という nueファイルから HTML を出力するファイルを見てみたいと思います。
次回の記事があったら、compile.js と nue.js を読んでみたいと思います。
render.js の使い方
render.js の使い方は、以下のような感じです。
1. まず、サーバーサイドで生成して欲しい nue ファイルを作ります(こちらの記事から内容を借用いたしました)。
<section @name="image-gallery" class="gallery">
<div>
<a class="seek prev" @click="index--" :if="index"></a>
<img src="{ basedir }/{ images[index] }">
<a class="seek next" @click="index++"
:if="images.length - index > 1"></a>
</div>
<p style="border-bottom:{index}px solid black;">{index}</p>
<nav>
<a :for="src, i in images"
:if="index > -1"
class="{ current: i == index }"
@click="index = i">
{i + 1}
</a>
</nav>
<script>
index = 0
</script>
</section>
vue と書き方が似ていますね。
2. render するためのファイルを作り、レンダーするファイルを出力します。(こちらの記事から内容を借用いたしました)
import { parse, render } from 'nue-core'
import { promises as fs } from 'node:fs'
const read = (async (name, dir = '.') => {
const page = await fs.readFile(dir + '/' + name, 'utf-8');
return page;
});
// read dependencies (server-side components)
(async function () {
const page = await read('App.nue.html')
const props = {
images: ['lemons.jpg', 'peas.jpg', 'popcorn.jpg', 'tomatoes.jpg'],
basedir: '/images/fruits'
}
const html = await render(page, props)
// write index.html
await fs.writeFile('./www/app.html', html)
console.log('wrote', 'www/app.html')
})()
3. ファイルを出力します。
node render_file.js
これで、www/app.html 配下に(フロントのリアクティブな動きはないですが)ファイルの出力ができました。
<section class="gallery">
<div>
<a class="seek prev"></a>
<img src="/images/fruits/lemons.jpg">
<a class="seek next"></a>
</div>
<p style="border-bottom:0px solid black;">0</p>
<nav>
<a class="current">1</a><a>2</a><a>3</a><a>4</a>
</nav>
</section>
リアクティブな動きはないですが、とりあえず出力されました。
では、この render.js の中身を見てみましょう。
render.js の中身
ここまで読んで、render.js は nue ファイルから HTML を出力するものなのが分かったと思います。
では、どうやって nue ファイルから HTML を出力しているのでしょうか?
順を追って説明すると、以下のような流れになります(ソースコードはこちらにあります)。
1. nue ファイルの整形処理(コメントを削除したり、scriptタグから scriptを取得したりする)
2. nue ファイルの HTML 構造ツリーを辿り、以下の要素で以下の処理を行う
A:ルートノード
子ノードを辿る
B:テキストノード
{variable}
のような変数が入るものの場合は、変数を探して代入する
C:タグ
・if文・for文・html・bind・attr・カスタムコンポーネントで各々の操作をする
タグノードの各々の操作
・if文(例 `:if="variable = true"`)の場合、nextSibling をして、true になる条件式でタグを返す ・for文(例 `:for="item in items"`)の場合、条件を回す配列変数を取得し、その各要素の変数で新しいタグを生成する ・html(例:`:html="variable"`)の場合、その変数を要素に追加する ・bindの場合、アトリビュートに変数の値を代入する ・attrの場合、アトリビュートに変数の値を代入する ・カスタムコンポーネント([ここ](https://qiita.com/haruyan_hopemucci/items/f5d1605baf24e01b8306#todoコンポーネント)を参照)の場合、フロントのコンポーネントなら アトリビュートの name に `nue-island` をつけた空のコンポーネントを出力し、サーバーサイドのコンポーネントならそのコンポーネントを出力する。3. その結果を、HTML で出力する
コンパイラ周りを少しでも見たことがあるなら、子要素を辿るのなどは想像がつきやすいと思います。では、コードを読んでみましょう(コードはこちら)。
nueファイルの整形処理
まずは、先ほど出した render_file.js
から、render.js の肝となる関数 render
をの中身を見てみましょう。
export function render(template, data, deps) {
+ const comps = parse(template)
if (Array.isArray(deps)) comps.push(...deps)
return comps[0].render(data, comps)
}
内容を見ると、parse という関数を呼んでいるみたいですね。
parse は 276行目から281行目にあります。
export function parse(template) {
const { children } = mkdom(template)
const nodes = children.filter(el => el.type == 'tag')
const global_js = getJS(children)
return nodes.map(node => createComponent(node, global_js))
}
mkdom
は fn.js にあり、HTML の文字列を htmlparser2 でパースしています。その children から タグの子要素のみを抽出し、JS を取得しています。
その後の createComponent の render が最初に出た render の return 文で呼ばれています。そこまでは、渡されたdata と 要素のJS の値を連結させています。
function createComponent(node, global_js='') {
const name = getComponentName(node)
// javascript
const js = getJS(node.children)
const Impl = js[0] && exec(`class Impl { ${ js } }\n${global_js}`)
const tmpl = getOuterHTML(node)
function create(data, deps=[], inner) {
if (Impl) data = Object.assign(new Impl(data), data) // ...spread won't work
+ return processNode({ root: mkdom(tmpl), data, deps, inner })
}
return {
name,
tagName: node.tagName,
create,
render: function(data, deps) {
const node = create(data, deps)
return getOuterHTML(node)
}
}
}
このファイルの render で呼ばれている create 内の processNode で、nue ファイルの HTML 構造ツリーを辿り 各操作をしていきます。
nue ファイルの HTML 構造ツリーを辿り、各操作を行う
次に見るのは、processChild という関数で、ここでは上の方で書かれた if文・for文・html・bind・attr・カスタムコンポーネントで各々の操作を行います。
コードは長いので、簡略化して書きます。
function processNode(opts) {
const { root, data, deps, inner } = opts
function walk(node) {
const { name, type, attribs, nextSibling } = node
// root
if (type == 'root') {
walkChildren(node) // 子要素を辿る
// content
} else if (type == 'text') {
setContent(node, data) // {変数} を表示させる
// element
} else if (type == 'tag' || type == 'style') {
// if文の操作
// for文の操作
// htmlの操作
walkChildren(node)
// bind・attr・slotの操作
// カスタムコンポーネントの操作
}
}
function walkChildren(node) {
let child = node.firstChild
while (child) {
child = walk(child)?.next || child.nextSibling
}
}
walk(root)
return root
}
walkChildren と最初の walk で子要素を辿っている全体像が分かると思います。
ただ詳細はここだけでは分かりませんね。1つ1つ見てみましょう!
text タイプ1 {{}} の場合
では最初に、type == text
の setContent はどのような仕組みで 変数を表示させているのでしょうか?
ここのコードは下のようになっています。
function setContent(node, data) {
const str = node.data || ''
if (str.includes('{')) {
if (str.startsWith('{{')) {
if (str.endsWith('}}')) {
node.data = ''
+ const expr = setContext(str.slice(2, -2))
+ DOM.appendChild(node.parentNode, parseDocument(exec(expr, data)))
}
} else {
+ node.data = renderExpr(str, data)
}
}
}
重要そうな部分は強調した部分になりそうですね。{{}}
で囲われた部分から見てみます。
簡単にいうと、setContext と exec で 変数を値に変換していそうな感じがしますね。
setContext は expr.js の 24〜27行目にあって、
// 'the foo' + foo -> 'the foo' + _.foo
export function setContext(expr) {
return ('' + expr).split(STRING).map((el, i) => i % 2 == 0 ? setContextTo(el) : el).join('')
}
コメントにあるように、変数の前に _.
の文字を加えています。
ではなぜ 変数の前に _.
がつけるような文字列を作っているのでしょうか?
それは、前に出た data オブジェクトを _ で渡して 変数を生成しているからです。
それを行っているのが、exec です。
exec は fn.js の 79〜90行目にあります。
// exec('`font-size:${_.size + "px"}`;', data)
export function exec(expr, data={}) {
+ const fn = new Function('_', 'return ' + expr)
try {
const val = fn(data)
return val == null ? '' : isJS(val) ? val : '' + val
} catch (e) {
console.info('🔻 expr', expr, e)
return ''
}
}
この強調した部分で _ に data を詰め込んだコンテキストで、_. がついた変数を呼び出しています。
text タイプ1 {} の場合
では次に、{}
で囲われた renderExpr を見てみましょう。
ここは、上の {{}}
と違って 普通の文字列が入ることもあるので、操作が少し複雑になっています。
renderExpr は parseExpr の配列を exec して、それを trim join しています。
// name == optional
function renderExpr(str, data, is_class) {
const arr = exec('[' + parseExpr(str) + ']', data)
return arr.filter(el => is_class ? el : el != null).join('').trim()
}
では、parseExpr は何をしているのでしょうか?
ここでは、{}
部分で split して、{}
で囲われた部分のみに、上で書いた setContext(._
をつける作業)を行っています。
// style="color: blue; font-size: { size }px"
export function parseExpr(str, is_style) {
const ret = []
str.trim().split(EXPR).map((str, i) => {
// normal string
if (i % 2 == 0) {
+ if (str) ret.push(`'${str}'`) // 普通の文字列の場合
// Object: { is-active: isActive() }
} else if (isObject(str.trim())) {
const vals = parseClass(str)
ret.push(...vals)
} else {
+ ret.push(setContext(str.trim())) // {} がつく変数の場合
}
})
return ret
}
タグノードの if文
次に、タグノードの if文を見てみましょう。
// if
let expr = getDel(':if', attribs)
if (expr && !processIf(node, expr, data)) return nextSibling
まず、getDel でアトリビュートを取得して、DOM から削除しています(今後も出てきます)。
ここに出てくる processIf
で if文の操作をします。該当箇所は、下のようになります。
function getIfBlocks(root, expr) {
const arr = [{ root, expr }]
while (root = DOM.nextElementSibling(root)) {
const { attribs } = root
const expr = getDel(':else-if', attribs) || getDel(':else', attribs) != null
if (expr) arr.push({ root, expr })
else break
}
return arr
}
function processIf(node, expr, data) {
const blocks = getIfBlocks(node, expr)
const flag = blocks.find(el => {
const val = exec(setContext(el.expr), data)
return val && val != 'false'
})
blocks.forEach(el => { if (el != flag) removeElement(el.root) })
return flag
}
まず、processIf の getIfBlocks の arr に最初にgetDel で取得した ifの条件文を入れます。その後で、DOM.nextElementSibling
で次の要素を取得し else-if・else なら条件式を追加し、それ以外なら、ループを終了します。
その結果、processIf の blocks には if文・else-if文・else文(elseの場合は null・つまり偽の値でないこと)が入ります。
この blockの中から、最初にあった 真の値のタグを exec と setContext で出力しています。
タグノードの for文
次に、タグノードの for文を見てみましょう。
getDel は一緒なので、processFor だけが、if文との違いですね。
この processFor が下のような内容になります。
// for
function processFor(node, expr, data, deps) {
+ const [ $keys, for_expr, $index, is_object_loop ] = parseFor(expr)
const items = exec(for_expr, data) || []
items.forEach((item, i) => {
// proxy
const proxy = new Proxy({}, {
// プロキシー
})
// clone
const root = parseDocument(getOuterHTML(node))
DOM.prepend(node, processNode({ root, data: proxy, deps, inner: node.children }))
})
removeElement(node)
}
processFor の最初の parseFor が expr.js の 94行目〜114行目までにあります。
export function parseFor(str) {
let [prefix, _, expr ] = str.trim().split(/\s+(in|of)\s+/)
prefix = prefix.replace('(', '').replace(')', '').trim()
expr = setContextTo(expr)
// Object.entries()
if (prefix[0] == '[') {
const keys = parseKeys(prefix)
return [ keys.slice(0, 2), expr, keys[2] || '$index', true ]
// Object deconstruction
} else if (prefix[0] == '{') {
const { keys, index } = parseObjectKeys(prefix)
return [ keys, expr, index || '$index' ]
// Normal loop variable
} else {
const [ key, index='$index' ] = prefix.split(/\s?,\s?/)
return [ key, expr, index ]
}
}
expr には、ループを回す配列が入るので普通に setContextTo だけして、render.js の方で exec しています。
prefix を見ると、配列の展開の仕方には3つあることが分かりますね。ただ全体として言えることは、return している配列は、1つ目の要素には key には各要素の変数名(prefixから生成)を、2つ目の要素の expr にはループを回す配列(expr)を、3つ目の要素の index にはループのインデックス(prefixから生成)を、4つ目の要素にはオブジェクトループかどうかのフラグが入ります。
ここでの結果を使って、processFor の processFor の proxy で 値をとってきます。
// proxy
const proxy = new Proxy({}, {
get(_, key) {
if (is_object_loop) {
const i = $keys.indexOf(key)
if (i >= 0) return item[i]
}
return key === $keys ? item || data[key] :
key == $index ? items.indexOf(item) :
$keys.includes(key) ? item[key] :
data[key]
}
})
先に、for文のコンテキスト items から値をとって、それがなかったら、全体のコンテキスト data から値を取っているのが分かると思います。
この proxy を data につっこんで 各 for文で作り、processNode を回しているので、items のコンテキストが入ってきます。
タグノードの HTML
html 部分のコードは下のようになっています。
// html
expr = getDel(':html', attribs)
if (expr) {
const html = exec(setContext(expr), data)
DOM.appendChild(node, parseDocument(html))
}
ここでも exec と setContext しているので、上と同じに見えますね。分からなかったら、text タイプ1 {{}} の場合 と text タイプ1 {} の場合 を参照してみてください。
walkChildren
ここで一度
walkChildren(node)
しています。その中身は、
function walkChildren(node) {
let child = node.firstChild
while (child) {
child = walk(child)?.next || child.nextSibling
}
}
のようになっています。これで、最初に一度だけ walk するだけで子ノードまで探索してくれるようになっています。
タグノードの bind・attr
ここでは、bind か attr を getDel で取得し DOM から削除して、その値が $attrs
の場合は data 全てをアトリビュートに、それ以外の場合は exec と setContext という前にあった方法でアトリビュートをマージしています。
// bind
expr = getDel(':bind', attribs) || getDel(':attr', attribs)
if (expr) {
const attr = expr == '$attrs' ? data : exec(setContext(expr), data)
Object.assign(attribs, attr)
}
タグノードのカスタムコンポーネント
// custom component
const is_custom = !STD.includes(name)
const component = deps.find(el => el.name == name)
// client side component
if (is_custom && !component) {
setJSONData(node, data)
node.attribs.island = name
node.name = 'nue-island'
return // must return
}
// after custom, but before SSR components (for all nodes)
for (let key in attribs) setAttribute(key, attribs, data)
// server side component
if (component) processChild(component, node, deps)
この部分は分かりづらいかもしれませんが、react のようにコンポーネントで開発ができるので、別コンポーネントをレンダーしたり、クライアントでレンダーできるように渡すようなことをしています。
サーバーサイドで渡すコンポーネントは deps(第三引数)に最初の render をするときに渡せばサーバーサイドで表示できるようになります。
ここまでが、nue ファイルの HTML 構造ツリーを辿り、各要素に操作をする部分でした。
HTML で出力する
ここまで長かったですが、ここまで実行する元を覚えているでしょうか?
それは、render の関数で呼び出されている、createComponent で作られた render でした。
export function render(template, data, deps) {
const comps = parse(template)
if (Array.isArray(deps)) comps.push(...deps)
+ return comps[0].render(data, comps)
}
この createComponent で作られた render関数を見ると、getOuterHTML で htmlparser2 のパース形式から HTML に変換しているのが分かると思います。
render: function(data, deps) {
const node = create(data, deps)
+ return getOuterHTML(node)
}
長かったですが、ここまでで nueファイルは HTML に変換されました。
ここまで読んでいただきありがとうございます。
次は、コンパイル(フロントエンドでリアクティブにする部分)を見ていきます。