지금까지 만든 페이지가 포스트 조회, 생성임. 편집까지 만들어보자. 아니, 귀찮으니깐 삭제 기능부터 만들자.

삭제 기능 구현

조회 페이지의 각 포스트 하단에 삭제 버튼을 만들고 버튼을 클릭할때 호출할 DELETE /api/posts?id= API를 만들면 되겠다. application에 delete 메쏘드 기능도 추가하자. (현제는 post, get까지 구현된 상황임)

const destroy = (path, fn) => {
  if (!path || !fn) throw Error('path and fn is required')
  fn.__method = 'delete'
  use(path, fn)
}

return {
  use,
  get,
  post,
  delete: destroy,
  listen,
  server,
}

자바스크립트에서 delete는 예약어라서 바로 사용할순 없어서 destory()란 이름으로 함수를 만들어 모듈 객체에 delete 속성으로 destory() 함수를 할당했다.

app.js 에서 라우팅 로직을 추가한다.

app.get('/api/posts', require('./routes/api/post').index)
app.post('/api/posts', require('./routes/api/post').create)

// 삭제 api 추가 
app.delete('/api/posts', require('./routes/api/post').destroy)

포스트(post) 모듈의 destory() 함수도 간단히 만들었다. 디비가 없으니간 디비 역할을하는 posts 배열에서 삭제할 포스트를 아이디로 찾아서 제거한다.

const destroy = (req, res, next) => {
  const id = req.params.id * 1
  posts = posts.filter(post => post.id !== id)
  res.status(204).send()
}

템플릿 엔진

개발한 페이지는 두 개다.

  • /index.html
  • /new.html

사실 두 페이지는 중복된 마크업을 사용하고 있다. HTML 헤더 부분과 사이트 네비게이션 바가 그렇다. 아무래도 중복된 코드는 재활용할수 있도록 만드는 것이 당연한데… 그래서 템플릿 엔진이 필요하겠군.

템플릿 엔진의 역할은:

  • 템플릿 조각들을 모아서 하나의 HTML 코드를 만든다
  • 데이터를 이용한 동적 HTML을 만든다

서브 템플릿

먼저 템플릿 조각을 모아 나의 html 코드를 생성하는 기능부터 만들어 보자. 만약 이 기능이 지원된다는 난 뷰 코드를 이런식으로 작성하고 싶다.

include 'header.view'

<div>Post list</div>

header.view로 분리된 중복 마크업을 include 하여 뷰를 만드는 것이다.

  • 우선은 뷰 파일을 읽어서 include ‘header.view’ 부분을 찾아야겠지
  • header.view 파일을 읽어서 이 부분과 바꿔치기 해야한다
  • 그리고 이 동작은 재귀적으로 동작해야한다. include 가 없을때까지 계속 뷰 조각들을 읽어 내야하는 거다

먼저는 라우팅 핸들러에서 index.view 파일을 읽어 렌더링 하도록했다.

function (req, res, next) {
  fs.readFile(`${viewPath}/index.view`, (err, file) => {
    if (err) return next(err)

    render(file.toString(), html => {
      res.set('Content-Type', 'text/html').send(html)
    })

  })
}

파일 내용을 읽은 후 render() 함수로 처리하고 처리 결결 만들어질 HTML 문자열을 res 객체로 응답하는 코드다.

뷰 파일을 읽어내는 render() 함수를 구현해 보자.

const render = (html, cb) => {
  let {text, partialName} = findPartials(html)

  if (!partialName) return cb(html)

  fs.readFile(`${viewPath}/${partialName}`, (err, file) => {
    if (err) throw err

    text = text.replace(`}}`, file.toString())
    render(text, cb)
  })
}

렌더 함수는 뷰 파일을 읽은 문자열을 html 변수로 받는다. 이 문자열을 파싱하는 중에 인클루드된 뷰파일을 읽어야 할수 있기 때문에 비동기로 움직일 것이다. 그래서 콜백 함수 cb를 두번째 인자로 받았다.

