Cách để npm packages chạy trong browser

Tôi thường để npm dependency hỗ trợ bên ngoài trong quá trình phát triển ban đầu của CodeSandbox. Tôi cứ nghĩ rằng việc cài đặt một cách tùy ý hay cho số lượng package trong browser theo ý thích luôn là việc không tưởng.

Ngày nay, hỗ trợ npm là một trong những tính năng làm nên CodeSandbox. Đã mất rất nhiều lần thử nghiệm và lặp để khiến nó có thể hoạt động được trong mọi trường hợp, chúng tôi cũng phải viết đi viết lại nhiều lần và tin rằng trong tương lai cũng phải như vậy. Tôi sẽ giải thích cách thức hỗ trợ NPM hoạt động, những gì đã được sửa và cũng như những cơ hội cải thiện thêm.

Phiên bản “đầu tiên”

Không biết bắt đầu như thế nào cho dễ nên tôi sẽ cho bạn xem hình dưới đây, vốn là phiên bản đầu tiên của hỗ trợ npm.

Có thể thấy tại phiên bản này, nó vô cùng đơn giản bởi còn không hẳn là hỗ trợ npm nữa. Tôi chỉ là tự cài đặt dependencies và quẹt các dependency call với chúng. Tất nhiên là cách này không thể thực hiện được với qui mô lớn khoảng 400,000 package, bao gồm nhiều phiên bản khác nhau.

Mặc dù “đầu tiên” không thể dùng được tốt, nó cũng đã tiếp thêm lửa nhiệt cho tôi tiếp tục làm thêm 2 dependencies hoạt động được trong một môi trường sandbox.

Phiên bản Webpack

Như nói trên, tôi đã rất hài lòng với phiên bản đầu tiên và cho rằng nó đủ khả năng cho một MVP. Tôi còn không nghĩ là nó có thể cài đặt bất kì dependency nào khác mà không phải cần tới phép màu nào cả. Mãi cho đến khi tôi gặp https://esnextb.in/. Họ đã hỗ trợ bất kì dependency nào từ npm, bạn chỉ cần define chúng trong package.json và nó sẽ tự hoạt động một cách thần kì.

Đây là một khoảnh khắc quan trọng của tôi do trước đó ý nghĩ hỗ trợ npm là bất khả thi luôn hiện hữu trong tôi. Sau lần bắt gặp định mệnh đó, ý tưởng mới liên tục nhen nhóm trong đầu tôi, quả thật đừng bao giờ từ bỏ khi bạn còn chưa thử qua.

Vậy là tôi dành thời gian suy nghĩ cách thức và nhận ra rằng phiên bản đầu tiên quá phức tạp hóa vấn đề lên thế nên tôi phải vẽ một sơ đồ đơn giản nó như sau:

Có một lợi thế trong cách tiếp vận phức tạp này: thực hiện nó hóa ra lại khá dễ.

Tôi đã học được rằng Webpack DLL Plugin có khả năng bundle các dependencies và cho ra một JS bundle với một biểu hiện (manifest) như sau:

{
  "name": "dll_bundle",
  "content": {
    "./node_modules/fbjs/lib/emptyFunction.js": 0,
    "./node_modules/fbjs/lib/invariant.js": 1,
    "./node_modules/fbjs/lib/warning.js": 2,
    "./node_modules/react": 3,
    "./node_modules/fbjs/lib/emptyObject.js": 4,
    "./node_modules/object-assign/index.js": 5,
    "./node_modules/prop-types/checkPropTypes.js": 6,
    "./node_modules/prop-types/lib/ReactPropTypesSecret.js": 7,
    "./node_modules/react/cjs/react.development.js": 8
  }
}

Mọi path đều được kết nối tới module của nó. Nếu tôi cần react thì sẽ chỉ cần call  dll_bundle(3) và tôi sẽ có React! Quá là tuyệt vời cho trường hợp của tôi nên ngay lập tức bắt tay vào làm và kết quả cho ra là như sau:

