본문으로 건너뛰기

"javascript" 태그로 연결된 112개 게시물개의 게시물이 있습니다.

모든 태그 보기

· 약 2분

이 공격은 JSON.parseObject.deepCopy 를 사용하는 모든 로직에서 발생할 수 있다.

2019-04-17 에 발표된 이 취약점은 $.extends 구문 상에서 프로토타입 XSS 공격이 가능하다.

예시

var myObject = '{"myProperty" : "a", "__proto__" : { "isAdmin" : true } }';
var newObject = $.extend(true, {}, JSON.parse(myObject));
console.log(newObject.isAdmin); // true

var temp = {};
console.log(temp.isAdmin); // true

패치

취약점 패치는 아래 부분을 변경해준다.

jQuery.extend = jQuery.fn.extend = function () {
// 사이 로직 생략
for (; i < length; i++) {
if ((options = arguments[i]) != null) {
for (name in options) {
src = target[name];
copy = options[name];

// [AS-IS]
// if (target === copy) {
// [TO-BE]
if (name === "__proto__" || target === copy) {
continue;
}

if (
deep &&
copy &&
(jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))
) {
if (copyIsArray) {
copyIsArray = false;
clone = src && jQuery.isArray(src) ? src : [];
} else {
clone = src && jQuery.isPlainObject(src) ? src : {};
}

target[name] = jQuery.extend(deep, clone, copy);
} else if (copy !== undefined) {
target[name] = copy;
}
}
}
}

return target;
};

· 약 1분

document.querySelectorAll('.class') 와 같이 엘레먼트를 받았는데, for문을 돌리면 오류가 발생한다.

// 이 방법이나
let classes = Array.from(document.querySelectorAll(".class"));
// 이렇게 가능하다
classes = [...document.querySelectorAll(".class")];

// 구버전 브라우저를 지원해야한다면
classes = Array.prototype.slice.call(document.querySelectorAll(".class"));

여담

puppeteer 로 크롤링할 때 항상 헷갈린다. 삽질로 찾았는데 stackoverflow 에 정리된 게 있는 듯..

· 약 15분

PWA의 캐싱과 푸쉬 기능은 익히 알려져 있지만, 홈 설치에 대해선 제대로 되어있는 문서가 거의 없다. 기능은 기존 바로가기의 전체화면 버전이라 간단해보이지만 구현해보면 빙산의 일각인 걸 절실히 깨닫게 된다.

Add To Home Screen, 홈 설치, 웹 앱 설치

사전지식

아래 사전지식이 없으면 이 문서가 읽기 힘들 수 있다.

  • es6
  • 서비스워커
  • 캐싱 전략

조건

홈 설치가 되려면 다음과 같은 조건이 필요하다. 크롬 측 설명인데 다른점이 많아서 하나씩 까보자.

  • HTTPS
  • 서비스워커 설치
  • start_url 이 오프라인 사용가능
  • 최소 192px 아이콘 지원
  • manifest.json 에 name 과 short_name 값 등록

HTTPS

HTTPS 를 통해 서비스가 제공되어야한다고 나오는데, 도메인 뿐아니라 페이지의 모든 리소스가 HTTPS 프로톨콜로 전송되어야한다.

서비스워커 설치

PWA 를 시작하는데 필수 요소니 자세한 설명은 생략

start_url 오프라인 사용가능

어느 캐싱전략을 사용해도 상관없으나 오프라인 모드로 연결이 되어야한다. 국내 서비스일 경우 크게 느리지 않으므로 Network First Strategy 로 캐싱하는 걸 추천한다. 국외 서비스일 경우 로드되는 리소스까지 캐싱전략을 세워야될 수 있다.

여기서 start_url 에 querystring 을 추가해 사용하는 경우가 대부분일텐데 캐싱되는 URL 은 querystring 이 없다는 걸 감안해야한다.

예를 들어보자.

{
// 보통 fetch request 로 접근되는 경로는 다음과 같다.
"start_url": "/",
// 검색 파라미터가 달려있다면 이런 모양일 것이다.
"start_url": "/?NaPm=",

// 하지만 manifest.json 에 들어있는 경로는 다음과 같을 것이다.
"start_url": "/?pwa=1&utm_source=pwa"
}

유저가 접근시 ['NaPm', 'pwa', 'utm_source'] 등의 파라미터를 제거해야 경로가 캐시처리되며 오프라인 접근이 될 것이다.

아이콘 지원

크롬에선 최소 192px 의 아이콘을 지원하라고 나와있지만 스플래시 스크린에서 사용되는 아이콘은 512px 이 우선이다. 게다가 엣지 등의 다른 브라우저는 512px 도 있어야한다.

큰 사이즈의 이미지가 있을 경우 설치시에 네이티브 코드에서 이미지를 리사이징해서 아이콘을 만드므로 512px 하나만 있어도 되지만 홈 설치 시간이 느려지는 원인이 되므로 최소 192px, 512px 두 개는 지원해주자.

