본문으로 바로가기

From neutrino v9 to Webpack v4 이전

category 🎁 Module bundler/📖 Webpack 2021. 11. 21. 17:45

교체 이유

 

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

 

https://react-svgr.com/

 

SVGR - Transforms SVG into React Components. - SVGR

Transforms SVG into React Components.

react-svgr.com

 

 

compress

 

https://v4.webpack.js.org/plugins/compression-webpack-plugin/

 

CompressionWebpackPlugin | webpack

webpack is a module bundler. Its main purpose is to bundle JavaScript files for usage in a browser, yet it is also capable of transforming, bundling, or packaging just about any resource or asset.

v4.webpack.js.org

 

 

 

 

빌드 속도 개선

 

 

 

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

 

GitHub - privatenumber/esbuild-loader: ⚡️ Speed up your Webpack build with esbuild

⚡️ Speed up your Webpack build with esbuild. Contribute to privatenumber/esbuild-loader development by creating an account on GitHub.

github.com

 

 

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 }


darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체