뷰 파일 내용인 html 문자열에서 하위 뷰 파일을 찾아내기 위해 findPartials() 함수를 이용한다. 이 녀석은 전달한 문자열 text와 하위 템플릿인 partialName 문자열을 반환하다.

하위 템플릿이 없으면 곧바로 템플릿 문자열을 반환한다.

하위 템플릿이 있으면 이 파일을 읽는다. 그리고 하위 템플릿 선언부 (include '.view')와 교체한다.

이렇게 처리한 템플릿은 계속해서 반복한다. 하나의 뷰 파일에는 여러개의 하위 뷰 파일이 인클루드 될 수 있기 때문이다.

아래는 findPartial() 함수다.

const findPartials = text => {
  let partialName = text.match(/include '.*\.view'/)

  if (!partialName) return {text, partialName}

  partialName = partialName[0].replace(/include '(.*\.view)'/, '$1')
  text = text.replace(/include '(.*\.view)'/, '}')

  return {text, partialName}
}

자 그럼 뷰 파일이 제대로 렌더링 되는지 확인해 볼까?

index.view:

<!DOCTYPE html>
<html>
<head>
    <title>Blog</title>
</head>
<body>
include 'header.view'
include 'header.view'

<div>index view</div>
</body>
</html>

header.view:

<div>header view</div>

title.view:

include 'title.view'
<div>header view</div>

인덱스 뷰는 헤더뷰 두 개를 포함한다. 헤더뷰는 타이틀 뷰를 포함한다. 요청해 보면:

curl -vs localhost:3000/index2.html

<!DOCTYPE html>
<html>
<head>
    <title>Blog</title>
</head>
<body>
<div>title view</div>
<div>header view</div>

<div>title view</div>
<div>header view</div>

<div>index view</div>
</body>

오예~ 아주 잘 움직이구만!

동적 템플릿

뷰 파일에 데이터을 넣어서 HTML을 생성하는 기능도 추가해보자. 먼저는 템프릿에 데이터는 {{ }}로 설정할 거다.

<div></div>

이 코드에 넣은 데이터 객체는 뷰 파일을 파싱하는 render() 함수의 인자로 전달한다.

const data = {msg: 'hello world'}
render(file.toString(), data, html => {
  res.set('Content-Type', 'text/html').send(html)
})

템플릿을 파싱하는 부분에서 모든 서브 템플릿을 합친 문자열 html에 데이터 내용을 교체한다.

render(file.toString(), data, html => {
  
  // 데이터로 동적인 HTML을 생성한다 
  Object.keys(data).forEach(key => {
    html = html.replace(RegExp(`}`, 'g'), data[key])
  })
  
  res.set('Content-Type', 'text/html').send(html)
})

옳지! 제대로 동작한다.

리팩토링

좋아. 좀 더 편리하게 사용할 수 있게 리팩토링 해보자. 뷰 렌더 함수는 요청에 대해 적적한 뷰 파일을 찾아 응답하는 것이기 때문에 응답 객체의 역할이 맞다. res.render('index') 형태로 사용하고 싶다.

response.js:

const respose = (res, appData) => {
  res.render = res.render || ((view, data) => {
    if (!appData.views) throw Error('views path is required')

    const render = (html, cb) => { /* .... */ }

    const findPartials = text => { /* .... */ }

    fs.readFile(`${appData.views}/${view}.view`, (err, file) => {
      if (err) return next(err)
      render(file.toString(), html => {

        Object.keys(data).forEach(key => {
          html = html.replace(RegExp(`}`, 'g'), data[key])
        })

        res.set('Content-Type', 'text/html').send(html)
      })
    })
  })
  
  // ...
}

응답 객체에 render() 함수로 코드를 옮겼다. 뷰 렌더링을 모두 마치면 알아서 res.send() 함수로 응답하도록 처리했다.