여력이 된다면 더 많은 사이즈의 이미지를 지원해줘도 된다. 이미지 업로드 하는 페이지의 리사이징 비용과 이미지별 트래픽 비용도 추가되겠지만 항상 유저의 속도가 중요한 것 아닌가?

이미지가 많을 경우 아래 쪽에 언급할 compatible 기능에서 문제가 생길 수 있다.

앱 이름

name 속성은 스플래시 스크린에 사용되며, short_name 은 바로가기 앱 명에 사용된다. name 속성에 빈 값이 들어갈 경우 (빈 값엔 space 및 'ㄱ' 한자 1키도 포함된다.) short_name 으로 대체된다. 두 속성이 모두 빈 값일 경우 홈 설치가 비활성화 된다.

이 기능은 스펙에 명시되어 있어서 변경 불가능하다.

크롬에선 스플래시 스크린 하단에 앱 이름이 노출되며 기타 브라우저에선 중앙에 노출된다. 최근에 하단으로 변경된 것으로 보인다.

고려 대상

필수 조건에는 없지만 고려해야할 대상들은 아래와 같다.

  • background_color
  • theme_color
  • display
  • related_applications

background_color

이 속성은 스플래시 스크린의 배경색을 담당하는데, 배경색은 스플래시 스크린 상의 앱 이름 (name 속성)의 색상에 영향을 미친다.

앱 이름은 배경색이 거므스름한지의 여부에 따라 흰색 또는 검정색으로 노출된다. 이 공식은 contrast ratio 란 스펙으로 정의되어 있으며, 안드로이드 내부에 구현되어있는 소스는 다음과 같다.

private static final float CONTRAST_LIGHT_ITEM_THRESHOLD = 3f;

/**
* Calculates the contrast between the given color and white, using the algorithm provided by
* the WCAG v2 in http://www.w3.org/TR/WCAG20/#contrast-ratiodef.
*/
private static float getContrastForColor(int color) {
float bgR = Color.red(color) / 255f;
float bgG = Color.green(color) / 255f;
float bgB = Color.blue(color) / 255f;
bgR = (bgR < 0.03928f) ? bgR / 12.92f : (float) Math.pow((bgR + 0.055f) / 1.055f, 2.4f);
bgG = (bgG < 0.03928f) ? bgG / 12.92f : (float) Math.pow((bgG + 0.055f) / 1.055f, 2.4f);
bgB = (bgB < 0.03928f) ? bgB / 12.92f : (float) Math.pow((bgB + 0.055f) / 1.055f, 2.4f);
float bgL = 0.2126f * bgR + 0.7152f * bgG + 0.0722f * bgB;
return Math.abs((1.05f) / (bgL + 0.05f));
}

/**
* Check whether lighter or darker foreground elements (i.e. text, drawables etc.)
* should be used depending on the given background color.
* @param backgroundColor The background color value which is being queried.
* @return Whether light colored elements should be used.
*/
public static boolean shouldUseLightForegroundOnBackground(int backgroundColor) {
return getContrastForColor(backgroundColor) >= CONTRAST_LIGHT_ITEM_THRESHOLD;
}

theme_color

이 속성은 주소창의 색상 또는 standalone 모드 시의 상단 상태바의 색상으로 노출된다. 하지만 IOS Safari 에서는 ['black', 'black-translucent'] 두 가지 색상으로만 선택이 가능하다.

이 것도 위의 Contrast 알고리즘을 활용해 거므스름한 여부에 따라 설정하면 된다.

display

크롬 문서엔 standalone, fullscreen, minimal-ui 모두 A2HS 의 기능을 활용할 수 있다고 설명되어 있으나, 실제로는 앞의 두 속성만 가능하다. 삼성브라우저는 minimal-ui 속성을 지원하지 않는다.

앱을 가지고 있을 경우 앱 다운로드를 유도할 수 있는데, manifest 에 아래 속성만 넣으면 된다. id 는 apk 업로드할 때 그 것과 같다.

{
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"id": "com.google.samples.apps.iosched"
}
]
}

Install Banner

App Install Prompt 또는 각각 브라우저마다 불리는 용어가 다른 이 기능은 PWA 접속시 홈 화면에 추가하라는 유도 배너를 말한다. 이 기능은 브라우저별로 이벤트 및 노출 유무가 천차만별이다.

스펙

배너설치아이콘배너재노출일브라우저아이콘
Chrome노출-~3 months-
Firefox-노출-표시
Samsung노출노출~2 weeks표시
Edge노출-?표시
UC?-??
Opera노출-?표시
Whale?---

크롬 빼고는 지원되는 브라우저에서 홈 설치시에 브라우저 아이콘이 모두 노출된다. 이러면 웹 푸쉬를 받을 시에 원래 브라우저에 뱃지가 생긴다고 보면 된다.

