pages 파일 기반 라우팅
지금까지는 pages에 App.tsx만 있어서 하나의 파일에 대해 html을 만들어서 전송했었다.
하지만 Next에서는 pages에 있는 모든 파일이 라우팅되어, 해당 경로로 접근하였을때 페이지를 제공해준다.
이번에는 이 기능을 구현해 볼 것이다.
pageFiles찾기
우선 이를 위해서는 pages의 파일이름들을 가져올 수 있어야한다. 그래서 바벨로 트랜스파일링된 dist/pages내부의 모든 tsx파일을 찾아올 것이다.
const PAGES_PATH = 'dist/pages';
export const getPagesFiles = async (pagesPath = PAGES_PATH) => {
const files: string[] = await new Promise((resolve, reject) => {
readdir(pagesPath, (err, files) => {
if (err) reject();
resolve(files);
});
});
return files;
};
이전의 getServerSideProps함수를 모두 가져오는 함수와 똑같다. 이걸 통해서 이후에 Page 컴포넌트를 동적으로 가져올 수 있게 된다.
const { default: Component } = require(
path.resolve(`${PAGES_PATH}`, fullFileName) //getPagesFiles를 통해 가져온 파일이름
);
그런데 이러면 이때 각 Page의 hydration에 필요한 js파일도 달라지게된다. 즉, 클라이언트에서 받을 번들파일을 만들어줘야하는데, 이 역시 페이지마다 동적으로 생성되어야 한다.
이전의 index의 경우 아래와 같은 클라이언트 js파일이 있었고, createHTML에서 이 코드를 번들링한 결과를 <script>태그로 넣어주었었다.
//이전의 public/index.tsx -> rollup을 통해 번들링되어 /dist/public/index.js가 된다.
import * as Page from '@/pages/index';
import { hydrate } from '@core/render';
hydrate(Page.default, document.getElementById('_miniNext'));
//createHTML
export const createHTML = (
element: MiniReactNode,
initialServerProps?: Record<string, string>,
) => {
const root = `
<html>
...
<body>
<div id="_miniNext">${_createHTML(element)}</div>
</body>
...
<script src='index.js'></script> //여기서 번들링한 index.js를 보내준다.
</html>`;
return root;
};
createHTML의 수정
따라서 이걸 동적으로 처리해주도록 createHTML에서 fileName을 받도록 수정했다.
export const createHTML = (
element: MiniReactNode,
initialServerProps?: Record<string, string>,
fileName: string
) => {
const root = `
<html>
<head>
<title>MiniNext</title>
</head>
<body>
<div id="_miniNext">${_createHTML(element)}</div>
</body>
<script>
window._miniNextData=${JSON.stringify(initialServerProps)}
</script>
<script src='${fileName}.js'></script>
</html>`;
return root;
};
이걸 활용해서 서버 응답을 만들어줄 것이다.
페이지마다 번들파일 생성하기
페이지별로 번들 엔트리파일 생성하기
그런데 createHTML의 script태그를 통해 보내줄 페이지별 번들 엔트리 파일이 없다.
이건 public에 정적으로 작성했었는데, 이제는 정적으로 작성할 수 없으므로 동적으로 생성되게 해주어야한다.
import { existsSync, mkdirSync, writeFile } from 'fs';
import path from 'path';
import { getPagesFiles } from './routeMapper';
export const createClientFiles = async () => {
const files = await getPagesFiles('src/pages'); // src/pages를 전부 읽어온다.
if (!existsSync(path.resolve(`public`))) //root 디렉토리에public폴더가 없으면 만든다.
mkdirSync(path.resolve(`public`)); //런타임 이전에 실행되므로 블로킹을 고려하지 않았다.
files.forEach((fullFileName) => {
const [fileName] = fullFileName.split('.tsx');
writeFile( //번들 엔트리 파일을 생성한다.
path.resolve(`public/${fileName}.tsx`),
`import * as Page from '@/pages/${fileName}';
import { hydrate } from '@core/render';
hydrate(Page.default, document.getElementById('_miniNext'));
`,
(err) => {
console.log(err);
}
);
});
};
createClientFiles();
rollup.config.js
rollup에서 만들어준 pulic경로의 파일들에 대해서 번들을 만들도록 처리해줄 것이다.
const babel = require('@rollup/plugin-babel');
const resolve = require('@rollup/plugin-node-resolve');
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
const path = require('path');
const fs = require('fs');
const files = fs.readdirSync('public'); //public의 모든 파일을 읽어온다.
// 이 파일은 런타임 전에 실행될 것이므로 블로킹을 고려하지 않았다.
가져온 파일을 바탕으로, 번들링 엔트리 포인트를 만들어줘야한다.
const createConfigFile = (fullFileName) => {
const [fileName] = fullFileName.split('.tsx');
return {
input: `./public/${fullFileName}`, //찾아온 파일
output: {
file: `./dist/public/${fileName}.js`, //dist/public을 통해 정적파일로제공한다.
format: 'es',
},
plugins: [
babel({
babelHelpers: 'bundled',
presets: ['@babel/preset-env', '@babel/preset-typescript'],
plugins: [
...
],
extensions,
}),
resolve({
extensions,
}),
],
};
};
그리고 이렇게 만든 함수를 통해 config 배열을 생성해서 반환해주도록 했다.
module.exports = files.map((fullFilename) => createConfigFile(fullFilename));
라우팅 경로 설정하기
이제는 getPagesFiles를 통해 찾은 모든 경로에 대해 get메서드에 대한 응답 데이터를 만들어주면 된다.
const getRoutingPath = (fileName: string) => {
//확장자 없는 파일이름에 따라 라우팅 경로를 만들어준다.
if (fileName === 'index') return '/'; //index인경우
return `/${fileName.toLowerCase()}`; //아닌경우
};
export const routeMapper = async (app: Express) => {
const files = await getPagesFiles(); // dist/pages내부의 .js파일을 가져온다.
//가져온 모든 pages내부의 파일에 대해서
files.forEach((fullFileName) => {
const [fileName] = fullFileName.split('.js'); //.js를 뜯은 파일이름을 기반으로 라우팅을 해줄 것이다.
app.get(
getRoutingPath(fileName),
async (req: Request, res: Response) => {
//ServerSideProps와 Component를 가져와서 html을 생성하고, hydration을 해준다.
const serverSideFunctions = await getServerSidePropsFunction();
const serverSideFunction = serverSideFunctions[fullFileName];
const serverSideProps = serverSideFunction?.().props;
const { default: Component } = require(
path.resolve(`${PAGES_PATH}`, fullFileName)
);
const html = createHTML(
<Component {...serverSideProps} />,
serverSideProps,
fileName //동적으로 js파일을 넘겨준다.
);
res.send(html);
}
);
});
};
서버 스크립트 수정하기
이제 이렇게 작성한 로직을 순서대로 작동할 수 있게 만들어주자
build.sh
babel src --extensions .ts,.tsx --out-dir dist //babel 트랜스파일링
node dist/core/createClientFiles.js //pages기반으로 public폴더, 파일 생성
rollup --config rollup.config.js //dist/public 파일 번들링
정리
정리하면 이렇게 그려볼 수 있다.
결과
이제 단일한 Pages 폴더 요소에 대해서 잘 작동하는 것을 볼 수 있다.
중첩 라우팅으로 업그레이드하기
getPagesFiles
이제는 pages에 폴더가 들어갈 수 있다.
이런 폴더 구조에서 getPagesFiles가 수행되면 이렇게 나와야 한다.
따라서 readdir에서 폴더를 읽은 경우, 재귀적으로 getPagesFiles를 호출해서 경로를 만들어주어야한다.
처음에는 이를 재귀함수로 직접 구현했지만, 찾다보니 readDir의 recursive옵션을 통해 해결할 수 있었다.
import { readdir } from 'fs';
import path from 'path';
const PAGES_PATH = 'dist/pages';
export const getPagesFiles = async (pathString = PAGES_PATH) => {
return await new Promise<string[]>((resolve, reject) => {
readdir(path.resolve(pathString), { recursive: true }, (err, files) => {
if (err) reject(err);
//확장자가 없는 경우를 무시한다
resolve((files as string[]).filter((file) => file.match('(.js)|(.tsx)')));
});
});
};
이제 이렇게 만든 getPagesFiles를 적용시켜주자.
getPagesFiles를 쓰는 곳은 createClientFiles, routeMapper, getServerSidePropsFunctions이다.
createClientFiles
중첩적으로 폴더를 만들어주는게 핵심이다.
import { existsSync, mkdirSync, writeFile } from 'fs';
import path from 'path';
import { getPagesFiles } from './getPagesFiles';
export const createClientFiles = async () => {
const files = await getPagesFiles('src/pages'); //src/pages를 모두 읽어옴
//프로젝트 루트에 public 폴더가 없는경우 만듦
const isExists = existsSync(path.resolve('public'));
if (!isExists) mkdirSync(path.resolve('public'));
files.forEach((fullFileName) => {
//읽어온 파일에서 src/pages와 tsx제거
const fileName = fullFileName
.replace(/src\/pages\//, '')
.replace(/\.tsx/, '');
//중첩라우팅인경우
if (fileName.match('/')) {
const filePaths = fileName.split('/');
//파일 이름을 제외한, 디렉토리만을 가져온다.
const dirPath = filePaths.slice(0, -1).join('/');
//해당 디렉토리가 없다면, 중첩적으로 폴더를만들어준다.
const isExists = existsSync(path.resolve('public', dirPath));
if (!isExists) {
mkdirSync(path.resolve('public', dirPath), {
recursive: true,
});
}
}
writeFile(
path.resolve(`public/${fileName}.tsx`),
`import * as Page from '@/pages/${fileName}';
import { hydrate } from '@core/render';
hydrate(Page.default, document.getElementById('_miniNext'));
`,
(err) => {
if (err) console.log(err);
}
);
});
};
createClientFiles();
getServerSidePropsFunction
import path from 'path';
import { getPagesFiles } from './getPagesFiles';
type ServerSideFunction = Record<string, Function>;
export const getServerSidePropsFunction = async () => {
const result: ServerSideFunction = {};
const files = await getPagesFiles();
files.map((file) => {
const { getServerSideProps } = require(path.resolve('dist/pages', file));
result[file] = getServerSideProps;
});
return result;
};
routeMapper
const PAGES_PATH = 'dist/pages';
import { readdir } from 'fs';
import path from 'path';
import { Express, Request, Response } from 'express';
import { getServerSidePropsFunction } from './getServerSidePropsFunction';
import { createHTML } from './createHTML';
import { getPagesFiles } from './getPagesFiles';
const getRoutingPath = (fileName: string) => {
//index 단일 경우에는 /를, 아닌 경우에는 /와 소문자로 구성된 경로를 반환한다.
if (fileName === 'index') return '/';
if (fileName.match('index')) return `/${fileName.replace('index', '')}`;
return `/${fileName.toLowerCase()}`;
};
export const routeMapper = async (app: Express) => {
const files = await getPagesFiles();
files.forEach((fullFileName) => {
//dist/pages와 .js를 제거한다.
const fileName = fullFileName
.replace(/dist\/pages\//, '')
.replace(/.js/, '');
app.get(
getRoutingPath(fileName),
async (req: Request, res: Response) => {
const serverSideFunctions = await getServerSidePropsFunction();
const serverSideFunction = serverSideFunctions[fullFileName];
const serverSideProps = serverSideFunction?.().props;
const { default: Component } = require(
path.resolve('dist/pages', fullFileName)
);
const html = createHTML(
<Component {...serverSideProps} />,
serverSideProps,
fileName
);
res.send(html);
}
);
});
};
rollup.config.js
rollup.config.js도 재귀적으로 public폴더를 탐색해서 파일 번들링 entry포인트를 찾을 수 있도록 구성해야한다.
const babel = require('@rollup/plugin-babel');
const resolve = require('@rollup/plugin-node-resolve');
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
const path = require('path');
const fs = require('fs');
const files = fs
.readdirSync('public', { recursive: true })
.filter((fileName) => fileName.match(/.tsx/));
const createConfigFile = (fullFileName) => {
const [fileName] = fullFileName.split('.tsx');
return {
input: `./public/${fullFileName}`,
output: {
file: `./dist/public/${fileName}.js`,
format: 'es',
},
plugins: [
babel({
babelHelpers: 'bundled',
presets: ['@babel/preset-env', '@babel/preset-typescript'],
plugins: [
[
'@babel/plugin-transform-react-jsx',
{ importSource: '@core', runtime: 'automatic' },
],
[
'module-resolver',
{
alias: {
'@core': path.resolve(__dirname, 'src/core'),
'@/utils': path.resolve(__dirname, 'src/utils'),
'@/pages': path.resolve(__dirname, 'src/pages'),
},
},
],
],
extensions,
}),
resolve({
extensions,
}),
],
};
};
module.exports = files.map((fullFilename) => createConfigFile(fullFilename));
결과