저는 현재 Nextjs13의 server component를 활용하여 개발을 하고 있습니다.
그러던 중 하나의 불편함을 느꼇습니다. server component
에서 여러가지 제약이 있는데 그 제약을 확인하기가 약간의 불편함이 있었습니다.
개발을 하며 빌드
를 해야 그 제약을 확인할 수 있었고 제약중 하나인 client component에서 server component를 import하지 못하는것
은 확인하지 못하였습니다.
그래서 이러한 제약을 개발하며 에디터 (vscode)
단에서 확인하면서 개발하면 좋지 않을까? 라는 생각으로 eslint-plugin
을 이용했습니다.
https://eslint.org/docs/latest/extend/plugins
eslint의 공식문서에는 custom rule을 만들때 굉장히 잘 나와있습니다.
일단 eslint는 코드를 ast tree 형태로 변경을 하여 파일, 함수, 변수, jsx, 토큰 단위들로 확인을 할 수 있습니다.
https://astexplorer.net/ 이 사이트를 사용하면 코드를 ast tree로 변경했을때 어떠한 결과값이 나오는지를 확인할 수 있습니다.
eslint의 custom rule을 만들게 될때의 지켜야 될것이 있습니다.
['example']
식으로 추가하면 됩니디.{plugin}/{rules}
형식으로 들어가게 됩니다. plugins: ['example'],
rules: {
"example/test-rule", ["warn"] // warn, error, off
},
eslint-custom-rule
을 만들때는 다음과 같은 형태로 만들어 줘야합니다. rule에는 meta정보
를 담는 meta
프로퍼티와 룰을 만들 수 있는 create
프로퍼티를 이용하여 커스텀 룰을 만들수 있습니다.
export const customRule = {
meta: {
type: 'problem',
fixable: true,
docs: {
url: '...',
},
},
create: (context) => {
return {
Program: function (node) {
context.report({
node,
message: `에러에러`,
});
}
}
}
그리고 module.exports의 rules에 test-rule
을 key로 하면 위의 예시처럼 example/test-rule
로 사용할 수 있습니다.
module.exports = {
rules: {
'test-rule': {...customRule}
},
};
create
안에서는 https://astexplorer.net/
에서 나오는 파란색 부분을 key
로 이용할 수 있습니다. 예를 들어서 Program
, JSXElement
, ExportNamedDeclaration
같은것 들이죠.
이것들은 eslint에서 selector이라고 부르며 단순 참조하는것뿐만 아니라 내부에서 로직적인 처리, onCodePathStart
같은 이벤트핸들러를 key로 둘수도 있습니다.
https://eslint.org/docs/latest/extend/selectors
context객체에는 source코드를 가져오는 기능, 파일네임을 가져오는 기능, 실제 에러를 보여주도록 하는 기능등 custom rule을 만들때 필요한 유틸적인 요소들을 포함한 객체입니다.
context객체는 처음의 create
의 매개변수 에서 가져올 수 있습니다.
https://eslint.org/docs/latest/extend/custom-rules#the-context-object
https://eslint.org/docs/latest/extend/code-path-analysis
node객체는 context객체와는 다르게 create
의 매개변수가 아닌 그 하위인 Program
, JSXElement
등의 selector들의 매개변수로 참조를 할수있습니다.
이 node객체에는 그 selector에 해당하는 정보가 담기게 됩니다.
예를 들어 Program
같은 경우 하나의 파일단위로 탐색을 하는것이기때문에 context.getSourceCode(node)
를 하게되면 그 파일의 소스코드가 모두 나오게됩니다.
추가적으로 JSXElement
의 node객체를 이용하여 context.getSourceCode(node)
를 하게되면 <div>테스트</div>
같은 Jsx요소를 결과로 받을 수 있습니다.
이제 이러한 커스텀 룰을 이용하여 server component rule
을 만들때입니다.
create: function (node) {
let isServerComponent = true;
Program: function (node) {
const sourceCode = context.getSourceCode().getText(node);
const extension = filename.substring(filename.lastIndexOf('.') + 1);
if (extension === 'tsx' || extension === 'jsx') {
if (sourceCode.includes('use client')) {
isServerComponent = false;
}
} else {
isServerComponent = false;
}
}
}
먼저 let isServerComponent = true
를 선언하여 Program
selector에서 서버 컴포넌트를 판별합니다.
확장자가 tsx
, jsx
인것중에 파일에 'use client'
가 포함되어있는것을 client component로 판별을 하도록 했습니다.
create: function (node) {
ExportNamedDeclaration: function (node) {
if (node.declaration?.type === 'VariableDeclaration') {
if (node.declaration.declarations[0].id.name.match(/^use[A-Z]/)) {
isCustomHook = true;
}
}
if (node.declaration?.type === 'FunctionDeclaration') {
if (node.declaration.id.name.match(/^use[A-Z]/)) {
isCustomHook = true;
}
}
},
ExportDefaultDeclaration: function (node) {
if (node.declaration?.type === 'Identifier') {
if (node.declaration.name.match(/^use[A-Z]/)) {
isCustomHook = true;
}
}
if (node.declaration?.type === 'FunctionDeclaration') {
if (node.declaration.id.name.match(/^use[A-Z]/)) {
isCustomHook = true;
}
}
},
}
ExportNamedDeclaration
, ExportDefaultDeclaration
를 이용하여 혹시 export named
, export default
로 내보내는 이름을 파악한 다음에 그 이름이 use
로 시작한다면 customHook으로 판별하도록 했습니다.
이 2개의 조합으로 custom hook은 아니면서 server component인것들을 판별하여 custom rule을 적용시킬 예정입니다.
Program: function (node) {
const sourceCode = context.getSourceCode().getText(node);
const filename = context.getFilename();
const extension = filename.substring(filename.lastIndexOf('.') + 1);
// 다른곳에서 판별한 server component처리 로직
if (isServerComponent && !isCustomHook) {
const fileName = context.getFilename();
const { options } = context;
const option = options.find((opt) => 'middle' in opt);
const middle = option.middle;
if (fileName.includes('tsx') && !fileName.endsWith(`index.${middle}.tsx`)) {
const suggestedFileName = fileName.replace(/\.tsx$/, `.${middle}.tsx`);
context.report({
node,
message: `server component's file name should be '${suggestedFileName}'`,
});
}
}
},
server component에 대한 file name을 제한하는 룰입니다. server component에 file name convention을 지정하는룰입니다.
index.${middle}.tsx
를 convention으로 하여서 client component와의 확실한 구분을 하도록 하였고, 이는 나중에 no-import-use-client
rule에 이용과 함께 이용이 됩니다.
plugins: ['server-component-rules'],
rules: {
"server-component-rules/file-name", ["error", {middle: 'server'}] // warn, error, off
},
또한 사용하는 측에서 배열의 두번째 요소의 옵션에 {middle: {name}}
과 같은 식으로 이용을 하면 됩니다. 위의 예시로는 서버컴포넌트의 컨벤션은 index.server.tsx
이 됩니다.
ImportDeclaration: function (node) {
if (isServerComponent || isRouteHandler) {
return;
}
if (isCustomHook) {
return;
}
const importedComponent = node.source.value;
const importSourceCode = context.getSourceCode().getText(node);
if (!importSourceCode.includes('/') || !importSourceCode.includes('from')) {
// 외부 라이브러리들 import를 제외하는것입니다. ex) import React from 'react';
return;
}
const { options } = context;
const option = options.find((opt) => 'middle' in opt);
const middle = option.middle;
if (importSourceCode.split('from')[1].split('/').at(-1).includes(middle)) {
context.report({
node,
message: `Can't import server component in client component (${importedComponent})`,
});
}
},
client component에서는 server component를 import하지 못한다라는 룰입니다.
ImportDeclaration
selector를 이용하여 모든 import 선언문을 가져오고 그 import 선언문에서 from
뒤에 있는 확장자를 가져와서 server component
인지 판별하고 에러를 뱉게 하는 기능입니다.
현재로서는 import한 컴포넌트가 서버 컴포넌트인지 판단하는 방법이 file naming 밖에 없어서 위의 server-component-rules/file-name
룰과 함께 이용을 하여 제어할 수 있습니다.
JSXAttribute: function (node) {
const attributeName = node.name.name;
if (attributeName.startsWith('on') && isServerComponent && !isCustomHook) {
context.report({
node,
message: `Can't use ${attributeName} in server component`,
});
}
},
};
다음은 server component에서 이벤트 핸들러를 사용하지못하는 룰입니다.
JSXAttribute
를 이용하면 JSXElement에 있는 attribute들을 참조할 수 있습니다. 여기서 node.name.name
을 참조하면 그 attribute의 key값을 가져올 수 있는데요.
이렇게 그 attribute를 가져온 후 앞에 on
으로 시작하는 속성이면 에러를 뱉도록 처리하였습니다.
Identifier: function (node) {
const { name } = node;
if (!isServerComponent) {
return;
}
if (isCustomHook) {
return;
}
if (name === 'document' || name === 'window') {
context.report({
node,
message: `Do not use browser APIs such as '${name}' in server component`,
});
}
},
다음은 서버 컴포넌트에서 window나 document같은 브라우저의 객체를 참조하지 못하는 룰입니다.
Identifier
selector를 이용하여 모든 식별자들을 받아오고 그 식별자의 이름이 document, window인 것에 에러를 뱉도록 처리하였습니다.
하지만 window객체같은 경우 내부 메서드들을 바로 참조해도 문제가 없는데요.
현재 이 window객체의 내부 메서드까지 판단할 수는 없는 문제가 있습니다.
CallExpression: function (node) {
if (node.callee.type === 'Identifier') {
const { name } = node.callee;
if (name.match(/^use[A-Z]/) && isServerComponent && !isCustomHook) {
context.report({
node,
message: `Do not use ${name} hook inside JSX files in server component`,
});
}
}
},
마지막으로 서버 컴포넌트에서 hook을 사용하지 못하는 룰입니다.
CallExpression
selector를 이용하면 호출한 함수들을 다 받아올 수 있습니다. custom hook도 함수이기 때문에 여기에 포함이 됩니다.
그런 다음 그 식별자(함수)의 이름을 가져온뒤 시작이 use로 시작하며 그 다음에 대문자가 오는 camel케이스인지 파악을 하도록 했습니다.
startsWith('use')로 처리를 하니 user... 이라는 함수도 가져와지는 문제가 있더라고요. 🌧
이걸 만들고 난후 server component에서 가끔씩 하는 실수들을 코드를 작성하면서 바로 처리하여 생산성이 올라가고 기존 잡을 수 없는 client component에서 server component를 Import 못하는 룰
을 린트시에 에러가 나올수 있도록 하여 예상치 못한 런타임 문제를 해결하였습니다.
이러한 rule 제한 외에도 팀에서 특정 컨벤션을 문서화로만 관리하지말고 이렇게 커스텀 룰을 만들어서 제한하는 방법도 좋다고 생각합니다.
https://github.com/hwangstar156/eslint-plugin-server-component-rules
실제 소스코드는 여기서 확인할 수 있습니다.
지링지린다..