배너를 닫을 시에 재노출 기간이 비상식적으로 길다. 배너는 prompt() 메소드로 강제 노출이 불가능하며, 버튼을 클릭하는 액션이 있어야만 한다. 재노출일을 초기화할 시에는 브라우저의 데이터를 모두 삭제해야한다. (서비스워커 삭제로 불가능, 계정 또는 브라우저에 저장되는 값으로 추정됨)

UC 브라우저는 문서도 있고 설치가 되었던 것 같은데 최근 설치가 되지 않는다. Whale 는 크로미움 기반이라 설치가 되었던 것 같은데 최근 설치가 되지 않는다.

IOS Safari

모바일계의 IE인 사파리는 Web App Manifest 기능을 아직 개발 중에 있다. 그리고 매우 불친절한 개발자 문서1불친절한 개발자 문서2만 가지고 있다.

사파리 버그

사파리에서만 나타나는 크리티컬한 버그가 몇 가지 있다.

OAuth 인증 불가

정확히 말하면 타 도메인 또는 새 창을 사용하는 SSO 와 OAuth 인증이 불가능하다. 사파리 자체 버그로 IOS 12.2.0 버전에 수정되었다.

제스처 사용 불가

뒤로가기 제스처 사용이 불가능하다. 이를 위해 하이브리드 웹앱처럼 하단 네비게이션 바를 만들어줄 수 있다. 이 버그도 IOS 12.2.0 버전에 수정되었다.

전용 속성

홈 화면 아이콘과 스플래시 스크린, 테마 색상을 meta, link 태그로 만들어줘야한다. 이를 위해 manifest.json 의 값을 가져와 canvas 를 통해 프론트에서 이미지를 만들어야한다.

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="AppTitle" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-itunes-app" content="app-id=myAppStoreID" />
<link rel="apple-touch-icon" href="touch-icon-iphone.png" />
<link rel="apple-touch-startup-image" href="icon.png" />

Compatible

Chrome, Safari, Edge 등 호환성을 맞추다보면 머리에 쥐가 날지도 모른다. 이럴 때 브라우저별 호환성을 맞춰주는 google 에서 제공되는 라이브러리가 있다.

pwa.compat 으로 다음과 같이 로드하면 된다.

<link rel="manifest" href="manifest.json" />
<script async src="path/to/pwacompat.min.js"></script>

다음과 같은 호환성이 해결된다.

  • Safari 전용 속성
  • Safari 홈 화면 아이콘과 스플래시 이미지
  • Edge, IE 전용 속성
  • meta 태그로 추가되지 않은 manifest.json 속성

단점

manifest.json 과 icon 배열의 수만큼 request 수가 증가한다. static resource 이므로 HTTP Cache 로 해결 가능하다.

버그

삼성브라우저에서 앱 이름에 " 큰따옴표가 들어간 경우 설치가 실패한다. Manifest JSON 을 파싱하면서 브라우저 내에서 오류가 발생한다.

통계

홈 설치를 한 뒤에 유저가 얼마나 설치했을지 통계를 내는 것 또한 중요한데, beforeInstallPromptappInstalled 이벤트는 브라우저 호환성이 보장되지 않는다.

따라서 위 이벤트로 로깅하는 건 무의미하고, start_url 에 들어온 파라미터로 통계를 내면 된다.

테스트

크롬 개발자도구의 application > Manifest > Add to homescreen 으로 이벤트를 발생시킬 수 있다고 하나 잘 되진 않는다.

홈 설치 조건을 만족시킬 경우, 크롬 설정 아이콘에서 "앱 명 추가" 라는 설정 메뉴가 하나 더 보이는데 이 기능으로 조건을 만족하는지와 브라우저 PWA 정도는 테스트 가능하다.

모바일 디버깅용으론 eruda를 사용하자.

여담

IE7-8 호환성 맞추던 시절이 떠올랐다. 아직 픽스될 버그가 많으며, 안정될 때까지 최소 1년은 더 필요할 것으로 보인다. 추가되고 고쳐져야할 기능도 몇 가지 있다.

  • 브라우저 호환성 (주소창 옆 아이콘으로 통일될 듯)
  • 이벤트 호환성
  • manifest href 에 blob 형태 지원 (현재 json decode 하면서 경로 오류 발생)
  • manifest가 변경될 경우 업데이트알림 (이슈)

개인 홈페이지에 적용할 경우 Web App Manifest Generator 를 이용하는게 속 편하다.

Desktop PWATWA 는 다루지 않았다.

레퍼런스

· 약 2분

문서가 있는데 생각보다 사용하기가 어렵다. 하나하나 시작해보자.

인증 토큰 발급

Cridentials page에서 Create credentials 를 눌러 OAuth Client ID 를 발급받는다.

예제

받은 Client ID 를 메타태그에 넣어준다.

example
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hello Analytics Reporting API V4</title>
<meta name="google-signin-client_id" content="OAuth 2.0 Client ID">
<meta name="google-signin-scope" content="https://www.googleapis.com/auth/analytics.readonly">
</head>
<body>
<h1>Analytics Reporting API V4</h1>
<p>
<div class="g-signin2" data-onsuccess="queryReports"></div>
</p>