Cho mỗi request đến packager tôi sẽ phải tạo một directory mới trong /tmp/:hash, sẽ chạy  yarn add ${dependencyList} và để webpack được bundle ngay sau đó. Tôi thường lưu bundle mới này trên Gcloud như một cách caching. Đơn giản hơn so với biểu đồ, đa phần là vì tôi thay việc cài đặt dependencies với yarn và bundling với webpack.

Khi bạn load một sandbox, đầu tiên ta phải chắc chắn rằng phải có bundle và biểu hiện (manifest) trước khi đánh giá. Trong quá trình đánh giá, ta sẽ call dll_bundle(:id)  cho từng dependencies.

Tuy vậy, vẫn còn rất nhiều giới hạn trong hệ thống này. Nó không hề hỗ trợ những file nằm trong biểu đồ dependency của Webpack. Do đó, những trường hợp như require('react-icons/lib/fa/fa-beer') sẽ không hoạt động, bởi nó không bao giờ được yêu cầu bởi entry point của dependency.

Dù thế, tôi vẫn tung ra CodeSandbox với phiên bản này. Ngay sau đó, cha đẻ của WebpackBin, Christian Alfoni, đã liên lạc với tôi. Hóa ra chúng tôi đều có hệ thống khá tương tự nhau để hỗ trợ npm dependencies và đều gặp cùng một vấn đề. Do đó chúng tôi quyết định bắt tay hợp sức để tạo ra packager tối thượng!

Webpack với entries

Packager tối thượng vẫn giữ những tính năng có trong các phiên bản trước nhưng Christian có tạo một thuật toán sẽ thêm files vào bundle tùy theo tầm quan trọng của chúng. Điều đó có nghĩa là ta phải tự add thủ công các entry points vào để bảo đảm Webpack sẽ bundle cả những file đó. Sau rất nhiều chỉnh sửa, nó đã có khả năng hoạt động với bất kì tình huống nào.

Hệ thống mới cũng được nâng cấp về kiến trúc: chúng tôi có một dịch vụ dll với mục tiêu cân bằng load và cache. Ngoài ra, còn có nhiều packager thực hiện bundling.

Chúng tôi muốn mọi người đều có thể sử dịch vụ packager. Do đó mà chúng tôi đã làm ra một website để giải thích cách thức dịch vụ này hoạt động và cách mà bạn có thể dùng nó. May mắn thay ý tưởng trở nên cực kì thành công, CodePen blog cũng có nhắc tới chúng tôi.

Tuy vậy, vẫn có nhiều điểm yếu trong packager tối thượng. Chi phí tăng chóng mặt khi qui mô mở rộng. Ngoài ra, ta phải rebundle lại toàn bộ packager mỗi khi thêm vào một dependency mới.

Không cần máy chủ

Tôi luôn muốn thử qua công nghệ không cần máy chủ hay còn được gọi là ‘serverless’. Với nó bạn define một function vốn sẽ execute một request, function này sẽ tự hoạt động và thực hiện nhiệm vụ rồi sau đó tự xóa mình. Nhờ đó mà khả năng mở rộng của bạn là rất lớn, giả sử như bạn có 1000 request cùng một lúc thì sẽ không có vấn đề gì khi cho 1000 server thực hiện ngay lập tức. Điều này cũng có nghĩa bạn sẽ chỉ phải trả phí khi server chạy thôi.

Serverless nghe thực sự quá hoàn hảo cho dịch vụ của chúng tôi. Nó không hề chạy liên tục cũng như có thể chạy nhiều request cùng lúc. Do đó mà tôi khá hào hứng khi bắt đầu dùng tới Serverless.

Quá trình convert diễn ra khá là êm xui, mất chỉ 2 ngày để có một phiên bản chạy tốt. Và như vậy tôi tạo ra 3 serverless functions:

  1. Metadata resolver: nó chuyên giải quyết các versions và peerDependencies sau đó request packager function.
  2. Packager: nó chịu trách nhiệm cài đặt và bundling các dependencies.
  3. Uglifier: Giúp ngặn chặn các kết quả bị sai, lỗi khi bundle.