response 생성시 appData 변수를 받고 있는데 어플리케이션 객체에서 넘어온 데이터다.

application.js:

const Application = () => {
  const appData = {}
  
  const server = http.createServer((req, res) => {
    req = request(req)
    res = response(res, appData)
      
    // ...
  })
  
  // ...
  
  const set = (key, value) => {
    appData[key] = value
  }

  return {
    // ...
    set,
  }
}

set() 함수로 어플리케이션 설정 정보를 저장할 수 있다. 우선은 뷰를 위한 뷰 폴더 경로를 설정하기 위해 사용했다.

app.js:

app.set('views', path.join(__dirname, './views'))

정리하자면…

  • app.set()으로 뷰 템플릿 경로를 설정하고
  • res.render()로 html을 생성하고 응답할수 있게 되었다

잘되는군… 흠~ 그런데..

/, /index.html 처럼 하나의 핸들러함수를 공유하는 경우 404 에러가 나온다.

app.get('/index.html', index.listPost)
app.get('/', index.listPost)

뭐가 문제지?

미들웨어 함수 fn의 fn.__path, fn.__method 사용하는 코드 때문이다.

index.listPost.__path 를 설정하는데

  • app.get('/index.html', index.listPost) 코드에서 index.listPost.__path = '/index.html'로 설정하고
  • app.get('/', index.listPost) 코드에서 index.listPost.__path = '/'로 덮어 씌워버린다.

결국 /index.html로 요청하면 404 에러 코드를 응답하게되는 거다.

함수를 복제하자. clone() 폴리필을 추가해서 해결했다.

application.js:

// clone 폴리필  
Function.prototype.clone = function() {
  var that = this;
  var temp = function temporary() { return that.apply(this, arguments); };
  for( key in this ) {
    temp[key] = this[key];
  }
  return temp;
}

핸들러 함수를 수정하는 코드가 나오면 클론해서 복제본을 사용했다.

application.js:

const get = (path, fn) => {
  if (!path || !fn) throw Error('path and fn is required')
  fn = fn.clone()
  fn.__method = 'get'
  use(path, fn)
};

기존 마크업 -> 뷰 파일로 변경

기존 마크업을 뷰 템플릿으로 렌더링 할 준비가 됐다.

header.view:

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="header">
    <div class="container">
        <h1><a href="/">Blog</a></h1>
        <nav>
            <ul>
                <li>
                    <a href="/new.html">New</a>
                </li>
            </ul>
        </nav>

    </div>
</div>

footer.view:

<script type="module" src=""></script>
</body>
</html>

공통으로 사용할 헤더 템플릿이다. title과 scriptPath를 데이터를 주입받아서 렌더링하게 된다.

index.view:

include 'header.view'

<div class="content">
    <div class="container">
        <div class="posts"></div>
    </div>

    <div class="container">
        <div class="pagination"></div>
    </div>
</div>

include 'footer.view'

header.view와 footer.view를 인클루드 했다.

new.view:

include 'header.view'

<div class="content">
    <div class="container">

        <form id="new-form">
            <p>
                <input type="text" name="title" placeholder="title" autofocus>
            </p>
            <p>
                <textarea name="body" placeholder="type something..."></textarea>
            </p>
            <p>
                <button type="submit">Save</button>
                <button type="reset">Cancel</button>
            </p>
        </form>
    </div>
</div>

include 'footer.view'

마찬가지로 header.view와 footer.view 인클루드 했다.

const listPost = (req, res, next) => {
  debug('listPost()')
  const data = {
      title: 'Blog',
      scriptPath: 'js/index.js'
    }
  res.render('index', data)
}

const newPost = (req, res, next) => {
  const data = {
      title: 'New Post',
      scriptPath: 'js/new.js'
    }
  res.render('new', data)
}

오늘은 여기까지!