<!-- The API response will be printed here. -->
<textarea cols="80" rows="20" id="query-output"></textarea>

<script>
// Replace with your view ID.
var VIEW_ID = '136416454';

// Query the API and print the results to the page.
function queryReports() {
gapi.client.request({
path: '/v4/reports:batchGet',
root: 'https://analyticsreporting.googleapis.com/',
method: 'POST',
body: {
reportRequests: [
{
viewId: VIEW_ID,
dateRanges: [
{
startDate: '7daysAgo',
endDate: '6daysAgo'
}
],
metrics: [
{
expression: 'ga:sessions'
}
]
}
]
}
}).then(function (response) {
var formattedJson = JSON.stringify(response.result, null, 2);
document.getElementById('query-output').value = formattedJson;
}, console.error.bind(console));
}
</script>
<script src="https://apis.google.com/js/client:platform.js"></script>
</body>
</html>

파라미터 확인

문서의 내용을 확인하기 보다 Query Explorer에서 테스트 후에 그 값들을 Body 로 옮겨 적는 게 확인하기 쉽다.

· 약 4분

페이지나 리소스가 있는지 체크하는 방법은 서버사이드에선 엄청 간단하다. 단 두 가지 기능만 있으면 해결된다. 심지어 certinfo 값 안에선 인증서 만료일까지 확인할 수 있다.

  • HEAD 메소드
  • CURL

하지만 수 백개의 리소스를 동시에 체크해야할 경우는 어떨까? 리소스가 있는 서버에선 DDoS 공격으로 오인할 수 있고, 심지어 내 서버가 차단될 가능성도 있다.

클라이언트에서 리소스를 확인할 수 있는 방법이 있을까?

ajax

  • 당연하지만 크로스도메인 XHR 은 CORS 가 없는 이상 막힌다.
  • HEAD 메소드도 똑같다.
  • fetch 도 똑같다.

script

  • script tag 를 DOM 에 렌더링하는 것이므로 XSS 공격이 가능하다.
  • 스크립트 태그에 한해서 onLoad 와 onError 로 체크가 가능하다.

image

  • 이미지 태그에 한해서 onLoad 와 onError 로 체크가 가능하다.
  • css 일 경우 onLoad 와 onError 로 체크가 가능하다.
  • DOM 에 렌더링하는 것이므로 UI가 틀어질 수 있다.
  • html 등의 페이지도 체크가 가능하지만 firefox 등의 브라우저에서 일관성이 없다.
  • IE와 Edge 브라우저에서 없는 css 인 경우에도 onError 이벤트가 발생하지 않았다.

마지막 이슈는 크리티컬했는데, 다행히 에러를 만들어 낼 수 있었다. cssRules 는 스타일시트가 없을 경우 접근할 수 없는 내부 값이기에 에러를 던진다.

link.onload = (loadEvent) => {
// ie, edge 체크
const isIE = /MSIE|Trident|Edge/i.test(navigator.userAgent);

// sheet 는 현재 로드 된 스타일시트 엘레먼트
if (isIE && loadEvent.target.sheet) {
try {
// 강제로 시트 내의 cssRuls 값에 접근한다.
let temp = loadEvent.target.sheet.cssRules;
} catch (e) {
// onError 와 같은 이벤트 처리
}
}
};

video

  • 없는 영상에 대해 onError 가 동작하지 않는다.

기타

  • embed 나 iframe 은 X-Frame-Options 헤더에 차단되거나 XSS 공격이 가능하다.
  • onError 이벤트의 일관성이 없다.

해결

삽질의 결과로 js (script), css (link), image (img) 에 한해서 리소스 체크가 가능한 걸 확인했다. 하지만 js 와 css 의 렌더링으로 인해 페이지가 틀어지는 걸 어떻게 방지할 수 있을까?

불현듯 샌드박스란 단어가 떠올랐다.

sandbox

보이지 않는 샌드박스 프레임을 만들고, 거기에서 위험한 일을 하면 된다.

<iframe id="sandbox" src="about:blank" style="display:none;" />

만들고

$sandbox.contentDocument.write(`
<html>
<head>
<script>
여기에 프레임간 메세징과 리소스별 onLoad, onError 체크 로직을 넣는다.
</script>
</head>
</html>
`);

넣고

메인 프레임에선 샌드박스 메소드를 postMessage 하면 된다.

· 약 3분

아래 데이터로 php 와 js 의 다른 점을 확인해보자. id 가 3 이상인 id 만 추출하고 싶었다.

users.json
[
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "[email protected]"
},
{
"id": 2,
"name": "Ervin Howell",
"username": "Antonette",
"email": "[email protected]"
},
{
"id": 3,
"name": "Clementine Bauch",
"username": "Samantha",
"email": "[email protected]"
},
{
"id": 4,
"name": "Patricia Lebsack",
"username": "Karianne",
"email": "[email protected]"
},
{
"id": 5,
"name": "Chelsey Dietrich",
"username": "Kamren",
"email": "[email protected]"
}
]