Tôi chạy song song hai dịch vụ mới và cũ khác nhau! Giá chi phí giảm từ $100 xuống còn có 0.18$  trong khi response time được cải thiện trong khoảng 40% tới 70%.

Tuy vậy, sau một thời gian dùng thì tôi nhận ra nó tồn tại một điểm yếu: một lambda function chỉ có tối đa 500MB disk space, như vậy có nghĩa là một số các dependencies sẽ không thể install. Đây là một yếu điểm chết người và tôi  

Serverless – Phiên bản mới

Một vài tháng trôi qua và tôi tung ra một bundler mới cho CodeSandbox. Nó cực kì mạnh mẽ, cho phép dễ dàng hỗ trợ nhiều libraries như Vue và Preact. Bằng việc hỗ trợ những libraries này chúng ta có nhiều requests khá thú vị. Ví dụ: Nếu bạn muốn dùng một React library trong Preact, thì sẽ cần thay  require('react') thành  require('preact-compat'). Còn với Vue, bạn sẽ muốn giải quyết `@/components/App.vue` tới sandbox files của mình. Packager của chúng tôi thì không làm cái này cho dependencies mà sẽ do bundler đảm nhiệm.

Đó cũng là lúc mà tôi chợt nghĩ sao không thử để browser bundler lo việc packaging? Nếu ta gửi các files cần thiết tới browser, chúng ta sẽ để bundler lo việc bundling của dependencies. Như vậy nó sẽ nhanh hơn, bởi vì ta không evaluate toàn bộ bundle mà chỉ một phần của nó.

Có một lợi thế cho cách thức này: chúng ta sẽ có thể install và cache dependencies một cách độc lập. Ta có thể hợp các dependency files trên client lại. Điều đó có nghĩa là nếu bạn request một dependency mới trên các dependencies đã có sẵn, chúng ta sẽ chỉ cần tập hợp các files cho dependency mới! Nhờ đó mà giải quyết vấn đề giới hạn 500MB từ AWS Lambda, bởi chúng ta chỉ install một dependency. Ta cũng có thể bỏ Webpack ra khỏi packager, bởi giờ đây nó chỉ chịu trách nhiệm việc phân loại file và gửi chúng đi.

Cách nó hoạt động

Khi chúng ta request một kết hợp các dependencies, đầu tiên sẽ check liệu nó đã có sẵn S3. Nếu không thì ta request từ dịch vụ api; khi đó nó sẽ request toàn bộ packager cho mỗi dependency. Ngay khi ta nhận được thông báo 200 OK thì sẽ lại request S3 lần nữa.

Packager installs các dependencies sử dụng yarn và tiếm kiếm tất cả các file liên quan bằng cách đi qua AST của mọi files trong directory của entry point. Nó tìm kiếm require statements và thêm chúng vào file list. Một ví dụ output (của react@latest) sẽ như sau:

