From neutrino v9 to Webpack v4 이전
교체 이유
neutrino를 교체하고자 하였으니, 저희가 사용 중인 neutrino 9버전에서는 typescript presets이 존재하지 않는다는 것을 알게 되었습니다. 뿐만 아니라 neutrino로 비롯한 매우 다양한 문제가 있었다.
1. neutrino-eslint를 사용하여 우리가 필요한 별도의 eslint 추가 구성을 하기에 불편했다.
+ 추가로 prettier-standard라는, lint와 formatter를 합친 지옥 같은 녀석이 neutrino와 맞물려 매우 불편했다.
2. webpack 코드를 구성해야 하는데 netrino는 이를 black box로 처리하여 커스텀하기 불편했다.
+ typescript 마이그레이션하는데 neutrino typescript plugin이 없었다. webpack 같았으면 후루룩 뚝딱하는데 neutrino라는 녀석 때문에 업계에서 통용되지 않는 녀석을 공부한다는 게 찜찜하고 불편했다. (https://github.com/neutrinojs/neutrino/issues/1269)
3. neutrino가 더 이상 발전하지 못하고 webpack 4에 머무르고 있으며 버전 업그레이드를 할 의지가 커뮤니티에서 보이지 않는다.
그래서 갈아 엎고 webpack으로 다시 만들기로 하였다.
우선 기존은 neutirno를 살펴보자.
./neutrino.js
const standard = require('@neutrinojs/standardjs')
const react = require('@neutrinojs/react')
const jest = require('@neutrinojs/jest')
const fbConfig = require('./firebase.conf')
const storybook = require('neutrino-preset-storybook-react')
const copy = require('@neutrinojs/copy')
const SentryWebpackPlugin = require('@sentry/webpack-plugin')
const DEV = process.env.TARGET_ENV === 'development'
const STAGING = process.env.TARGET_ENV === 'staging'
const PRODUCTION = process.env.TARGET_ENV === 'production'
const getEnv = () => process.env.TARGET_ENV || 'development'
const dotenv = require('dotenv').config({
path: `${__dirname}/.env.${getEnv()}`
})
const getReleaseVersion = () => {
const regexp = /refs[/]tags[/](.+)/g
const match = regexp.exec(process.env.RELEASE_VERSION)
if (!match) return process.env.RELEASE_VERSION
return match[1]
}
module.exports = {
options: {
root: __dirname
},
use: [
neutrino => {
if (STAGING || PRODUCTION) {
neutrino.config.plugin('sentry').use(
new SentryWebpackPlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: 'business-canvas',
project: 'typed-app-react',
include: './build',
release: getReleaseVersion(),
ignore: ['node_modules', 'webpack.config.js', 'functions', 'api']
})
)
}
},
storybook({
addons: ['@storybook/addon-controls', '@storybook/addon-actions']
}),
standard(),
react({
env: {
TARGET_ENV: getEnv(),
firebase: PRODUCTION
? fbConfig.prod
: STAGING
? fbConfig.staging
: DEV
? fbConfig.dev
: fbConfig.local,
...dotenv.parsed,
...(process.env.RELEASE_VERSION
? { RELEASE_VERSION: process.env.RELEASE_VERSION }
: {}),
...(process.env.EXTENSION_MODE
? { EXTENSION_MODE: process.env.EXTENSION_MODE }
: {})
},
html: {
title: 'Typed',
favicon: 'favicon.ico',
template: 'index.ejs',
templateParameters: {
gtmId: process.env.GTM_ID,
hotjarSiteId: process.env.HOTJAR_SITE_ID,
heapId: process.env.HEAP_ID,
googleTrackingId: process.env.GOOGLE_TRACKING_ID,
googleOptimizerId: process.env.GOOGLE_OPTIMIZER_ID,
channelTalkPluginKey: process.env.CHANNELTALK_PLUGIN_KEY,
chaanelTalkAccessSecret: process.env.CHANNELTALK_ACCESS_SECRET
}
},
babel: {
plugins: [
'emotion',
[
'module-resolver',
{
alias: {
'@': './src'
}
}
],
'@babel/proposal-nullish-coalescing-operator'
]
},
devtool: 'source-map'
}),
copy({
patterns: ['*.html'],
pluginId: 'copy'
}),
jest({
setupFiles: ['./test/setup'],
setupFilesAfterEnv: ['./test/setup.integration'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/viewer/',
'<rootDir>/functions/',
'<rootDir>/api/'
],
moduleNameMapper: {
'@/(.*)$': '<rootDir>/src/$1'
}
})
]
}
./webpack.config.js
const neutrino = require('neutrino')
module.exports = neutrino().webpack()
./package.json 중 기존 webpack 관련 패키지의 버전
"webpack": "4.44.1",
"webpack-cli": "^4.3.0",
"webpack-dev-server": "3.11.0"
기존 명령어들
"start": "webpack serve --mode development --port 7000",
"start:extension": "EXTENSION_MODE=true webpack-dev-server --mode development",
"build": "TARGET_ENV=production webpack --mode production",
"build:production": "npm run build",
"build:local": "webpack --mode development --watch",
"build:staging": "TARGET_ENV=staging webpack --mode production",
"build:dev": "TARGET_ENV=development webpack --mode production",
"build:pdf-viewer": "cd viewer/vendors/pdf.js && npx gulp generic",
"lint": "eslint --cache --format codeframe --ext mjs,jsx,js src test functions/src",
"preemulate": "mkdir -p local_db",
"emulate": "GOOGLE_APPLICATION_CREDENTIALS=fb-adminsdk-sa.json && ENVIRONMENT=local firebase emulators:start -P dev --import=./local_db --export-on-exit",
"deploy": "firebase deploy --only storage,firestore,functions",
"seed": "FIRESTORE_EMULATOR_HOST=localhost:8080 FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 npm run --prefix functions register-schema:local && node ./seed",
"prepopulate-seed": "mkdir -p local_db",
"populate-seed": "firebase emulators:exec --only firestore,auth --import=./local_db \"npm run seed\" --export-on-exit",
"build-storybook": "build-storybook"
제일 간단한 jest를 분리하자.
./jest.config.js
module.exports = {
moduleDirectories: ['node_modules'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'@/(.*)$': '<rootDir>/src/$1'
},
transform: {
'\\.[jt]sx?$': 'babel-jest',
'^.+\\.svg$': 'jest-svg-transformer'
},
transformIgnorePatterns: ['<rootDir>/node_modules/'],
setupFiles: ['./test/setup'],
setupFilesAfterEnv: ['./test/setup.integration'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/viewer/',
'<rootDir>/functions/',
'<rootDir>/api/'
]
}
@neutrino/jest에서 이미 구성 되어 있던 설정들을 명시적으로 설정해주어야 함. babel 세팅이 안 되어 있어 추가 설정함.
module.exports = {
presets: ['@babel/preset-env'],
plugins: [['@babel/plugin-transform-runtime']]
}
어떻게 옮겨갈 것인가?
@neutrinojs/react 내부를 살펴보기보다는 빌드된 결과물을 확인하고 이에 맞춰 webpack을 구성하기로 함.
./build 경로에 assets으로 모두 번들링 됨.
sourcemap이 생성되고 있고, woff, svg, png, jpg에 대한 대응이 필요함.
우선 구동이 되게 하자! (webpack 4를 이용하여 우선은 예전처럼 서비스하는 것이 목표이다!)
// **** CAUTIONS ****
// html-webpack-plugin 4버전 사용할 것. 5버전 이상 webpack 5 요구
// copy-webpack-plugin 6버전 사용할 것. 6버전 이상 webpack 5 요구
// mini-css-extract-plugin 1버전 사용할 것.
// ts-loader 8버전 이하 사용할 것.
const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyPlugin = require('copy-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const SentryWebpackPlugin = require('@sentry/webpack-plugin')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const ESLintPlugin = require('eslint-webpack-plugin')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
.BundleAnalyzerPlugin
const fbConfig = require('./firebase.conf')
const appBuildPath = path.resolve(__dirname, 'build')
const DEV = process.env.TARGET_ENV === 'development'
const STAGING = process.env.TARGET_ENV === 'staging'
const PRODUCTION = process.env.TARGET_ENV === 'production'
const getEnv = () => process.env.TARGET_ENV || 'development'
const dotenv = require('dotenv').config({
path: `${__dirname}/.env.${getEnv()}`
})
const getReleaseVersion = () => {
const regexp = /refs[/]tags[/](.+)/g
const match = regexp.exec(process.env.RELEASE_VERSION)
if (!match) return process.env.RELEASE_VERSION
return match[1]
}
const injectPlugins = targetEnv => {
if (targetEnv === 'development') {
return [
// https://v4.webpack.js.org/plugins/hot-module-replacement-plugin/
new webpack.HotModuleReplacementPlugin(),
new BundleAnalyzerPlugin({
openAnalyzer: false,
analyzerPort: 8888
})
]
} else {
return [
// https://github.com/getsentry/sentry-webpack-plugin
new SentryWebpackPlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: 'business-canvas',
project: 'typed-app-react',
include: './build',
release: getReleaseVersion(),
ignore: ['node_modules', 'webpack.config.js', 'functions', 'api']
}),
// https://v4.webpack.js.org/plugins/mini-css-extract-plugin/
new MiniCssExtractPlugin({
filename: 'assets/[name].[contenthash:8].css'
}),
// https://github.com/johnagan/clean-webpack-plugin
new CleanWebpackPlugin()
]
}
}
const config = env => {
if (!(DEV | STAGING | PRODUCTION)) {
throw new Error('TARGET_ENV is not defined or typo error')
}
return {
target: 'web',
devtool: 'source-map',
context: path.resolve(__dirname),
stats: { children: false, entrypoints: false, modules: false },
node: { Buffer: false, fs: 'empty', tls: 'empty' },
entry: {
index: ['./src/index.tsx']
},
resolve: {
alias: { '@': path.join(__dirname, 'src') },
extensions: [
'.tsx',
'.ts',
'.jsx',
'.js',
'.mjs',
'.json',
'.web.jsx',
'.web.js',
'.wasm'
]
},
module: {
rules: [
{
test: /\.html$/i,
loader: 'html-loader',
options: {
attrs: ['img:src', 'link:href']
}
},
{
test: /\.(ts|tsx)?$/i,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true // disable type checker - we will use it in fork plugin
}
}
]
},
{
test: /\.(mjs|jsx|js)$/,
exclude: /node_modules/,
include: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, 'test')
],
use: [
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
debug: false,
useBuiltIns: false,
shippedProposals: true,
targets: {
browsers: [
'last 2 Chrome versions',
'last 2 Firefox versions',
'last 2 Edge versions',
'last 2 Opera versions',
'last 2 Safari versions',
'last 2 iOS versions'
]
}
}
],
[
'@babel/preset-react',
{ development: true, useSpread: true }
]
],
plugins: [
'emotion',
'@babel/plugin-proposal-class-properties',
[
'module-resolver',
{
alias: {
'@': './src'
}
}
],
'@babel/proposal-nullish-coalescing-operator'
],
cacheDirectory: true,
cacheCompression: false
}
}
]
},
{
oneOf: [
{
test: /\.module\.css$/,
use: [
DEV
? {
loader: 'style-loader'
}
: {
loader: MiniCssExtractPlugin.loader,
options: { esModule: true }
},
{
loader: 'css-loader',
options: { importLoaders: 0, modules: true }
}
]
},
{
test: /\.css$/,
use: [
DEV
? {
loader: 'style-loader'
}
: {
loader: MiniCssExtractPlugin.loader,
options: { esModule: true }
},
{
loader: 'css-loader',
options: { importLoaders: 0 }
}
]
}
]
},
{
test: /\.(eot|ttf|woff|woff2)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: 'file-loader', // https://v4.webpack.js.org/loaders/file-loader/
options: {
name: DEV
? 'assets/[name].[ext]'
: 'assets/[name].[hash:8].[ext]'
}
}
]
},
{
test: /\.(ico|png|jpg|jpeg|gif|svg|webp)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: 'url-loader', // https://v4.webpack.js.org/loaders/url-loader/
options: {
limit: 8192,
name: DEV
? 'assets/[name].[ext]'
: 'assets/[name].[hash:8].[ext]'
}
}
]
}
]
},
plugins: [
// https://v4.webpack.js.org/plugins/progress-plugin/
new webpack.ProgressPlugin(),
// https://v4.webpack.js.org/plugins/environment-plugin/
new webpack.EnvironmentPlugin({
TARGET_ENV: getEnv(),
firebase: PRODUCTION
? fbConfig.prod
: STAGING
? fbConfig.staging
: DEV
? fbConfig.local // FIXME: 이후 branch 전략 수정이 곧 있을 것이라고 함. 일단은 target env = development라고 할지라도 local 환경 사용하기로 함
: fbConfig.dev,
...dotenv.parsed,
...(process.env.RELEASE_VERSION
? { RELEASE_VERSION: process.env.RELEASE_VERSION }
: {}),
...(process.env.EXTENSION_MODE
? { EXTENSION_MODE: process.env.EXTENSION_MODE }
: {}),
AMPLITUDE_API_KEY: process.env.AMPLITUDE_API_KEY
}),
// https://v4.webpack.js.org/plugins/html-webpack-plugin/
// https://github.com/jantimon/html-webpack-plugin
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'index.ejs'),
title: 'Typed',
filename: 'index.html',
hash: false,
inject: 'body',
scriptLoading: 'blocking',
compile: true,
favicon: 'favicon.ico',
minify: 'auto',
showErrors: true,
chunks: ['index'],
excludeChunks: [],
chunksSortMode: 'auto',
meta: { viewport: 'width=device-width, initial-scale=1' },
base: false,
appMountId: 'root',
lang: 'en',
templateParameters: {
gtmId: process.env.gtmId || '',
hotjarSiteId: process.env.hotjarSiteId || '',
heapId: process.env.heapId || '',
googleTrackingId: process.env.googleTrackingId || '',
googleOptimizerId: process.env.googleOptimizerId || '',
channelTalkPluginKey: process.env.channelTalkPluginKey || '',
chaanelTalkAccessSecret: process.env.chaanelTalkAccessSecret || ''
}
}),
// https://v4.webpack.js.org/plugins/copy-webpack-plugin/
new CopyPlugin({
patterns: [{ from: '*.html' }]
}),
// https://webpack.js.org/plugins/eslint-webpack-plugin/
new ESLintPlugin({
useEslintrc: true
}),
// https://github.com/TypeStrong/fork-ts-checker-webpack-plugin#readme
new ForkTsCheckerWebpackPlugin(),
...injectPlugins(process.env.TARGET_ENV)
],
optimization: {
minimize: DEV ? false : true,
splitChunks: {
chunks: 'all',
maxInitialRequests: DEV ? Infinity : 5,
name: DEV ? true : false
},
runtimeChunk: 'single'
},
devServer: {
port: 5000,
hot: true,
historyApiFallback: true,
overlay: true,
stats: { all: false, errors: true, timings: true, warnings: true }
},
output: {
path: appBuildPath,
publicPath: '/',
filename: DEV ? 'assets/[name].js' : 'assets/[name].[contenthash:8].js'
}
}
}
module.exports = env => config(env)
간단히 설명하자면
html-loader -> Exports HTML as string. HTML is minimized when the compiler demands.
css-loader -> The css-loader interprets @import and url() like import/require() and will resolve them.
style-loader -> html header에 style 태그로 추가
일반적으로 다음 순서대로 적용함 ["style-loader", "css-loader", "postcss-loader", "sass-loader"],
ts-loader -> https://www.npmjs.com/package/ts-loader
babel-loader -> babel 돌려주는 친구임.
url-loader -> limit이하의 파일들을 base64로 변경
file-loader -> 이미지 파일의 이름을 만들고 폴더를 이동시키는 기능을 한다
추가적으로 뭔가 더 할 수 있는게 있지 않을까?
css prefix
postcss, postcss-loader
svg
compress
https://v4.webpack.js.org/plugins/compression-webpack-plugin/
빌드 속도 개선
1. speed-measure-webpack-plugin
https://www.npmjs.com/package/speed-measure-webpack-plugin
전자가 개발 환경이고 후자가 production 빌드이다.
1. babel-loader, ts-loader를 esbuild-loader로 바꿔보자.
https://github.com/privatenumber/esbuild-loader
2. cache-loader (현재 제거. 불필요)
https://v4.webpack.js.org/loaders/cache-loader/
3. node inspector
Important! If you need polyfills in your code, consider including core-js in your package.json. This is will configure @babel/preset-env to automatically include polyfills based on usage. *
https://v4.webpack.js.org/plugins/profiling-plugin/
bundle size 최적화
https://hyperconnect.github.io/2019/07/29/Optimize-webview-bundle-size-1.html
https://hyperconnect.github.io/2019/08/14/Optimize-webview-bundle-size-2.html
import { format } from 'date-fns' // BAD
import format from 'date-fns/format' // GOOD
내가 만났던 에러들
어노테이션 관련 에러들
1.
Error: Compiling RuleSet failed: A Rule must not have a 'loader' property when it has a 'use' property
2-1.
Error: Duplicate plugin/preset detected. If you'd like to use two separate instances of a plugin, they need separate names, e.g. (... 예시는 생략함) => 실수로 같은 presets을 사용함.
2-2.
A Rule must not have a 'options' property when it has a 'use' property (at ruleSet[1].rules[0].options: [object Object])
결론적으로 구성을 잘못한거임.
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: ['@babel/plugin-proposal-object-rest-spread']
}
}
}
]
}
4.
Can't resolve 'crypto' in '/Users/darrenkwon/typed-app/node_modules/crypto-js'
breaking change
webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "crypto": require.resolve("crypto-browserify") }'
- install 'crypto-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "crypto": false }