AS-IS

JS

머리 속으로 돌려본 원래 느낌은 이랬다.

let userIdxs = users.map((user) => {
if (user.id >= 3) {
return user;
}
});

// userIdxs [ null, null, 3, 4, 5 ]

userIdxs = userIdxs.filter(Boolean);

// userIdxs [ 3, 4, 5 ]

php

생각 없이 짜면 array_map 을 먼저 사용할 수 있다.

$userIdxs = array_map(function ($user) {
if ($user['id'] >= 3) {
return $user['id'];
}
}, $users);

// userIdxs [ null, null, 3, 4, 5 ]
// 참담한 결과가 나왔다.

$userIdxs = array_filter($userIdxs);

// 필터를 먹여도 id: 3의 인덱스는 2이다.
// 이걸 해결하려면 array_values 를 한번 더 사용한다.
$userIdxs = array_values($userIdxs);
// userIdxs [ 3, 4, 5 ]

array_values(array_filter(array_map())) 과 같이 호출할 수 있긴 하다.

너무 지저분했다.

TO-BE

조금만 생각해도 FP 의 개념에 어긋남을 느낄 수 있다. 범위를 줄이고 나서 해당 값을 추출하는 게 맞다.

JS2

const userIdxs = users.filter((user) => user.id >= 3).map((user) => user.id);

// userIdxs [ 3, 4, 5 ]

php2

array_values 를 쓰지 않고도 깔끔한 코딩이 가능하다.

$userIdxs = array_filter($users, function ($user) {
return $user['id'] >= 3;
});

$userIdxs = array_map(function ($user) {
return $user['id'];
}, $userIdxs);

// userIdxs [ 3, 4, 5 ]

lara

물론 더 멋진 방법이 있다.

$userIdxs = collect($users)
->filter(function ($user) {
return $user['id'] >= 3;
})
->map(function ($user) {
return $user['id'];
})
->all();

// userIdxs [ 3, 4, 5 ]

여담

php, js, java, python 을 넘나들다보니 사용하는 언어의 흐름에 대한 개념이 1/n 로 줄어드는 것 같다.

· 약 2분

블로그에 붙힐만한 댓글 라이브러리로는 Disqus, Commento, livere 등이 있지만 깃헙 페이지라 Gitment를 사용하고 싶었다.

사전 준비

Github > Settings > Developer settings > OAuth Apps 메뉴로 들어가 새로운 OAuth App 을 만들어준다.

준비

Client ID 와 Client Secret 을 저장해 놓고 Authorization callback URL 은 Homepage URL 과 같은 주소를 입력한다.

# Application name
GracefulLight

# Homepage URL
https://gracefullight.github.io

# Application description
GracefulLights Blog

# Authorization callback URL
https://gracefullight.github.io

소스 추가

원하는 페이지에 소스를 추가한다.

<script src="https://cdn.jsdelivr.net/npm/[email protected]/js/browser/bluebird.core.min.js"></script>
<section class="comments" id="comments">
<div id="gitment_thread"></div>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/[email protected]/style/default.css"
/>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gitment.browser.js"></script>
<script>
var gitment = new Gitment({
id: "Gitment 를 구분할 아이디 (ex: 페이지 제목 또는 포스팅 일시)",
owner: "github 아이디 (ex: gracefullight)",
repo: "repository 명 (ex: gracefullight.github.io)",
oauth: {
client_id: "위에서 발급 받은 client_id",
client_secret: "위에서 발급 받은 client_secret",
},
});
gitment.render("gitment_thread");
</script>
</section>

gitment.min.js 파일은 없으므로, 직접 minify 해서 사용하면 된다. bluebird (promise) core 를 추가한 이유는, IE 에서 gitment 를 지원해야하기 때문이다.

옵션 관리

위 4개 옵션 외에 추가로 옵션을 더 줄 수 있다. desc 와 labels 정도가 추가되면 좋을 것 같다.

댓글 쓰기

깃허브 아이디로 로그인한 뒤 Initialize Comments 를 누르고 댓글을 작성하면 된다.

· 약 3분

Nuxt Project 에 Particles.js 를 붙히고 싶었다. 어떻게하면 쉽게 붙힐 수 있을까 하다가 멀리 돌아오게 된 삽질기다.

시작

vue-plugin 사용

공식 문서 를 봤었고 Vue Plugin일 경우 너무나 쉽게 추가가 가능한 것처럼 보였다. 얼른 vue-particles 를 설치하고 플러그인을 만들어 등록했다.

만들고

vue-particles.js
import Vue from "vue";
import VueParticles from "vue-particles";

Vue.use(VueParticles);

등록했다.

nuxt.config.js
module.exports = {
plugins: ["~/plugins/vue-particles"],
};

그런데 화면에 Particle이 보이지 않는다.

no-ssr

구글링을 하니, 플러그인에 no-ssr 옵션을 주면 해결이 된다고 한다.