{
    "aliases": {
        "asap": "asap/browser-asap.js",
        "asap/asap": "asap/browser-asap.js",
        "asap/asap.js": "asap/browser-asap.js",
        "asap/raw": "asap/browser-raw.js",
        "asap/raw.js": "asap/browser-raw.js",
        "asap/test/domain.js": "asap/test/browser-domain.js",
        "core-js": "core-js/index.js",
        "encoding": "encoding/lib/encoding.js",
        "fbjs": "fbjs/index.js",
        "iconv-lite": "iconv-lite/lib/index.js",
        "iconv-lite/extend-node": false,
        "iconv-lite/streams": false,
        "is-stream": "is-stream/index.js",
        "isomorphic-fetch": "isomorphic-fetch/fetch-npm-browserify.js",
        "js-tokens": "js-tokens/index.js",
        "loose-envify": "loose-envify/index.js",
        "node-fetch": "node-fetch/index.js",
        "object-assign": "object-assign/index.js",
        "promise": "promise/index.js",
        "prop-types": "prop-types/index.js",
        "react": "react/index.js",
        "setimmediate": "setimmediate/setImmediate.js",
        "ua-parser-js": "ua-parser-js/src/ua-parser.js",
        "whatwg-fetch": "whatwg-fetch/fetch.js"
    },
    "contents": {
        "react/index.js": {
            "requires": [
                "./cjs/react.development.js"
            ],
            "content": "/* code */"
        },
        "object-assign/index.js": {
            "requires": [],
            "content": "/* code */"
        },
        "fbjs/lib/emptyObject.js": {
            "requires": [],
            "content": "/* code */"
        },
        "fbjs/lib/invariant.js": {
            "requires": [],
            "content": "/* code */"
        },
        "fbjs/lib/emptyFunction.js": {
            "requires": [],
            "content": "/* code */"
        },
        "react/cjs/react.development.js": {
            "requires": [
                "object-assign",
                "fbjs/lib/warning",
                "fbjs/lib/emptyObject",
                "fbjs/lib/invariant",
                "fbjs/lib/emptyFunction",
                "prop-types/checkPropTypes"
            ],
            "content": "/* code */"
        },
        "fbjs/lib/warning.js": {
            "requires": [
                "./emptyFunction"
            ],
            "content": "/* code */"
        },
        "prop-types/checkPropTypes.js": {
            "requires": [
                "fbjs/lib/invariant",
                "fbjs/lib/warning",
                "./lib/ReactPropTypesSecret"
            ],
            "content": "/* code */"
        },
        "prop-types/lib/ReactPropTypesSecret.js": {
            "requires": [],
            "content": "/* code */"
        },
        "react/package.json": {
            "requires": [],
            "content": "/* code */"
        }
    },
    "dependency": {
        "name": "react",
        "version": "16.0.0"
    },
    "dependencyDependencies": {
        "asap": "2.0.6",
        "core-js": "1.2.7",
        "encoding": "0.1.12",
        "fbjs": "0.8.16",
        "iconv-lite": "0.4.19",
        "is-stream": "1.1.0",
        "isomorphic-fetch": "2.2.1",
        "js-tokens": "3.0.2",
        "loose-envify": "1.3.1",
        "node-fetch": "1.7.3",
        "object-assign": "4.1.1",
        "promise": "7.3.1",
        "prop-types": "15.6.0",
        "setimmediate": "1.0.5",
        "ua-parser-js": "0.7.14",
        "whatwg-fetch": "2.0.3"
    }
}

Lợi thế

Tiết kiệm chi phí

Với cái giá $0.02 cho 2 ngày deploy packager so với việc phải trả $100 một tháng.  

Hiệu năng cao

Giờ bạn có thể lấy một tỏ hợp mới của dependencies chỉ trong 3 giây trong khi với hệ thống cũ thì nó mất tới một phút.

Đa năng

Bundler giờ đây xử lí dependencies như là local files. Điều đó có nghĩa là error stack dễ theo dõi hơn, ta cũng có thể thêm bất kì file nào từ dependencies (như ,scss, .vue, etc.) cũng như dễ dàng hỗ trợ các thứ như aliases.

Release

2 ngày trước, tôi có dùng packager mới song song với cả cũ, để tạo cache. Nó đã cached 2,000 tổ hợp khác nhau và 1400 dependencies khác nhau.

Hãy dùng Serverless!

Tôi thật sự rất ấn tượng với serverless,nó khiến việc mở rộng server và quản lí cực kì dễ dàng. Điểm yếu duy nhất là khâu setup khá phức tạp, nhưng những thành viên tại serverless.com đã khiến nó trở nên dễ dàng đi rất nhiều. Tôi thật sự tin rằng serverless sẽ là tương lai của nhiều app trong thế giới lập trình.

Nguồn: TopDev via hackernoon