nuxt.config.js
module.exports = {
plugins: [
{
src: "~/plugins/vue-particles",
ssr: false,
},
],
};
Particles.vue
<template>
<no-ssr>
<vue-particles />
</no-ssr>
</template>

대충 이런식으로 코딩했더니 화면에서 볼 수 있게 되었다.

그런데... IE11에서는 스크립트 오류가 발생하기 시작했다.

IE 오류

vue-particles 자체에 const 구문을 사용하고 있기 때문에, no-ssr 옵션을 준다면 번들링 시 로직을 건너 뛰기에, 크롬에서는 실행이 되지만 IE에서는 실행되지 않는 치명적인 오류가 발생했다.

그래서 다른 방법을 시도해봤다.

script 삽입하기

nuxt.config.js 에서 head 태그를 이용하면 스크립트를 추가할 수 있고, window.particlesJS 처럼 전역 변수로 참조하면 될 줄 알았다.

하지만 번들링 시 window 객체가 없어 aframe 벤더가 필요하다고 오류가 발생했다. 쓸데 없는 리소스를 추가해야되니 여기서 멈추었다.

해결

process.browser

window-document-undefined 문서에 따르면 이런 참조 문제를 해결할 수 있다고 한다.

Particles.vue
<template>
<vue-particles />
</template>
<script>
if (process.browser) {
require("vue-particles");
}

export default {
mounted() {
if (window.particlesJS) {
window.particlesJS.load();
}
},
};
</script>

위와 같이 로직을 변경해주니 IE11 에서도 정상 작동하였다.

여담

vue-particles 의 문서엔 완벽한 nuxt 호환이라 되어있지만 예외적인 상황이 있는 듯 하다.

· 약 15분

잊기 전에 슈도코드를 정리해놓자.

var 전체모듈 = [
function () {
const 합계함수 = (a, b) => a + b;
return 합계함수;
},

function () {
const 내부_합계함수 = 전체모듈[0]();
const 합계 = 내부_합계함수(10, 20);
console.log(합계);
return 합계;
},
];

let 시작모듈_인덱스 = 1;
전체모듈[시작모듈_인덱스]();

해석

배열에 다 때려넣고 호출해서 사용하는 방법이다 물론 내부는 더 복잡하다, 코드 스플리팅이 된다면 더더욱.

복잡한 내부

https://github.com/hg-pyun/minipack-kr/blob/master/src/minipack.js
/**
* @source https://github.com/hg-pyun/minipack-kr/blob/master/src/minipack.js
*
* 모듈 번들러들은 작은 코드 조각들을 웹 브라우저에서 실행될 수 있는 크고 복잡한 파일로 컴파일합니다.
* 이 작은 조각들은 단지 자바스크립트 파일들일 뿐이며, 이들 사이의 종속성은 모듈 시스템에 의해 표현됩니다
* (https://webpack.js.org/concepts/modules).
*
* 모듈 번들러들은 entry file 이라는 개념을 가지고 있습니다. 브라우저에 스크립트 태그를 몇개 추가하여
* 실행하는 대신, 번들 담당자에게 응용 프로그램의 메인 파일이 무엇인지 알려 줍니다. 이 파일이 어플리케이션을
* 실행하는 진입점이 됩니다.
*
* 번들러는 entry file의 의존성을 분석합니다. 그리고 그 다음 파일의 의존성을 파악합니다.
* 이 작업은 애플리케이션의 모든 모듈과 각 모듈이 서로 어떻게 의존하는지 파악할 때까지 반복됩니다.
*
* 이러한 프로젝트에 대한 이해를 종속성 그래프라 부릅니다.
*
* 이 예제에서는 종속성 그래프를 만들고 이 그래프를 사용하여 모든 모듈들을 하나의 번들로 패키징 합니다.
* 그럼 시작해 보겠습니다 :)
*
* 참고: 이 예제는 매우 단순화되어 있습니다. 순환 참조, 캐싱 모듈, 파싱 최적화 등에 대한 내용은 생략
* 하여 가능한가 단순하게 만들었습니다.
*/

const fs = require("fs");
const path = require("path");
const babylon = require("babylon");
const traverse = require("babel-traverse").default;
const { transformFromAst } = require("babel-core");

let ID = 0;

// 우선 file path를 받는 함수를 생성하고
// 파일을 내용을 읽고, 종속성을 추출합니다.
function createAsset(filename) {
// 파일의 내용을 문자열로 읽습니다.
const content = fs.readFileSync(filename, "utf-8");

// 이제 이 파일이 어떤 파일에 종속되는지 알아보겠습니다. 우리는 import 문자열을 보고 의존성을
// 파악할 수 있습니다 하지만, 이것은 단순한 접근법이어서, 대신에 자바스크립트 파서를 사용하겠습니다.

// 자바스크립트 파서들은 자바스크립트 코드를 읽고 이해할 수 있도록 도와주는 툴입니다.
// 파서는 AST(abstract syntax tree)라는 좀더 추상화된 모델을 생성합니다.
//
// AST에 대해 이해하려면 AST Explorer(https://astexplorer.net)을 꼭 보기를 강력하게 추천합니다.
// AST가 어떻게 이루어져 있는지 확인할 수 있습니다.
//
// AST는 우리의 코드에 대해 많은 정보를 가지고 있습니다. 우리는 쿼리를 이용하여
// 우리의 코드가 하려는 일에 대해 이해할 수 있습니다.
const ast = babylon.parse(content, {
sourceType: "module",
});

// 이 배열은 현재 모듈의 의존성을 상대 경로로 가지고 있을 것입니다.
const dependencies = [];

// 우리는 AST 순회를 통해 각각의 모듈들이 어떤 의존성을 가지고 있는지 이해하려 합니다.
// 이것을 통해 AST안에서 모든 import keyword 선언을 파악할 수 있습니다.
traverse(ast, {
// ECMAScript 모듈들은 정적이므로 매우 파악하기 쉽습니다.이는 변수를 가져올 수 없거나 조건부로
// 다른 모듈을 가져올 수 없음을 의미합니다. import 구분을 볼 때 마다 카운팅을 하고 의존성을 가지고
// 있는 것으로 간주 할 수 있습니다.
ImportDeclaration: ({ node }) => {
// import 구문마다 dependencies 배열에 값을 추가합니다.
dependencies.push(node.source.value);
},
});

// 또한 간단한 카운터를 이용하여 이 모듈에 고유 식별자를 할당합니다.
const id = ID++;

// 우리는 일부 브라우저에서만 지원하는 ECMAScript module들이나 기능들을 사용할 가능성도 있습니다.
// 우리가 만드는 번들이 모든 브라우저에서 돌아가도록 Babel을 이용해서 transpile할 수 있습니다
// (https://babeljs.io 참고).
//
// `presets` 옵션은 Babel이 어떻게 우리 코드를 바꿀지에 대해 결정합니다. 우리는 `babel-preset-env`
// 를 이용하여 대부분의 브라우저에서 우리의 코드를 사용할 수 있도록 바꾸도록 하겠습니다.
const { code } = transformFromAst(ast, null, {
presets: ["env"],
});

// 이 모듈에 대한 정보를 return 합니다.
return {
id,
filename,
dependencies,
code,
};
}

// 이제 단일 모듈의 종속성을 추출할 수 있으므로, entry file의 의존성을 추출하는 것부터 시작하겠습니다.
// 이 작업은 애플리케이션의 모든 모듈과 각 모듈이 서로 어떻게 의존하는지를 파악할 때까지 계속 진행할 것입니다.
// 이 작업의 결과물을 의존성 그래프라 부릅니다.
function createGraph(entry) {
// entry file부터 분석을 시작합니다.
const mainAsset = createAsset(entry);

// queue를 사용해서 모든 asset의 의존성을 분석하도록 하겠습니다. 이 작업을 위해
// entry asset을 가지고 있는 배열을 정의합니다.
const queue = [mainAsset];

// 여기서 queue의 반복을 위해 `for ... of` 반복문을 사용합니다. 처음에는 queue가 asset을 하나만
// 가지고 있지만 작업이 반복되는 동안에 새로운 asset들을 queue에 추가합니다. 이 반복문은 queue가
// 비어질 때 까지 계속됩니다.
for (const asset of queue) {
// 모든 asset들은 의존성이 있는 모듈에 대한 상대경로들을 리스트로 가지고 있습니다. 우리는 그 리스트를
// 순회하면서 `createAsset()`함수로 분석하고, 아래 객체를 통하여 모듈들의 의존성을 추척할 것입니다.
asset.mapping = {};

// 이것은 이 모듈이 있는 디렉토리입니다.
const dirname = path.dirname(asset.filename);

// 종속성에 대한 상대 경로 리스트를 순회합니다.
asset.dependencies.forEach((relativePath) => {
// `createAsset()` 함수는 절대 경로가 필요합니다. dependencies 배열은 상대 경로를 가지고
// 있는 배열입니다. 이러한 경로들은 모듈이 import된 file에 따라 상대적입니다. 따라서 부모 asset의
// 경로를 이용해서 상대 경로를 절대경로로 바꿔야 합니다.
const absolutePath = path.join(dirname, relativePath);

// asset의 내용울 분석하고, 내용을 읽고, 의존성을 추출합니다.
const child = createAsset(absolutePath);

// `asset`의 의존성은 `child`에게 달려있습니다. 우리는 `mapping` 객체에 relativePath와 child.id를
// 이용해서 관계를 표현할 수 있습니다.
asset.mapping[relativePath] = child.id;

// 마지막으로 child asset을 queue에 추가하여 구문 분석이 반복되도록 합니다.
queue.push(child);
});
}

// 이 시점에서 queue는 애플리케이션의 모든 모듈이 포함된 배열입니다. 이것이 우리가 그래프를 표현하는 방법입니다.
return queue;
}

// 다음으로, 그래프를 이용하여 브라우저에서 실행할 수 있는 번들을 반환하는 함수를 정의합니다.
//
// 우리의 번들은 self-invoking(자신을 부를수 있는)함수를 가지고 있습니다.
//
// (function() {})()
//
// 이 함수는 하나의 인자만 받을 수 있습니다: 모든 모듈의 정보를 가지고 있는 그래프.
function bundle(graph) {
let modules = "";

// 이 함수를 구성하기 전에 매개 변수로 전달할 객체를 만들겠습니다. 반드시 알아둬야할 것은 우리가 만드는
// 스트링은 2개의 중괄호({})로 감싸져 있어야 한다는 것입니다. 우리는 다음과 같은 포멧으로 추가할
// 것입니다: `key: value,`.
graph.forEach((mod) => {
// 그래프안에 있는 모든 모듈들은 entry를 객체로 가지고 있습니다. 우리는 module의 id를
// 값에 대한 키로 사용합니다.(각 모듈마다 2개의 값이 있습니다.)
//
// 찻번째 값은 함수로 감싼 각 모듈의 코드입니다. 그 이유는 모듈의 scope를 지정해야 하기 때문입니다.
// 한 모듈에서 변수를 정의하면 다른 모듈이나 글로벌 scope에 영향을 주지 않아야 합니다.
//
// transpiled된 모듈들은 CommonJS 모듈 시스템을 사용합니다:
// 해당 모듈 시스템은 `require`, `module`, 그리고 `exports`를 통해 모듈화 합니다.
// 이 키워드들은 일반적으로 브라우저에서 사용할수 없으므로, 우리의 함수를 이용하여 주입해야 합니다.
//
// 두번째 값은 모듈간의 의존성 매핑을 stringify하는 것입니다. 다음과 같은 객체입니다.
// { './relative/path': 1 }.
//
// transpiled된 우리의 모듈들이 상대경로와 합께 `require()`를 호출하기 때문입니다. 이 함수를 호출하면
// 그래프에서 이 모듈의 상대 경로에 해당하는 모듈을 확인할 수 있습니다.
modules += `${mod.id}: [
function (require, module, exports) { ${mod.code} },
${JSON.stringify(mod.mapping)},
],`;
});

// 마지막으로 self-invoking 함수의 body를 만듭니다.
//
// `require()` 함수를 만들며 시작하겠습니다: 모듈 id를 받아 앞서 만든 모듈 오브젝트에서 `module`을
// 찾습니다. function wrapper와 맵핑 객체를 얻기위해 two-value 객체를 이용합니다.
//
// 모듈의 코드는 모듈의 id들 대신 상대경로와 함께 `require()`함수를 호출합니다. 우리가 만든 require 함수는
// id 받습니다. 또한 두개의 모듈은 동일한 상대 경로를 요구할 수 있지만, 실제론 두개의 다른 모듈들을
// 의미하게 됩니다.
//
// 이 문제를 해결하기 위해 별도의 `require` 함수를 제공합니다. 모듈의 맵핑 오브젝트를 이용하여 상대경로를 ids에 할당합니다.
// 맵핑 오브젝트는 구체적인 모듈을 가져오기 위한 용도로, 상대 경로와 모듈 ids를 맵핑합니다.
//
// 마지막으로, 모듈이 require 되었을 때 exports 객체의 값이 노출되어야 합니다. 따라서 모듈 코드에 의해 변환 된
// `exports` 객체는 `require()`로 반환됩니다.
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports : {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`;

// 결과를 반환합니다. 만세! :)
return result;
}

const graph = createGraph("./example/entry.js");
const result = bundle(graph);

console.log(result);

여기의 번역된 내용을 확인해보자.

· 약 2분

window.scrollTo(0, 0) 를 아무리 해봐도 스크롤이 맨 위로 올라가지 않을 때 많은 구글링을 한 뒤 다음과 같은 해결책을 적용해보았다

  • react-scroll 라이브러리를 써보고
  • 혹시 window 가 레이아웃 컴포넌트에 갇혀있어서 그런가 window 를 App.js 에서 Top 기능을 쓰는 곳까지 내려보고
  • jQuery 를 추가해서도 해보고
  • 별 짓을 다하다가

아무 것도 안 되서 리프레쉬하러 옥상에 갔다가 갑자기 든 생각이 있었다

잠깐만 component 라는게 하나의 element 안에서 쇼를 하는 거 잖아. 그럼 element 안에서 스크롤을 이동하면?

Footer.js
class Footer extends Component {
scrollToTop = (event) => {
document.getElementById("root").scrollTo(0, 0);
};

render() {
return (
<div>
<div className="top">
<a onClick={this.scrollToTop}>위로가기</a>
</div>
</div>
);
}
}

index.js 에서 ReactDOM 을 렌더링해주는 element(나같은 경우엔 div#root)를 찾아서 그 엘레먼트의 스크롤을 올려주자

여담

책에서 봤는데 뇌가 쉴 때 가장 좋은 아이디어가 나온다고 했는데, 사실인 것 같다